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