Skip to main content

progit_plugin_sdk/traits/
experimental_diff_renderer.rs

1// SPDX-License-Identifier: LSL-1.0
2// Copyright (c) 2026 Markus Maiwald
3
4//! # DiffRenderer (experimental_)
5//!
6//! Runtime-agnostic diff-rendering trait. Plugins implement this to provide
7//! syntax-aware diff rendering; the host calls it without knowing whether the
8//! implementation is Lua, WASM, or native.
9//!
10//! ## Stability
11//!
12//! This trait is experimental as of SDK 0.3. Once `progit-syntax-diff` v0.1
13//! ships and exercises the API in production, it will be promoted to the
14//! stable `traits::DiffRenderer` namespace.
15//!
16//! ## Trait firewall
17//!
18//! Implementations of this trait may be backed by Lua or WASM, but the trait
19//! itself MUST NOT reference `mlua::*` or `wasmtime::*` types. The TUI calls
20//! a `Box<dyn DiffRenderer>` and never sees the runtime.
21//!
22//! ## Design rationale
23//!
24//! - `DiffRequest` carries content, never paths — plugins do not perform I/O.
25//! - `DiffResponse` is fully serializable — crosses the Lua/WASM boundary as
26//!   JSON without callbacks or closures.
27//! - `TokenSpan` reuse — diff coloring is a specialized case of token
28//!   highlighting plus line-kind metadata.
29//! - `max_lines` truncation rather than streaming callbacks — Lua has no
30//!   native async; the host calls `render` again with a larger budget for
31//!   incremental display.
32
33use serde::{Deserialize, Serialize};
34
35use crate::render::TokenSpan;
36use crate::traits::core::PluginResult;
37
38/// Patch / diff input.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct DiffRequest {
41    /// Source-side blob. `None` for newly created files.
42    pub old_content: Option<String>,
43    /// Target-side blob. `None` for deletions.
44    pub new_content: Option<String>,
45    /// Detected language by the host (filename → language id mapping).
46    pub language: Option<String>,
47    /// Render style requested.
48    pub view: DiffView,
49    /// Hard cap. Host requests partial render past this line count.
50    pub max_lines: usize,
51    /// Word-level intra-line diff requested?
52    pub word_diff: bool,
53}
54
55/// Render style.
56#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "lowercase")]
58pub enum DiffView {
59    Unified,
60    Split,
61}
62
63/// Render result. A sequence of styled lines the host can render directly.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct DiffResponse {
66    pub lines: Vec<DiffLine>,
67    /// True if the host should request more by raising `max_lines`.
68    pub truncated: bool,
69}
70
71/// One rendered line.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct DiffLine {
74    pub kind: DiffLineKind,
75    /// Old-side line number. `None` for added lines.
76    pub old_lineno: Option<u32>,
77    /// New-side line number. `None` for removed lines.
78    pub new_lineno: Option<u32>,
79    /// Styled spans for the line content.
80    pub spans: Vec<TokenSpan>,
81}
82
83#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum DiffLineKind {
86    Context,
87    Added,
88    Removed,
89    /// Modified — at least one word differs from the paired line. Intra-line
90    /// word diff is encoded in the spans (e.g. via `TokenSpan` emphasis).
91    Modified,
92    /// Hunk header line (e.g. `@@ -5,7 +5,8 @@`).
93    HunkHeader,
94}
95
96/// The runtime-agnostic trait the TUI calls.
97///
98/// Plugins implement this. The SDK glue maps it onto Lua / WASM bindings
99/// behind opaque types. The TUI sees only `Box<dyn DiffRenderer>`.
100pub trait DiffRenderer {
101    /// Returns the unique provider name. Must match the plugin manifest's
102    /// `name` field for routing.
103    fn provider_name(&self) -> &str;
104
105    /// Returns the languages this renderer can handle. Use `vec!["*".into()]`
106    /// for any-language fallback.
107    fn supported_languages(&self) -> Vec<String>;
108
109    /// Render a diff.
110    ///
111    /// Host caches by `(hash(old_content), hash(new_content), language, view, word_diff)`.
112    /// On cache miss the implementation must respond promptly — keep work
113    /// linear in `(old_content.len() + new_content.len())` and avoid I/O.
114    ///
115    /// For very large diffs the host calls this repeatedly with growing
116    /// `max_lines` and uses the `truncated` flag to know when to ask for more.
117    fn render(&mut self, request: &DiffRequest) -> PluginResult<DiffResponse>;
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn diff_view_round_trips_through_serde() {
126        let s = serde_json::to_string(&DiffView::Split).unwrap();
127        assert_eq!(s, "\"split\"");
128        let v: DiffView = serde_json::from_str("\"unified\"").unwrap();
129        assert_eq!(v, DiffView::Unified);
130    }
131
132    #[test]
133    fn diff_line_kind_round_trips_through_serde() {
134        let s = serde_json::to_string(&DiffLineKind::HunkHeader).unwrap();
135        assert_eq!(s, "\"hunk_header\"");
136    }
137
138    #[test]
139    fn diff_response_serializes_empty() {
140        let resp = DiffResponse { lines: vec![], truncated: false };
141        let s = serde_json::to_string(&resp).unwrap();
142        assert!(s.contains("\"lines\":[]"));
143        assert!(s.contains("\"truncated\":false"));
144    }
145}