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