Skip to main content

linesmith_core/plugins/
output.rs

1//! Validates the value a plugin's `render(ctx)` function returns and
2//! converts it into a [`RenderedSegment`] the layout engine can consume.
3//!
4//! Return shape per `docs/specs/plugin-api.md` §Plugin return shape:
5//!
6//! - `()` (rhai unit) → hide this segment for this invocation
7//! - A map with `runs: [#{ text, role?, fg?, bold?, italic?, underline?, dim? }, ...]`
8//!   plus optional `width` and `right_separator`
9//!
10//! [`RenderedSegment`] is single-style, so multi-run input surfaces
11//! as [`PluginError::MalformedReturn`] with a message pointing at the
12//! limitation.
13//!
14//! `hyperlink` threads through to `Style.hyperlink` so capable
15//! terminals render the run as an OSC 8 link.
16//!
17//! `bg` and `width` are silently accepted but not acted on: `bg`
18//! requires a host `Style` field that doesn't exist yet; `width` is
19//! recomputed from rendered text. Silence is deliberate — a plugin
20//! author who writes these fields for forward compatibility doesn't
21//! trigger a load error.
22
23use rhai::{Array, Dynamic, Map};
24
25use linesmith_plugin::PluginError;
26
27use crate::segments::{sanitize_control_chars, RenderedSegment, Separator};
28use crate::theme::{Color, Role, Style};
29
30/// Convert a plugin's render-return value into a [`RenderedSegment`].
31/// `()` yields `Ok(None)` (segment hidden); any other shape mismatch
32/// yields [`PluginError::MalformedReturn`].
33pub fn validate_return(value: Dynamic, id: &str) -> Result<Option<RenderedSegment>, PluginError> {
34    if value.is_unit() {
35        return Ok(None);
36    }
37    let map = value
38        .try_cast::<Map>()
39        .ok_or_else(|| malformed(id, "render() must return `()` or a map"))?;
40
41    let runs = parse_runs(&map, id)?;
42    let (text, style) = parse_single_run(&runs, id)?;
43    let separator = match map.get("right_separator") {
44        Some(d) => Some(parse_right_separator(d.clone(), id)?),
45        None => None,
46    };
47
48    let rendered = match separator {
49        Some(sep) => RenderedSegment::with_separator(text, sep),
50        None => RenderedSegment::new(text),
51    };
52    Ok(Some(rendered.with_style(style)))
53}
54
55fn parse_runs(map: &Map, id: &str) -> Result<Array, PluginError> {
56    let runs_val = map
57        .get("runs")
58        .ok_or_else(|| malformed(id, "render() return map is missing `runs`"))?;
59    let arr = runs_val
60        .clone()
61        .try_cast::<Array>()
62        .ok_or_else(|| malformed(id, "`runs` must be an array"))?;
63    if arr.is_empty() {
64        return Err(malformed(id, "`runs` array must not be empty"));
65    }
66    Ok(arr)
67}
68
69fn parse_single_run(runs: &Array, id: &str) -> Result<(String, Style), PluginError> {
70    if runs.len() > 1 {
71        return Err(malformed(
72            id,
73            "linesmith currently supports exactly one styled run per render; multi-run output is deferred",
74        ));
75    }
76    let run = runs[0]
77        .clone()
78        .try_cast::<Map>()
79        .ok_or_else(|| malformed(id, "each entry in `runs` must be a map"))?;
80
81    let text = run
82        .get("text")
83        .ok_or_else(|| malformed(id, "run map is missing `text`"))?
84        .clone()
85        .try_cast::<String>()
86        .ok_or_else(|| malformed(id, "`text` must be a string"))?;
87
88    let style = parse_style(&run, id)?;
89    Ok((text, style))
90}
91
92fn parse_style(run: &Map, id: &str) -> Result<Style, PluginError> {
93    let mut style = Style::default();
94
95    if let Some(role_dyn) = run.get("role") {
96        let role_name = role_dyn
97            .clone()
98            .try_cast::<String>()
99            .ok_or_else(|| malformed(id, "`role` must be a string"))?;
100        style.role = Some(parse_role(&role_name, id)?);
101    }
102
103    if let Some(fg_dyn) = run.get("fg") {
104        let fg_hex = fg_dyn
105            .clone()
106            .try_cast::<String>()
107            .ok_or_else(|| malformed(id, "`fg` must be a hex color string"))?;
108        style.fg = Some(parse_hex_color(&fg_hex, id)?);
109    }
110
111    for (key, slot) in [
112        ("bold", &mut style.bold),
113        ("italic", &mut style.italic),
114        ("underline", &mut style.underline),
115        ("dim", &mut style.dim),
116    ] {
117        if let Some(dyn_val) = run.get(key) {
118            *slot = dyn_val
119                .clone()
120                .try_cast::<bool>()
121                .ok_or_else(|| malformed(id, &format!("`{key}` must be a bool")))?;
122        }
123    }
124
125    if let Some(link_dyn) = run.get("hyperlink") {
126        let url = link_dyn
127            .clone()
128            .try_cast::<String>()
129            .ok_or_else(|| malformed(id, "`hyperlink` must be a string"))?;
130        if !url.is_empty() {
131            style.hyperlink = Some(url);
132        }
133    }
134
135    Ok(style)
136}
137
138fn parse_role(name: &str, id: &str) -> Result<Role, PluginError> {
139    match name {
140        "foreground" => Ok(Role::Foreground),
141        "background" => Ok(Role::Background),
142        "muted" => Ok(Role::Muted),
143        "primary" => Ok(Role::Primary),
144        "accent" => Ok(Role::Accent),
145        "success" => Ok(Role::Success),
146        "warning" => Ok(Role::Warning),
147        "error" => Ok(Role::Error),
148        "info" => Ok(Role::Info),
149        "success_dim" => Ok(Role::SuccessDim),
150        "warning_dim" => Ok(Role::WarningDim),
151        "error_dim" => Ok(Role::ErrorDim),
152        "primary_dim" => Ok(Role::PrimaryDim),
153        "accent_dim" => Ok(Role::AccentDim),
154        "surface" => Ok(Role::Surface),
155        "border" => Ok(Role::Border),
156        other => Err(malformed(
157            id,
158            &format!("unknown role `{other}`; see plugin-api.md §Plugin return shape"),
159        )),
160    }
161}
162
163fn parse_hex_color(hex: &str, id: &str) -> Result<Color, PluginError> {
164    let body = hex
165        .strip_prefix('#')
166        .ok_or_else(|| malformed(id, &format!("hex color must start with `#`, got `{hex}`")))?;
167    if body.len() != 6 {
168        return Err(malformed(
169            id,
170            &format!("hex color must be `#rrggbb` (6 hex digits), got `{hex}`"),
171        ));
172    }
173    let r = u8::from_str_radix(&body[0..2], 16)
174        .map_err(|_| malformed(id, &format!("invalid hex color `{hex}`")))?;
175    let g = u8::from_str_radix(&body[2..4], 16)
176        .map_err(|_| malformed(id, &format!("invalid hex color `{hex}`")))?;
177    let b = u8::from_str_radix(&body[4..6], 16)
178        .map_err(|_| malformed(id, &format!("invalid hex color `{hex}`")))?;
179    Ok(Color::TrueColor { r, g, b })
180}
181
182fn parse_right_separator(sep: Dynamic, id: &str) -> Result<Separator, PluginError> {
183    let s = sep
184        .try_cast::<String>()
185        .ok_or_else(|| malformed(id, "`right_separator` must be a string"))?;
186    Ok(match s.as_str() {
187        "space" => Separator::Space,
188        "theme" => Separator::Theme,
189        "none" => Separator::None,
190        // Plugin-supplied separators flow straight to the terminal via
191        // `Separator::text()`. Without stripping control bytes here, a
192        // hostile plugin could smuggle ANSI/OSC sequences through
193        // `right_separator` that `RenderedSegment::new` would have
194        // caught on `runs[*].text`.
195        _ => Separator::Literal(sanitize_control_chars(s).into()),
196    })
197}
198
199fn malformed(id: &str, message: &str) -> PluginError {
200    PluginError::MalformedReturn {
201        id: id.to_string(),
202        message: message.to_string(),
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::plugins::build_engine;
210    use rhai::Engine;
211
212    fn eval_and_validate(script: &str, id: &str) -> Result<Option<RenderedSegment>, PluginError> {
213        let engine: std::sync::Arc<Engine> = build_engine();
214        let value: Dynamic = engine.eval(script).expect("rhai eval ok");
215        validate_return(value, id)
216    }
217
218    #[test]
219    fn unit_return_hides_segment() {
220        assert_eq!(eval_and_validate("()", "t"), Ok(None));
221    }
222
223    #[test]
224    fn single_run_text_only() {
225        let rendered = eval_and_validate(r#"#{ runs: [#{ text: "hello" }] }"#, "t")
226            .unwrap()
227            .expect("rendered");
228        assert_eq!(rendered.text(), "hello");
229        assert_eq!(rendered.style(), &Style::default());
230    }
231
232    #[test]
233    fn single_run_with_role() {
234        let rendered = eval_and_validate(r#"#{ runs: [#{ text: "ok", role: "success" }] }"#, "t")
235            .unwrap()
236            .expect("rendered");
237        assert_eq!(rendered.style().role, Some(Role::Success));
238    }
239
240    #[test]
241    fn single_run_with_decorations() {
242        let rendered = eval_and_validate(
243            r#"#{ runs: [#{ text: "x", bold: true, italic: true, underline: true, dim: true }] }"#,
244            "t",
245        )
246        .unwrap()
247        .expect("rendered");
248        let s = rendered.style();
249        assert!(s.bold);
250        assert!(s.italic);
251        assert!(s.underline);
252        assert!(s.dim);
253    }
254
255    #[test]
256    fn single_run_with_hex_fg() {
257        let rendered = eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#ff8040" }] }"##, "t")
258            .unwrap()
259            .expect("rendered");
260        assert_eq!(
261            rendered.style().fg,
262            Some(Color::TrueColor {
263                r: 0xff,
264                g: 0x80,
265                b: 0x40
266            })
267        );
268    }
269
270    #[test]
271    fn right_separator_space() {
272        let rendered = eval_and_validate(
273            r#"#{ runs: [#{ text: "x" }], right_separator: "space" }"#,
274            "t",
275        )
276        .unwrap()
277        .expect("rendered");
278        assert_eq!(rendered.right_separator(), Some(&Separator::Space));
279    }
280
281    #[test]
282    fn right_separator_literal_preserves_user_string() {
283        let rendered = eval_and_validate(
284            r#"#{ runs: [#{ text: "x" }], right_separator: " | " }"#,
285            "t",
286        )
287        .unwrap()
288        .expect("rendered");
289        match rendered.right_separator() {
290            Some(Separator::Literal(s)) => assert_eq!(s, " | "),
291            other => panic!("expected literal separator, got {other:?}"),
292        }
293    }
294
295    #[test]
296    fn right_separator_empty_string_is_literal() {
297        let rendered =
298            eval_and_validate(r#"#{ runs: [#{ text: "x" }], right_separator: "" }"#, "t")
299                .unwrap()
300                .expect("rendered");
301        match rendered.right_separator() {
302            Some(Separator::Literal(s)) => assert_eq!(s, ""),
303            other => panic!("expected literal separator, got {other:?}"),
304        }
305    }
306
307    #[test]
308    fn non_map_non_unit_return_rejected() {
309        let err = eval_and_validate(r#""just a string""#, "t").unwrap_err();
310        assert!(matches!(err, PluginError::MalformedReturn { .. }));
311    }
312
313    #[test]
314    fn missing_runs_key_rejected() {
315        let err = eval_and_validate(r#"#{ width: 5 }"#, "t").unwrap_err();
316        let PluginError::MalformedReturn { message, .. } = err else {
317            panic!("expected MalformedReturn");
318        };
319        assert!(message.contains("runs"), "message: {message}");
320    }
321
322    #[test]
323    fn empty_runs_rejected() {
324        let err = eval_and_validate(r#"#{ runs: [] }"#, "t").unwrap_err();
325        let PluginError::MalformedReturn { message, .. } = err else {
326            panic!("expected MalformedReturn");
327        };
328        assert!(message.contains("empty"));
329    }
330
331    #[test]
332    fn multi_run_rejected_with_deferred_note() {
333        let err =
334            eval_and_validate(r#"#{ runs: [#{ text: "a" }, #{ text: "b" }] }"#, "t").unwrap_err();
335        let PluginError::MalformedReturn { message, .. } = err else {
336            panic!("expected MalformedReturn");
337        };
338        assert!(
339            message.contains("one") || message.contains("multi-run"),
340            "message should flag the single-run restriction: {message}"
341        );
342    }
343
344    #[test]
345    fn run_without_text_rejected() {
346        let err = eval_and_validate(r#"#{ runs: [#{ role: "primary" }] }"#, "t").unwrap_err();
347        assert!(matches!(err, PluginError::MalformedReturn { .. }));
348    }
349
350    #[test]
351    fn run_text_wrong_type_rejected() {
352        let err = eval_and_validate(r#"#{ runs: [#{ text: 42 }] }"#, "t").unwrap_err();
353        let PluginError::MalformedReturn { message, .. } = err else {
354            panic!("expected MalformedReturn");
355        };
356        assert!(message.contains("text"));
357    }
358
359    #[test]
360    fn unknown_role_rejected() {
361        let err =
362            eval_and_validate(r#"#{ runs: [#{ text: "x", role: "mystery" }] }"#, "t").unwrap_err();
363        let PluginError::MalformedReturn { message, .. } = err else {
364            panic!("expected MalformedReturn");
365        };
366        assert!(message.contains("mystery"));
367    }
368
369    #[test]
370    fn hex_color_missing_hash_rejected() {
371        let err =
372            eval_and_validate(r#"#{ runs: [#{ text: "x", fg: "ff0000" }] }"#, "t").unwrap_err();
373        let PluginError::MalformedReturn { message, .. } = err else {
374            panic!("expected MalformedReturn");
375        };
376        assert!(message.contains("start with"), "message: {message}");
377    }
378
379    #[test]
380    fn hex_color_wrong_length_rejected() {
381        let err =
382            eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#abc" }] }"##, "t").unwrap_err();
383        let PluginError::MalformedReturn { message, .. } = err else {
384            panic!("expected MalformedReturn");
385        };
386        assert!(message.contains("6 hex digits"), "message: {message}");
387    }
388
389    #[test]
390    fn hex_color_alpha_form_rejected() {
391        // `#rrggbbaa` is a plausible plugin-author mistake (web colors
392        // often carry an alpha byte). Pin it to the 6-digit branch.
393        let err = eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#ff804080" }] }"##, "t")
394            .unwrap_err();
395        let PluginError::MalformedReturn { message, .. } = err else {
396            panic!("expected MalformedReturn");
397        };
398        assert!(message.contains("6 hex digits"), "message: {message}");
399    }
400
401    #[test]
402    fn hex_color_empty_body_rejected() {
403        let err = eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#" }] }"##, "t").unwrap_err();
404        let PluginError::MalformedReturn { message, .. } = err else {
405            panic!("expected MalformedReturn");
406        };
407        assert!(message.contains("6 hex digits"), "message: {message}");
408    }
409
410    #[test]
411    fn hex_color_uppercase_accepted() {
412        let rendered = eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#FF8040" }] }"##, "t")
413            .unwrap()
414            .expect("rendered");
415        assert_eq!(
416            rendered.style().fg,
417            Some(Color::TrueColor {
418                r: 0xff,
419                g: 0x80,
420                b: 0x40
421            })
422        );
423    }
424
425    #[test]
426    fn hex_color_non_hex_digits_rejected() {
427        let err =
428            eval_and_validate(r##"#{ runs: [#{ text: "x", fg: "#zzzzzz" }] }"##, "t").unwrap_err();
429        let PluginError::MalformedReturn { message, .. } = err else {
430            panic!("expected MalformedReturn");
431        };
432        assert!(message.contains("invalid hex"), "message: {message}");
433    }
434
435    #[test]
436    fn decoration_wrong_type_rejected() {
437        let err =
438            eval_and_validate(r#"#{ runs: [#{ text: "x", bold: "yes" }] }"#, "t").unwrap_err();
439        let PluginError::MalformedReturn { message, .. } = err else {
440            panic!("expected MalformedReturn");
441        };
442        assert!(message.contains("bold"));
443    }
444
445    #[test]
446    fn all_16_roles_parse_via_snake_case_token() {
447        // Regression guard: if a Role variant is added and
448        // `parse_role` isn't updated, this table test fails.
449        let cases: &[(&str, Role)] = &[
450            ("foreground", Role::Foreground),
451            ("background", Role::Background),
452            ("muted", Role::Muted),
453            ("primary", Role::Primary),
454            ("accent", Role::Accent),
455            ("success", Role::Success),
456            ("warning", Role::Warning),
457            ("error", Role::Error),
458            ("info", Role::Info),
459            ("success_dim", Role::SuccessDim),
460            ("warning_dim", Role::WarningDim),
461            ("error_dim", Role::ErrorDim),
462            ("primary_dim", Role::PrimaryDim),
463            ("accent_dim", Role::AccentDim),
464            ("surface", Role::Surface),
465            ("border", Role::Border),
466        ];
467        for (token, expected) in cases {
468            let script = format!(r#"#{{ runs: [#{{ text: "x", role: "{token}" }}] }}"#);
469            let rendered = eval_and_validate(&script, "t")
470                .unwrap_or_else(|e| panic!("role `{token}` failed: {e}"))
471                .expect("rendered");
472            assert_eq!(
473                rendered.style().role,
474                Some(*expected),
475                "role token `{token}` should parse to {expected:?}"
476            );
477        }
478    }
479
480    #[test]
481    fn all_separator_strings_map_correctly() {
482        let cases: &[(&str, Separator)] = &[
483            ("space", Separator::Space),
484            ("theme", Separator::Theme),
485            ("none", Separator::None),
486        ];
487        for (token, expected) in cases {
488            let script = format!(r#"#{{ runs: [#{{ text: "x" }}], right_separator: "{token}" }}"#);
489            let rendered = eval_and_validate(&script, "t")
490                .unwrap_or_else(|e| panic!("separator `{token}` failed: {e}"))
491                .expect("rendered");
492            assert_eq!(rendered.right_separator(), Some(expected));
493        }
494    }
495
496    #[test]
497    fn control_chars_in_plugin_text_are_stripped() {
498        let rendered = eval_and_validate(r#"#{ runs: [#{ text: "evil\u001B[2Jafter" }] }"#, "t")
499            .unwrap()
500            .expect("rendered");
501        assert!(!rendered.text().contains('\x1b'), "ESC must be stripped");
502        assert!(rendered.text().contains("evil"));
503        assert!(rendered.text().contains("after"));
504    }
505
506    #[test]
507    fn control_chars_in_plugin_separator_are_stripped() {
508        // Only control chars get dropped; printable residue (`[2J`)
509        // stays, but without the leading ESC it's inert.
510        let rendered = eval_and_validate(
511            r#"#{ runs: [#{ text: "x" }], right_separator: "\u001B[2J|" }"#,
512            "t",
513        )
514        .unwrap()
515        .expect("rendered");
516        match rendered.right_separator() {
517            Some(Separator::Literal(s)) => {
518                assert!(!s.contains('\x1b'), "ESC must be stripped, got {s:?}");
519                assert!(s.contains('|'), "surviving printable bytes kept: {s:?}");
520            }
521            other => panic!("expected literal separator, got {other:?}"),
522        }
523    }
524
525    #[test]
526    fn runs_field_non_array_rejected() {
527        let err = eval_and_validate(r#"#{ runs: "not an array" }"#, "t").unwrap_err();
528        let PluginError::MalformedReturn { message, .. } = err else {
529            panic!("expected MalformedReturn");
530        };
531        assert!(message.contains("runs"), "message: {message}");
532        assert!(message.to_lowercase().contains("array"));
533    }
534
535    #[test]
536    fn runs_element_non_map_rejected() {
537        let err = eval_and_validate(r#"#{ runs: [42] }"#, "t").unwrap_err();
538        let PluginError::MalformedReturn { message, .. } = err else {
539            panic!("expected MalformedReturn");
540        };
541        assert!(message.to_lowercase().contains("map"));
542    }
543
544    #[test]
545    fn right_separator_non_string_rejected() {
546        let err = eval_and_validate(r#"#{ runs: [#{ text: "x" }], right_separator: 42 }"#, "t")
547            .unwrap_err();
548        let PluginError::MalformedReturn { message, .. } = err else {
549            panic!("expected MalformedReturn");
550        };
551        assert!(message.contains("right_separator") || message.contains("string"));
552    }
553
554    #[test]
555    fn unsupported_bg_field_silently_ignored() {
556        let rendered = eval_and_validate(r##"#{ runs: [#{ text: "x", bg: "#000000" }] }"##, "t")
557            .unwrap()
558            .expect("rendered");
559        assert_eq!(rendered.text(), "x");
560    }
561
562    #[test]
563    fn hyperlink_field_threads_to_style() {
564        let rendered = eval_and_validate(
565            r#"#{ runs: [#{ text: "x", hyperlink: "https://example.com" }] }"#,
566            "t",
567        )
568        .unwrap()
569        .expect("rendered");
570        assert_eq!(rendered.text(), "x");
571        assert_eq!(
572            rendered.style().hyperlink.as_deref(),
573            Some("https://example.com")
574        );
575    }
576
577    #[test]
578    fn empty_hyperlink_string_does_not_set_link() {
579        // `hyperlink: ""` folds to None so the emitter doesn't wrap
580        // text in a link-to-nothing OSC 8 pair. Empty URL `\x1b]8;;\x1b\\`
581        // is the canonical OSC 8 close sequence per ECMA-48 — using it
582        // as a link target would semantically tell the terminal "no
583        // link," which is just absence with extra bytes.
584        let rendered = eval_and_validate(r#"#{ runs: [#{ text: "x", hyperlink: "" }] }"#, "t")
585            .unwrap()
586            .expect("rendered");
587        assert_eq!(rendered.style().hyperlink, None);
588    }
589
590    #[test]
591    fn non_string_hyperlink_rejected() {
592        let err =
593            eval_and_validate(r#"#{ runs: [#{ text: "x", hyperlink: 42 }] }"#, "t").unwrap_err();
594        let msg = format!("{err}");
595        assert!(msg.contains("hyperlink"), "got: {msg}");
596    }
597}