@perspective-dev/viewer 5.2.0 → 5.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/cdn/perspective-viewer.js +8 -8
  2. package/dist/cdn/perspective-viewer.js.map +3 -3
  3. package/dist/css/blueprint.css +1 -0
  4. package/dist/css/botanical.css +1 -1
  5. package/dist/css/dracula.css +1 -1
  6. package/dist/css/eggplant.css +1 -0
  7. package/dist/css/gruvbox-dark.css +1 -1
  8. package/dist/css/gruvbox.css +1 -1
  9. package/dist/css/icons.css +1 -1
  10. package/dist/css/intl/de.css +1 -1
  11. package/dist/css/intl/es.css +1 -1
  12. package/dist/css/intl/fr.css +1 -1
  13. package/dist/css/intl/ja.css +1 -1
  14. package/dist/css/intl/pt.css +1 -1
  15. package/dist/css/intl/zh.css +1 -1
  16. package/dist/css/intl.css +1 -1
  17. package/dist/css/ledger.css +1 -0
  18. package/dist/css/monokai.css +1 -1
  19. package/dist/css/nord.css +1 -0
  20. package/dist/css/phosphor.css +1 -1
  21. package/dist/css/pro-dark.css +1 -1
  22. package/dist/css/pro.css +1 -1
  23. package/dist/css/solarized-dark.css +1 -1
  24. package/dist/css/solarized.css +1 -1
  25. package/dist/css/themes.css +1 -1
  26. package/dist/css/vaporwave.css +1 -1
  27. package/dist/css/velvet.css +1 -0
  28. package/dist/docs/perspective-docs.json +1 -1
  29. package/dist/esm/column-format.d.ts +16 -3
  30. package/dist/esm/perspective-viewer.d.ts +1 -0
  31. package/dist/esm/perspective-viewer.inline.js +9 -9
  32. package/dist/esm/perspective-viewer.inline.js.map +3 -3
  33. package/dist/esm/perspective-viewer.js +8 -8
  34. package/dist/esm/perspective-viewer.js.map +3 -3
  35. package/dist/esm/plugin.d.ts +10 -0
  36. package/dist/esm/ts-rs/PanelViewerConfig.d.ts +1 -1
  37. package/dist/esm/ts-rs/SaveWorkspaceOptions.d.ts +10 -0
  38. package/dist/esm/ts-rs/ViewerConfig.d.ts +1 -1
  39. package/dist/esm/ts-rs/WorkspaceConfig.d.ts +9 -1
  40. package/dist/esm/ts-rs/WorkspaceConfigUpdate.d.ts +13 -3
  41. package/dist/wasm/perspective-viewer.d.ts +43 -36
  42. package/dist/wasm/perspective-viewer.js +198 -136
  43. package/dist/wasm/perspective-viewer.wasm +0 -0
  44. package/dist/wasm/perspective-viewer.wasm.d.ts +19 -17
  45. package/package.json +1 -1
  46. package/src/css/column-settings-panel.css +28 -5
  47. package/src/css/column-style.css +291 -31
  48. package/src/css/containers/tabs.css +13 -3
  49. package/src/css/dom/select.css +21 -9
  50. package/src/css/form/code-editor.css +4 -4
  51. package/src/css/plugin-selector.css +1 -0
  52. package/src/css/plugin-settings-panel.css +72 -0
  53. package/src/css/viewer.css +0 -5
  54. package/src/rust/agent/tools.rs +53 -19
  55. package/src/rust/components/column_selector/active_column.rs +2 -2
  56. package/src/rust/components/column_selector/add_expression_button.rs +3 -3
  57. package/src/rust/components/column_selector/aggregate_selector.rs +2 -3
  58. package/src/rust/components/column_selector/config_selector.rs +8 -0
  59. package/src/rust/components/column_selector/expr_edit_button.rs +4 -10
  60. package/src/rust/components/column_selector/inactive_column.rs +2 -2
  61. package/src/rust/components/column_selector.rs +48 -4
  62. package/src/rust/components/column_settings_sidebar/style_tab/primitive_field.rs +293 -126
  63. package/src/rust/components/column_settings_sidebar/style_tab.rs +330 -234
  64. package/src/rust/components/column_settings_sidebar.rs +88 -69
  65. package/src/rust/components/containers/control_group.rs +91 -0
  66. package/src/rust/components/containers/mod.rs +1 -0
  67. package/src/rust/components/containers/select.rs +8 -2
  68. package/src/rust/components/datetime_column_style/simple.rs +15 -4
  69. package/src/rust/components/datetime_column_style.rs +50 -10
  70. package/src/rust/components/editable_header.rs +85 -144
  71. package/src/rust/components/expression_editor.rs +9 -16
  72. package/src/rust/components/form/code_editor.rs +16 -6
  73. package/src/rust/components/form/debug.rs +1 -0
  74. package/src/rust/components/form/mod.rs +3 -1
  75. package/src/rust/components/form/multi_stop_gradient_selector.rs +476 -0
  76. package/src/rust/components/form/named_value_picker.rs +86 -0
  77. package/src/rust/components/form/palette_selector.rs +440 -0
  78. package/src/rust/components/main_panel/msg.rs +2 -2
  79. package/src/rust/components/main_panel/reconcile.rs +4 -4
  80. package/src/rust/components/plugin_tab.rs +126 -112
  81. package/src/rust/components/settings_panel.rs +26 -20
  82. package/src/rust/components/status_bar.rs +0 -1
  83. package/src/rust/components/style_controls/number_string_format/digits_section.rs +22 -10
  84. package/src/rust/components/style_controls/number_string_format/misc_section.rs +6 -2
  85. package/src/rust/components/style_controls/number_string_format/style_section.rs +1 -0
  86. package/src/rust/components/style_controls/number_string_format/types.rs +24 -0
  87. package/src/rust/components/style_controls/number_string_format.rs +37 -14
  88. package/src/rust/components/viewer/msg.rs +46 -7
  89. package/src/rust/components/viewer/panels.rs +11 -9
  90. package/src/rust/components/viewer/render.rs +8 -3
  91. package/src/rust/components/viewer/settings.rs +163 -23
  92. package/src/rust/components/viewer/snapshots.rs +46 -21
  93. package/src/rust/components/viewer/wiring.rs +14 -25
  94. package/src/rust/components/viewer.rs +12 -8
  95. package/src/rust/config/column_config_schema.rs +435 -45
  96. package/src/rust/config/css_scale.rs +882 -0
  97. package/src/rust/config/datetime_column_style.rs +4 -21
  98. package/src/rust/config/mod.rs +2 -0
  99. package/src/rust/config/number_string_format.rs +211 -18
  100. package/src/rust/config/options.rs +10 -0
  101. package/src/rust/config/viewer_config.rs +49 -55
  102. package/src/rust/config/workspace_config.rs +132 -5
  103. package/src/rust/custom_elements/viewer.rs +214 -155
  104. package/src/rust/custom_events.rs +14 -1
  105. package/src/rust/js/regular_layout.rs +3 -3
  106. package/src/rust/lib.rs +2 -0
  107. package/src/rust/presentation/column_locator.rs +19 -5
  108. package/src/rust/presentation.rs +209 -36
  109. package/src/rust/queries/column_locator.rs +74 -44
  110. package/src/rust/queries/get_viewer_config.rs +0 -3
  111. package/src/rust/queries/mod.rs +2 -0
  112. package/src/rust/queries/palette_set.rs +82 -0
  113. package/src/rust/queries/plugin_column_styles.rs +6 -2
  114. package/src/rust/renderer/dispatch.rs +50 -12
  115. package/src/rust/renderer/plugin_config.rs +362 -53
  116. package/src/rust/renderer.rs +15 -42
  117. package/src/rust/session/column_defaults_update.rs +15 -8
  118. package/src/rust/session/metadata.rs +5 -1
  119. package/src/rust/session/props.rs +1 -0
  120. package/src/rust/session.rs +9 -6
  121. package/src/rust/tasks/create_panel.rs +44 -57
  122. package/src/rust/tasks/edit_expression.rs +19 -23
  123. package/src/rust/tasks/edit_window.rs +8 -10
  124. package/src/rust/tasks/eject.rs +4 -1
  125. package/src/rust/tasks/pipeline.rs +2 -2
  126. package/src/rust/tasks/presize_panels.rs +44 -5
  127. package/src/rust/tasks/reset_all.rs +4 -4
  128. package/src/rust/tasks/restore_and_render.rs +17 -44
  129. package/src/rust/tasks/restore_panel.rs +11 -13
  130. package/src/rust/tasks/set_edit_mode.rs +3 -1
  131. package/src/rust/tasks/sync_update_panels.rs +11 -1
  132. package/src/rust/tasks/update_theme.rs +23 -18
  133. package/src/rust/utils/css_vars.rs +26 -0
  134. package/src/rust/utils/mod.rs +2 -0
  135. package/src/rust/utils/spawn.rs +1 -1
  136. package/src/rust/workspace.rs +435 -272
  137. package/src/svg/lock.svg +7 -0
  138. package/src/themes/blueprint.css +190 -0
  139. package/src/themes/botanical.css +14 -17
  140. package/src/themes/defaults.css +4 -110
  141. package/src/themes/dracula.css +19 -14
  142. package/src/themes/eggplant.css +189 -0
  143. package/src/themes/gruvbox-dark.css +23 -15
  144. package/src/themes/gruvbox.css +11 -1
  145. package/src/themes/icons.css +2 -2
  146. package/src/themes/intl/de.css +23 -1
  147. package/src/themes/intl/es.css +23 -1
  148. package/src/themes/intl/fr.css +23 -1
  149. package/src/themes/intl/ja.css +23 -1
  150. package/src/themes/intl/pt.css +23 -1
  151. package/src/themes/intl/zh.css +23 -1
  152. package/src/themes/intl.css +26 -4
  153. package/src/themes/ledger.css +195 -0
  154. package/src/themes/monokai.css +24 -14
  155. package/src/themes/nord.css +186 -0
  156. package/src/themes/phosphor.css +7 -14
  157. package/src/themes/pro-dark.css +12 -16
  158. package/src/themes/pro.css +118 -0
  159. package/src/themes/solarized-dark.css +9 -16
  160. package/src/themes/solarized.css +7 -0
  161. package/src/themes/themes.css +10 -5
  162. package/src/themes/vaporwave.css +22 -14
  163. package/src/themes/velvet.css +192 -0
  164. package/src/ts/column-format.ts +52 -2
  165. package/src/ts/perspective-viewer.ts +1 -0
  166. package/src/ts/plugin.ts +14 -0
  167. package/src/ts/ts-rs/PanelViewerConfig.ts +1 -1
  168. package/src/ts/ts-rs/SaveWorkspaceOptions.ts +11 -0
  169. package/src/ts/ts-rs/ViewerConfig.ts +1 -1
  170. package/src/ts/ts-rs/WorkspaceConfig.ts +8 -2
  171. package/src/ts/ts-rs/WorkspaceConfigUpdate.ts +11 -3
  172. package/src/rust/components/form/color_range_selector.rs +0 -137
  173. package/src/svg/dropdown-selector-light.svg +0 -3
  174. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline0.js +0 -0
  175. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline1.js +0 -0
  176. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline2.js +0 -0
  177. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline3.js +0 -0
  178. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline4.js +0 -0
  179. /package/dist/wasm/snippets/{perspective-viewer-02e461f3d7062b26 → perspective-viewer-87bd21b215d0631a}/inline5.js +0 -0
