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}