Skip to main content

perspective_client/config/
expressions.rs

1// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
3// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
4// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
5// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
6// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
8// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9// ┃ This file is part of the Perspective library, distributed under the terms ┃
10// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
13//! Perspective supports _expression columns_, which are virtual columns
14//! calculated as part of the [`crate::View`], optionally using values from its
15//! underlying [`crate::Table`]'s columns. Such expression columns are defined
16//! in Perspective's expression language, an extended version of
17//! [ExprTK](https://github.com/ArashPartow/exprtk), which is itself quite similar
18//! (in design and features) to expressions in Excel.
19//!
20//! ## UI
21//!
22//! Expression columns can be created in `<perspective-viewer>` by clicking the
23//! "New Column" button at the bottom of the column list, or via the API by
24//! adding the expression to the `expressions` config key when calling
25//! `viewer.restore()`.
26//!
27//! By default, such expression columns are not "used", and will appear above
28//! the `Table`'s other deselected columns in the column list, with an
29//! additional set of buttons for:
30//!
31//! - _Editing_ the column's expression. Doing so will update the definitions of
32//!   _all_ usage of the expression column.
33//! - _Deleting_ the column. Clicking `Reset` (or calling the `reset()` method)
34//!   will not delete expressions unless the `Shift` key is held (or `true`
35//!   parameter supplied, respectively). This button only appears if the
36//!   expression column i unused.
37//!
38//! To use the column, just drag/select the column as you would a normal column,
39//! e.g. as a "Filter", "Group By", etc. Expression columns will recalculate
40//! whenever their dependent columns update.
41//!
42//! ## Perspective Extensions to ExprTK
43//!
44//! ExprTK has its own
45//! [excellent documentation](http://www.partow.net/programming/exprtk/) which
46//! covers the core langauge in depth, which is an excellent place to start in
47//! learning the basics. In addition to these features, Perspective adds a few
48//! of its own custom extensions and syntax.
49//!
50//! #### Static Typing
51//!
52//! In addition to `float` values which ExprTK supports natively, Perspective's
53//! expression language also supports Perspective's other types `date`,
54//! `datetime`, `integer`, `boolean`; as well as rudimentary type-checking,
55//! which will report an <span>error</span> when the values/columns supplied as
56//! arguments cannot be resolved to the expected type, e.g. `length(x)` expects
57//! an argument `x` of type `string` and is not a valid expression for an `x` of
58//! another type. Perspective supplies a set of _cast_ functions for converting
59//! between types where possible e.g. `string(x)` to cast a variable `x` to a
60//! `string`.
61//!
62//! #### Expression Column Name
63//!
64//! Expressions are named by their key in the `ViewConfig::expressions`
65//! `HashMap`. This name will be used in the `<perspective-viewer>` UI when
66//! referring to the column, but will also be used in the API when specifying
67//! e.g. `group_by` or `sort` fields. When creating a new column via
68//! `<oerspective-viewer>`'s expression editor, new columns will get a default
69//! name (which you may delete or change):
70//!
71//! ```html
72//! // New Column 1
73//! ```
74//!
75//! Without such a comment, an expression will show up in the
76//! `<perspective-viewer>` API and UI as itself (clipped to a reasonable length
77//! for the latter).
78//!
79//! #### Referencing [`crate::Table`] Columns
80//!
81//! Columns from the [`crate::Table`] can be referenced in an expression with
82//! _double quotes_.
83//!
84//! ```text
85//! // Expected Sales ("Sales" * 10) + "Profit"
86//! ```
87//!
88//! #### String Literals
89//!
90//! In contrast to standard ExprTK, string literals are declared with _single
91//! quotes_:
92//!
93//! ```text
94//! // Profitable
95//! if ("Profit" > 0) {
96//!   'Stonks'
97//! } else {
98//!   'Not Stonks'
99//! }
100//! ```
101//!
102//! #### Extended Library
103//!
104//! Perspective adds many of its own functions in addition to `ExprTK`'s
105//! standard ones, including common functions for `datetime` and `string` types
106//! such as `substring()`, `bucket()`, `day_of_week()`, etc. A full list of
107//! available functions is available in the
108//! [Expression Columns API](../obj/perspective-viewer-exprtk).
109//!
110//! ## Examples
111//!
112//! #### Casting
113//!
114//! Just `2`, as an `integer` (numeric literals currently default to `float`
115//! unless cast).
116//!
117//! ```text
118//! integer(2)
119//! ```
120//!
121//! #### Variables
122//!
123//! ```text
124//! // My Column Name
125//! var incrementedBy200 := "Sales" + 200;
126//! var half := incrementedBy200 / 2;
127//! half
128//! ```
129//!
130//! ```text
131//! // Complex Expression
132//! var upperCustomer := upper("Customer Name");
133//! var separator := concat(upperCustomer, ' | ');
134//! var profitRatio := floor(percent_of("Profit", "Sales")); // Remove trailing decimal.
135//! var combined := concat(separator, string(profitRatio));
136//! var percentDisplay := concat(combined, '%');
137//! percentDisplay
138//! ```
139//!
140//! #### Conditionals
141//!
142//! ```text
143//! // Conditional
144//! var priceAdjustmentDate := date(2016, 6, 18);
145//! var finalPrice := "Sales" - "Discount";
146//! var additionalModifier := 0;
147//!
148//! if("Order Date" > priceAdjustmentDate) {
149//!   finalPrice -= 5;
150//!   additionalModifier -= 2;
151//! }
152//! else
153//!   finalPrice += 5;
154//!
155//! finalPrice + additionalModifier
156//! ```
157
158#![cfg_attr(not(feature = "omit_metadata"), doc = include_str!("../../../docs/expression_gen.md"))]
159
160use std::borrow::Cow;
161use std::collections::HashMap;
162use std::fmt;
163
164use serde::de::value::MapAccessDeserializer;
165use serde::de::{MapAccess, Visitor};
166use serde::{Deserialize, Deserializer, Serialize};
167use ts_rs::TS;
168
169#[derive(Serialize, Clone, PartialEq, Debug, Default, TS)]
170pub struct Expressions(pub HashMap<String, String>);
171
172impl<'de> Deserialize<'de> for Expressions {
173    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
174        deserializer.deserialize_any(ExpressionsVisitor)
175    }
176}
177
178struct ExpressionsVisitor;
179
180impl<'de> Visitor<'de> for ExpressionsVisitor {
181    type Value = Expressions;
182
183    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
184        formatter.write_str(
185            "an object mapping expression names to expressions (upgrade the legacy array format \
186             with `perspective-migrate`)",
187        )
188    }
189
190    fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
191        HashMap::deserialize(MapAccessDeserializer::new(map)).map(Expressions)
192    }
193}
194
195impl std::ops::Deref for Expressions {
196    type Target = HashMap<String, String>;
197
198    fn deref(&self) -> &Self::Target {
199        &self.0
200    }
201}
202
203impl std::ops::DerefMut for Expressions {
204    fn deref_mut(&mut self) -> &mut Self::Target {
205        &mut self.0
206    }
207}
208
209#[derive(Clone, Debug, PartialEq, TS)]
210pub struct Expression<'a> {
211    pub name: Cow<'a, str>,
212    pub expression: Cow<'a, str>,
213}
214
215impl<'a> Expression<'a> {
216    /// If name is None, the expression is used as the name.
217    pub fn new(name: Option<Cow<'a, str>>, expression: Cow<'a, str>) -> Self {
218        Self {
219            name: name.unwrap_or_else(|| expression.clone()),
220            expression,
221        }
222    }
223}
224
225impl<'a> FromIterator<Expression<'a>> for Expressions {
226    fn from_iter<T: IntoIterator<Item = Expression<'a>>>(iter: T) -> Self {
227        Self(
228            iter.into_iter()
229                .map(|x| (x.name.as_ref().to_owned(), x.expression.as_ref().to_owned()))
230                .collect(),
231        )
232    }
233}
234
235impl Expressions {
236    pub fn insert(&mut self, expr: &Expression) {
237        self.0.insert(
238            expr.name.as_ref().to_owned(),
239            expr.expression.as_ref().to_owned(),
240        );
241    }
242}
243
244#[doc(hidden)]
245#[derive(Serialize, Clone, Copy, PartialEq)]
246pub struct CompletionItemSuggestion {
247    pub label: &'static str,
248    pub insert_text: &'static str,
249    pub documentation: &'static str,
250}
251
252#[doc(hidden)]
253pub static COMPLETIONS: [CompletionItemSuggestion; 79] = [
254    CompletionItemSuggestion {
255        label: "var",
256        insert_text: "var ${1:x := 1}",
257        documentation: "Declare a new local variable",
258    },
259    CompletionItemSuggestion {
260        label: "abs",
261        insert_text: "abs(${1:x})",
262        documentation: "Absolute value of x",
263    },
264    CompletionItemSuggestion {
265        label: "avg",
266        insert_text: "avg(${1:x})",
267        documentation: "Average of all inputs",
268    },
269    CompletionItemSuggestion {
270        label: "bucket",
271        insert_text: "bucket(${1:x}, ${2:y})",
272        documentation: "Bucket x by y",
273    },
274    CompletionItemSuggestion {
275        label: "ceil",
276        insert_text: "ceil(${1:x})",
277        documentation: "Smallest integer >= x",
278    },
279    CompletionItemSuggestion {
280        label: "exp",
281        insert_text: "exp(${1:x})",
282        documentation: "Natural exponent of x (e ^ x)",
283    },
284    CompletionItemSuggestion {
285        label: "floor",
286        insert_text: "floor(${1:x})",
287        documentation: "Largest integer <= x",
288    },
289    CompletionItemSuggestion {
290        label: "frac",
291        insert_text: "frac(${1:x})",
292        documentation: "Fractional portion (after the decimal) of x",
293    },
294    CompletionItemSuggestion {
295        label: "iclamp",
296        insert_text: "iclamp(${1:x})",
297        documentation: "Inverse clamp x within a range",
298    },
299    CompletionItemSuggestion {
300        label: "inrange",
301        insert_text: "inrange(${1:x})",
302        documentation: "Returns whether x is within a range",
303    },
304    CompletionItemSuggestion {
305        label: "log",
306        insert_text: "log(${1:x})",
307        documentation: "Natural log of x",
308    },
309    CompletionItemSuggestion {
310        label: "log10",
311        insert_text: "log10(${1:x})",
312        documentation: "Base 10 log of x",
313    },
314    CompletionItemSuggestion {
315        label: "log1p",
316        insert_text: "log1p(${1:x})",
317        documentation: "Natural log of 1 + x where x is very small",
318    },
319    CompletionItemSuggestion {
320        label: "log2",
321        insert_text: "log2(${1:x})",
322        documentation: "Base 2 log of x",
323    },
324    CompletionItemSuggestion {
325        label: "logn",
326        insert_text: "logn(${1:x}, ${2:N})",
327        documentation: "Base N log of x where N >= 0",
328    },
329    CompletionItemSuggestion {
330        label: "max",
331        insert_text: "max(${1:x})",
332        documentation: "Maximum value of all inputs",
333    },
334    CompletionItemSuggestion {
335        label: "min",
336        insert_text: "min(${1:x})",
337        documentation: "Minimum value of all inputs",
338    },
339    CompletionItemSuggestion {
340        label: "mul",
341        insert_text: "mul(${1:x})",
342        documentation: "Product of all inputs",
343    },
344    CompletionItemSuggestion {
345        label: "percent_of",
346        insert_text: "percent_of(${1:x})",
347        documentation: "Percent y of x",
348    },
349    CompletionItemSuggestion {
350        label: "pow",
351        insert_text: "pow(${1:x}, ${2:y})",
352        documentation: "x to the power of y",
353    },
354    CompletionItemSuggestion {
355        label: "root",
356        insert_text: "root(${1:x}, ${2:N})",
357        documentation: "N-th root of x where N >= 0",
358    },
359    CompletionItemSuggestion {
360        label: "round",
361        insert_text: "round(${1:x})",
362        documentation: "Round x to the nearest integer",
363    },
364    CompletionItemSuggestion {
365        label: "sgn",
366        insert_text: "sgn(${1:x})",
367        documentation: "Sign of x: -1, 1, or 0",
368    },
369    CompletionItemSuggestion {
370        label: "sqrt",
371        insert_text: "sqrt(${1:x})",
372        documentation: "Square root of x",
373    },
374    CompletionItemSuggestion {
375        label: "sum",
376        insert_text: "sum(${1:x})",
377        documentation: "Sum of all inputs",
378    },
379    CompletionItemSuggestion {
380        label: "trunc",
381        insert_text: "trunc(${1:x})",
382        documentation: "Integer portion of x",
383    },
384    CompletionItemSuggestion {
385        label: "acos",
386        insert_text: "acos(${1:x})",
387        documentation: "Arc cosine of x in radians",
388    },
389    CompletionItemSuggestion {
390        label: "acosh",
391        insert_text: "acosh(${1:x})",
392        documentation: "Inverse hyperbolic cosine of x in radians",
393    },
394    CompletionItemSuggestion {
395        label: "asin",
396        insert_text: "asin(${1:x})",
397        documentation: "Arc sine of x in radians",
398    },
399    CompletionItemSuggestion {
400        label: "asinh",
401        insert_text: "asinh(${1:x})",
402        documentation: "Inverse hyperbolic sine of x in radians",
403    },
404    CompletionItemSuggestion {
405        label: "atan",
406        insert_text: "atan(${1:x})",
407        documentation: "Arc tangent of x in radians",
408    },
409    CompletionItemSuggestion {
410        label: "atanh",
411        insert_text: "atanh(${1:x})",
412        documentation: "Inverse hyperbolic tangent of x in radians",
413    },
414    CompletionItemSuggestion {
415        label: "cos",
416        insert_text: "cos(${1:x})",
417        documentation: "Cosine of x",
418    },
419    CompletionItemSuggestion {
420        label: "cosh",
421        insert_text: "cosh(${1:x})",
422        documentation: "Hyperbolic cosine of x",
423    },
424    CompletionItemSuggestion {
425        label: "cot",
426        insert_text: "cot(${1:x})",
427        documentation: "Cotangent of x",
428    },
429    CompletionItemSuggestion {
430        label: "sin",
431        insert_text: "sin(${1:x})",
432        documentation: "Sine of x",
433    },
434    CompletionItemSuggestion {
435        label: "sinc",
436        insert_text: "sinc(${1:x})",
437        documentation: "Sine cardinal of x",
438    },
439    CompletionItemSuggestion {
440        label: "sinh",
441        insert_text: "sinh(${1:x})",
442        documentation: "Hyperbolic sine of x",
443    },
444    CompletionItemSuggestion {
445        label: "tan",
446        insert_text: "tan(${1:x})",
447        documentation: "Tangent of x",
448    },
449    CompletionItemSuggestion {
450        label: "tanh",
451        insert_text: "tanh(${1:x})",
452        documentation: "Hyperbolic tangent of x",
453    },
454    CompletionItemSuggestion {
455        label: "deg2rad",
456        insert_text: "deg2rad(${1:x})",
457        documentation: "Convert x from degrees to radians",
458    },
459    CompletionItemSuggestion {
460        label: "deg2grad",
461        insert_text: "deg2grad(${1:x})",
462        documentation: "Convert x from degrees to gradians",
463    },
464    CompletionItemSuggestion {
465        label: "rad2deg",
466        insert_text: "rad2deg(${1:x})",
467        documentation: "Convert x from radians to degrees",
468    },
469    CompletionItemSuggestion {
470        label: "grad2deg",
471        insert_text: "grad2deg(${1:x})",
472        documentation: "Convert x from gradians to degrees",
473    },
474    CompletionItemSuggestion {
475        label: "concat",
476        insert_text: "concat(${1:x}, ${2:y})",
477        documentation: "Concatenate string columns and string literals, such \
478                        as:\nconcat(\"State\" ', ', \"City\")",
479    },
480    CompletionItemSuggestion {
481        label: "order",
482        insert_text: "order(${1:input column}, ${2:value}, ...)",
483        documentation: "Generates a sort order for a string column based on the input order of \
484                        the parameters, such as:\norder(\"State\", 'Texas', 'New York')",
485    },
486    CompletionItemSuggestion {
487        label: "upper",
488        insert_text: "upper(${1:x})",
489        documentation: "Uppercase of x",
490    },
491    CompletionItemSuggestion {
492        label: "lower",
493        insert_text: "lower(${1:x})",
494        documentation: "Lowercase of x",
495    },
496    CompletionItemSuggestion {
497        label: "hour_of_day",
498        insert_text: "hour_of_day(${1:x})",
499        documentation: "Return a datetime's hour of the day as a string",
500    },
501    CompletionItemSuggestion {
502        label: "month_of_year",
503        insert_text: "month_of_year(${1:x})",
504        documentation: "Return a datetime's month of the year as a string",
505    },
506    CompletionItemSuggestion {
507        label: "day_of_week",
508        insert_text: "day_of_week(${1:x})",
509        documentation: "Return a datetime's day of week as a string",
510    },
511    CompletionItemSuggestion {
512        label: "now",
513        insert_text: "now()",
514        documentation: "The current datetime in local time",
515    },
516    CompletionItemSuggestion {
517        label: "today",
518        insert_text: "today()",
519        documentation: "The current date in local time",
520    },
521    CompletionItemSuggestion {
522        label: "is_null",
523        insert_text: "is_null(${1:x})",
524        documentation: "Whether x is a null value",
525    },
526    CompletionItemSuggestion {
527        label: "is_not_null",
528        insert_text: "is_not_null(${1:x})",
529        documentation: "Whether x is not a null value",
530    },
531    CompletionItemSuggestion {
532        label: "coalesce",
533        insert_text: "coalesce(${1:x}, ${2:y})",
534        documentation: "Returns the first non-null argument.",
535    },
536    CompletionItemSuggestion {
537        label: "contains",
538        insert_text: "contains(${1:x}, ${2:'substr'})",
539        documentation: "Whether the string column or value contains the literal substring.",
540    },
541    CompletionItemSuggestion {
542        label: "not",
543        insert_text: "not(${1:x})",
544        documentation: "not x",
545    },
546    CompletionItemSuggestion {
547        label: "true",
548        insert_text: "true",
549        documentation: "Boolean value true",
550    },
551    CompletionItemSuggestion {
552        label: "false",
553        insert_text: "false",
554        documentation: "Boolean value false",
555    },
556    CompletionItemSuggestion {
557        label: "if",
558        insert_text: "if (${1:condition}) {} else if (${2:condition}) {} else {}",
559        documentation: "An if/else conditional, which evaluates a condition such as:\n if \
560                        (\"Sales\" > 100) { true } else { false }",
561    },
562    CompletionItemSuggestion {
563        label: "for",
564        insert_text: "for (${1:expression}) {}",
565        documentation: "A for loop, which repeatedly evaluates an incrementing expression such \
566                        as:\nvar x := 0; var y := 1; for (x < 10; x += 1) { y := x + y }",
567    },
568    CompletionItemSuggestion {
569        label: "string",
570        insert_text: "string(${1:x})",
571        documentation: "Converts the given argument to a string",
572    },
573    CompletionItemSuggestion {
574        label: "integer",
575        insert_text: "integer(${1:x})",
576        documentation: "Converts the given argument to a 32-bit integer. If the result \
577                        over/under-flows, null is returned",
578    },
579    CompletionItemSuggestion {
580        label: "float",
581        insert_text: "float(${1:x})",
582        documentation: "Converts the argument to a float",
583    },
584    CompletionItemSuggestion {
585        label: "date",
586        insert_text: "date(${1:year}, ${1:month}, ${1:day})",
587        documentation: "Given a year, month (1-12) and day, create a new date",
588    },
589    CompletionItemSuggestion {
590        label: "datetime",
591        insert_text: "datetime(${1:timestamp})",
592        documentation: "Given a POSIX timestamp of milliseconds since epoch, create a new datetime",
593    },
594    CompletionItemSuggestion {
595        label: "boolean",
596        insert_text: "boolean(${1:x})",
597        documentation: "Converts the given argument to a boolean",
598    },
599    CompletionItemSuggestion {
600        label: "random",
601        insert_text: "random()",
602        documentation: "Returns a random float between 0 and 1, inclusive.",
603    },
604    CompletionItemSuggestion {
605        label: "match",
606        insert_text: "match(${1:string}, ${2:pattern})",
607        documentation: "Returns True if any part of string matches pattern, and False otherwise.",
608    },
609    CompletionItemSuggestion {
610        label: "match_all",
611        insert_text: "match_all(${1:string}, ${2:pattern})",
612        documentation: "Returns True if the whole string matches pattern, and False otherwise.",
613    },
614    CompletionItemSuggestion {
615        label: "search",
616        insert_text: "search(${1:string}, ${2:pattern})",
617        documentation: "Returns the substring that matches the first capturing group in pattern, \
618                        or null if there are no capturing groups in the pattern or if there are \
619                        no matches.",
620    },
621    CompletionItemSuggestion {
622        label: "indexof",
623        insert_text: "indexof(${1:string}, ${2:pattern}, ${3:output_vector})",
624        documentation: "Writes into index 0 and 1 of output_vector the start and end indices of \
625                        the substring that matches the first capturing group in \
626                        pattern.\n\nReturns true if there is a match and output was written, or \
627                        false if there are no capturing groups in the pattern, if there are no \
628                        matches, or if the indices are invalid.",
629    },
630    CompletionItemSuggestion {
631        label: "substring",
632        insert_text: "substring(${1:string}, ${2:start_idx}, ${3:length})",
633        documentation: "Returns a substring of string from start_idx with the given length. If \
634                        length is not passed in, returns substring from start_idx to the end of \
635                        the string. Returns null if the string or any indices are invalid.",
636    },
637    CompletionItemSuggestion {
638        label: "replace",
639        insert_text: "replace(${1:string}, ${2:pattern}, ${3:replacer})",
640        documentation: "Replaces the first match of pattern in string with replacer, or return \
641                        the original string if no replaces were made.",
642    },
643    CompletionItemSuggestion {
644        label: "replace_all",
645        insert_text: "replace_all(${1:string}, ${2:pattern}, ${3:replacer})",
646        documentation: "Replaces all non-overlapping matches of pattern in string with replacer, \
647                        or return the original string if no replaces were made.",
648    },
649    CompletionItemSuggestion {
650        label: "index",
651        insert_text: "index()",
652        documentation: "Looks up the index value of the current row",
653    },
654    CompletionItemSuggestion {
655        label: "col",
656        insert_text: "col(${1:string})",
657        documentation: "Looks up a column value by name",
658    },
659    CompletionItemSuggestion {
660        label: "vlookup",
661        insert_text: "vlookup(${1:string}, ${2:uint64})",
662        documentation: "Looks up a value in another column by index",
663    },
664];
665
666#[test]
667fn test_completions_insert_text_matches_label() {
668    for comp in COMPLETIONS {
669        let label = comp.label;
670        let insert_text = comp.insert_text;
671        assert!(
672            insert_text.starts_with(label),
673            "insert_text for label {label} does not start with {label}:\n    {insert_text}"
674        );
675    }
676}