Skip to main content

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}