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 : }
|