@@ -0,0 +1,882 @@
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
+ use std::collections::BTreeMap;
14
+
15
+ use serde_json::Value;
16
+
17
+ use super::{GradientStopSpec, canonicalize_gradient_stops};
18
+
19
+ pub const USER_VAR_PREFIX: &str = "--psp-user--";
20
+
21
+ #[derive(Clone, Debug, PartialEq)]
22
+ pub struct NamedValue {
23
+ pub name: String,
24
+ pub value: String,
25
+ }
26
+
27
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
28
+ pub enum CssKind {
29
+ Gradient,
30
+ Palette,
31
+ Color,
32
+ }
33
+
34
+ impl CssKind {
35
+ pub const ALL: [CssKind; 3] = [CssKind::Gradient, CssKind::Palette, CssKind::Color];
36
+
37
+ pub fn var_prefix(self) -> &'static str {
38
+ match self {
39
+ CssKind::Gradient => "--psp-user--gradient-",
40
+ CssKind::Palette => "--psp-user--palette-",
41
+ CssKind::Color => "--psp-user--color-",
42
+ }
43
+ }
44
+
45
+ pub fn of_var(name: &str) -> Option<CssKind> {
46
+ CssKind::ALL
47
+ .into_iter()
48
+ .find(|kind| name.starts_with(kind.var_prefix()))
49
+ }
50
+
51
+ pub fn canonicalize(self, src: &str) -> Result<String, String> {
52
+ match self {
53
+ CssKind::Gradient => CssGradient::parse(src).map(|x| x.to_css()),
54
+ CssKind::Palette => CssPalette::parse(src).map(|x| x.to_css()),
55
+ CssKind::Color => CssColor::parse(src).map(|x| x.to_css()),
56
+ }
57
+ }
58
+
59
+ pub fn short_name(self, var_name: &str) -> String {
60
+ var_name
61
+ .strip_prefix(self.var_prefix())
62
+ .unwrap_or(var_name)
63
+ .to_owned()
64
+ }
65
+ }
66
+
67
+ /// The property name a whole-value `var(--psp-user--…)` reference
68
+ /// names, or `None` when `src` is not one.
69
+ pub fn parse_var_ref(src: &str) -> Option<&str> {
70
+ let inner = src.trim().strip_prefix("var(")?.strip_suffix(')')?.trim();
71
+ if !inner.starts_with(USER_VAR_PREFIX)
72
+ || inner.len() == USER_VAR_PREFIX.len()
73
+ || inner.contains(|c: char| c.is_whitespace() || c == ',' || c == ')')
74
+ {
75
+ return None;
76
+ }
77
+
78
+ Some(inner)
79
+ }
80
+
81
+ /// `var(name)`.
82
+ pub fn format_var_ref(name: &str) -> String {
83
+ format!("var({name})")
84
+ }
85
+
86
+ fn parse_kind_var(src: &str, kind: CssKind) -> Result<Option<String>, String> {
87
+ let Some(name) = parse_var_ref(src) else {
88
+ return Ok(None);
89
+ };
90
+
91
+ if name.starts_with(kind.var_prefix()) {
92
+ Ok(Some(name.to_owned()))
93
+ } else {
94
+ Err(format!(
95
+ "`{src}` is not a {} reference (expected `var({}…)`)",
96
+ kind_label(kind),
97
+ kind.var_prefix()
98
+ ))
99
+ }
100
+ }
101
+
102
+ fn kind_label(kind: CssKind) -> &'static str {
103
+ match kind {
104
+ CssKind::Gradient => "gradient",
105
+ CssKind::Palette => "palette",
106
+ CssKind::Color => "color",
107
+ }
108
+ }
109
+
110
+ fn hex_channel(src: &str) -> Result<u8, String> {
111
+ u8::from_str_radix(src, 16).map_err(|_| format!("invalid hex color `#{src}`"))
112
+ }
113
+
114
+ fn channel_from_token(token: &str) -> Result<u8, String> {
115
+ let token = token.trim();
116
+ let value = match token.strip_suffix('%') {
117
+ Some(pct) => {
118
+ pct.trim()
119
+ .parse::<f64>()
120
+ .map_err(|_| format!("invalid color channel `{token}`"))?
121
+ / 100.0
122
+ * 255.0
123
+ },
124
+ None => token
125
+ .parse::<f64>()
126
+ .map_err(|_| format!("invalid color channel `{token}`"))?,
127
+ };
128
+
129
+ Ok(value.round().clamp(0.0, 255.0) as u8)
130
+ }
131
+
132
+ /// A color literal — `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`
133
+ /// or `rgba()` — normalized to lowercase `#rrggbb`.
134
+ pub fn normalize_css_color(src: &str) -> Result<String, String> {
135
+ let src = src.trim();
136
+ if let Some(hex) = src.strip_prefix('#') {
137
+ let (r, g, b) = match hex.len() {
138
+ 3 | 4 => {
139
+ let d = |i: usize| hex_channel(&hex[i..i + 1]).map(|x| x * 17);
140
+ (d(0)?, d(1)?, d(2)?)
141
+ },
142
+ 6 | 8 => (
143
+ hex_channel(&hex[0..2])?,
144
+ hex_channel(&hex[2..4])?,
145
+ hex_channel(&hex[4..6])?,
146
+ ),
147
+ _ => return Err(format!("invalid hex color `{src}`")),
148
+ };
149
+
150
+ return Ok(format!("#{r:02x}{g:02x}{b:02x}"));
151
+ }
152
+
153
+ let lower = src.to_ascii_lowercase();
154
+ let body = lower
155
+ .strip_prefix("rgba(")
156
+ .or_else(|| lower.strip_prefix("rgb("))
157
+ .and_then(|x| x.strip_suffix(')'))
158
+ .ok_or_else(|| format!("unsupported color `{src}`"))?;
159
+
160
+ let body = body.split('/').next().unwrap_or("");
161
+ let tokens: Vec<&str> = body
162
+ .split(|c: char| c == ',' || c.is_whitespace())
163
+ .filter(|x| !x.is_empty())
164
+ .collect();
165
+
166
+ if tokens.len() < 3 || tokens.len() > 4 {
167
+ return Err(format!("unsupported color `{src}`"));
168
+ }
169
+
170
+ Ok(format!(
171
+ "#{:02x}{:02x}{:02x}",
172
+ channel_from_token(tokens[0])?,
173
+ channel_from_token(tokens[1])?,
174
+ channel_from_token(tokens[2])?
175
+ ))
176
+ }
177
+
178
+ fn split_entries(body: &str) -> Vec<&str> {
179
+ let mut parts = vec![];
180
+ let mut depth = 0i32;
181
+ let mut start = 0;
182
+ for (i, ch) in body.char_indices() {
183
+ match ch {
184
+ '(' => depth += 1,
185
+ ')' => depth -= 1,
186
+ ',' if depth == 0 => {
187
+ parts.push(&body[start..i]);
188
+ start = i + 1;
189
+ },
190
+ _ => {},
191
+ }
192
+ }
193
+
194
+ parts.push(&body[start..]);
195
+ parts
196
+ }
197
+
198
+ fn is_direction_token(entry: &str) -> bool {
199
+ let entry = entry.trim().to_ascii_lowercase();
200
+ if entry.starts_with("to ") {
201
+ return true;
202
+ }
203
+
204
+ ["deg", "rad", "grad", "turn"].iter().any(|unit| {
205
+ entry
206
+ .strip_suffix(unit)
207
+ .map(|n| n.parse::<f64>().is_ok())
208
+ .unwrap_or(false)
209
+ })
210
+ }
211
+
212
+ fn tokenize_linear_gradient(src: &str) -> Result<Vec<(String, Option<f64>)>, String> {
213
+ let src = src.trim();
214
+ let lower = src.to_ascii_lowercase();
215
+ let body = lower
216
+ .strip_prefix("linear-gradient(")
217
+ .and_then(|x| x.strip_suffix(')'))
218
+ .ok_or_else(|| format!("expected `linear-gradient(…)`, got `{src}`"))?;
219
+
220
+ let entries = split_entries(body);
221
+ let mut out = vec![];
222
+ for (index, entry) in entries.iter().enumerate() {
223
+ let entry = entry.trim();
224
+ if entry.is_empty() {
225
+ return Err(format!("empty entry in `{src}`"));
226
+ }
227
+
228
+ if index == 0 && is_direction_token(entry) {
229
+ continue;
230
+ }
231
+
232
+ let (color, position) = match entry.rfind(|c: char| c.is_whitespace()) {
233
+ Some(at) if !entry[at..].contains(')') => {
234
+ let tail = entry[at..].trim();
235
+ if let Some(pct) = tail.strip_suffix('%') {
236
+ let value = pct
237
+ .parse::<f64>()
238
+ .map_err(|_| format!("invalid stop position `{tail}`"))?;
239
+ (entry[..at].trim(), Some(value / 100.0))
240
+ } else if tail.starts_with(|c: char| c.is_ascii_digit() || c == '.' || c == '-') {
241
+ return Err(format!(
242
+ "unsupported stop position `{tail}` (only `%` is accepted)"
243
+ ));
244
+ } else {
245
+ (entry, None)
246
+ }
247
+ },
248
+ _ => (entry, None),
249
+ };
250
+
251
+ out.push((normalize_css_color(color)?, position));
252
+ }
253
+
254
+ Ok(out)
255
+ }
256
+
257
+ fn format_percent(offset: f64) -> String {
258
+ let text = format!("{:.1}", offset * 100.0);
259
+ let text = text.strip_suffix(".0").unwrap_or(&text).to_owned();
260
+ format!("{text}%")
261
+ }
262
+
263
+ /// The canonical gradient string for `stops`: `linear-gradient(to
264
+ /// right, #rrggbb P%, …)` with every position explicit.
265
+ pub fn gradient_to_css(stops: &[GradientStopSpec]) -> String {
266
+ let body = canonicalize_gradient_stops(stops.to_vec())
267
+ .iter()
268
+ .map(|stop| format!("{} {}", stop.color, format_percent(stop.offset)))
269
+ .collect::<Vec<_>>()
270
+ .join(", ");
271
+
272
+ format!("linear-gradient(to right, {body})")
273
+ }
274
+
275
+ /// The canonical palette string for `colors`: `linear-gradient(to
276
+ /// right, #rrggbb, …)` with no positions.
277
+ pub fn palette_to_css(colors: &[String]) -> String {
278
+ format!("linear-gradient(to right, {})", colors.join(", "))
279
+ }
280
+
281
+ /// A serialized [`ControlSpec::GradientStops`] value.
282
+ ///
283
+ /// [`ControlSpec::GradientStops`]: super::ControlSpec::GradientStops
284
+ #[derive(Clone, Debug, PartialEq)]
285
+ pub enum CssGradient {
286
+ /// Canonical stops (sorted, rounded), length ≥ 2.
287
+ Literal(Vec<GradientStopSpec>),
288
+ /// A `--psp-user--gradient-…` property name.
289
+ Var(String),
290
+ }
291
+
292
+ impl CssGradient {
293
+ /// Parse a gradient string of at least 2 stops, with optional
294
+ /// positions filled by the CSS implicit rules.
295
+ pub fn parse(src: &str) -> Result<Self, String> {
296
+ if let Some(name) = parse_kind_var(src, CssKind::Gradient)? {
297
+ return Ok(CssGradient::Var(name));
298
+ }
299
+
300
+ let entries = tokenize_linear_gradient(src)?;
301
+ if entries.len() < 2 {
302
+ return Err(format!("a gradient needs at least 2 stops: `{src}`"));
303
+ }
304
+
305
+ let mut offsets: Vec<Option<f64>> = entries.iter().map(|(_, p)| *p).collect();
306
+ let last = offsets.len() - 1;
307
+ offsets[0].get_or_insert(0.0);
308
+ offsets[last].get_or_insert(1.0);
309
+ let mut i = 1;
310
+ while i < last {
311
+ if offsets[i].is_some() {
312
+ i += 1;
313
+ continue;
314
+ }
315
+
316
+ let mut j = i + 1;
317
+ while offsets[j].is_none() {
318
+ j += 1;
319
+ }
320
+
321
+ let before = offsets[i - 1].unwrap();
322
+ let after = offsets[j].unwrap();
323
+ let span = (j - (i - 1)) as f64;
324
+ for (k, slot) in offsets.iter_mut().enumerate().take(j).skip(i) {
325
+ *slot = Some(before + ((k - (i - 1)) as f64 / span) * (after - before));
326
+ }
327
+
328
+ i = j;
329
+ }
330
+
331
+ let stops = entries
332
+ .into_iter()
333
+ .zip(offsets)
334
+ .map(|((color, _), offset)| GradientStopSpec {
335
+ color,
336
+ offset: offset.unwrap(),
337
+ })
338
+ .collect();
339
+
340
+ Ok(CssGradient::Literal(canonicalize_gradient_stops(stops)))
341
+ }
342
+
343
+ pub fn to_css(&self) -> String {
344
+ match self {
345
+ CssGradient::Literal(stops) => gradient_to_css(stops),
346
+ CssGradient::Var(name) => format_var_ref(name),
347
+ }
348
+ }
349
+ }
350
+
351
+ /// A serialized [`ControlSpec::Palette`] value.
352
+ ///
353
+ /// [`ControlSpec::Palette`]: super::ControlSpec::Palette
354
+ #[derive(Clone, Debug, PartialEq)]
355
+ pub enum CssPalette {
356
+ /// Ordered `#rrggbb` entries, length ≥ 1.
357
+ Literal(Vec<String>),
358
+ /// A `--psp-user--palette-…` property name.
359
+ Var(String),
360
+ }
361
+
362
+ impl CssPalette {
363
+ /// Parse a palette string of at least 1 entry, with positions
364
+ /// forbidden.
365
+ pub fn parse(src: &str) -> Result<Self, String> {
366
+ if let Some(name) = parse_kind_var(src, CssKind::Palette)? {
367
+ return Ok(CssPalette::Var(name));
368
+ }
369
+
370
+ let entries = tokenize_linear_gradient(src)?;
371
+ if entries.is_empty() {
372
+ return Err(format!("a palette needs at least 1 color: `{src}`"));
373
+ }
374
+
375
+ let mut colors = Vec::with_capacity(entries.len());
376
+ for (color, position) in entries {
377
+ if position.is_some() {
378
+ return Err(format!(
379
+ "palette entries must not carry positions (a positioned `linear-gradient()` \
380
+ is a gradient, not a palette): `{src}`"
381
+ ));
382
+ }
383
+
384
+ colors.push(color);
385
+ }
386
+
387
+ Ok(CssPalette::Literal(colors))
388
+ }
389
+
390
+ pub fn to_css(&self) -> String {
391
+ match self {
392
+ CssPalette::Literal(colors) => palette_to_css(colors),
393
+ CssPalette::Var(name) => format_var_ref(name),
394
+ }
395
+ }
396
+ }
397
+
398
+ /// A serialized [`ControlSpec::Color`] value.
399
+ ///
400
+ /// [`ControlSpec::Color`]: super::ControlSpec::Color
401
+ #[derive(Clone, Debug, PartialEq)]
402
+ pub enum CssColor {
403
+ /// Lowercase `#rrggbb`.
404
+ Literal(String),
405
+ /// A `--psp-user--color-…` property name.
406
+ Var(String),
407
+ }
408
+
409
+ impl CssColor {
410
+ pub fn parse(src: &str) -> Result<Self, String> {
411
+ if let Some(name) = parse_kind_var(src, CssKind::Color)? {
412
+ return Ok(CssColor::Var(name));
413
+ }
414
+
415
+ normalize_css_color(src).map(CssColor::Literal)
416
+ }
417
+
418
+ pub fn to_css(&self) -> String {
419
+ match self {
420
+ CssColor::Literal(color) => color.clone(),
421
+ CssColor::Var(name) => format_var_ref(name),
422
+ }
423
+ }
424
+ }
425
+
426
+ pub fn canonicalize_css_gradient(src: &str) -> Result<String, String> {
427
+ CssKind::Gradient.canonicalize(src)
428
+ }
429
+
430
+ pub fn canonicalize_css_palette(src: &str) -> Result<String, String> {
431
+ CssKind::Palette.canonicalize(src)
432
+ }
433
+
434
+ pub fn canonicalize_css_color(src: &str) -> Result<String, String> {
435
+ CssKind::Color.canonicalize(src)
436
+ }
437
+
438
+ /// One CSS literal stored in a panel's `columns_config`: its column,
439
+ /// key, schema kind and canonical value — the unit of "in use".
440
+ #[derive(Clone, Debug, PartialEq)]
441
+ pub struct CssLiteralUse {
442
+ pub column: String,
443
+ pub key: String,
444
+ pub kind: CssKind,
445
+ pub literal: String,
446
+ }
447
+
448
+ /// The workspace palette: `restored` unioned with every in-use
449
+ /// literal, each named deterministically.
450
+ pub fn assign_palette_names(
451
+ restored: &BTreeMap<String, String>,
452
+ host: &[NamedValue],
453
+ in_use: &[(CssKind, String)],
454
+ is_taken: &dyn Fn(&str) -> bool,
455
+ ) -> BTreeMap<String, String> {
456
+ let mut set = restored.clone();
457
+ for (kind, literal) in in_use {
458
+ if palette_name_for(&set, *kind, literal).is_some() {
459
+ continue;
460
+ }
461
+
462
+ let name = host
463
+ .iter()
464
+ .find(|entry| entry.name.starts_with(kind.var_prefix()) && entry.value == *literal)
465
+ .map(|entry| entry.name.clone())
466
+ .unwrap_or_else(|| {
467
+ (1..)
468
+ .map(|n| format!("{}{n}", kind.var_prefix()))
469
+ .find(|name| !set.contains_key(name) && !is_taken(name))
470
+ .unwrap()
471
+ });
472
+
473
+ set.insert(name, literal.clone());
474
+ }
475
+
476
+ set
477
+ }
478
+
479
+ /// The name under which `set` holds `literal` as a value of `kind`.
480
+ pub fn palette_name_for(
481
+ set: &BTreeMap<String, String>,
482
+ kind: CssKind,
483
+ literal: &str,
484
+ ) -> Option<String> {
485
+ set.iter()
486
+ .find(|(name, value)| name.starts_with(kind.var_prefix()) && *value == literal)
487
+ .map(|(name, _)| name.clone())
488
+ }
489
+
490
+ /// Replace every whole-value `var(--psp-user--…)` string in one
491
+ /// column's config entry with the literal `lookup` resolves it to,
492
+ /// removing and reporting keys that do not resolve.
493
+ pub fn resolve_css_refs(
494
+ entry: &mut serde_json::Map<String, Value>,
495
+ lookup: &dyn Fn(&str) -> Option<String>,
496
+ ) -> Vec<(String, String)> {
497
+ let mut dropped = vec![];
498
+ let refs: Vec<(String, String)> = entry
499
+ .iter()
500
+ .filter_map(|(key, value)| {
501
+ let name = parse_var_ref(value.as_str()?)?;
502
+ Some((key.clone(), name.to_owned()))
503
+ })
504
+ .collect();
505
+
506
+ for (key, name) in refs {
507
+ let resolved = CssKind::of_var(&name)
508
+ .and_then(|kind| lookup(&name).and_then(|raw| kind.canonicalize(&raw).ok()));
509
+
510
+ match resolved {
511
+ Some(literal) => {
512
+ entry.insert(key, Value::String(literal));
513
+ },
514
+ None => {
515
+ entry.remove(&key);
516
+ dropped.push((key, name));
517
+ },
518
+ }
519
+ }
520
+
521
+ dropped
522
+ }
523
+
524
+ #[cfg(test)]
525
+ mod tests {
526
+ use serde_json::json;
527
+
528
+ use super::*;
529
+
530
+ fn stop(color: &str, offset: f64) -> GradientStopSpec {
531
+ GradientStopSpec {
532
+ color: color.to_owned(),
533
+ offset,
534
+ }
535
+ }
536
+
537
+ #[test]
538
+ fn colors_normalize_to_lowercase_hex() {
539
+ assert_eq!(normalize_css_color("#ABC").unwrap(), "#aabbcc");
540
+ assert_eq!(normalize_css_color("#abcd").unwrap(), "#aabbcc");
541
+ assert_eq!(normalize_css_color(" #FF7F0E ").unwrap(), "#ff7f0e");
542
+ assert_eq!(normalize_css_color("#ff7f0e80").unwrap(), "#ff7f0e");
543
+ assert_eq!(normalize_css_color("rgb(255, 127, 14)").unwrap(), "#ff7f0e");
544
+ assert_eq!(
545
+ normalize_css_color("rgba(255,127,14,0.5)").unwrap(),
546
+ "#ff7f0e"
547
+ );
548
+ assert_eq!(
549
+ normalize_css_color("rgb(255 127 14 / 50%)").unwrap(),
550
+ "#ff7f0e"
551
+ );
552
+ assert_eq!(
553
+ normalize_css_color("rgb(100%, 0%, 50%)").unwrap(),
554
+ "#ff0080"
555
+ );
556
+ assert_eq!(normalize_css_color("rgb(300, -5, 14)").unwrap(), "#ff000e");
557
+ assert!(normalize_css_color("red").is_err());
558
+ assert!(normalize_css_color("#12345").is_err());
559
+ assert!(normalize_css_color("hsl(0, 100%, 50%)").is_err());
560
+ assert!(normalize_css_color("#gggggg").is_err());
561
+ assert!(normalize_css_color("").is_err());
562
+ }
563
+
564
+ #[test]
565
+ fn var_refs_are_whole_value_and_namespaced() {
566
+ assert_eq!(
567
+ parse_var_ref("var(--psp-user--gradient-1)"),
568
+ Some("--psp-user--gradient-1")
569
+ );
570
+ assert_eq!(
571
+ parse_var_ref(" var( --psp-user--color-hot ) "),
572
+ Some("--psp-user--color-hot")
573
+ );
574
+ assert_eq!(parse_var_ref("var(--psp-charts--gradient)"), None);
575
+ assert_eq!(parse_var_ref("var(--psp-user--)"), None);
576
+ assert_eq!(parse_var_ref("var(--psp-user--gradient-1, red)"), None);
577
+ assert_eq!(parse_var_ref("#ff0000"), None);
578
+ assert_eq!(
579
+ parse_var_ref("linear-gradient(var(--psp-user--color-1), #fff)"),
580
+ None
581
+ );
582
+ }
583
+
584
+ #[test]
585
+ fn kind_is_carried_by_the_reference_prefix() {
586
+ assert_eq!(
587
+ CssKind::of_var("--psp-user--gradient-1"),
588
+ Some(CssKind::Gradient)
589
+ );
590
+ assert_eq!(
591
+ CssKind::of_var("--psp-user--palette-warm"),
592
+ Some(CssKind::Palette)
593
+ );
594
+ assert_eq!(CssKind::of_var("--psp-user--color-1"), Some(CssKind::Color));
595
+ assert_eq!(CssKind::of_var("--psp-user--other-1"), None);
596
+ assert_eq!(
597
+ CssKind::Palette.short_name("--psp-user--palette-warm"),
598
+ "warm"
599
+ );
600
+
601
+ assert_eq!(
602
+ CssGradient::parse("var(--psp-user--gradient-1)").unwrap(),
603
+ CssGradient::Var("--psp-user--gradient-1".to_owned())
604
+ );
605
+ assert!(CssGradient::parse("var(--psp-user--palette-1)").is_err());
606
+ assert!(CssPalette::parse("var(--psp-user--gradient-1)").is_err());
607
+ assert!(CssColor::parse("var(--psp-user--palette-1)").is_err());
608
+ assert_eq!(
609
+ CssColor::parse("var(--psp-user--color-1)")
610
+ .unwrap()
611
+ .to_css(),
612
+ "var(--psp-user--color-1)"
613
+ );
614
+
615
+ assert_eq!(
616
+ canonicalize_css_palette("var(--psp-user--palette-1)").unwrap(),
617
+ "var(--psp-user--palette-1)"
618
+ );
619
+ }
620
+
621
+ #[test]
622
+ fn gradient_round_trip_is_canonical_and_idempotent() {
623
+ let src = "linear-gradient(to right, #0366d6 0%, #ffffff 33.3%, #ff7f0e 100%)";
624
+ let canon = canonicalize_css_gradient(src).unwrap();
625
+ assert_eq!(canon, src);
626
+ assert_eq!(canonicalize_css_gradient(&canon).unwrap(), canon);
627
+
628
+ let CssGradient::Literal(stops) = CssGradient::parse(src).unwrap() else {
629
+ panic!("expected literal");
630
+ };
631
+
632
+ assert_eq!(stops, vec![
633
+ stop("#0366d6", 0.0),
634
+ stop("#ffffff", 0.333),
635
+ stop("#ff7f0e", 1.0),
636
+ ]);
637
+
638
+ let css = gradient_to_css(&[
639
+ stop("#222222", 1.7),
640
+ stop("#111111", 1.0 / 3.0),
641
+ stop("#000000", -0.5),
642
+ ]);
643
+
644
+ assert_eq!(
645
+ css,
646
+ "linear-gradient(to right, #000000 0%, #111111 33.3%, #222222 100%)"
647
+ );
648
+
649
+ assert_eq!(format_percent(0.375), "37.5%");
650
+ assert_eq!(format_percent(0.1), "10%");
651
+ assert_eq!(format_percent(1.0), "100%");
652
+ }
653
+
654
+ #[test]
655
+ fn gradient_accepts_loose_input() {
656
+ assert_eq!(
657
+ canonicalize_css_gradient("linear-gradient(#0366d6 0%, #ff7f0e 100%)").unwrap(),
658
+ "linear-gradient(to right, #0366d6 0%, #ff7f0e 100%)"
659
+ );
660
+
661
+ assert_eq!(
662
+ canonicalize_css_gradient("LINEAR-GRADIENT(90deg, RGB(3, 102, 214), #FFF, #ff7f0e)")
663
+ .unwrap(),
664
+ "linear-gradient(to right, #0366d6 0%, #ffffff 50%, #ff7f0e 100%)"
665
+ );
666
+
667
+ assert_eq!(
668
+ canonicalize_css_gradient(
669
+ "linear-gradient(to bottom, #000 10%, #111, #222, #333 70%, #444)"
670
+ )
671
+ .unwrap(),
672
+ "linear-gradient(to right, #000000 10%, #111111 30%, #222222 50%, #333333 70%, \
673
+ #444444 100%)"
674
+ );
675
+
676
+ assert_eq!(
677
+ canonicalize_css_gradient("linear-gradient(#fff 120%, #000 -5%)").unwrap(),
678
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)"
679
+ );
680
+ }
681
+
682
+ #[test]
683
+ fn gradient_rejects_malformed_input() {
684
+ assert!(canonicalize_css_gradient("#ff0000").is_err());
685
+ assert!(canonicalize_css_gradient("linear-gradient(#ff0000)").is_err());
686
+ assert!(canonicalize_css_gradient("linear-gradient(#ff0000 0px, #fff 10px)").is_err());
687
+ assert!(canonicalize_css_gradient("linear-gradient(red, blue)").is_err());
688
+ assert!(canonicalize_css_gradient("radial-gradient(#000, #fff)").is_err());
689
+ assert!(canonicalize_css_gradient("linear-gradient(#000, , #fff)").is_err());
690
+ assert!(canonicalize_css_gradient("").is_err());
691
+ }
692
+
693
+ #[test]
694
+ fn palette_round_trip_and_rules() {
695
+ let src = "linear-gradient(to right, #0366d6, #ff7f0e, #2ca02c)";
696
+ assert_eq!(canonicalize_css_palette(src).unwrap(), src);
697
+ assert_eq!(
698
+ CssPalette::parse(src).unwrap(),
699
+ CssPalette::Literal(vec![
700
+ "#0366d6".to_owned(),
701
+ "#ff7f0e".to_owned(),
702
+ "#2ca02c".to_owned()
703
+ ])
704
+ );
705
+
706
+ assert_eq!(
707
+ canonicalize_css_palette("linear-gradient(90deg, rgb(3,102,214), #FFF)").unwrap(),
708
+ "linear-gradient(to right, #0366d6, #ffffff)"
709
+ );
710
+
711
+ assert_eq!(
712
+ canonicalize_css_palette("linear-gradient(#abc)").unwrap(),
713
+ "linear-gradient(to right, #aabbcc)"
714
+ );
715
+
716
+ assert_eq!(
717
+ palette_to_css(&["#000000".to_owned(), "#ffffff".to_owned()]),
718
+ "linear-gradient(to right, #000000, #ffffff)"
719
+ );
720
+ }
721
+
722
+ #[test]
723
+ fn palette_rejects_positions() {
724
+ let gradient = gradient_to_css(&[stop("#000000", 0.0), stop("#ffffff", 1.0)]);
725
+ assert!(CssPalette::parse(&gradient).is_err());
726
+ assert!(canonicalize_css_palette("linear-gradient(#000, #fff 50%)").is_err());
727
+ assert!(canonicalize_css_palette("linear-gradient()").is_err());
728
+ assert!(canonicalize_css_palette("#000000").is_err());
729
+
730
+ let palette = palette_to_css(&["#000000".to_owned(), "#ffffff".to_owned()]);
731
+ assert_eq!(
732
+ canonicalize_css_gradient(&palette).unwrap(),
733
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)"
734
+ );
735
+ }
736
+
737
+ #[test]
738
+ fn color_canonicalizes_literals_and_passes_refs() {
739
+ assert_eq!(canonicalize_css_color("#ABC").unwrap(), "#aabbcc");
740
+ assert_eq!(
741
+ canonicalize_css_color("var(--psp-user--color-hot)").unwrap(),
742
+ "var(--psp-user--color-hot)"
743
+ );
744
+ assert!(canonicalize_css_color("var(--psp-user--gradient-1)").is_err());
745
+ assert!(canonicalize_css_color("linear-gradient(#000, #fff)").is_err());
746
+ }
747
+
748
+ #[test]
749
+ fn palette_set_unions_restored_and_in_use_with_stable_names() {
750
+ let mut restored = BTreeMap::new();
751
+ restored.insert(
752
+ "--psp-user--gradient-heat".to_owned(),
753
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
754
+ );
755
+ restored.insert("--psp-user--color-unused".to_owned(), "#123456".to_owned());
756
+ let host = vec![NamedValue {
757
+ name: "--psp-user--palette-1".to_owned(),
758
+ value: "linear-gradient(to right, #ff0000, #00ff00)".to_owned(),
759
+ }];
760
+ let taken =
761
+ |name: &str| name == "--psp-user--gradient-1" || name == "--psp-user--palette-1";
762
+ let in_use = vec![
763
+ (
764
+ CssKind::Gradient,
765
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)".to_owned(),
766
+ ),
767
+ (
768
+ CssKind::Gradient,
769
+ "linear-gradient(to right, #111111 0%, #222222 100%)".to_owned(),
770
+ ),
771
+ (
772
+ CssKind::Gradient,
773
+ "linear-gradient(to right, #111111 0%, #222222 100%)".to_owned(),
774
+ ),
775
+ (
776
+ CssKind::Palette,
777
+ "linear-gradient(to right, #ff0000, #00ff00)".to_owned(),
778
+ ),
779
+ (CssKind::Color, "#abcdef".to_owned()),
780
+ ];
781
+
782
+ let set = assign_palette_names(&restored, &host, &in_use, &taken);
783
+ assert_eq!(
784
+ set.iter()
785
+ .map(|(k, v)| (k.as_str(), v.as_str()))
786
+ .collect::<Vec<_>>(),
787
+ vec![
788
+ ("--psp-user--color-1", "#abcdef"),
789
+ ("--psp-user--color-unused", "#123456"),
790
+ (
791
+ "--psp-user--gradient-2",
792
+ "linear-gradient(to right, #111111 0%, #222222 100%)"
793
+ ),
794
+ (
795
+ "--psp-user--gradient-heat",
796
+ "linear-gradient(to right, #000000 0%, #ffffff 100%)"
797
+ ),
798
+ (
799
+ "--psp-user--palette-1",
800
+ "linear-gradient(to right, #ff0000, #00ff00)"
801
+ ),
802
+ ]
803
+ );
804
+
805
+ assert_eq!(
806
+ palette_name_for(
807
+ &set,
808
+ CssKind::Gradient,
809
+ "linear-gradient(to right, #111111 0%, #222222 100%)"
810
+ )
811
+ .as_deref(),
812
+ Some("--psp-user--gradient-2")
813
+ );
814
+ assert_eq!(palette_name_for(&set, CssKind::Gradient, "#abcdef"), None);
815
+
816
+ assert_eq!(assign_palette_names(&set, &host, &in_use, &taken), set);
817
+ }
818
+
819
+ #[test]
820
+ fn resolver_inlines_known_refs_and_drops_the_rest() {
821
+ let host = |name: &str| -> Option<String> {
822
+ match name {
823
+ "--psp-user--gradient-1" => Some("linear-gradient(#000, #fff)".to_owned()),
824
+ "--psp-user--palette-warm" => Some("linear-gradient(#f00, #ff0)".to_owned()),
825
+ "--psp-user--color-hot" => Some("rgb(255, 0, 0)".to_owned()),
826
+ "--psp-user--palette-bad" => Some("linear-gradient(#000 0%, #fff 100%)".to_owned()),
827
+ "--psp-user--color-empty" => Some(" ".to_owned()),
828
+ _ => None,
829
+ }
830
+ };
831
+
832
+ let mut entry = json!({
833
+ "gradient": "var(--psp-user--gradient-1)",
834
+ "palette": "var(--psp-user--palette-warm)",
835
+ "color": "var(--psp-user--color-hot)",
836
+ "bad_palette": "var(--psp-user--palette-bad)",
837
+ "missing": "var(--psp-user--gradient-missing)",
838
+ "empty": "var(--psp-user--color-empty)",
839
+ "unknown_kind": "var(--psp-user--other-1)",
840
+ "literal": "#123456",
841
+ "number": 3,
842
+ })
843
+ .as_object()
844
+ .unwrap()
845
+ .clone();
846
+
847
+ let mut dropped = resolve_css_refs(&mut entry, &host);
848
+ dropped.sort();
849
+ assert_eq!(dropped, vec![
850
+ (
851
+ "bad_palette".to_owned(),
852
+ "--psp-user--palette-bad".to_owned()
853
+ ),
854
+ ("empty".to_owned(), "--psp-user--color-empty".to_owned()),
855
+ (
856
+ "missing".to_owned(),
857
+ "--psp-user--gradient-missing".to_owned()
858
+ ),
859
+ ("unknown_kind".to_owned(), "--psp-user--other-1".to_owned()),
860
+ ]);
861
+
862
+ assert_eq!(
863
+ entry,
864
+ json!({
865
+ "gradient": "linear-gradient(to right, #000000 0%, #ffffff 100%)",
866
+ "palette": "linear-gradient(to right, #ff0000, #ffff00)",
867
+ "color": "#ff0000",
868
+ "literal": "#123456",
869
+ "number": 3,
870
+ })
871
+ .as_object()
872
+ .unwrap()
873
+ .clone()
874
+ );
875
+
876
+ assert!(entry.values().all(|v| {
877
+ v.as_str()
878
+ .map(|s| parse_var_ref(s).is_none())
879
+ .unwrap_or(true)
880
+ }));
881
+ }
882
+ }