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

            Line data    Source code
       1              : //! Completion inside comments: the names of comment tags and the content of `@vis`, `@board` and `@flow` tags.
       2              : //!
       3              : //! - `@` at the start of a comment line completes the tag names.
       4              : //! - `@vis ` completes the kinds, `@board ` and `@flow ` their args, like the `@vis board ` and `@vis flow ` of
       5              : //!   older scripts.
       6              : //! - In the JSON of `@vis vegalite`, a tolerant scanner finds the key path at the cursor and whether a
       7              : //!   key or a value follows, also in JSON that is not complete yet. Keys come from a table per path,
       8              : //!   values from the Vega-Lite enums and, for `field`, from the output columns of the query.
       9              : //! - In the template of `@vis markdown`, `{{` completes the output columns.
      10              : //!
      11              : //! Candidates replace the token at the cursor, JSON keys and values are inserted as JSON strings.
      12              : 
      13              : use crate::analyzer::statement_output_columns;
      14              : use crate::completion::{Candidate, CandidateTags, Completion, Score};
      15              : use crate::vis_text::{JsonScanner, JsonSlot, JsonTokenKind, PathItem, TagText};
      16              : use dashql_buffers::completion::CandidateTag;
      17              : use dashql_buffers::parser::{CommentBlockType, SymbolSpan};
      18              : use dashql_parser::comment_blocks::{CommentBlock, COMMENT_TAGS, COMMENT_TAG_END};
      19              : use dashql_parser::parser::ParsedScript;
      20              : use dashql_parser::span::TextSpan;
      21              : use std::sync::Arc;
      22              : 
      23              : /// The tag of visualizations
      24              : const VIS_TAG: &[u8] = b"vis";
      25              : /// The tag of the dashboard placement
      26              : const BOARD_TAG: &[u8] = b"board";
      27              : /// The tag of the position on the dataflow canvas
      28              : const FLOW_TAG: &[u8] = b"flow";
      29              : /// The kinds of `@vis` tags
      30              : const VIS_KINDS: &[&str] = &["vegalite", "markdown", "table"];
      31              : /// The args of `@board`
      32              : const BOARD_ARGS: &[&str] = &["row", "column", "width", "height"];
      33              : /// The args of `@flow`
      34              : const FLOW_ARGS: &[&str] = &["x", "y", "width", "height"];
      35              : 
      36              : /// The top-level properties of a Vega-Lite spec
      37              : const SPEC_KEYS: &[&str] = &[
      38              :     "mark", "encoding", "title", "description", "transform", "layer", "width", "height", "params", "resolve",
      39              :     "projection", "facet", "spec", "repeat", "hconcat", "vconcat", "concat", "autosize", "padding",
      40              :     "background", "view", "config", "name",
      41              : ];
      42              : /// The mark types
      43              : const MARK_TYPES: &[&str] = &[
      44              :     "bar", "line", "point", "area", "arc", "rect", "rule", "text", "tick", "circle", "square", "trail",
      45              :     "boxplot", "errorband", "errorbar", "geoshape", "image",
      46              : ];
      47              : /// The properties of a mark definition
      48              : const MARK_KEYS: &[&str] = &[
      49              :     "type", "color", "fill", "stroke", "opacity", "fillOpacity", "strokeOpacity", "strokeWidth", "strokeDash",
      50              :     "size", "shape", "angle", "point", "line", "filled", "tooltip", "interpolate", "tension", "orient",
      51              :     "thickness", "cornerRadius", "radius", "innerRadius", "outerRadius", "align", "baseline", "dx", "dy",
      52              :     "fontSize", "fontWeight", "text", "clip",
      53              : ];
      54              : /// The encoding channels
      55              : const CHANNELS: &[&str] = &[
      56              :     "x", "y", "color", "size", "shape", "opacity", "tooltip", "text", "theta", "radius", "x2", "y2", "xOffset",
      57              :     "yOffset", "fill", "stroke", "fillOpacity", "strokeOpacity", "strokeWidth", "strokeDash", "angle", "theta2",
      58              :     "radius2", "latitude", "longitude", "latitude2", "longitude2", "row", "column", "facet", "detail", "key",
      59              :     "order", "href", "url", "description",
      60              : ];
      61              : /// The properties of a field definition
      62              : const FIELD_DEF_KEYS: &[&str] = &[
      63              :     "field", "type", "aggregate", "timeUnit", "bin", "title", "sort", "stack", "scale", "axis", "legend",
      64              :     "format", "formatType", "value", "datum", "condition", "impute", "bandPosition",
      65              : ];
      66              : /// The field types
      67              : const FIELD_TYPES: &[&str] = &["quantitative", "temporal", "ordinal", "nominal", "geojson"];
      68              : /// The aggregate operations
      69              : const AGGREGATES: &[&str] = &[
      70              :     "count", "sum", "mean", "average", "median", "min", "max", "distinct", "valid", "missing", "values",
      71              :     "product", "variance", "variancep", "stdev", "stdevp", "stderr", "q1", "q3", "ci0", "ci1", "argmin",
      72              :     "argmax",
      73              : ];
      74              : /// The time units
      75              : const TIME_UNITS: &[&str] = &[
      76              :     "year", "quarter", "month", "week", "day", "date", "hours", "minutes", "seconds", "milliseconds",
      77              :     "dayofyear", "yearquarter", "yearmonth", "yearweek", "yearmonthdate", "yearmonthdatehours",
      78              :     "yearmonthdatehoursminutes", "yearmonthdatehoursminutesseconds", "monthdate", "hoursminutes",
      79              :     "hoursminutesseconds", "minutesseconds",
      80              : ];
      81              : /// The properties of a scale
      82              : const SCALE_KEYS: &[&str] = &[
      83              :     "type", "domain", "domainMin", "domainMax", "domainMid", "range", "rangeMin", "rangeMax", "scheme", "zero",
      84              :     "nice", "clamp", "reverse", "round", "padding", "paddingInner", "paddingOuter", "interpolate", "exponent",
      85              :     "base", "constant", "bins", "align",
      86              : ];
      87              : /// The scale types
      88              : const SCALE_TYPES: &[&str] = &[
      89              :     "linear", "log", "pow", "sqrt", "symlog", "time", "utc", "ordinal", "band", "point", "sequential",
      90              :     "quantile", "quantize", "threshold", "bin-ordinal", "identity",
      91              : ];
      92              : /// The properties of an axis
      93              : const AXIS_KEYS: &[&str] = &[
      94              :     "title", "orient", "format", "formatType", "grid", "ticks", "tickCount", "tickSize", "labels",
      95              :     "labelAngle", "labelFontSize", "labelOverlap", "labelLimit", "domain", "direction", "offset", "values",
      96              :     "titleFontSize", "zindex",
      97              : ];
      98              : /// The properties of a legend
      99              : const LEGEND_KEYS: &[&str] = &[
     100              :     "title", "orient", "format", "formatType", "direction", "columns", "symbolType", "labelLimit", "padding",
     101              :     "offset", "values", "type", "zindex",
     102              : ];
     103              : 
     104          100 : fn is_word_byte(c: u8) -> bool {
     105          100 :     c.is_ascii_alphanumeric() || c == b'_' || c == b'-'
     106          100 : }
     107              : 
     108              : /// The candidates of a completion in a comment
     109              : struct Candidates {
     110              :     /// The text range that a candidate replaces
     111              :     target: TextSpan,
     112              :     /// The typed prefix that filters the candidates
     113              :     prefix: String,
     114              :     /// The candidates with their scores, in the order they were added
     115              :     out: Vec<Candidate>,
     116              : }
     117              : 
     118              : impl Candidates {
     119           23 :     fn new(target: TextSpan, prefix: &[u8]) -> Self {
     120           23 :         Self {
     121           23 :             target,
     122           23 :             prefix: String::from_utf8_lossy(prefix).to_ascii_lowercase(),
     123           23 :             out: Vec::new(),
     124           23 :         }
     125           23 :     }
     126              : 
     127              :     /// Add a candidate if it matches the prefix, `insert` is the text that replaces the target
     128          325 :     fn add(&mut self, name: &str, insert: String) {
     129          325 :         let lower = name.to_ascii_lowercase();
     130          325 :         let tags: CandidateTags = if self.prefix.is_empty() || lower.starts_with(&self.prefix) {
     131           66 :             CandidateTag::PREFIX_MATCH.0
     132          259 :         } else if lower.contains(&self.prefix) {
     133           23 :             CandidateTag::SUBSTRING_MATCH.0
     134              :         } else {
     135          236 :             return;
     136              :         };
     137          704 :         if self.out.iter().any(|c| &*c.completion_text == insert.as_str()) {
     138            0 :             return;
     139           89 :         }
     140           89 :         let location = SymbolSpan::new(self.target.offset, self.target.length);
     141           89 :         let mut candidate = Candidate::new(Arc::from(insert), 0, tags, location, location);
     142           89 :         candidate.completion_text_is_verbatim = true;
     143           89 :         self.out.push(candidate);
     144          325 :     }
     145              : 
     146           16 :     fn add_all(&mut self, names: &[&str], quote: bool) {
     147          302 :         for name in names {
     148          302 :             self.add(name, if quote { json_string(name) } else { name.to_string() });
     149              :         }
     150           16 :     }
     151              : 
     152              :     /// The top k candidates: prefix matches before substring matches, otherwise in the order they were added
     153           23 :     fn finish(mut self, k: usize) -> Vec<Candidate> {
     154           23 :         let count = self.out.len() as Score;
     155           89 :         for (i, candidate) in self.out.iter_mut().enumerate() {
     156           89 :             let prefix = candidate.candidate_tags & CandidateTag::PREFIX_MATCH.0 != 0;
     157           89 :             candidate.score = (count - i as Score) + if prefix { count } else { 0 };
     158              :         }
     159           74 :         self.out.sort_by(|l, r| r.score.cmp(&l.score));
     160           23 :         self.out.truncate(k);
     161           23 :         self.out
     162           23 :     }
     163              : }
     164              : 
     165          304 : fn json_string(text: &str) -> String {
     166          304 :     serde_json::to_string(text).expect("a string always serializes")
     167          304 : }
     168              : 
     169              : /// The comment that contains a text offset
     170           28 : fn comment_at(comments: &[TextSpan], offset: usize) -> Option<TextSpan> {
     171           28 :     let i = comments.partition_point(|c| c.offset as usize <= offset);
     172           28 :     let comment = *comments.get(i.checked_sub(1)?)?;
     173           28 :     (offset > comment.offset as usize && offset <= comment.end() as usize).then_some(comment)
     174           28 : }
     175              : 
     176              : /// Complete the name of a tag when the cursor is in `@name` at the start of a comment line
     177           28 : fn complete_tag_name(text: &[u8], comments: &[TextSpan], offset: usize, k: usize) -> Option<Vec<Candidate>> {
     178           28 :     let comment = comment_at(comments, offset)?;
     179           28 :     let comment_begin = comment.offset as usize;
     180         1057 :     let line_begin = text[..offset].iter().rposition(|&c| c == b'\n').map_or(0, |n| n + 1).max(comment_begin);
     181           28 :     let mut i = line_begin;
     182           28 :     let is_line_comment = text[comment_begin..].starts_with(b"--");
     183              :     // Skip the comment marker, or the gutter of a block comment line
     184           28 :     if line_begin == comment_begin {
     185           25 :         let marker = if is_line_comment { b'-' } else { b'*' };
     186           25 :         i += 2;
     187           25 :         while i < offset && text[i] == marker {
     188            0 :             i += 1;
     189            0 :         }
     190            3 :     }
     191           62 :     while i < offset && text[i].is_ascii_whitespace() {
     192           34 :         i += 1;
     193           34 :     }
     194           28 :     if line_begin != comment_begin && i < offset && text[i] == b'*' {
     195            0 :         i += 1;
     196            0 :         while i < offset && text[i].is_ascii_whitespace() {
     197            0 :             i += 1;
     198            0 :         }
     199           28 :     }
     200           83 :     if i >= offset || text[i] != b'@' || !text[i + 1..offset].iter().all(|&c| is_word_byte(c)) {
     201           26 :         return None;
     202            2 :     }
     203            2 :     let name_begin = i + 1;
     204            2 :     let mut name_end = offset;
     205            2 :     while name_end < comment.end() as usize && is_word_byte(text[name_end]) {
     206            0 :         name_end += 1;
     207            0 :     }
     208            2 :     let target = TextSpan::new(name_begin as u32, (name_end - name_begin) as u32);
     209            2 :     let mut candidates = Candidates::new(target, &text[name_begin..offset]);
     210           12 :     for &tag in COMMENT_TAGS {
     211              :         // The end tag is never needed, it is only accepted for older scripts
     212           12 :         if tag != COMMENT_TAG_END {
     213           10 :             candidates.add(tag, tag.to_string());
     214           10 :         }
     215              :     }
     216            2 :     Some(candidates.finish(k))
     217           28 : }
     218              : 
     219              : /// The JSON context at the end of a text
     220              : #[derive(Debug, PartialEq, Eq)]
     221              : struct JsonContext {
     222              :     path: Vec<PathItem>,
     223              :     slot: JsonSlot,
     224              :     /// The token at the cursor: its begin and whether it is a string
     225              :     token: Option<(usize, bool)>,
     226              : }
     227              : 
     228              : /// Scan JSON up to the cursor, which is the end of `json`. The JSON does not need to be complete.
     229           26 : fn scan_json(json: &[u8]) -> Option<JsonContext> {
     230           26 :     let mut scanner = JsonScanner::new(json);
     231          254 :     while let Some(token) = scanner.next() {
     232              :         // A scalar that ends at the cursor is still typed, unless it is a closed string
     233          248 :         let typed = matches!(
     234          248 :             token.kind,
     235              :             JsonTokenKind::String { closed: false } | JsonTokenKind::Number | JsonTokenKind::Literal | JsonTokenKind::Word
     236              :         );
     237          248 :         if typed && token.end == json.len() {
     238           20 :             let string = matches!(token.kind, JsonTokenKind::String { .. });
     239              :             return Some(JsonContext {
     240           20 :                 path: scanner.path(),
     241           20 :                 slot: token.slot?,
     242           20 :                 token: Some((token.begin, string)),
     243              :             });
     244          228 :         }
     245              :     }
     246              :     Some(JsonContext {
     247            6 :         path: scanner.path(),
     248            6 :         slot: scanner.slot()?,
     249            3 :         token: None,
     250              :     })
     251           26 : }
     252              : 
     253              : /// The candidates of a JSON context in a Vega-Lite spec
     254           17 : fn add_json_candidates(candidates: &mut Candidates, context: &JsonContext, columns: &[Arc<str>]) {
     255           21 :     let key = |item: &PathItem, name: &str| matches!(item, PathItem::Key(k) if k == name);
     256              :     // Nested specs are specs
     257           17 :     let mut path = &context.path[..];
     258              :     loop {
     259           14 :         match path {
     260            2 :             [PathItem::Key(k), PathItem::Index, rest @ ..] if matches!(k.as_str(), "layer" | "hconcat" | "vconcat" | "concat") => {
     261            1 :                 path = rest
     262              :             }
     263           14 :             [PathItem::Key(k), rest @ ..] if k == "spec" => path = rest,
     264           17 :             _ => break,
     265              :         }
     266              :     }
     267           17 :     let add_columns = |candidates: &mut Candidates| {
     268            5 :         for column in columns {
     269            5 :             candidates.add(column, json_string(column));
     270            5 :         }
     271            2 :     };
     272           17 :     match (&context.slot, path) {
     273            8 :         (JsonSlot::Value(k), _) if k == "field" => add_columns(candidates),
     274              :         // The path ends at the key of the array, the array itself is the innermost container
     275            1 :         (JsonSlot::Element, [.., g]) if key(g, "groupby") => add_columns(candidates),
     276            8 :         (JsonSlot::Key, []) => candidates.add_all(SPEC_KEYS, true),
     277            7 :         (JsonSlot::Value(k), []) if k == "mark" => candidates.add_all(MARK_TYPES, true),
     278            2 :         (JsonSlot::Key, [m]) if key(m, "mark") => candidates.add_all(MARK_KEYS, true),
     279            1 :         (JsonSlot::Value(k), [m]) if key(m, "mark") && k == "type" => candidates.add_all(MARK_TYPES, true),
     280            1 :         (JsonSlot::Key, [e]) if key(e, "encoding") => candidates.add_all(CHANNELS, true),
     281            9 :         (slot, [e, PathItem::Key(_), rest @ ..]) if key(e, "encoding") => {
     282              :             // A channel holds a field definition, or an array of them (e.g. tooltip)
     283            9 :             let rest = match rest {
     284            1 :                 [PathItem::Index, rest @ ..] => rest,
     285            8 :                 rest => rest,
     286              :             };
     287            9 :             match (slot, rest) {
     288            5 :                 (JsonSlot::Key, []) => candidates.add_all(FIELD_DEF_KEYS, true),
     289            4 :                 (JsonSlot::Value(k), []) => match k.as_str() {
     290            3 :                     "type" => candidates.add_all(FIELD_TYPES, true),
     291            2 :                     "aggregate" => candidates.add_all(AGGREGATES, true),
     292            1 :                     "timeUnit" => candidates.add_all(TIME_UNITS, true),
     293            0 :                     _ => {}
     294              :                 },
     295            3 :                 (JsonSlot::Key, [s]) if key(s, "scale") => candidates.add_all(SCALE_KEYS, true),
     296            1 :                 (JsonSlot::Value(k), [s]) if key(s, "scale") && k == "type" => candidates.add_all(SCALE_TYPES, true),
     297            2 :                 (JsonSlot::Key, [a]) if key(a, "axis") => candidates.add_all(AXIS_KEYS, true),
     298            1 :                 (JsonSlot::Key, [l]) if key(l, "legend") => candidates.add_all(LEGEND_KEYS, true),
     299            0 :                 _ => {}
     300              :             }
     301              :         }
     302            0 :         _ => {}
     303              :     }
     304           17 : }
     305              : 
     306              : /// The statement whose description holds a comment block
     307           18 : fn statement_of_block(parsed: &ParsedScript, block: usize) -> Option<usize> {
     308           18 :     parsed.associate_descriptions().iter().position(|d| {
     309           18 :         let begin = d.description_blocks_begin as usize;
     310           18 :         (begin..begin + d.description_blocks_count as usize).contains(&block)
     311           18 :     })
     312           18 : }
     313              : 
     314              : /// Complete the content of a `@vis`, `@board` or `@flow` tag
     315           26 : fn complete_vis_tag(completion: &Completion, parsed: &ParsedScript, offset: usize, k: usize) -> Option<Vec<Candidate>> {
     316           26 :     let script = parsed.scanned.text.as_bytes();
     317           26 :     let blocks = &parsed.comment_blocks;
     318           45 :     let name_of = |b: &CommentBlock| &script[b.tag_span.offset as usize..b.tag_span.end() as usize];
     319           27 :     let (block_id, block): (usize, &CommentBlock) = blocks.blocks.iter().enumerate().find(|(_, b)| {
     320           27 :         b.block_type == CommentBlockType::DASHQL_TAG
     321           22 :             && matches!(name_of(b), VIS_TAG | BOARD_TAG | FLOW_TAG)
     322           22 :             && b.tag_span.end() as usize <= offset
     323           22 :             && offset <= b.text_span.end() as usize
     324           27 :     })?;
     325           21 :     let name = name_of(block);
     326           21 :     let lines = &blocks.lines[block.lines_begin as usize..(block.lines_begin + block.lines_count) as usize];
     327           21 :     let columns = || {
     328           18 :         let statement = statement_of_block(parsed, block_id)?;
     329           18 :         Some(statement_output_columns(completion.cursor.script.analyzed?, statement))
     330           18 :     };
     331           21 :     let tag = TagText::new(&parsed.scanned.text, lines, block.text_span);
     332              :     // Without content, the cursor is right after the tag
     333           21 :     let at = if lines.is_empty() || offset < lines[0].offset as usize { 0 } else { tag.to_joined(offset) };
     334           21 :     let text = tag.text.as_bytes();
     335           21 :     let span = |begin: usize, end: usize| if tag.is_empty() { TextSpan::new(offset as u32, 0) } else { tag.span(begin, end) };
     336              :     // `@board` and `@flow` start with their args, `@vis` with its kind
     337           21 :     let (kind, kind_end): (&[u8], usize) = if name == VIS_TAG {
     338          164 :         let kind_end = text.iter().position(|c| c.is_ascii_whitespace()).unwrap_or(text.len());
     339           19 :         if at <= kind_end {
     340            1 :             let mut candidates = Candidates::new(span(0, kind_end), &text[..at]);
     341            1 :             candidates.add_all(VIS_KINDS, false);
     342            1 :             return Some(candidates.finish(k));
     343           18 :         }
     344           18 :         (&text[..kind_end], kind_end)
     345              :     } else {
     346            2 :         (name, 0)
     347              :     };
     348           20 :     match kind {
     349           20 :         kind @ (b"board" | b"card" | b"flow") => {
     350            2 :             let args = if kind == b"flow" { FLOW_ARGS } else { BOARD_ARGS };
     351           14 :             let first_line_end = text.iter().position(|&c| c == b'\n').unwrap_or(text.len());
     352            2 :             if at > first_line_end {
     353            0 :                 return None;
     354            2 :             }
     355            4 :             let arg_begin = text[..at].iter().rposition(|c| c.is_ascii_whitespace()).map_or(0, |n| n + 1);
     356            2 :             if text[arg_begin..at].contains(&b'=') {
     357            0 :                 return None;
     358            2 :             }
     359            2 :             let mut arg_end = at;
     360            2 :             while arg_end < first_line_end && !text[arg_end].is_ascii_whitespace() && text[arg_end] != b'=' {
     361            0 :                 arg_end += 1;
     362            0 :             }
     363            2 :             let has_equals = text.get(arg_end) == Some(&b'=');
     364            2 :             let used: Vec<&[u8]> = text[kind_end..first_line_end]
     365           14 :                 .split(|c| c.is_ascii_whitespace())
     366           10 :                 .filter_map(|arg| arg.iter().position(|&c| c == b'=').map(|eq| &arg[..eq]))
     367            2 :                 .collect();
     368            2 :             let mut candidates = Candidates::new(span(arg_begin, arg_end), &text[arg_begin..at]);
     369            8 :             for &arg in args {
     370            8 :                 if !used.contains(&arg.as_bytes()) || text[arg_begin..arg_end].starts_with(arg.as_bytes()) {
     371            6 :                     candidates.add(arg, if has_equals { arg.to_string() } else { format!("{arg}=") });
     372            2 :                 }
     373              :             }
     374            2 :             Some(candidates.finish(k))
     375              :         }
     376           18 :         b"vegalite" => {
     377           17 :             let context = scan_json(&text[kind_end..at])?;
     378           17 :             let (begin, end) = match context.token {
     379           16 :                 Some((begin, true)) => {
     380              :                     // The string token ends after its word, with the closing quote if one follows
     381           16 :                     let begin = kind_end + begin;
     382           16 :                     let mut end = at;
     383           16 :                     while end < text.len() && (is_word_byte(text[end]) || text[end] == b'$') {
     384            0 :                         end += 1;
     385            0 :                     }
     386           16 :                     if text.get(end) == Some(&b'"') {
     387           14 :                         end += 1;
     388           14 :                     }
     389           16 :                     (begin, end)
     390              :                 }
     391            1 :                 Some((begin, false)) => {
     392            1 :                     let begin = kind_end + begin;
     393            1 :                     let mut end = at;
     394            1 :                     while end < text.len() && !b"{}[],:\" \t\r\n".contains(&text[end]) {
     395            0 :                         end += 1;
     396            0 :                     }
     397            1 :                     (begin, end)
     398              :                 }
     399            0 :                 None => (at, at),
     400              :             };
     401           17 :             let prefix_begin = if context.token.is_some_and(|(_, string)| string) { begin + 1 } else { begin };
     402           17 :             let mut candidates = Candidates::new(span(begin, end), &text[prefix_begin..at]);
     403           17 :             add_json_candidates(&mut candidates, &context, &columns().unwrap_or_default());
     404           17 :             Some(candidates.finish(k))
     405              :         }
     406              :         b"markdown" => {
     407            3 :             let open = text[..at].windows(2).rposition(|w| w == b"{{")? + 2;
     408            1 :             let name = &text[open..at];
     409            2 :             if !name.iter().all(|&c| is_word_byte(c) || c == b' ') {
     410            0 :                 return None;
     411            1 :             }
     412            1 :             let begin = open + name.iter().take_while(|&&c| c == b' ').count();
     413            1 :             let mut end = at;
     414            1 :             while end < text.len() && is_word_byte(text[end]) {
     415            0 :                 end += 1;
     416            0 :             }
     417            1 :             let mut candidates = Candidates::new(span(begin, end), &text[begin..at]);
     418            2 :             for column in columns().unwrap_or_default() {
     419            2 :                 candidates.add(&column, column.to_string());
     420            2 :             }
     421            1 :             Some(candidates.finish(k))
     422              :         }
     423            0 :         _ => None,
     424              :     }
     425           26 : }
     426              : 
     427              : /// Complete at a cursor inside a comment. Without candidates, the completion stays empty.
     428           28 : pub(crate) fn complete_in_comment(completion: &mut Completion, k: usize) {
     429           28 :     let cursor = completion.cursor;
     430           28 :     let Some(scanned) = cursor.script.scanned else { return };
     431           28 :     let text = scanned.text.as_bytes();
     432           28 :     let offset = cursor.text_offset.min(text.len());
     433           28 :     let candidates = complete_tag_name(text, &scanned.comments, offset, k)
     434           28 :         .or_else(|| complete_vis_tag(completion, cursor.script.parsed?, offset, k));
     435           28 :     if let Some(candidates) = candidates {
     436           23 :         completion.top_candidates = candidates;
     437           25 :     }
     438           28 : }
     439              : 
     440              : #[cfg(test)]
     441              : mod tests {
     442              :     use super::*;
     443              : 
     444            9 :     fn context(json: &str) -> Option<JsonContext> {
     445            9 :         scan_json(json.as_bytes())
     446            9 :     }
     447              : 
     448            3 :     fn path(keys: &[&str]) -> Vec<PathItem> {
     449            7 :         keys.iter().map(|k| if *k == "[]" { PathItem::Index } else { PathItem::Key(k.to_string()) }).collect()
     450            3 :     }
     451              : 
     452              :     #[test]
     453            1 :     fn scans_incomplete_json() {
     454            1 :         assert_eq!(context(" {").map(|c| c.slot), Some(JsonSlot::Key));
     455            1 :         assert_eq!(context(" {\"ma").unwrap().token, Some((2, true)));
     456            1 :         let c = context("{\"mark\": ").unwrap();
     457            1 :         assert_eq!((c.path, c.slot), (vec![], JsonSlot::Value("mark".into())));
     458            1 :         let c = context("{\"mark\": \"bar\", \"encoding\": {\"x\": {\"field\": \"re").unwrap();
     459            1 :         assert_eq!((c.path, c.slot), (path(&["encoding", "x"]), JsonSlot::Value("field".into())));
     460            1 :         let c = context("{\"layer\": [{\"mark\": \"bar\"}, {\"encoding\": {").unwrap();
     461            1 :         assert_eq!((c.path, c.slot), (path(&["layer", "[]", "encoding"]), JsonSlot::Key));
     462            1 :         let c = context("{\"encoding\": {\"x\": {\"field\": \"a\", ty").unwrap();
     463            1 :         assert_eq!((c.path, c.slot, c.token.map(|t| t.1)), (path(&["encoding", "x"]), JsonSlot::Key, Some(false)));
     464              :         // After a complete value or a key, nothing is completed
     465            1 :         assert_eq!(context("{\"mark\": \"bar\""), None);
     466            1 :         assert_eq!(context("{\"mark\" "), None);
     467            1 :         assert_eq!(context(""), None);
     468            1 :     }
     469              : }
        

Generated by: LCOV version 2.0-1