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

            Line data    Source code
       1              : //! The annotation of a statement: the comment tags in front of it (`@vis`, `@board`, `@flow`, `@param`),
       2              : //! read as one list and written back as one comment.
       3              : //!
       4              : //! An annotation is the description of a statement (the preamble) and the tags in their order. The preamble is the
       5              : //! text of the ordinary comments in front of the statement and the text before the first tag of a block comment. A
       6              : //! tag has a name and content lines, the first line is the text on the line of the tag and may be empty. Reading
       7              : //! collects the comments in front of a statement, wherever the tags are and in whichever form. Writing prints the
       8              : //! annotation as one comment in place of the first of them and removes the others, so every edit leaves the
       9              : //! annotation consolidated with the description on top. The content of the tags is not interpreted.
      10              : //!
      11              : //! Annotations are printed Javadoc-style, the formatter prints tagged comments the same way: a single
      12              : //! line stays on the line of `/**` (`/** @vis table */`), otherwise `/**` and `*/` are on lines of their
      13              : //! own and every line has a ` * ` gutter, which keeps lines that start with `*`. An empty line separates the
      14              : //! description from the tags. `/*` would nest and `*/`
      15              : //! would end the comment, an annotation with them cannot be printed.
      16              : //!
      17              : //! The spans of the parsed script are applied to the given text, which may be newer than the parse
      18              : //! (see docs/design/core_quirks.md).
      19              : 
      20              : use crate::error::{Error, Result};
      21              : use dashql_buffers::parser::CommentBlockType;
      22              : use dashql_buffers::status::StatusCode;
      23              : use dashql_parser::comment_blocks::{is_comment_tag, CommentBlock, CommentBlocks, COMMENT_TAG_END, COMMENT_TAG_PREFIX};
      24              : use dashql_parser::parser::ParsedScript;
      25              : use dashql_parser::span::TextSpan;
      26              : use serde::{Deserialize, Serialize};
      27              : 
      28              : /// The gutter of the lines of a printed annotation
      29              : pub(crate) const ANNOTATION_GUTTER: &str = " * ";
      30              : 
      31              : /// A tag of an annotation, as JSON `{"name": ..., "lines": [...]}`
      32              : #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
      33              : pub struct AnnotationTag {
      34              :     /// The tag name without `@`, e.g. `vis`
      35              :     pub name: String,
      36              :     /// The content lines, the first one is the text on the line of the tag and may be empty
      37              :     #[serde(default)]
      38              :     pub lines: Vec<String>,
      39              : }
      40              : 
      41              : impl AnnotationTag {
      42              :     /// A tag from its name and content lines
      43          444 :     pub fn new(name: impl Into<String>, lines: Vec<String>) -> Self {
      44          444 :         Self { name: name.into(), lines }
      45          444 :     }
      46              : 
      47              :     /// A tag with a text that starts on the line after the tag
      48            3 :     pub fn with_text(name: impl Into<String>, text: &str) -> Self {
      49            3 :         Self::new(name, std::iter::once(String::new()).chain(text.lines().map(str::to_string)).collect())
      50            3 :     }
      51              : 
      52              :     /// The first word of the content, the kind of a `@vis` tag
      53          218 :     pub fn kind(&self) -> &str {
      54          218 :         self.lines.first().and_then(|l| l.split_whitespace().next()).unwrap_or("")
      55          218 :     }
      56              : 
      57              :     /// The content as text: the lines joined with line breaks, without an empty first line
      58           23 :     pub fn text(&self) -> String {
      59           23 :         let lines = match self.lines.split_first() {
      60           23 :             Some((first, rest)) if first.is_empty() => rest,
      61           22 :             _ => &self.lines[..],
      62              :         };
      63           23 :         lines.join("\n")
      64           23 :     }
      65              : 
      66              :     /// The printed lines: the tag with the text on its line, then the other content lines
      67          312 :     fn printed_lines(&self) -> impl Iterator<Item = String> + '_ {
      68          312 :         let mut first = format!("{COMMENT_TAG_PREFIX}{}", self.name);
      69          312 :         if let Some(text) = self.lines.first().filter(|l| !l.is_empty()) {
      70          272 :             first.push(' ');
      71          272 :             first.push_str(text);
      72          272 :         }
      73          312 :         std::iter::once(first).chain(self.lines.iter().skip(1).cloned())
      74          312 :     }
      75              : 
      76              :     /// The number of printed lines
      77          119 :     fn printed_len(&self) -> usize {
      78          119 :         self.lines.len().max(1)
      79          119 :     }
      80              : }
      81              : 
      82              : /// The annotation of a statement, as JSON `{"preamble": [...], "tags": [...]}`
      83              : #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
      84              : #[serde(default)]
      85              : pub struct Annotation {
      86              :     /// The description: the text of the ordinary comments and the text before the first tag of a block comment
      87              :     pub preamble: Vec<String>,
      88              :     /// The tags in their order
      89              :     pub tags: Vec<AnnotationTag>,
      90              : }
      91              : 
      92              : /// Does a line start with a known tag? It would start a new tag when read back.
      93          305 : fn starts_with_tag(line: &str) -> bool {
      94          305 :     let Some(rest) = line.trim_start().strip_prefix(COMMENT_TAG_PREFIX) else { return false };
      95           32 :     let name = rest.split(|c: char| c.is_whitespace()).next().unwrap_or("");
      96            8 :     is_comment_tag(name.as_bytes())
      97          305 : }
      98              : 
      99              : impl Annotation {
     100              :     /// Read an annotation from comment blocks: ordinary blocks are the preamble, tagged blocks the tags.
     101              :     /// `text` is the script text.
     102          278 :     pub(crate) fn from_blocks<'a>(text: &[u8], blocks: &CommentBlocks, range: impl Iterator<Item = &'a CommentBlock>) -> Self {
     103          848 :         let span_text = |span: TextSpan| String::from_utf8_lossy(&text[span.offset as usize..span.end() as usize]).into_owned();
     104          278 :         let mut annotation = Annotation::default();
     105          376 :         for block in range {
     106          376 :             let spans = &blocks.lines[block.lines_begin as usize..(block.lines_begin + block.lines_count) as usize];
     107          515 :             let mut lines: Vec<String> = spans.iter().map(|&l| span_text(l)).collect();
     108          376 :             if block.block_type != CommentBlockType::DASHQL_TAG {
     109           43 :                 annotation.preamble.append(&mut lines);
     110           43 :                 continue;
     111          333 :             }
     112              :             // The parser drops the empty text of a tag that is alone on its line
     113          333 :             let tag_end = block.tag_span.end() as usize;
     114          333 :             let inline = spans.first().is_some_and(|first| !text[tag_end..first.offset as usize].contains(&b'\n'));
     115          333 :             if !inline {
     116           44 :                 lines.insert(0, String::new());
     117          289 :             }
     118          333 :             annotation.tags.push(AnnotationTag::new(span_text(block.tag_span), lines));
     119              :         }
     120          278 :         annotation
     121          278 :     }
     122              : 
     123              :     /// Does the annotation have neither tags nor text?
     124          109 :     pub fn is_empty(&self) -> bool {
     125          109 :         self.preamble.is_empty() && self.tags.is_empty()
     126          109 :     }
     127              : 
     128              :     /// The empty line between the description and the tags, if there are both
     129          284 :     fn separator(&self) -> Option<String> {
     130          284 :         (!self.preamble.is_empty() && !self.tags.is_empty()).then(String::new)
     131          284 :     }
     132              : 
     133              :     /// The lines of the comment without its markers
     134          202 :     pub fn lines(&self) -> Vec<String> {
     135          202 :         let tags = self.tags.iter().flat_map(AnnotationTag::printed_lines);
     136          202 :         self.preamble.iter().cloned().chain(self.separator()).chain(tags).collect()
     137          202 :     }
     138              : 
     139              :     /// The index of the first printed line of a tag
     140           82 :     fn first_line_of(&self, tag: usize) -> usize {
     141           82 :         let separator = usize::from(self.separator().is_some());
     142           82 :         self.preamble.len() + separator + self.tags[..tag].iter().map(AnnotationTag::printed_len).sum::<usize>()
     143           82 :     }
     144              : 
     145              :     /// Check that the annotation reads back unchanged
     146          225 :     fn check(&self) -> Result<()> {
     147          338 :         for tag in &self.tags {
     148          338 :             if !is_comment_tag(tag.name.as_bytes()) || tag.name == COMMENT_TAG_END {
     149            2 :                 return Err(Error::invalid_argument(format!("unknown comment tag \"{}\"", tag.name)));
     150          336 :             }
     151              :         }
     152          334 :         let content = self.preamble.iter().chain(self.tags.iter().flat_map(|t| t.lines.iter()));
     153          652 :         for line in content {
     154          652 :             if line.contains(['\n', '\r']) {
     155            1 :                 return Err(Error::invalid_argument("a line of a comment tag cannot contain a line break"));
     156          651 :             }
     157          651 :             if line.contains("/*") || line.contains("*/") {
     158           13 :                 return Err(Error::invalid_argument("a comment tag cannot contain /* or */"));
     159          638 :             }
     160              :         }
     161          319 :         let continued = self.preamble.iter().chain(self.tags.iter().flat_map(|t| t.lines.iter().skip(1)));
     162          325 :         if let Some(line) = continued.into_iter().find(|l| starts_with_tag(l)) {
     163            7 :             return Err(Error::invalid_argument(format!("the line \"{line}\" would start a comment tag")));
     164          202 :         }
     165          202 :         Ok(())
     166          225 :     }
     167              : 
     168              :     /// Print the annotation as one comment, the lines after the first take `indent`. Returns the comment and
     169              :     /// the spans of its lines without the comment markers.
     170          225 :     pub fn print(&self, indent: &str) -> Result<(String, Vec<(usize, usize)>)> {
     171          225 :         self.check()?;
     172          202 :         let lines = self.lines();
     173          202 :         let mut out = String::new();
     174          202 :         let mut spans = Vec::with_capacity(lines.len());
     175          202 :         if let [line] = &lines[..] {
     176           43 :             out.push_str("/** ");
     177           43 :             spans.push((out.len(), out.len() + line.len()));
     178           43 :             out.push_str(line);
     179           43 :             out.push_str(" */");
     180           43 :             return Ok((out, spans));
     181          159 :         }
     182          159 :         out.push_str("/**");
     183          593 :         for line in &lines {
     184          593 :             out.push('\n');
     185          593 :             out.push_str(indent);
     186          593 :             out.push_str(if line.is_empty() { ANNOTATION_GUTTER.trim_end() } else { ANNOTATION_GUTTER });
     187          593 :             spans.push((out.len(), out.len() + line.len()));
     188          593 :             out.push_str(line);
     189              :         }
     190          159 :         out.push('\n');
     191          159 :         out.push_str(indent);
     192          159 :         out.push_str(" */");
     193          159 :         Ok((out, spans))
     194          225 :     }
     195              : }
     196              : 
     197              : /// Check that a statement of a parsed script can be annotated
     198          161 : fn check_statement(parsed: &ParsedScript, statement_id: usize) -> Result<()> {
     199          161 :     if !parsed.errors.is_empty() {
     200            0 :         return Err(Error::status(StatusCode::SCRIPT_NOT_PARSED));
     201          161 :     }
     202          161 :     if statement_id >= parsed.statements.len() {
     203            4 :         return Err(Error::invalid_argument("unknown statement"));
     204          157 :     }
     205          157 :     Ok(())
     206          161 : }
     207              : 
     208              : /// Read the annotation of a statement and the comments that hold it, ascending indexes into the comments
     209              : /// of the script. Every comment in front of the statement belongs to it, the ordinary ones are its description.
     210          157 : fn read_with_comments(parsed: &ParsedScript, statement_id: usize) -> (Annotation, Vec<usize>) {
     211          157 :     let blocks = &parsed.comment_blocks;
     212          157 :     let description = parsed.associate_descriptions()[statement_id];
     213          157 :     let begin = description.description_blocks_begin as usize;
     214          157 :     let range = &blocks.blocks[begin..begin + description.description_blocks_count as usize];
     215          159 :     let comments = (description.description_begin..description.description_begin + description.description_count).map(|c| c as usize).collect();
     216          157 :     (Annotation::from_blocks(parsed.scanned.text.as_bytes(), blocks, range.iter()), comments)
     217          157 : }
     218              : 
     219              : /// Read the annotation of a statement
     220           50 : pub fn read_annotation(parsed: &ParsedScript, statement_id: usize) -> Result<Annotation> {
     221           50 :     check_statement(parsed, statement_id)?;
     222           47 :     Ok(read_with_comments(parsed, statement_id).0)
     223           50 : }
     224              : 
     225              : /// Whitespace within a line
     226           56 : fn is_horizontal_space(c: u8) -> bool {
     227           56 :     matches!(c, b' ' | b'\t' | b'\r')
     228           56 : }
     229              : 
     230              : /// The beginning of the whitespace before an offset on its line
     231          213 : fn skip_space_before(text: &[u8], mut offset: usize) -> usize {
     232          223 :     while offset > 0 && is_horizontal_space(text[offset - 1]) {
     233           10 :         offset -= 1;
     234           10 :     }
     235          213 :     offset
     236          213 : }
     237              : 
     238              : /// The end of the whitespace after an offset on its line
     239           11 : fn skip_space_after(text: &[u8], mut offset: usize) -> usize {
     240           12 :     while offset < text.len() && is_horizontal_space(text[offset]) {
     241            1 :         offset += 1;
     242            1 :     }
     243           11 :     offset
     244           11 : }
     245              : 
     246              : /// Does an offset start its line, after whitespace?
     247          107 : fn starts_line(text: &[u8], offset: usize) -> bool {
     248          107 :     let before = skip_space_before(text, offset);
     249          107 :     before == 0 || text[before - 1] == b'\n'
     250          107 : }
     251              : 
     252              : /// The text range that removes comments: their lines if nothing else is on them, otherwise the comments
     253              : /// and the blanks that separate them
     254           11 : fn removal(text: &[u8], comments: TextSpan) -> (usize, usize) {
     255           11 :     let (begin, end) = (comments.offset as usize, comments.end() as usize);
     256           11 :     let after = skip_space_after(text, end);
     257           11 :     let ends_line = after == text.len() || text[after] == b'\n';
     258           11 :     match (starts_line(text, begin), ends_line) {
     259           10 :         (true, true) => (skip_space_before(text, begin), (after + 1).min(text.len())),
     260            1 :         (true, false) => (begin, after),
     261            0 :         (false, _) => (skip_space_before(text, begin), end),
     262              :     }
     263           11 : }
     264              : 
     265              : /// Replace text ranges, from back to front. The ranges are ascending and do not overlap.
     266          105 : fn splice(text: &str, edits: &[(usize, usize, String)]) -> Result<String> {
     267          210 :     let valid = |offset: usize| offset <= text.len() && text.is_char_boundary(offset);
     268          105 :     if !edits.iter().all(|&(begin, end, _)| begin <= end && valid(begin) && valid(end)) {
     269            0 :         return Err(Error::invalid_argument("the parsed script is outdated"));
     270          105 :     }
     271          105 :     let mut out = text.to_string();
     272          105 :     for (begin, end, insert) in edits.iter().rev() {
     273          105 :         out.replace_range(*begin..*end, insert);
     274          105 :     }
     275          105 :     Ok(out)
     276          105 : }
     277              : 
     278              : /// Rewrite the annotation of a statement. `edit` changes the annotation and returns the index of the tag
     279              : /// it wrote, if any. The annotation is printed as one comment in place of its first comments, the other
     280              : /// comments in front of the statement are removed. An edit that neither writes a tag nor changes the annotation leaves
     281              : /// the text alone. Returns the new text and the span of the written tag in it, from `@` to the end of its
     282              : /// last line.
     283          111 : pub(crate) fn rewrite(
     284          111 :     text: &str,
     285          111 :     parsed: &ParsedScript,
     286          111 :     statement_id: usize,
     287          111 :     edit: impl FnOnce(&mut Annotation) -> Result<Option<usize>>,
     288          111 : ) -> Result<(String, Option<TextSpan>)> {
     289          111 :     check_statement(parsed, statement_id)?;
     290          110 :     let (before, comments) = read_with_comments(parsed, statement_id);
     291          110 :     let mut annotation = before.clone();
     292          110 :     let written = edit(&mut annotation)?;
     293          110 :     if written.is_none() && annotation == before {
     294            2 :         return Ok((text.to_string(), None));
     295          108 :     }
     296          108 :     let outdated = || Error::invalid_argument("the parsed script is outdated");
     297          108 :     let bytes = text.as_bytes();
     298              :     // The comments of the annotation, comments that are separated by whitespace only form a run. Removing a run
     299              :     // removes the blank lines between its comments too, they would be left behind.
     300          108 :     let mut runs: Vec<TextSpan> = Vec::new();
     301          126 :     for &id in &comments {
     302          122 :         let comment = parsed.scanned.comments[id];
     303          122 :         if comment.end() as usize > bytes.len() {
     304            1 :             return Err(outdated());
     305          121 :         }
     306          121 :         match runs.last_mut() {
     307           32 :             Some(run) if bytes[run.end() as usize..comment.offset as usize].iter().all(u8::is_ascii_whitespace) => {
     308           32 :                 *run = TextSpan::new(run.offset, comment.end() - run.offset);
     309           32 :             }
     310           89 :             _ => runs.push(comment),
     311              :         }
     312              :     }
     313          107 :     let mut edits = Vec::with_capacity(runs.len().max(1));
     314          107 :     let mut span = None;
     315          107 :     let mut removed = &runs[..];
     316          107 :     if !annotation.is_empty() {
     317              :         // The comment replaces the first run, or goes on a line of its own before the statement
     318           96 :         let (mut begin, end) = match runs.split_first() {
     319           78 :             Some((first, rest)) => {
     320           78 :                 removed = rest;
     321           78 :                 (first.offset as usize, first.end() as usize)
     322              :             }
     323              :             None => {
     324           18 :                 let at = parsed.associate_descriptions()[statement_id].statement_span.offset as usize;
     325           18 :                 (at, at)
     326              :             }
     327              :         };
     328           96 :         if end > bytes.len() || !text.is_char_boundary(begin) {
     329            0 :             return Err(outdated());
     330           96 :         }
     331           96 :         let line_start = starts_line(bytes, begin);
     332           96 :         let indent = if line_start { &text[skip_space_before(bytes, begin)..begin] } else { "" };
     333           96 :         let (comment, line_spans) = annotation.print(indent)?;
     334           94 :         let mut insert = String::new();
     335           94 :         if runs.is_empty() && !line_start {
     336            1 :             begin = skip_space_before(bytes, begin);
     337            1 :             insert.push('\n');
     338           93 :         }
     339           94 :         if let Some(i) = written {
     340           82 :             let first_line = annotation.first_line_of(i);
     341           82 :             let last_line = first_line + annotation.tags[i].printed_len() - 1;
     342           82 :             let offset = begin + insert.len() + line_spans[first_line].0;
     343           82 :             span = Some(TextSpan::new(offset as u32, (line_spans[last_line].1 - line_spans[first_line].0) as u32));
     344           82 :         }
     345           94 :         insert.push_str(&comment);
     346           94 :         if runs.is_empty() {
     347           18 :             insert.push('\n');
     348           18 :             insert.push_str(indent);
     349           76 :         }
     350           94 :         edits.push((begin, end, insert));
     351           11 :     }
     352          105 :     for &run in removed {
     353           11 :         let (begin, end) = removal(bytes, run);
     354           11 :         edits.push((begin, end, String::new()));
     355           11 :     }
     356          105 :     Ok((splice(text, &edits)?, span))
     357          111 : }
     358              : 
     359              : /// Replace the annotation of a statement, an empty annotation removes it. Returns the new text.
     360            7 : pub fn write_annotation(text: &str, parsed: &ParsedScript, statement_id: usize, annotation: &Annotation) -> Result<String> {
     361            7 :     Ok(rewrite(text, parsed, statement_id, |current| {
     362            7 :         *current = annotation.clone();
     363            7 :         Ok(None)
     364            7 :     })?
     365              :     .0)
     366            7 : }
     367              : 
     368              : /// Set or remove a tag that a script holds once, in the annotation of any statement. The first tag that `is_tag`
     369              : /// matches in statement order is replaced where it is, the others are removed. Without one, a new tag goes into the
     370              : /// annotation of `target`, the last statement if None, at the index that `insert_at` picks among its tags. Removing the
     371              : /// tag removes every match.
     372           26 : pub(crate) fn rewrite_script_tag(
     373           26 :     text: &str,
     374           26 :     parsed: &ParsedScript,
     375           26 :     is_tag: impl Fn(&AnnotationTag) -> bool,
     376           26 :     tag: Option<AnnotationTag>,
     377           26 :     target: Option<usize>,
     378           26 :     insert_at: impl Fn(&[AnnotationTag]) -> usize,
     379           26 : ) -> Result<String> {
     380           26 :     if !parsed.errors.is_empty() {
     381            0 :         return Err(Error::status(StatusCode::SCRIPT_NOT_PARSED));
     382           26 :     }
     383           26 :     let mut statements = Vec::new();
     384           40 :     for statement_id in 0..parsed.statements.len() {
     385           40 :         if read_annotation(parsed, statement_id)?.tags.iter().any(&is_tag) {
     386           13 :             statements.push(statement_id);
     387           27 :         }
     388              :     }
     389           26 :     let last = parsed.statements.len().checked_sub(1);
     390           26 :     let target = match (statements.first(), &tag) {
     391           10 :         (Some(&first), _) => first,
     392            3 :         (None, None) => return Ok(text.to_string()),
     393           13 :         (None, Some(_)) => target.filter(|&s| s < parsed.statements.len()).or(last).ok_or_else(|| Error::invalid_argument("the script has no statement"))?,
     394              :     };
     395           22 :     if !statements.contains(&target) {
     396           12 :         statements.push(target);
     397           13 :     }
     398              :     // Back to front, an edit leaves the spans of the statements before it alone
     399           22 :     statements.sort_unstable();
     400           22 :     let mut text = text.to_string();
     401           25 :     for &statement_id in statements.iter().rev() {
     402           25 :         let write = if statement_id == target { tag.clone() } else { None };
     403           25 :         text = rewrite(&text, parsed, statement_id, |annotation: &mut Annotation| {
     404           25 :             let tags = &mut annotation.tags;
     405           25 :             let first = tags.iter().position(&is_tag);
     406           25 :             tags.retain(|t| !is_tag(t));
     407           25 :             Ok(write.map(|tag| {
     408           19 :                 let i = first.unwrap_or_else(|| insert_at(tags)).min(tags.len());
     409           19 :                 tags.insert(i, tag);
     410           19 :                 i
     411           19 :             }))
     412           25 :         })?
     413              :         .0;
     414              :     }
     415           22 :     Ok(text)
     416           26 : }
     417              : 
     418              : #[cfg(test)]
     419              : mod tests {
     420              :     use super::*;
     421              :     use dashql_parser::parser::parse;
     422              :     use dashql_parser::scanner::scan;
     423              :     use std::sync::Arc;
     424              : 
     425            5 :     fn parsed(text: &str) -> ParsedScript {
     426            5 :         parse(Arc::new(scan(text, 0, 0)))
     427            5 :     }
     428              : 
     429           13 :     fn lines(lines: &[&str]) -> Vec<String> {
     430           20 :         lines.iter().map(|l| l.to_string()).collect()
     431           13 :     }
     432              : 
     433              :     #[test]
     434            1 :     fn reads_the_tags_of_every_comment() {
     435            1 :         let text = "-- @param\n-- Sales.\n-- per region\n-- @end\n-- note\n/* Revenue.\n   @vis table */\nselect 1";
     436            1 :         let annotation = read_annotation(&parsed(text), 0).unwrap();
     437              :         // Ordinary comments are the description, wherever they are
     438            1 :         assert_eq!(annotation.preamble, lines(&["note", "Revenue."]));
     439            1 :         assert_eq!(
     440              :             annotation.tags,
     441            1 :             vec![AnnotationTag::new("param", lines(&["", "Sales.", "per region"])), AnnotationTag::new("vis", lines(&["table"]))]
     442              :         );
     443            1 :         assert_eq!(annotation.tags[0].text(), "Sales.\nper region");
     444            1 :         assert_eq!(annotation.tags[1].kind(), "table");
     445            1 :         assert!(read_annotation(&parsed("select 1"), 0).unwrap().is_empty());
     446            1 :         assert!(read_annotation(&parsed("select 1"), 1).is_err());
     447            1 :     }
     448              : 
     449              :     #[test]
     450            1 :     fn prints_javadoc_style() {
     451            1 :         let mut annotation = Annotation { preamble: vec![], tags: vec![AnnotationTag::new("vis", lines(&["table"]))] };
     452            1 :         assert_eq!(annotation.print("").unwrap().0, "/** @vis table */");
     453            1 :         annotation.tags.insert(0, AnnotationTag::with_text("param", "Sales.\n\n* per region"));
     454            1 :         assert_eq!(annotation.print("  ").unwrap().0, "/**\n   * @param\n   * Sales.\n   *\n   * * per region\n   * @vis table\n   */");
     455            1 :     }
     456              : 
     457              :     #[test]
     458            1 :     fn rejects_annotations_that_do_not_read_back() {
     459            6 :         let tag = |name: &str, content: &[&str]| Annotation { preamble: vec![], tags: vec![AnnotationTag::new(name, lines(content))] };
     460            1 :         assert!(tag("todo", &["x"]).print("").is_err());
     461            1 :         assert!(tag("end", &[""]).print("").is_err());
     462            1 :         assert!(tag("param", &["", "a */ b"]).print("").is_err());
     463            1 :         assert!(tag("param", &["", "a\nb"]).print("").is_err());
     464            1 :         assert!(tag("param", &["", "@vis table"]).print("").is_err());
     465              :         // A tag on the line of the tag is text, `@word` that is no tag is text
     466            1 :         assert!(tag("param", &["@vis table", "@todo"]).print("").is_ok());
     467            1 :     }
     468              : 
     469              :     #[test]
     470            1 :     fn writes_the_annotation_as_one_comment() {
     471            1 :         let text = "-- @param Sales.\n\n/* @vis table */\nselect 1";
     472            1 :         let script = parsed(text);
     473            1 :         let mut annotation = read_annotation(&script, 0).unwrap();
     474            1 :         annotation.tags[0] = AnnotationTag::with_text("param", "Revenue.");
     475            1 :         assert_eq!(write_annotation(text, &script, 0, &annotation).unwrap(), "/**\n * @param\n * Revenue.\n * @vis table\n */\nselect 1");
     476              :         // An unchanged annotation leaves the text alone, an empty one removes the comments
     477            1 :         assert_eq!(write_annotation(text, &script, 0, &read_annotation(&script, 0).unwrap()).unwrap(), text);
     478            1 :         assert_eq!(write_annotation(text, &script, 0, &Annotation::default()).unwrap(), "select 1");
     479              :         // Statements of every kind have annotations
     480            1 :         let text = "create table t (a int)";
     481            1 :         let annotation = Annotation { preamble: vec![], tags: vec![AnnotationTag::new("param", lines(&["Tables."]))] };
     482            1 :         assert_eq!(write_annotation(text, &parsed(text), 0, &annotation).unwrap(), "/** @param Tables. */\ncreate table t (a int)");
     483            1 :     }
     484              : 
     485              :     #[test]
     486            1 :     fn converts_to_and_from_json() {
     487            1 :         let annotation = Annotation { preamble: lines(&["Note."]), tags: vec![AnnotationTag::new("vis", lines(&["table"]))] };
     488            1 :         let json = serde_json::to_string(&annotation).unwrap();
     489            1 :         assert_eq!(json, r#"{"preamble":["Note."],"tags":[{"name":"vis","lines":["table"]}]}"#);
     490            1 :         assert_eq!(serde_json::from_str::<Annotation>(&json).unwrap(), annotation);
     491            1 :         assert_eq!(serde_json::from_str::<Annotation>("{}").unwrap(), Annotation::default());
     492            1 :         assert!(serde_json::from_str::<Annotation>("\"x\"").is_err());
     493            1 :         assert!(serde_json::from_str::<Annotation>(r#"{"tags": [{"lines": []}]}"#).is_err());
     494            1 :         assert!(serde_json::from_str::<Annotation>(r#"{"tags": [{"name": "vis", "lines": [1]}]}"#).is_err());
     495            1 :     }
     496              : }
        

Generated by: LCOV version 2.0-1