progit-plugin-sdk 0.3.0

Plugin SDK for ProGit — sandboxed LuaJIT runtime with capability-based security. LSL-1.0 (file-level copyleft, proprietary plugins allowed via the commercial bridge).
Documentation
// SPDX-License-Identifier: LSL-1.0
// Copyright (c) 2025 Markus Maiwald

//! Render-time plugin contract — synchronous highlight requests.
//!
//! [ARCH] Lifecycle hooks (`on_issue_created`, ...) and structured events
//! (`PluginEvent`) handle async/asynchronous workflows. Render-time work
//! is different: the host is in the middle of drawing a frame and needs
//! `Vec<TokenSpan>` *now* with per-call latency in the low microseconds.
//!
//! Plugins implement `Plugin::highlight` (see `crate::traits::core`).
//! The host calls it from a cache-miss path; the host is expected to
//! cache aggressively so the Lua roundtrip happens once per (lang,
//! content) pair, not once per frame.

use serde::{Deserialize, Serialize};

/// 24-bit RGB colour. Maps cleanly onto `ratatui::Color::Rgb`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rgb {
    pub r: u8,
    pub g: u8,
    pub b: u8,
}

impl Rgb {
    pub const fn new(r: u8, g: u8, b: u8) -> Self {
        Self { r, g, b }
    }
}

/// One styled run of text in a highlight response.
///
/// Plugins may omit `fg`/`bg` (= "use the host default") and may set
/// `bold`/`italic` independently. The host is responsible for ANSI /
/// truecolor mapping; plugins do not need to know terminal semantics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenSpan {
    /// Verbatim slice of the original content. Concatenating every span's
    /// `text` in order MUST reproduce the input — the host relies on this
    /// invariant for caret placement and selection.
    pub text: String,
    #[serde(default)]
    pub fg: Option<Rgb>,
    #[serde(default)]
    pub bg: Option<Rgb>,
    #[serde(default)]
    pub bold: bool,
    #[serde(default)]
    pub italic: bool,
}

impl TokenSpan {
    /// Plain unstyled span — convenience for "default" tokens.
    pub fn plain<S: Into<String>>(text: S) -> Self {
        Self {
            text: text.into(),
            fg: None,
            bg: None,
            bold: false,
            italic: false,
        }
    }
}

/// What the host asks a highlight provider to render.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HighlightRequest {
    /// Lowercase canonical language id (`"rust"`, `"python"`, `"json"`).
    /// `None` means "best effort" — the plugin may guess from content
    /// or return `None` to decline.
    #[serde(default)]
    pub language: Option<String>,
    /// The text to highlight. May be a single line or a multi-line block;
    /// the plugin should not assume one or the other.
    pub content: String,
}

/// What the plugin returns when it wants to highlight.
///
/// Returning `Ok(None)` from `Plugin::highlight` means "I am not a
/// highlight provider for this language" — the host falls through to
/// the next plugin (or to plain text).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HighlightResponse {
    pub spans: Vec<TokenSpan>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn span_text_concatenation_preserves_input() {
        // Doc-test the `concatenating spans MUST reproduce input` invariant.
        let original = "fn main() {}";
        let resp = HighlightResponse {
            spans: vec![
                TokenSpan {
                    text: "fn".into(),
                    fg: Some(Rgb::new(197, 134, 192)),
                    ..TokenSpan::plain("")
                },
                TokenSpan::plain(" main"),
                TokenSpan::plain("() {}"),
            ],
        };
        let reconstructed: String = resp.spans.iter().map(|s| s.text.clone()).collect();
        assert_eq!(reconstructed, original);
    }

    #[test]
    fn round_trips_through_json() {
        let req = HighlightRequest {
            language: Some("rust".into()),
            content: "let x = 1;".into(),
        };
        let s = serde_json::to_string(&req).unwrap();
        let back: HighlightRequest = serde_json::from_str(&s).unwrap();
        assert_eq!(back.language, Some("rust".into()));
        assert_eq!(back.content, "let x = 1;");
    }

    #[test]
    fn missing_optional_fields_default_cleanly() {
        // Lua plugins routinely omit fg/bg/bold/italic; serde must accept.
        let json = r#"{"text":"foo"}"#;
        let span: TokenSpan = serde_json::from_str(json).unwrap();
        assert_eq!(span.text, "foo");
        assert!(span.fg.is_none());
        assert!(!span.bold);
    }
}