LCOV - code coverage report
Current view: top level - dashql-parser/src - comment_blocks.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 98.7 % 454 448
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! Grouping comments into blocks.
       2              : //!
       3              : //! A block is either a single block comment or a run of line comments that start their own lines on
       4              : //! consecutive lines. A trailing line comment (`select 1; -- note`) is a block of its own.
       5              : //!
       6              : //! Comments are split into tagged blocks at every line whose first text is a known tag, `@<tag>`
       7              : //! (see [`COMMENT_TAGS`]). Any other `@word` is ordinary text. A tag runs to the next tag line or to
       8              : //! the end of the comment, like the block tags of Javadoc: in a block comment the comment end closes the
       9              : //! last tag, in a run of line comments the end of the run. Tags whose content is one line end at their line
      10              : //! if it has content (see [`Grouper::ends_at_its_line`]): the args of `@board`, `@flow` and `@vis table`, and
      11              : //! the value of `@param :name = <literal>`. Text before the first tag of a block comment and the lines after a
      12              : //! tag that ends at its line are ordinary blocks. Older scripts close the last region of line comments with
      13              : //! `-- @end`, the line is still accepted and ends the region, comments after it are ordinary comments.
      14              : //!
      15              : //! The content lines omit comment markers, block comment gutters (` * `), tag lines and leading or
      16              : //! trailing empty lines. Text following the opening tag on the same line is content. The parser does
      17              : //! not interpret the content, readers pick blocks by tag name and parse the content themselves, e.g.
      18              : //! as JSON.
      19              : 
      20              : use crate::span::TextSpan;
      21              : pub use dashql_buffers::parser::CommentBlockType;
      22              : 
      23              : /// The prefix of comment tags, e.g. `-- @vis`
      24              : pub const COMMENT_TAG_PREFIX: &str = "@";
      25              : /// The tag that may close a tagged region of line comments, accepted for older scripts and never needed
      26              : pub const COMMENT_TAG_END: &str = "end";
      27              : /// The known tags, any other `@word` is ordinary text
      28              : pub const COMMENT_TAGS: &[&str] = &["vis", "board", "flow", "param", "manual", COMMENT_TAG_END];
      29              : 
      30              : /// Is a tag name one of the known tags?
      31         1642 : pub fn is_comment_tag(name: &[u8]) -> bool {
      32         4948 :     COMMENT_TAGS.iter().any(|t| t.as_bytes() == name)
      33         1642 : }
      34              : 
      35              : /// A block of comments
      36              : #[derive(Clone, Copy, Debug, PartialEq, Eq)]
      37              : pub struct CommentBlock {
      38              :     /// The kind of block: an ordinary comment or a `@<tag>` region
      39              :     pub block_type: CommentBlockType,
      40              :     /// The whole block, including comment markers and tag lines
      41              :     pub text_span: TextSpan,
      42              :     /// The tag name (e.g. `vis`), empty for ordinary comments
      43              :     pub tag_span: TextSpan,
      44              :     /// Slice of the comments of the script
      45              :     pub comments_begin: u32,
      46              :     /// The number of comments in the block
      47              :     pub comments_count: u32,
      48              :     /// Slice of [`CommentBlocks::lines`]
      49              :     pub lines_begin: u32,
      50              :     /// The number of content lines of the block
      51              :     pub lines_count: u32,
      52              : }
      53              : 
      54              : /// The comments of a script grouped into blocks
      55              : #[derive(Clone, Debug, Default, PartialEq, Eq)]
      56              : pub struct CommentBlocks {
      57              :     /// The blocks in script order
      58              :     pub blocks: Vec<CommentBlock>,
      59              :     /// The content lines of all blocks
      60              :     pub lines: Vec<TextSpan>,
      61              :     /// Malformed comment tags
      62              :     pub warnings: Vec<(TextSpan, String)>,
      63              : }
      64              : 
      65              : /// Group comments into blocks.
      66         7402 : pub fn group_comments(text: &str, comments: &[TextSpan]) -> CommentBlocks {
      67         7402 :     group_comment_bytes(text.as_bytes(), comments)
      68         7402 : }
      69              : 
      70              : /// Group comments into blocks, for callers that hold the script text as bytes
      71         7589 : pub fn group_comment_bytes(text: &[u8], comments: &[TextSpan]) -> CommentBlocks {
      72         7589 :     let mut grouper = Grouper {
      73         7589 :         text,
      74         7589 :         comments,
      75         7589 :         out: CommentBlocks::default(),
      76         7589 :     };
      77         7589 :     grouper.group();
      78         7589 :     grouper.out
      79         7589 : }
      80              : 
      81              : /// Whitespace within a line
      82        24870 : fn is_horizontal_space(c: u8) -> bool {
      83        24870 :     matches!(c, b' ' | b'\t' | b'\r' | b'\x0c' | b'\x0b')
      84        24870 : }
      85              : 
      86              : /// A byte range [begin, end) in the script text
      87              : #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
      88              : struct Range {
      89              :     /// The first byte offset
      90              :     begin: usize,
      91              :     /// The byte offset past the end
      92              :     end: usize,
      93              : }
      94              : 
      95              : impl Range {
      96         5691 :     fn new(begin: usize, end: usize) -> Self {
      97         5691 :         Self { begin, end }
      98         5691 :     }
      99         6004 :     fn is_empty(&self) -> bool {
     100         6004 :         self.begin >= self.end
     101         6004 :     }
     102         3593 :     fn span(&self) -> TextSpan {
     103         3593 :         TextSpan::new(self.begin as u32, self.end.saturating_sub(self.begin) as u32)
     104         3593 :     }
     105              : }
     106              : 
     107              : /// A comment tag at the start of a line
     108              : struct Tag {
     109              :     /// The tag name
     110              :     name: Range,
     111              :     /// The text following the tag on the same line
     112              :     rest: Range,
     113              : }
     114              : 
     115              : /// Groups the comments of a script into blocks
     116              : struct Grouper<'a> {
     117              :     /// The script text
     118              :     text: &'a [u8],
     119              :     /// The comments of the script in script order
     120              :     comments: &'a [TextSpan],
     121              :     /// The comment blocks built so far
     122              :     out: CommentBlocks,
     123              : }
     124              : 
     125              : impl<'a> Grouper<'a> {
     126         4147 :     fn slice(&self, r: Range) -> &'a [u8] {
     127         4147 :         &self.text[r.begin..r.end.max(r.begin)]
     128         4147 :     }
     129              : 
     130              :     /// Trim horizontal whitespace on both sides
     131         7081 :     fn trim(&self, mut r: Range) -> Range {
     132        13568 :         while r.begin < r.end && is_horizontal_space(self.text[r.begin]) {
     133         6487 :             r.begin += 1;
     134         6487 :         }
     135         7660 :         while r.end > r.begin && is_horizontal_space(self.text[r.end - 1]) {
     136          579 :             r.end -= 1;
     137          579 :         }
     138         7081 :         r
     139         7081 :     }
     140              : 
     141              :     /// Read a tag from trimmed line content
     142         2340 :     fn read_tag(&self, line: Range) -> Option<Tag> {
     143         2340 :         if !self.slice(line).starts_with(COMMENT_TAG_PREFIX.as_bytes()) {
     144         1252 :             return None;
     145         1088 :         }
     146         1088 :         let name_begin = line.begin + COMMENT_TAG_PREFIX.len();
     147         1088 :         let mut name_end = name_begin;
     148         5212 :         while name_end < line.end {
     149         4905 :             let c = self.text[name_end];
     150         4905 :             if !(c.is_ascii_alphanumeric() || c == b'_' || c == b'-') {
     151          781 :                 break;
     152         4124 :             }
     153         4124 :             name_end += 1;
     154              :         }
     155              :         // The tag name must be followed by whitespace or the end of the line
     156         1088 :         if name_end == name_begin || (name_end < line.end && !is_horizontal_space(self.text[name_end])) {
     157            5 :             return None;
     158         1083 :         }
     159         1083 :         if !is_comment_tag(&self.text[name_begin..name_end]) {
     160           24 :             return None;
     161         1059 :         }
     162         1059 :         Some(Tag {
     163         1059 :             name: Range::new(name_begin, name_end),
     164         1059 :             rest: self.trim(Range::new(name_end, line.end)),
     165         1059 :         })
     166         2340 :     }
     167              : 
     168              :     /// Does a tag end at its line? Tags whose content is one line end there if their line has content, the lines
     169              :     /// after them are ordinary text: `@board` and `@flow` with their args, `@vis table` and the `@vis board`,
     170              :     /// `@vis card` and `@vis flow` of older scripts, and `@param :name = <literal>`. The Javadoc `@param x ...`,
     171              :     /// `@vis vegalite`, `@vis markdown` and the free text of `@manual` run to the next tag.
     172          374 :     fn ends_at_its_line(&self, tag: &Tag) -> bool {
     173          374 :         let rest = self.slice(tag.rest);
     174          374 :         match self.slice(tag.name) {
     175          374 :             _ if rest.is_empty() => false,
     176          257 :             b"board" | b"flow" => true,
     177           47 :             b"param" => rest.starts_with(b":"),
     178          199 :             b"vis" => {
     179         1511 :                 let kind = rest.split(|c| is_horizontal_space(*c)).next().unwrap_or_default();
     180          199 :                 matches!(kind, b"table" | b"board" | b"card" | b"flow")
     181              :             }
     182            0 :             _ => false,
     183              :         }
     184          374 :     }
     185              : 
     186              :     /// The offset of the line break after an offset, `limit` if there is none before it
     187            5 :     fn line_end(&self, offset: usize, limit: usize) -> usize {
     188            5 :         self.text[offset..limit].iter().position(|&c| c == b'\n').map_or(limit, |i| offset + i)
     189            5 :     }
     190              : 
     191              :     /// Is a comment the first text on its line?
     192          881 :     fn starts_line(&self, mut offset: usize) -> bool {
     193          912 :         while offset > 0 && is_horizontal_space(self.text[offset - 1]) {
     194           31 :             offset -= 1;
     195           31 :         }
     196          881 :         offset == 0 || self.text[offset - 1] == b'\n'
     197          881 :     }
     198              : 
     199              :     /// Is the gap between two line comments only whitespace with a single line break?
     200          555 :     fn is_contiguous_gap(&self, begin: usize, end: usize) -> bool {
     201          555 :         let mut line_breaks = 0;
     202          602 :         for &c in &self.text[begin..end] {
     203          602 :             if c == b'\n' {
     204          586 :                 line_breaks += 1;
     205          586 :             } else if !is_horizontal_space(c) {
     206           16 :                 return false;
     207            0 :             }
     208              :         }
     209          539 :         line_breaks <= 1
     210          555 :     }
     211              : 
     212         1549 :     fn is_line_comment(&self, comment: TextSpan) -> bool {
     213         1549 :         comment.length >= 2 && self.text[comment.offset as usize..].starts_with(b"--")
     214         1549 :     }
     215              : 
     216              :     /// The content of a line comment: everything after the dashes and a single space
     217          881 :     fn line_comment_content(&self, comment: TextSpan) -> Range {
     218          881 :         let mut begin = comment.offset as usize;
     219          881 :         let mut end = comment.end() as usize;
     220         2661 :         while begin < end && self.text[begin] == b'-' {
     221         1780 :             begin += 1;
     222         1780 :         }
     223          881 :         if begin < end && self.text[begin] == b' ' {
     224          862 :             begin += 1;
     225          862 :         }
     226          881 :         while end > begin && is_horizontal_space(self.text[end - 1]) {
     227            0 :             end -= 1;
     228            0 :         }
     229          881 :         Range::new(begin, end)
     230          881 :     }
     231              : 
     232              :     /// The content lines of a block comment without delimiters and gutters.
     233              :     ///
     234              :     /// A gutter (` * `) is stripped if every line after the first has one. The lines after the first lose
     235              :     /// their common indentation, so relative indentation (e.g. of a Markdown list) is kept.
     236          575 :     fn block_comment_lines(&self, comment: TextSpan) -> Vec<Range> {
     237          575 :         let mut begin = comment.offset as usize;
     238          575 :         let mut end = comment.end() as usize;
     239              :         // Strip `*/`, the scanner reports unterminated block comments as errors
     240          575 :         if comment.length >= 4 && &self.text[end - 2..end] == b"*/" {
     241          575 :             end -= 2;
     242          575 :         }
     243              :         // Strip `/*` and additional stars, as in `/**`
     244          575 :         begin = (begin + 2).min(end);
     245          690 :         while begin < end && self.text[begin] == b'*' {
     246          115 :             begin += 1;
     247          115 :         }
     248          575 :         while end > begin && self.text[end - 1] == b'*' {
     249            0 :             end -= 1;
     250            0 :         }
     251          575 :         let mut lines = Vec::new();
     252          575 :         let mut line_begin = begin;
     253        26574 :         for i in begin..=end {
     254        26574 :             if i < end && self.text[i] != b'\n' {
     255        25154 :                 continue;
     256         1420 :             }
     257         1420 :             lines.push(Range::new(line_begin, i));
     258         1420 :             line_begin = i + 1;
     259              :         }
     260         2902 :         let first_text = |r: Range| {
     261         2876 :             let t = self.trim(r);
     262         2876 :             (!t.is_empty()).then_some(t.begin)
     263         2876 :         };
     264          575 :         let has_gutter = lines.len() > 1
     265          242 :             && lines[1..].iter().any(|&l| first_text(l).is_some())
     266          543 :             && lines[1..].iter().all(|&l| first_text(l).is_none_or(|b| self.text[b] == b'*'));
     267          575 :         if has_gutter {
     268          401 :             for line in &mut lines[1..] {
     269          401 :                 if let Some(star) = first_text(*line) {
     270          308 :                     line.begin = star + 1;
     271          308 :                     if line.begin < line.end && self.text[line.begin] == b' ' {
     272          291 :                         line.begin += 1;
     273          291 :                     }
     274           93 :                 }
     275              :             }
     276          476 :         }
     277              :         // Remove the common indentation of the lines after the first
     278         1954 :         let indent = |r: Range| self.text[r.begin..r.end].iter().take_while(|&&c| is_horizontal_space(c)).count();
     279          934 :         let common = lines[1..].iter().filter(|&&l| first_text(l).is_some()).map(|&l| indent(l)).min().unwrap_or(0);
     280         1420 :         for (i, line) in lines.iter_mut().enumerate() {
     281         1420 :             let mut r = if i == 0 || first_text(*line).is_none() {
     282          806 :                 self.trim(*line)
     283              :             } else {
     284          614 :                 Range::new(line.begin + common, line.end)
     285              :             };
     286         1489 :             while r.end > r.begin && is_horizontal_space(self.text[r.end - 1]) {
     287           69 :                 r.end -= 1;
     288           69 :             }
     289         1420 :             *line = r;
     290              :         }
     291          575 :         lines
     292          575 :     }
     293              : 
     294           23 :     fn warn(&mut self, span: TextSpan, message: String) {
     295           23 :         self.out.warnings.push((span, message));
     296           23 :     }
     297              : 
     298              :     /// Emit a block that spans whole comments, leading and trailing empty lines are dropped
     299          539 :     fn emit(&mut self, block_type: CommentBlockType, comments_begin: usize, comments_end: usize, tag: Range, lines: &[Range]) {
     300          539 :         let first = self.comments[comments_begin];
     301          539 :         let last = self.comments[comments_end - 1];
     302          539 :         let text_span = TextSpan::new(first.offset, last.end() - first.offset);
     303          539 :         self.emit_span(block_type, comments_begin, comments_end, text_span, tag, lines);
     304          539 :     }
     305              : 
     306              :     /// Emit a block with an explicit text span, e.g. a part of a block comment
     307         1197 :     fn emit_span(
     308         1197 :         &mut self,
     309         1197 :         block_type: CommentBlockType,
     310         1197 :         comments_begin: usize,
     311         1197 :         comments_end: usize,
     312         1197 :         text_span: TextSpan,
     313         1197 :         tag: Range,
     314         1197 :         mut lines: &[Range],
     315         1197 :     ) {
     316         1360 :         while lines.first().is_some_and(Range::is_empty) {
     317          163 :             lines = &lines[1..];
     318          163 :         }
     319         1379 :         while lines.last().is_some_and(Range::is_empty) {
     320          182 :             lines = &lines[..lines.len() - 1];
     321          182 :         }
     322         1197 :         self.out.blocks.push(CommentBlock {
     323         1197 :             block_type,
     324         1197 :             text_span,
     325         1197 :             tag_span: tag.span(),
     326         1197 :             comments_begin: comments_begin as u32,
     327         1197 :             comments_count: (comments_end - comments_begin) as u32,
     328         1197 :             lines_begin: self.out.lines.len() as u32,
     329         1197 :             lines_count: lines.len() as u32,
     330         1197 :         });
     331         1197 :         self.out.lines.extend(lines.iter().map(Range::span));
     332         1197 :     }
     333              : 
     334              :     /// Is a tag the end tag?
     335         1059 :     fn is_end(&self, tag: &Tag) -> bool {
     336         1059 :         self.slice(tag.name) == COMMENT_TAG_END.as_bytes()
     337         1059 :     }
     338              : 
     339          575 :     fn group_block_comment(&mut self, comment_id: usize) {
     340          575 :         let comment = self.comments[comment_id];
     341          575 :         let mut lines = self.block_comment_lines(comment);
     342              :         // Every tag line starts a tagged block, the end tag is never needed in a block comment
     343          575 :         let mut tags: Vec<(usize, Tag)> = Vec::new();
     344         1420 :         for (i, &line) in lines.iter().enumerate() {
     345         1420 :             let Some(tag) = self.read_tag(self.trim(line)) else { continue };
     346          628 :             if self.is_end(&tag) {
     347            9 :                 self.warn(TextSpan::new(line.begin as u32, (line.end - line.begin) as u32), "@end without an opening tag".to_string());
     348            9 :                 continue;
     349          619 :             }
     350          619 :             tags.push((i, tag));
     351              :         }
     352          575 :         if tags.is_empty() {
     353          103 :             self.emit(CommentBlockType::COMMENT, comment_id, comment_id + 1, Range::default(), &lines);
     354          103 :             return;
     355          472 :         }
     356              :         // Text before the first tag is an ordinary block
     357          472 :         let first_tag = tags[0].0;
     358          472 :         let mut span_begin = comment.offset as usize;
     359          472 :         if lines[..first_tag].iter().any(|l| !l.is_empty()) {
     360           34 :             let tag_begin = tags[0].1.name.begin - COMMENT_TAG_PREFIX.len();
     361           34 :             let span = Range::new(span_begin, tag_begin).span();
     362           34 :             self.emit_span(CommentBlockType::COMMENT, comment_id, comment_id + 1, span, Range::default(), &lines[..first_tag]);
     363           34 :             span_begin = tag_begin;
     364          438 :         }
     365          619 :         for k in 0..tags.len() {
     366          619 :             let (line, ref tag) = tags[k];
     367          619 :             let (lines_end, span_end) = match tags.get(k + 1) {
     368          147 :                 Some((next, next_tag)) => (*next, next_tag.name.begin - COMMENT_TAG_PREFIX.len()),
     369          472 :                 None => (lines.len(), comment.end() as usize),
     370              :             };
     371          619 :             let name = tag.name;
     372          619 :             let rest = tag.rest;
     373              :             // The lines after a tag that ends at its line are ordinary text
     374          619 :             let after = lines[line + 1..lines_end].iter().any(|l| !l.is_empty());
     375          619 :             let (tag_lines_end, tag_end) = if after && self.ends_at_its_line(tag) {
     376            5 :                 (line + 1, self.line_end(rest.end, span_end))
     377              :             } else {
     378          614 :                 (lines_end, span_end)
     379              :             };
     380          619 :             lines[line] = rest;
     381          619 :             let span = Range::new(span_begin, tag_end).span();
     382          619 :             self.emit_span(CommentBlockType::DASHQL_TAG, comment_id, comment_id + 1, span, name, &lines[line..tag_lines_end]);
     383          619 :             if tag_end < span_end {
     384            5 :                 let span = Range::new(tag_end, span_end).span();
     385            5 :                 self.emit_span(CommentBlockType::COMMENT, comment_id, comment_id + 1, span, Range::default(), &lines[tag_lines_end..lines_end]);
     386          614 :             }
     387          619 :             span_begin = span_end;
     388              :         }
     389          575 :     }
     390              : 
     391          369 :     fn group_line_comments(&mut self, run_begin: usize, run_end: usize) {
     392          881 :         let lines: Vec<Range> = (run_begin..run_end).map(|i| self.line_comment_content(self.comments[i])).collect();
     393         1181 :         let line = |i: usize| lines[i - run_begin];
     394              :         // The comments of the run that are not yet emitted
     395          369 :         let mut pending = run_begin;
     396          369 :         let mut i = run_begin;
     397          844 :         while i < run_end {
     398          475 :             let Some(tag) = self.read_tag(self.trim(line(i))) else {
     399          235 :                 i += 1;
     400          235 :                 continue;
     401              :             };
     402          240 :             if self.is_end(&tag) {
     403           14 :                 self.warn(self.comments[i], "@end without an opening tag".to_string());
     404           14 :                 i += 1;
     405           14 :                 continue;
     406          226 :             }
     407              :             // A region ends at the next tag line, at an end line or at the end of the run, a tag that ends at its
     408              :             // line at the end line that follows it
     409          226 :             let mut close = i + 1;
     410          226 :             let mut closed_by_end = false;
     411          226 :             let ends_at_its_line = self.ends_at_its_line(&tag);
     412          487 :             while close < run_end {
     413          445 :                 if let Some(next) = self.read_tag(self.trim(line(close))) {
     414          191 :                     if !self.is_end(&next) {
     415           36 :                         break;
     416          155 :                     }
     417          155 :                     if next.rest.is_empty() {
     418          145 :                         closed_by_end = true;
     419          145 :                         break;
     420           10 :                     }
     421          254 :                 }
     422          264 :                 if ends_at_its_line {
     423            3 :                     break;
     424          261 :                 }
     425          261 :                 close += 1;
     426              :             }
     427          226 :             if pending < i {
     428           12 :                 self.emit(CommentBlockType::COMMENT, pending, i, Range::default(), &lines[pending - run_begin..i - run_begin]);
     429          214 :             }
     430          226 :             let mut content = Vec::with_capacity(close - i);
     431          226 :             content.push(tag.rest);
     432          226 :             content.extend((i + 1..close).map(line));
     433          226 :             let region_end = if closed_by_end { close + 1 } else { close };
     434          226 :             self.emit(CommentBlockType::DASHQL_TAG, i, region_end, tag.name, &content);
     435          226 :             pending = region_end;
     436          226 :             i = region_end;
     437              :         }
     438          369 :         if pending < run_end {
     439          198 :             self.emit(CommentBlockType::COMMENT, pending, run_end, Range::default(), &lines[pending - run_begin..]);
     440          218 :         }
     441          369 :     }
     442              : 
     443         7589 :     fn group(&mut self) {
     444         7589 :         let mut i = 0;
     445         8533 :         while i < self.comments.len() {
     446          944 :             let comment = self.comments[i];
     447          944 :             if !self.is_line_comment(comment) {
     448          575 :                 self.group_block_comment(i);
     449          575 :                 i += 1;
     450          575 :                 continue;
     451          369 :             }
     452              :             // A trailing line comment is a block of its own
     453          369 :             let mut run_end = i + 1;
     454          369 :             if self.starts_line(comment.offset as usize) {
     455          849 :                 while run_end < self.comments.len() {
     456          605 :                     let prev = self.comments[run_end - 1];
     457          605 :                     let next = self.comments[run_end];
     458          605 :                     if !self.is_line_comment(next)
     459          555 :                         || !self.is_contiguous_gap(prev.end() as usize, next.offset as usize)
     460          512 :                         || !self.starts_line(next.offset as usize)
     461              :                     {
     462           93 :                         break;
     463          512 :                     }
     464          512 :                     run_end += 1;
     465              :                 }
     466           32 :             }
     467          369 :             self.group_line_comments(i, run_end);
     468          369 :             i = run_end;
     469              :         }
     470         7589 :     }
     471              : }
     472              : 
     473              : #[cfg(test)]
     474              : mod tests {
     475              :     use super::*;
     476              :     use crate::scanner::scan;
     477              : 
     478           16 :     fn group(text: &str) -> CommentBlocks {
     479           16 :         let scanned = scan(text, 0, 0);
     480           16 :         group_comments(text, &scanned.comments)
     481           16 :     }
     482              : 
     483           22 :     fn line_texts(text: &str, blocks: &CommentBlocks, block: usize) -> Vec<String> {
     484           22 :         let b = blocks.blocks[block];
     485           22 :         blocks.lines[b.lines_begin as usize..(b.lines_begin + b.lines_count) as usize]
     486           22 :             .iter()
     487           33 :             .map(|l| text[l.offset as usize..l.end() as usize].to_string())
     488           22 :             .collect()
     489           22 :     }
     490              : 
     491              :     #[test]
     492            1 :     fn groups_line_comments() {
     493            1 :         let text = "-- a\n-- b\nselect 1; -- trailing\n\n-- c";
     494            1 :         let blocks = group(text);
     495            1 :         assert_eq!(blocks.blocks.len(), 3);
     496            1 :         assert_eq!(line_texts(text, &blocks, 0), vec!["a", "b"]);
     497            1 :         assert_eq!(line_texts(text, &blocks, 1), vec!["trailing"]);
     498            1 :         assert_eq!(line_texts(text, &blocks, 2), vec!["c"]);
     499            1 :     }
     500              : 
     501            9 :     fn tags<'a>(text: &'a str, blocks: &CommentBlocks) -> Vec<(CommentBlockType, &'a str)> {
     502            9 :         blocks
     503            9 :             .blocks
     504            9 :             .iter()
     505           29 :             .map(|b| (b.block_type, &text[b.tag_span.offset as usize..b.tag_span.end() as usize]))
     506            9 :             .collect()
     507            9 :     }
     508              : 
     509            5 :     fn block_texts<'a>(text: &'a str, blocks: &CommentBlocks) -> Vec<&'a str> {
     510           19 :         blocks.blocks.iter().map(|b| &text[b.text_span.offset as usize..b.text_span.end() as usize]).collect()
     511            5 :     }
     512              : 
     513              :     #[test]
     514            1 :     fn reads_tagged_regions() {
     515            1 :         let text = "-- @param Sales\n-- per region\n-- @end\n/* @board width=6 */\n/* @vis table */";
     516            1 :         let blocks = group(text);
     517            1 :         assert_eq!(
     518            1 :             tags(text, &blocks),
     519            1 :             vec![
     520            1 :                 (CommentBlockType::DASHQL_TAG, "param"),
     521            1 :                 (CommentBlockType::DASHQL_TAG, "board"),
     522            1 :                 (CommentBlockType::DASHQL_TAG, "vis"),
     523              :             ]
     524              :         );
     525            1 :         assert_eq!(line_texts(text, &blocks, 0), vec!["Sales", "per region"]);
     526              :         // The content is not interpreted, readers check whether it is JSON
     527            1 :         assert_eq!(line_texts(text, &blocks, 1), vec!["width=6"]);
     528            1 :         assert_eq!(line_texts(text, &blocks, 2), vec!["table"]);
     529            1 :         assert!(blocks.warnings.is_empty());
     530            1 :     }
     531              : 
     532              :     #[test]
     533            1 :     fn ignores_unknown_tags() {
     534            1 :         let text = "-- @todo fix this\n-- @see x\n/* @see x */";
     535            1 :         let blocks = group(text);
     536            1 :         assert_eq!(tags(text, &blocks), vec![(CommentBlockType::COMMENT, ""), (CommentBlockType::COMMENT, "")]);
     537            1 :         assert!(blocks.warnings.is_empty());
     538            1 :     }
     539              : 
     540              :     #[test]
     541            1 :     fn splits_block_comments_at_tags() {
     542            1 :         let text = "/* Sales.\n   @param Revenue.\n   @vis board width=6\n   @vis vegalite\n   {\"mark\": \"bar\"}\n*/";
     543            1 :         let blocks = group(text);
     544            1 :         assert_eq!(
     545            1 :             tags(text, &blocks),
     546            1 :             vec![
     547            1 :                 (CommentBlockType::COMMENT, ""),
     548            1 :                 (CommentBlockType::DASHQL_TAG, "param"),
     549            1 :                 (CommentBlockType::DASHQL_TAG, "vis"),
     550            1 :                 (CommentBlockType::DASHQL_TAG, "vis"),
     551              :             ]
     552              :         );
     553            1 :         assert_eq!(line_texts(text, &blocks, 0), vec!["Sales."]);
     554            1 :         assert_eq!(line_texts(text, &blocks, 1), vec!["Revenue."]);
     555            1 :         assert_eq!(line_texts(text, &blocks, 2), vec!["board width=6"]);
     556            1 :         assert_eq!(line_texts(text, &blocks, 3), vec!["vegalite", "{\"mark\": \"bar\"}"]);
     557              :         // The blocks cover the comment without gaps
     558            1 :         assert_eq!(block_texts(text, &blocks).concat(), text);
     559            4 :         assert!(blocks.blocks.iter().all(|b| (b.comments_begin, b.comments_count) == (0, 1)));
     560            1 :     }
     561              : 
     562              :     #[test]
     563            1 :     fn splits_line_comments_at_tags() {
     564            1 :         let text = "-- @param Revenue.\n-- @vis board width=6\n-- @vis table\n-- @end";
     565            1 :         let blocks = group(text);
     566            1 :         assert_eq!(
     567            1 :             tags(text, &blocks),
     568            1 :             vec![
     569            1 :                 (CommentBlockType::DASHQL_TAG, "param"),
     570            1 :                 (CommentBlockType::DASHQL_TAG, "vis"),
     571            1 :                 (CommentBlockType::DASHQL_TAG, "vis"),
     572              :             ]
     573              :         );
     574            1 :         assert_eq!(line_texts(text, &blocks, 1), vec!["board width=6"]);
     575            1 :         assert_eq!(block_texts(text, &blocks), vec!["-- @param Revenue.", "-- @vis board width=6", "-- @vis table\n-- @end"]);
     576            1 :         assert!(blocks.warnings.is_empty());
     577            1 :     }
     578              : 
     579              :     #[test]
     580            1 :     fn keeps_relative_indentation_in_block_comments() {
     581            1 :         let text = "/* @vis markdown\n     - a\n       - b\n\n     * c\n*/";
     582            1 :         let blocks = group(text);
     583            1 :         assert_eq!(line_texts(text, &blocks, 0), vec!["markdown", "- a", "  - b", "", "* c"]);
     584            1 :         let text = "/**\n * @param\n *   indented\n * plain\n */";
     585            1 :         let blocks = group(text);
     586            1 :         assert_eq!(line_texts(text, &blocks, 0), vec!["  indented", "plain"]);
     587            1 :     }
     588              : 
     589              :     #[test]
     590            1 :     fn warns_about_end_tags_without_a_region() {
     591              :         // A blank line ends the run of line comments and the region, the end line after it closes nothing
     592            1 :         let text = "-- @param\n-- x\n\n-- @end\n/* @end */";
     593            1 :         let blocks = group(text);
     594            2 :         let warnings: Vec<&str> = blocks.warnings.iter().map(|w| w.1.as_str()).collect();
     595            1 :         assert_eq!(warnings, vec!["@end without an opening tag", "@end without an opening tag"]);
     596            1 :         assert_eq!(
     597            1 :             tags(text, &blocks),
     598            1 :             vec![(CommentBlockType::DASHQL_TAG, "param"), (CommentBlockType::COMMENT, ""), (CommentBlockType::COMMENT, "")]
     599              :         );
     600            1 :     }
     601              : 
     602              :     #[test]
     603            1 :     fn closes_the_last_region_at_the_end_of_the_run() {
     604            1 :         let text = "-- @param Revenue.\n-- per region\n-- @vis table\nselect 1";
     605            1 :         let blocks = group(text);
     606            1 :         assert_eq!(tags(text, &blocks), vec![(CommentBlockType::DASHQL_TAG, "param"), (CommentBlockType::DASHQL_TAG, "vis")]);
     607            1 :         assert_eq!(line_texts(text, &blocks, 0), vec!["Revenue.", "per region"]);
     608            1 :         assert_eq!(line_texts(text, &blocks, 1), vec!["table"]);
     609            1 :         assert!(blocks.warnings.is_empty());
     610              :         // An end line still closes the region, comments after it are ordinary
     611            1 :         let text = "-- @param Revenue.\n-- @end\n-- note";
     612            1 :         let blocks = group(text);
     613            1 :         assert_eq!(tags(text, &blocks), vec![(CommentBlockType::DASHQL_TAG, "param"), (CommentBlockType::COMMENT, "")]);
     614            1 :         assert_eq!(block_texts(text, &blocks), vec!["-- @param Revenue.\n-- @end", "-- note"]);
     615            1 :         assert!(blocks.warnings.is_empty());
     616            1 :     }
     617              : 
     618              :     #[test]
     619            1 :     fn ends_one_line_tags_at_their_line() {
     620              :         // The lines after a value, args or `@vis table` are ordinary text
     621            1 :         let text = "/* @param :min = 10\n   Monthly revenue.\n   @board width=6\n   per region\n   @vis table\n   note */";
     622            1 :         let blocks = group(text);
     623            1 :         assert_eq!(
     624            1 :             tags(text, &blocks),
     625            1 :             vec![
     626            1 :                 (CommentBlockType::DASHQL_TAG, "param"),
     627            1 :                 (CommentBlockType::COMMENT, ""),
     628            1 :                 (CommentBlockType::DASHQL_TAG, "board"),
     629            1 :                 (CommentBlockType::COMMENT, ""),
     630            1 :                 (CommentBlockType::DASHQL_TAG, "vis"),
     631            1 :                 (CommentBlockType::COMMENT, ""),
     632              :             ]
     633              :         );
     634            1 :         assert_eq!(line_texts(text, &blocks, 0), vec![":min = 10"]);
     635            1 :         assert_eq!(line_texts(text, &blocks, 1), vec!["Monthly revenue."]);
     636            1 :         assert_eq!(line_texts(text, &blocks, 5), vec!["note"]);
     637            1 :         assert_eq!(block_texts(text, &blocks).concat(), text);
     638            1 :         let text = "-- @param :min = 10\n-- Monthly revenue\n-- @vis table\n-- @end\n-- @flow x=1 y=2\nselect 1";
     639            1 :         let blocks = group(text);
     640            1 :         assert_eq!(
     641            1 :             tags(text, &blocks),
     642            1 :             vec![
     643            1 :                 (CommentBlockType::DASHQL_TAG, "param"),
     644            1 :                 (CommentBlockType::COMMENT, ""),
     645            1 :                 (CommentBlockType::DASHQL_TAG, "vis"),
     646            1 :                 (CommentBlockType::DASHQL_TAG, "flow"),
     647              :             ]
     648              :         );
     649            1 :         assert_eq!(block_texts(text, &blocks), vec!["-- @param :min = 10", "-- Monthly revenue", "-- @vis table\n-- @end", "-- @flow x=1 y=2"]);
     650            1 :         assert!(blocks.warnings.is_empty());
     651              :         // Multi-line tags and tags without content on their line run to the next tag
     652            4 :         for (text, last) in [
     653            1 :             ("/* @param Revenue.\n   per region */", "per region"),
     654            1 :             ("/* @vis markdown\n   ### {{a}} */", "### {{a}}"),
     655            1 :             ("/* @manual\n   expensive */", "expensive"),
     656            1 :             ("/* @board\n   width=6 */", "width=6"),
     657            1 :         ] {
     658            4 :             let blocks = group(text);
     659            4 :             assert_eq!(blocks.blocks.len(), 1, "{text}");
     660            4 :             assert_eq!(line_texts(text, &blocks, 0).last().map(String::as_str), Some(last), "{text}");
     661              :         }
     662            1 :     }
     663              : }
        

Generated by: LCOV version 2.0-1