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 can be _named_ by providing a comment as the first line of the
65//! expression. 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;
162
163use serde::{Deserialize, Serialize};
164use ts_rs::TS;
165
166#[derive(Deserialize, Clone, PartialEq, Debug)]
167#[serde(untagged)]
168pub enum ExpressionsDeserde {
169    Array(Vec<String>),
170    Map(HashMap<String, String>),
171}
172
173#[derive(Deserialize, Serialize, Clone, PartialEq, Debug, Default, TS)]
174#[serde(from = "ExpressionsDeserde")]
175pub struct Expressions(pub HashMap<String, String>);
176
177impl std::ops::Deref for Expressions {
178    type Target = HashMap<String, String>;
179
180    fn deref(&self) -> &Self::Target {
181        &self.0
182    }
183}
184
185impl std::ops::DerefMut for Expressions {
186    fn deref_mut(&mut self) -> &mut Self::Target {
187        &mut self.0
188    }
189}
190
191fn upgrade_legacy_format(expressions: &[String]) -> HashMap<String, String> {
192    tracing::debug!("Legacy `expressions` format: {:?}", expressions);
193    expressions
194        .iter()
195        .map(|s| {
196            if let Some((name, expression)) = s.split_once('\n') {
197                if !expression.is_empty() && name.starts_with("//") {
198                    (name.split_at(2).1.trim().to_owned(), expression.to_owned())
199                } else {
200                    (s.to_owned(), s.to_owned())
201                }
202            } else {
203                (s.to_owned(), s.to_owned())
204            }
205        })
206        .collect::<HashMap<_, _>>()
207}
208
209impl From<ExpressionsDeserde> for Expressions {
210    fn from(value: ExpressionsDeserde) -> Self {
211        match value {
212            ExpressionsDeserde::Array(arr) => Self(upgrade_legacy_format(&arr)),
213            ExpressionsDeserde::Map(map) => Self(map),
214        }
215    }
216}
217
218#[derive(Clone, Debug, PartialEq, TS)]
219pub struct Expression<'a> {
220    pub name: Cow<'a, str>,
221    pub expression: Cow<'a, str>,
222}
223
224impl<'a> Expression<'a> {
225    /// If name is None, the expression is used as the name.
226    pub fn new(name: Option<Cow<'a, str>>, expression: Cow<'a, str>) -> Self {
227        Self {
228            name: name.unwrap_or_else(|| expression.clone()),
229            expression,
230        }
231    }
232}
233
234impl<'a> FromIterator<Expression<'a>> for Expressions {
235    fn from_iter<T: IntoIterator<Item = Expression<'a>>>(iter: T) -> Self {
236        Self(
237            iter.into_iter()
238                .map(|x| (x.name.as_ref().to_owned(), x.expression.as_ref().to_owned()))
239                .collect(),
240        )
241    }
242}
243
244impl Expressions {
245    pub fn insert(&mut self, expr: &Expression) {
246        self.0.insert(
247            expr.name.as_ref().to_owned(),
248            expr.expression.as_ref().to_owned(),
249        );
250    }
251}
252
253#[doc(hidden)]
254#[derive(Serialize, Clone, Copy)]
255pub struct CompletionItemSuggestion {
256    pub label: &'static str,
257    pub insert_text: &'static str,
258    pub documentation: &'static str,
259}
260
261#[doc(hidden)]
262pub static COMPLETIONS: [CompletionItemSuggestion; 77] = [
263    CompletionItemSuggestion {
264        label: "var",
265        insert_text: "var ${1:x := 1}",
266        documentation: "Declare a new local variable",
267    },
268    CompletionItemSuggestion {
269        label: "abs",
270        insert_text: "abs(${1:x})",
271        documentation: "Absolute value of x",
272    },
273    CompletionItemSuggestion {
274        label: "avg",
275        insert_text: "avg(${1:x})",
276        documentation: "Average of all inputs",
277    },
278    CompletionItemSuggestion {
279        label: "bucket",
280        insert_text: "bucket(${1:x}, ${2:y})",
281        documentation: "Bucket x by y",
282    },
283    CompletionItemSuggestion {
284        label: "ceil",
285        insert_text: "ceil(${1:x})",
286        documentation: "Smallest integer >= x",
287    },
288    CompletionItemSuggestion {
289        label: "exp",
290        insert_text: "exp(${1:x})",
291        documentation: "Natural exponent of x (e ^ x)",
292    },
293    CompletionItemSuggestion {
294        label: "floor",
295        insert_text: "floor(${1:x})",
296        documentation: "Largest integer <= x",
297    },
298    CompletionItemSuggestion {
299        label: "frac",
300        insert_text: "frac(${1:x})",
301        documentation: "Fractional portion (after the decimal) of x",
302    },
303    CompletionItemSuggestion {
304        label: "iclamp",
305        insert_text: "iclamp(${1:x})",
306        documentation: "Inverse clamp x within a range",
307    },
308    CompletionItemSuggestion {
309        label: "inrange",
310        insert_text: "inrange(${1:x})",
311        documentation: "Returns whether x is within a range",
312    },
313    CompletionItemSuggestion {
314        label: "log",
315        insert_text: "log(${1:x})",
316        documentation: "Natural log of x",
317    },
318    CompletionItemSuggestion {
319        label: "log10",
320        insert_text: "log10(${1:x})",
321        documentation: "Base 10 log of x",
322    },
323    CompletionItemSuggestion {
324        label: "log1p",
325        insert_text: "log1p(${1:x})",
326        documentation: "Natural log of 1 + x where x is very small",
327    },
328    CompletionItemSuggestion {
329        label: "log2",
330        insert_text: "log2(${1:x})",
331        documentation: "Base 2 log of x",
332    },
333    CompletionItemSuggestion {
334        label: "logn",
335        insert_text: "logn(${1:x}, ${2:N})",
336        documentation: "Base N log of x where N >= 0",
337    },
338    CompletionItemSuggestion {
339        label: "max",
340        insert_text: "max(${1:x})",
341        documentation: "Maximum value of all inputs",
342    },
343    CompletionItemSuggestion {
344        label: "min",
345        insert_text: "min(${1:x})",
346        documentation: "Minimum value of all inputs",
347    },
348    CompletionItemSuggestion {
349        label: "mul",
350        insert_text: "mul(${1:x})",
351        documentation: "Product of all inputs",
352    },
353    CompletionItemSuggestion {
354        label: "percent_of",
355        insert_text: "percent_of(${1:x})",
356        documentation: "Percent y of x",
357    },
358    CompletionItemSuggestion {
359        label: "pow",
360        insert_text: "pow(${1:x}, ${2:y})",
361        documentation: "x to the power of y",
362    },
363    CompletionItemSuggestion {
364        label: "root",
365        insert_text: "root(${1:x}, ${2:N})",
366        documentation: "N-th root of x where N >= 0",
367    },
368    CompletionItemSuggestion {
369        label: "round",
370        insert_text: "round(${1:x})",
371        documentation: "Round x to the nearest integer",
372    },
373    CompletionItemSuggestion {
374        label: "sgn",
375        insert_text: "sgn(${1:x})",
376        documentation: "Sign of x: -1, 1, or 0",
377    },
378    CompletionItemSuggestion {
379        label: "sqrt",
380        insert_text: "sqrt(${1:x})",
381        documentation: "Square root of x",
382    },
383    CompletionItemSuggestion {
384        label: "sum",
385        insert_text: "sum(${1:x})",
386        documentation: "Sum of all inputs",
387    },
388    CompletionItemSuggestion {
389        label: "trunc",
390        insert_text: "trunc(${1:x})",
391        documentation: "Integer portion of x",
392    },
393    CompletionItemSuggestion {
394        label: "acos",
395        insert_text: "acos(${1:x})",
396        documentation: "Arc cosine of x in radians",
397    },
398    CompletionItemSuggestion {
399        label: "acosh",
400        insert_text: "acosh(${1:x})",
401        documentation: "Inverse hyperbolic cosine of x in radians",
402    },
403    CompletionItemSuggestion {
404        label: "asin",
405        insert_text: "asin(${1:x})",
406        documentation: "Arc sine of x in radians",
407    },
408    CompletionItemSuggestion {
409        label: "asinh",
410        insert_text: "asinh(${1:x})",
411        documentation: "Inverse hyperbolic sine of x in radians",
412    },
413    CompletionItemSuggestion {
414        label: "atan",
415        insert_text: "atan(${1:x})",
416        documentation: "Arc tangent of x in radians",
417    },
418    CompletionItemSuggestion {
419        label: "atanh",
420        insert_text: "atanh(${1:x})",
421        documentation: "Inverse hyperbolic tangent of x in radians",
422    },
423    CompletionItemSuggestion {
424        label: "cos",
425        insert_text: "cos(${1:x})",
426        documentation: "Cosine of x",
427    },
428    CompletionItemSuggestion {
429        label: "cosh",
430        insert_text: "cosh(${1:x})",
431        documentation: "Hyperbolic cosine of x",
432    },
433    CompletionItemSuggestion {
434        label: "cot",
435        insert_text: "cot(${1:x})",
436        documentation: "Cotangent of x",
437    },
438    CompletionItemSuggestion {
439        label: "sin",
440        insert_text: "sin(${1:x})",
441        documentation: "Sine of x",
442    },
443    CompletionItemSuggestion {
444        label: "sinc",
445        insert_text: "sinc(${1:x})",
446        documentation: "Sine cardinal of x",
447    },
448    CompletionItemSuggestion {
449        label: "sinh",
450        insert_text: "sinh(${1:x})",
451        documentation: "Hyperbolic sine of x",
452    },
453    CompletionItemSuggestion {
454        label: "tan",
455        insert_text: "tan(${1:x})",
456        documentation: "Tangent of x",
457    },
458    CompletionItemSuggestion {
459        label: "tanh",
460        insert_text: "tanh(${1:x})",
461        documentation: "Hyperbolic tangent of x",
462    },
463    CompletionItemSuggestion {
464        label: "deg2rad",
465        insert_text: "deg2rad(${1:x})",
466        documentation: "Convert x from degrees to radians",
467    },
468    CompletionItemSuggestion {
469        label: "deg2grad",
470        insert_text: "deg2grad(${1:x})",
471        documentation: "Convert x from degrees to gradians",
472    },
473    CompletionItemSuggestion {
474        label: "rad2deg",
475        insert_text: "rad2deg(${1:x})",
476        documentation: "Convert x from radians to degrees",
477    },
478    CompletionItemSuggestion {
479        label: "grad2deg",
480        insert_text: "grad2deg(${1:x})",
481        documentation: "Convert x from gradians to degrees",
482    },
483    CompletionItemSuggestion {
484        label: "concat",
485        insert_text: "concat(${1:x}, ${2:y})",
486        documentation: "Concatenate string columns and string literals, such \
487                        as:\nconcat(\"State\" ', ', \"City\")",
488    },
489    CompletionItemSuggestion {
490        label: "order",
491        insert_text: "order(${1:input column}, ${2:value}, ...)",
492        documentation: "Generates a sort order for a string column based on the input order of \
493                        the parameters, such as:\norder(\"State\", 'Texas', 'New York')",
494    },
495    CompletionItemSuggestion {
496        label: "upper",
497        insert_text: "upper(${1:x})",
498        documentation: "Uppercase of x",
499    },
500    CompletionItemSuggestion {
501        label: "lower",
502        insert_text: "lower(${1:x})",
503        documentation: "Lowercase of x",
504    },
505    CompletionItemSuggestion {
506        label: "hour_of_day",
507        insert_text: "hour_of_day(${1:x})",
508        documentation: "Return a datetime's hour of the day as a string",
509    },
510    CompletionItemSuggestion {
511        label: "month_of_year",
512        insert_text: "month_of_year(${1:x})",
513        documentation: "Return a datetime's month of the year as a string",
514    },
515    CompletionItemSuggestion {
516        label: "day_of_week",
517        insert_text: "day_of_week(${1:x})",
518        documentation: "Return a datetime's day of week as a string",
519    },
520    CompletionItemSuggestion {
521        label: "now",
522        insert_text: "now()",
523        documentation: "The current datetime in local time",
524    },
525    CompletionItemSuggestion {
526        label: "today",
527        insert_text: "today()",
528        documentation: "The current date in local time",
529    },
530    CompletionItemSuggestion {
531        label: "is_null",
532        insert_text: "is_null(${1:x})",
533        documentation: "Whether x is a null value",
534    },
535    CompletionItemSuggestion {
536        label: "is_not_null",
537        insert_text: "is_not_null(${1:x})",
538        documentation: "Whether x is not a null value",
539    },
540    CompletionItemSuggestion {
541        label: "not",
542        insert_text: "not(${1:x})",
543        documentation: "not x",
544    },
545    CompletionItemSuggestion {
546        label: "true",
547        insert_text: "true",
548        documentation: "Boolean value true",
549    },
550    CompletionItemSuggestion {
551        label: "false",
552        insert_text: "false",
553        documentation: "Boolean value false",
554    },
555    CompletionItemSuggestion {
556        label: "if",
557        insert_text: "if (${1:condition}) {} else if (${2:condition}) {} else {}",
558        documentation: "An if/else conditional, which evaluates a condition such as:\n if \
559                        (\"Sales\" > 100) { true } else { false }",
560    },
561    CompletionItemSuggestion {
562        label: "for",
563        insert_text: "for (${1:expression}) {}",
564        documentation: "A for loop, which repeatedly evaluates an incrementing expression such \
565                        as:\nvar x := 0; var y := 1; for (x < 10; x += 1) { y := x + y }",
566    },
567    CompletionItemSuggestion {
568        label: "string",
569        insert_text: "string(${1:x})",
570        documentation: "Converts the given argument to a string",
571    },
572    CompletionItemSuggestion {
573        label: "integer",
574        insert_text: "integer(${1:x})",
575        documentation: "Converts the given argument to a 32-bit integer. If the result \
576                        over/under-flows, null is returned",
577    },
578    CompletionItemSuggestion {
579        label: "float",
580        insert_text: "float(${1:x})",
581        documentation: "Converts the argument to a float",
582    },
583    CompletionItemSuggestion {
584        label: "date",
585        insert_text: "date(${1:year}, ${1:month}, ${1:day})",
586        documentation: "Given a year, month (1-12) and day, create a new date",
587    },
588    CompletionItemSuggestion {
589        label: "datetime",
590        insert_text: "datetime(${1:timestamp})",
591        documentation: "Given a POSIX timestamp of milliseconds since epoch, create a new datetime",
592    },
593    CompletionItemSuggestion {
594        label: "boolean",
595        insert_text: "boolean(${1:x})",
596        documentation: "Converts the given argument to a boolean",
597    },
598    CompletionItemSuggestion {
599        label: "random",
600        insert_text: "random()",
601        documentation: "Returns a random float between 0 and 1, inclusive.",
602    },
603    CompletionItemSuggestion {
604        label: "match",
605        insert_text: "match(${1:string}, ${2:pattern})",
606        documentation: "Returns True if any part of string matches pattern, and False otherwise.",
607    },
608    CompletionItemSuggestion {
609        label: "match_all",
610        insert_text: "match_all(${1:string}, ${2:pattern})",
611        documentation: "Returns True if the whole string matches pattern, and False otherwise.",
612    },
613    CompletionItemSuggestion {
614        label: "search",
615        insert_text: "search(${1:string}, ${2:pattern})",
616        documentation: "Returns the substring that matches the first capturing group in pattern, \
617                        or null if there are no capturing groups in the pattern or if there are \
618                        no matches.",
619    },
620    CompletionItemSuggestion {
621        label: "indexof",
622        insert_text: "indexof(${1:string}, ${2:pattern}, ${3:output_vector})",
623        documentation: "Writes into index 0 and 1 of output_vector the start and end indices of \
624                        the substring that matches the first capturing group in \
625                        pattern.\n\nReturns true if there is a match and output was written, or \
626                        false if there are no capturing groups in the pattern, if there are no \
627                        matches, or if the indices are invalid.",
628    },
629    CompletionItemSuggestion {
630        label: "substring",
631        insert_text: "substring(${1:string}, ${2:start_idx}, ${3:length})",
632        documentation: "Returns a substring of string from start_idx with the given length. If \
633                        length is not passed in, returns substring from start_idx to the end of \
634                        the string. Returns null if the string or any indices are invalid.",
635    },
636    CompletionItemSuggestion {
637        label: "replace",
638        insert_text: "replace(${1:string}, ${2:pattern}, ${3:replacer})",
639        documentation: "Replaces the first match of pattern in string with replacer, or return \
640                        the original string if no replaces were made.",
641    },
642    CompletionItemSuggestion {
643        label: "replace_all",
644        insert_text: "replace_all(${1:string}, ${2:pattern}, ${3:replacer})",
645        documentation: "Replaces all non-overlapping matches of pattern in string with replacer, \
646                        or return the original string if no replaces were made.",
647    },
648    CompletionItemSuggestion {
649        label: "index",
650        insert_text: "index()",
651        documentation: "Looks up the index value of the current row",
652    },
653    CompletionItemSuggestion {
654        label: "col",
655        insert_text: "col(${1:string})",
656        documentation: "Looks up a column value by name",
657    },
658    CompletionItemSuggestion {
659        label: "vlookup",
660        insert_text: "vlookup(${1:string}, ${2:uint64})",
661        documentation: "Looks up a value in another column by index",
662    },
663];
664
665#[test]
666fn test_completions_insert_text_matches_label() {
667    for comp in COMPLETIONS {
668        let label = comp.label;
669        let insert_text = comp.insert_text;
670        assert!(
671            insert_text.starts_with(label),
672            "insert_text for label {label} does not start with {label}:\n    {insert_text}"
673        );
674    }
675}