progit_plugin_sdk/render.rs
1// SPDX-License-Identifier: LSL-1.0
2// Copyright (c) 2025 Markus Maiwald
3
4//! Render-time plugin contract — synchronous highlight requests.
5//!
6//! [ARCH] Lifecycle hooks (`on_issue_created`, ...) and structured events
7//! (`PluginEvent`) handle async/asynchronous workflows. Render-time work
8//! is different: the host is in the middle of drawing a frame and needs
9//! `Vec<TokenSpan>` *now* with per-call latency in the low microseconds.
10//!
11//! Plugins implement `Plugin::highlight` (see `crate::traits::core`).
12//! The host calls it from a cache-miss path; the host is expected to
13//! cache aggressively so the Lua roundtrip happens once per (lang,
14//! content) pair, not once per frame.
15
16use serde::{Deserialize, Serialize};
17
18/// 24-bit RGB colour. Maps cleanly onto `ratatui::Color::Rgb`.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20pub struct Rgb {
21 pub r: u8,
22 pub g: u8,
23 pub b: u8,
24}
25
26impl Rgb {
27 pub const fn new(r: u8, g: u8, b: u8) -> Self {
28 Self { r, g, b }
29 }
30}
31
32/// One styled run of text in a highlight response.
33///
34/// Plugins may omit `fg`/`bg` (= "use the host default") and may set
35/// `bold`/`italic` independently. The host is responsible for ANSI /
36/// truecolor mapping; plugins do not need to know terminal semantics.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct TokenSpan {
39 /// Verbatim slice of the original content. Concatenating every span's
40 /// `text` in order MUST reproduce the input — the host relies on this
41 /// invariant for caret placement and selection.
42 pub text: String,
43 #[serde(default)]
44 pub fg: Option<Rgb>,
45 #[serde(default)]
46 pub bg: Option<Rgb>,
47 #[serde(default)]
48 pub bold: bool,
49 #[serde(default)]
50 pub italic: bool,
51}
52
53impl TokenSpan {
54 /// Plain unstyled span — convenience for "default" tokens.
55 pub fn plain<S: Into<String>>(text: S) -> Self {
56 Self {
57 text: text.into(),
58 fg: None,
59 bg: None,
60 bold: false,
61 italic: false,
62 }
63 }
64}
65
66/// What the host asks a highlight provider to render.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct HighlightRequest {
69 /// Lowercase canonical language id (`"rust"`, `"python"`, `"json"`).
70 /// `None` means "best effort" — the plugin may guess from content
71 /// or return `None` to decline.
72 #[serde(default)]
73 pub language: Option<String>,
74 /// The text to highlight. May be a single line or a multi-line block;
75 /// the plugin should not assume one or the other.
76 pub content: String,
77}
78
79/// What the plugin returns when it wants to highlight.
80///
81/// Returning `Ok(None)` from `Plugin::highlight` means "I am not a
82/// highlight provider for this language" — the host falls through to
83/// the next plugin (or to plain text).
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct HighlightResponse {
86 pub spans: Vec<TokenSpan>,
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92
93 #[test]
94 fn span_text_concatenation_preserves_input() {
95 // Doc-test the `concatenating spans MUST reproduce input` invariant.
96 let original = "fn main() {}";
97 let resp = HighlightResponse {
98 spans: vec![
99 TokenSpan {
100 text: "fn".into(),
101 fg: Some(Rgb::new(197, 134, 192)),
102 ..TokenSpan::plain("")
103 },
104 TokenSpan::plain(" main"),
105 TokenSpan::plain("() {}"),
106 ],
107 };
108 let reconstructed: String = resp.spans.iter().map(|s| s.text.clone()).collect();
109 assert_eq!(reconstructed, original);
110 }
111
112 #[test]
113 fn round_trips_through_json() {
114 let req = HighlightRequest {
115 language: Some("rust".into()),
116 content: "let x = 1;".into(),
117 };
118 let s = serde_json::to_string(&req).unwrap();
119 let back: HighlightRequest = serde_json::from_str(&s).unwrap();
120 assert_eq!(back.language, Some("rust".into()));
121 assert_eq!(back.content, "let x = 1;");
122 }
123
124 #[test]
125 fn missing_optional_fields_default_cleanly() {
126 // Lua plugins routinely omit fg/bg/bold/italic; serde must accept.
127 let json = r#"{"text":"foo"}"#;
128 let span: TokenSpan = serde_json::from_str(json).unwrap();
129 assert_eq!(span.text, "foo");
130 assert!(span.fg.is_none());
131 assert!(!span.bold);
132 }
133}