progit-plugin-sdk 0.2.1

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) 2026 Markus Maiwald

//! # DiffRenderer (experimental_)
//!
//! Runtime-agnostic diff-rendering trait. Plugins implement this to provide
//! syntax-aware diff rendering; the host calls it without knowing whether the
//! implementation is Lua, WASM, or native.
//!
//! ## Stability
//!
//! This trait is experimental as of SDK 0.3. Once `progit-syntax-diff` v0.1
//! ships and exercises the API in production, it will be promoted to the
//! stable `traits::DiffRenderer` namespace.
//!
//! ## Trait firewall
//!
//! Implementations of this trait may be backed by Lua or WASM, but the trait
//! itself MUST NOT reference `mlua::*` or `wasmtime::*` types. The TUI calls
//! a `Box<dyn DiffRenderer>` and never sees the runtime.
//!
//! ## Design rationale
//!
//! - `DiffRequest` carries content, never paths — plugins do not perform I/O.
//! - `DiffResponse` is fully serializable — crosses the Lua/WASM boundary as
//!   JSON without callbacks or closures.
//! - `TokenSpan` reuse — diff coloring is a specialized case of token
//!   highlighting plus line-kind metadata.
//! - `max_lines` truncation rather than streaming callbacks — Lua has no
//!   native async; the host calls `render` again with a larger budget for
//!   incremental display.

use serde::{Deserialize, Serialize};

use crate::render::TokenSpan;
use crate::traits::core::PluginResult;

/// Patch / diff input.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffRequest {
    /// Source-side blob. `None` for newly created files.
    pub old_content: Option<String>,
    /// Target-side blob. `None` for deletions.
    pub new_content: Option<String>,
    /// Detected language by the host (filename → language id mapping).
    pub language: Option<String>,
    /// Render style requested.
    pub view: DiffView,
    /// Hard cap. Host requests partial render past this line count.
    pub max_lines: usize,
    /// Word-level intra-line diff requested?
    pub word_diff: bool,
}

/// Render style.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DiffView {
    Unified,
    Split,
}

/// Render result. A sequence of styled lines the host can render directly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffResponse {
    pub lines: Vec<DiffLine>,
    /// True if the host should request more by raising `max_lines`.
    pub truncated: bool,
}

/// One rendered line.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffLine {
    pub kind: DiffLineKind,
    /// Old-side line number. `None` for added lines.
    pub old_lineno: Option<u32>,
    /// New-side line number. `None` for removed lines.
    pub new_lineno: Option<u32>,
    /// Styled spans for the line content.
    pub spans: Vec<TokenSpan>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DiffLineKind {
    Context,
    Added,
    Removed,
    /// Modified — at least one word differs from the paired line. Intra-line
    /// word diff is encoded in the spans (e.g. via `TokenSpan` emphasis).
    Modified,
    /// Hunk header line (e.g. `@@ -5,7 +5,8 @@`).
    HunkHeader,
}

/// The runtime-agnostic trait the TUI calls.
///
/// Plugins implement this. The SDK glue maps it onto Lua / WASM bindings
/// behind opaque types. The TUI sees only `Box<dyn DiffRenderer>`.
pub trait DiffRenderer {
    /// Returns the unique provider name. Must match the plugin manifest's
    /// `name` field for routing.
    fn provider_name(&self) -> &str;

    /// Returns the languages this renderer can handle. Use `vec!["*".into()]`
    /// for any-language fallback.
    fn supported_languages(&self) -> Vec<String>;

    /// Render a diff.
    ///
    /// Host caches by `(hash(old_content), hash(new_content), language, view, word_diff)`.
    /// On cache miss the implementation must respond promptly — keep work
    /// linear in `(old_content.len() + new_content.len())` and avoid I/O.
    ///
    /// For very large diffs the host calls this repeatedly with growing
    /// `max_lines` and uses the `truncated` flag to know when to ask for more.
    fn render(&mut self, request: &DiffRequest) -> PluginResult<DiffResponse>;
}

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

    #[test]
    fn diff_view_round_trips_through_serde() {
        let s = serde_json::to_string(&DiffView::Split).unwrap();
        assert_eq!(s, "\"split\"");
        let v: DiffView = serde_json::from_str("\"unified\"").unwrap();
        assert_eq!(v, DiffView::Unified);
    }

    #[test]
    fn diff_line_kind_round_trips_through_serde() {
        let s = serde_json::to_string(&DiffLineKind::HunkHeader).unwrap();
        assert_eq!(s, "\"hunk_header\"");
    }

    #[test]
    fn diff_response_serializes_empty() {
        let resp = DiffResponse { lines: vec![], truncated: false };
        let s = serde_json::to_string(&resp).unwrap();
        assert!(s.contains("\"lines\":[]"));
        assert!(s.contains("\"truncated\":false"));
    }
}