linesmith_core/segments/builder/dispatch.rs
1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::sync::Arc;
4
5use rhai::{Dynamic, Engine, Map};
6
7use linesmith_plugin::{CompiledPlugin, PluginRegistry};
8
9use super::super::{
10 built_in_by_id, LineItem, Segment, Separator, BUILT_IN_SEGMENT_IDS, DEFAULT_SEGMENT_IDS,
11};
12use super::layout::{resolve_layout_separator, single_line_entries, validated_numbered_lines};
13use super::plugins::{apply_override, bundle_plugins, toml_table_to_dynamic};
14use crate::config;
15use crate::plugins::RhaiSegment;
16
17/// Build the default line: every built-in in canonical order with
18/// `Separator::Space` between each pair, no overrides applied.
19#[must_use]
20pub fn build_default_segments() -> Vec<LineItem> {
21 debug_assert!(
22 DEFAULT_SEGMENT_IDS
23 .iter()
24 .all(|id| BUILT_IN_SEGMENT_IDS.contains(id)),
25 "DEFAULT_SEGMENT_IDS must be a subset of BUILT_IN_SEGMENT_IDS so the \
26 `Cow::Borrowed(*id)` shortcut here matches what resolve_segment_id would emit"
27 );
28 let segs: Vec<(Cow<'static, str>, Box<dyn Segment>)> = DEFAULT_SEGMENT_IDS
29 .iter()
30 .filter_map(|id| built_in_by_id(id, None, &mut |_| {}).map(|seg| (Cow::Borrowed(*id), seg)))
31 .collect();
32 interleave_separators(segs, &Separator::Space)
33}
34
35/// Walk a built segment list and interleave `sep` between adjacent
36/// segments, producing the [`LineItem`] sequence the renderer consumes.
37/// No leading or trailing separator.
38fn interleave_separators(
39 segs: Vec<(Cow<'static, str>, Box<dyn Segment>)>,
40 sep: &Separator,
41) -> Vec<LineItem> {
42 let n = segs.len();
43 // n=0 saturates to 0; n>=1 gives 2n-1 slots (n segments + n-1 separators).
44 let mut items = Vec::with_capacity(n.saturating_mul(2).saturating_sub(1));
45 for (i, (id, segment)) in segs.into_iter().enumerate() {
46 items.push(LineItem::Segment { id, segment });
47 if i + 1 < n {
48 items.push(LineItem::Separator(sep.clone()));
49 }
50 }
51 items
52}
53
54/// Build a segment list from an optional [`Config`](config::Config).
55/// `None` or a config without a `[line]` section uses the default
56/// order. `warn` receives a one-line diagnostic for each validation
57/// rule triggered (pass `|_| {}` to discard).
58///
59/// `plugins` carries the discovered [`PluginRegistry`] plus its
60/// shared engine. Built-in ids win on collision (the registry already
61/// rejects plugins shadowing built-ins at load time, so a plugin
62/// reaching this function can only collide with another plugin or
63/// stand alone).
64///
65/// Implements the validation rules in `docs/specs/config.md`
66/// §Validation rules: unknown ids skip with a warning, duplicates
67/// keep the first, an explicit `segments = []` warns, inverted width
68/// bounds drop the override with a warning.
69///
70/// Single-line entry; multi-line callers should use [`build_lines`].
71/// When this function is invoked against a `layout = "multi-line"`
72/// config, it returns line 1 (sorted by numbered key) and warns,
73/// rather than reading the empty `[line].segments` field and
74/// returning nothing. Without that fallback, embedders loading the
75/// multi-line `power-user` preset would silently render a blank
76/// status line.
77pub fn build_segments(
78 config: Option<&config::Config>,
79 plugins: Option<(PluginRegistry, Arc<Engine>)>,
80 mut warn: impl FnMut(&str),
81) -> Vec<LineItem> {
82 let configured_line = config.and_then(|c| c.line.as_ref());
83 let layout_mode = config.map(|c| c.layout).unwrap_or_default();
84
85 // If the caller is on the legacy single-line API but the config
86 // declares multi-line, hand them line 1 with a warning. Without
87 // the fallback, `[line].segments` would be empty and the
88 // embedder would silently render a blank status line.
89 if matches!(layout_mode, config::LayoutMode::MultiLine) {
90 if let Some(first) = validated_numbered_lines(configured_line, &mut warn)
91 .and_then(|mut v| (!v.is_empty()).then(|| v.remove(0)))
92 {
93 warn("layout = \"multi-line\" passed to build_segments (the single-line API); rendering line 1 only. Call build_lines to render every [line.N] sub-table.");
94 let layout_separator = resolve_layout_separator(config, &mut warn);
95 let mut plugin_bundle = bundle_plugins(plugins);
96 let mut consumed = std::collections::HashSet::new();
97 return build_one_line(
98 &first,
99 config,
100 &mut plugin_bundle,
101 &mut consumed,
102 &layout_separator,
103 &mut warn,
104 );
105 }
106 // Multi-line declared but no usable [line.N]; fall through to
107 // the single-line path which already warns on empty segments.
108 }
109
110 if let Some(line) = configured_line {
111 if line.segments.is_empty() {
112 warn("[line].segments is empty; no segments will render");
113 }
114 }
115
116 let layout_separator = resolve_layout_separator(config, &mut warn);
117
118 let entries: Vec<config::LineEntry> = match configured_line {
119 Some(l) => l.segments.clone(),
120 None => DEFAULT_SEGMENT_IDS
121 .iter()
122 .map(|&s| config::LineEntry::Id(s.to_string()))
123 .collect(),
124 };
125
126 let mut plugin_bundle = bundle_plugins(plugins);
127 let mut consumed = std::collections::HashSet::new();
128 build_one_line(
129 &entries,
130 config,
131 &mut plugin_bundle,
132 &mut consumed,
133 &layout_separator,
134 &mut warn,
135 )
136}
137
138/// Build a list of segment lists, one per rendered line. Single-line
139/// configs return a vec of length 1; multi-line configs return one
140/// inner vec per `[line.N]` sub-table sorted by the parsed integer
141/// key. Edge cases per `docs/specs/config.md` §Edge cases:
142///
143/// - `layout = "multi-line"` without usable `[line.N]` sub-tables
144/// warns and falls back to single-line using `[line].segments`.
145/// - `layout = "single-line"` (or unset) with `[line.N]` tables
146/// present warns and ignores the numbered tables.
147/// - Numbered keys that don't parse as positive integers (e.g.
148/// `[line.foo]`) warn and drop.
149/// - In multi-line mode with both `[line].segments` and `[line.N]`
150/// present, the numbered tables win and `[line].segments` is
151/// ignored. The spec's edge-case table doesn't enumerate this
152/// combination; this is a builder-level precedence choice
153/// matching the principle that single-line callers should go
154/// through [`build_segments`].
155///
156/// Plugin segments can appear in at most one line per render: the
157/// shared plugin lookup is consumed on first use. A plugin id
158/// referenced again in a later line surfaces as "plugin '<id>' was
159/// rendered on an earlier line" so the user knows the cause is
160/// reuse, not a typo. v0.1 limitation; lifting it (Arc-shared or
161/// cloneable `CompiledPlugin`) is tracked separately.
162pub fn build_lines(
163 config: Option<&config::Config>,
164 plugins: Option<(PluginRegistry, Arc<Engine>)>,
165 mut warn: impl FnMut(&str),
166) -> Vec<Vec<LineItem>> {
167 let mode = config.map(|c| c.layout).unwrap_or_default();
168 let line_cfg = config.and_then(|c| c.line.as_ref());
169
170 let line_entry_lists: Vec<Vec<config::LineEntry>> = match mode {
171 config::LayoutMode::SingleLine => {
172 // Two single-line + numbered combinations: if `segments`
173 // is populated, the user picked single-line on purpose
174 // and the numbered tables are noise — warn and ignore.
175 // If `segments` is empty AND numbered is populated, the
176 // user almost certainly meant multi-line and forgot the
177 // `layout = "multi-line"` line; auto-promote with a hint
178 // rather than silently rendering nothing.
179 let has_numbered = line_cfg.is_some_and(|l| !l.numbered.is_empty());
180 let has_segments = line_cfg.is_some_and(|l| !l.segments.is_empty());
181 if has_numbered && !has_segments {
182 if let Some(promoted) = validated_numbered_lines(line_cfg, &mut warn) {
183 warn("[line.N] sub-tables present but no top-level `layout` field; treating as multi-line. Add `layout = \"multi-line\"` to silence this warning.");
184 promoted
185 } else {
186 warn("[line.N] sub-tables present but none are usable, and [line].segments is empty; nothing will render");
187 single_line_entries(line_cfg, &mut warn)
188 }
189 } else {
190 if has_numbered {
191 warn("layout is single-line but [line.N] sub-tables are present; ignoring numbered tables and rendering [line].segments");
192 }
193 single_line_entries(line_cfg, &mut warn)
194 }
195 }
196 config::LayoutMode::MultiLine => match validated_numbered_lines(line_cfg, &mut warn) {
197 Some(lines) => lines,
198 None => {
199 warn("layout = \"multi-line\" but no usable [line.N] sub-tables; falling back to single-line using [line].segments");
200 single_line_entries(line_cfg, &mut warn)
201 }
202 },
203 };
204
205 let layout_separator = resolve_layout_separator(config, &mut warn);
206 let mut plugin_bundle = bundle_plugins(plugins);
207 let mut consumed_plugins = std::collections::HashSet::<String>::new();
208
209 line_entry_lists
210 .into_iter()
211 .map(|entries| {
212 build_one_line(
213 &entries,
214 config,
215 &mut plugin_bundle,
216 &mut consumed_plugins,
217 &layout_separator,
218 &mut warn,
219 )
220 })
221 .collect()
222}
223/// Inner segment-building loop, shared by single-line `build_segments`
224/// and per-line `build_lines`. Walks `entries` and emits a
225/// [`LineItem`] sequence per ADR-0024:
226///
227/// - Bare-string / `type = "<segment-id>"` entries materialize as
228/// [`LineItem::Segment`].
229/// - `type = "separator"` entries materialize as [`LineItem::Separator`]
230/// using the entry's `character` override or the global
231/// `[layout_options].separator` fallback.
232/// - Adjacent segments with no explicit separator between them get
233/// the global `layout_separator` interleaved (preserves pre-ADR
234/// behavior for string-only configs).
235/// - A segment with `merge = true` suppresses the boundary at its
236/// right edge: the implicit interleave is skipped AND any
237/// immediately-following explicit separator is dropped silently.
238///
239/// Dedupes segment ids within a single call (so duplicates within
240/// one line warn) but not across calls — multi-line configs that
241/// list the same built-in id in two different lines produce two
242/// independent segment instances, which is the right behavior for
243/// stateless built-ins. Separator entries are NOT deduped (each
244/// separator entry is positionally distinct).
245///
246/// `consumed_plugins` tracks plugin ids removed from the shared
247/// lookup by earlier `build_one_line` calls. The lookup itself can't
248/// distinguish "never existed" from "consumed" after a `remove`, so
249/// we shadow consumption here. A lookup miss combined with a
250/// consumed-set hit produces the specific "rendered on an earlier
251/// line" warning; otherwise the generic "unknown segment id"
252/// diagnostic fires.
253fn build_one_line(
254 entries: &[config::LineEntry],
255 config: Option<&config::Config>,
256 plugin_bundle: &mut Option<(HashMap<String, CompiledPlugin>, Arc<Engine>)>,
257 consumed_plugins: &mut std::collections::HashSet<String>,
258 layout_separator: &Separator,
259 warn: &mut impl FnMut(&str),
260) -> Vec<LineItem> {
261 let mut seen = std::collections::HashSet::<String>::new();
262 let mut items: Vec<LineItem> = Vec::with_capacity(entries.len() * 2);
263 // True when the most-recently-pushed segment had `merge = true`.
264 // Cleared when the next segment lands; persists across an
265 // explicit separator entry (so `seg(merge), |, seg` drops the
266 // explicit separator AND the implicit interleave).
267 let mut merge_pending = false;
268
269 for entry in entries {
270 if matches!(entry, config::LineEntry::Item(item) if item.kind.as_deref() == Some("separator") && item.merge.is_some())
271 {
272 warn("[line].segments separator entry has `merge = ...`; ignoring (merge is for segment entries)");
273 }
274 if matches!(entry, config::LineEntry::Item(item) if item.kind.as_deref() != Some("separator") && item.character.is_some())
275 {
276 warn("[line].segments segment entry has `character = ...`; ignoring (character is for separator entries)");
277 }
278
279 match entry.kind() {
280 None => {
281 warn("[line].segments inline-table entry is missing `type`; skipping");
282 continue;
283 }
284 Some("separator") => {
285 if merge_pending {
286 // Suppress: the preceding segment opted into
287 // merge with its right neighbor. The merge flag
288 // stays armed until the next segment lands.
289 continue;
290 }
291 match items.last() {
292 Some(LineItem::Segment { .. }) => {} // proceed below
293 Some(LineItem::Separator(_)) => {
294 warn(
295 "[line].segments has consecutive separator entries; keeping the first",
296 );
297 continue;
298 }
299 None => {
300 warn("[line].segments leads with a separator entry; skipping");
301 continue;
302 }
303 }
304 let sep = entry.separator_character().map_or_else(
305 || layout_separator.clone(),
306 |c| Separator::Literal(Cow::Owned(c.to_string())),
307 );
308 items.push(LineItem::Separator(sep));
309 }
310 Some(id) => {
311 if !seen.insert(id.to_string()) {
312 warn(&format!(
313 "segment '{id}' listed more than once; keeping first occurrence"
314 ));
315 continue;
316 }
317 let cfg_override = config.and_then(|c| c.segments.get(id));
318 let extras = cfg_override.map(|ov| &ov.extra);
319 let inner = if let Some(b) = built_in_by_id(id, extras, warn) {
320 Some(b)
321 } else if let Some((lookup, engine)) = plugin_bundle.as_mut() {
322 lookup.remove(id).map(|plugin| {
323 consumed_plugins.insert(id.to_string());
324 let plugin_config = cfg_override.map_or_else(
325 || Dynamic::from_map(Map::new()),
326 |ov| toml_table_to_dynamic(&ov.extra),
327 );
328 Box::new(RhaiSegment::from_compiled(
329 plugin,
330 engine.clone(),
331 plugin_config,
332 )) as Box<dyn Segment>
333 })
334 } else {
335 None
336 };
337 let Some(inner) = inner else {
338 if consumed_plugins.contains(id) {
339 warn(&format!(
340 "plugin '{id}' was rendered on an earlier line; v0.1 supports each plugin on at most one line per render — skipping"
341 ));
342 } else {
343 warn(&format!("unknown segment id '{id}' — skipping"));
344 }
345 continue;
346 };
347 let seg = apply_override(id, inner, cfg_override, warn);
348
349 // Implicit interleave: when the previous item is a
350 // segment (no explicit separator between them) and
351 // the previous segment didn't request merge, insert
352 // the global default separator. When the previous
353 // item is already a Separator (explicit), don't
354 // double-up.
355 if matches!(items.last(), Some(LineItem::Segment { .. })) && !merge_pending {
356 items.push(LineItem::Separator(layout_separator.clone()));
357 }
358 items.push(LineItem::Segment {
359 id: resolve_segment_id(id),
360 segment: seg,
361 });
362 merge_pending = entry.merge();
363 }
364 }
365 }
366 items
367}
368
369/// Resolve a config-string id to `Cow<'static, str>` (per ADR-0026).
370/// Built-ins match [`BUILT_IN_SEGMENT_IDS`] and return `Cow::Borrowed`;
371/// plugin and user-config ids fall through to `Cow::Owned`.
372pub(super) fn resolve_segment_id(id: &str) -> Cow<'static, str> {
373 BUILT_IN_SEGMENT_IDS
374 .iter()
375 .find(|&&built_in| built_in == id)
376 .map_or_else(
377 || Cow::Owned(id.to_string()),
378 |&built_in| Cow::Borrowed(built_in),
379 )
380}