ocomment_plugin_sdk/lib.rs
1//! Versioned scanner-plugin boundary.
2//!
3//! A scanner plugin finds comments in a syntax `ocomment-core` has no scanner
4//! for and hands their spans back to the host, which puts them through the
5//! ordinary policy with
6//! [`transform_spans`](ocomment_core::transform_spans). This crate is the
7//! contract between the two: the [`PluginComment`] a guest returns, the
8//! [`API_VERSION`] it was built against, and the [`validate_comments`] check
9//! the host runs before it trusts any of it.
10//!
11//! A plugin is untrusted code, so nothing it returns is taken on faith. The
12//! host validates first and refuses the whole batch on the first fault; it
13//! never removes bytes on the strength of a span it has not checked.
14//!
15//! ```
16//! use ocomment_core::{ByteSpan, CommentKind};
17//! use ocomment_plugin_sdk::{API_VERSION, PluginComment, ValidationError, validate_comments};
18//!
19//! let source = b"a ;; note\n";
20//! let found = [PluginComment {
21//! span: ByteSpan::new(2, 9),
22//! kind: CommentKind::Line,
23//! }];
24//! assert!(validate_comments(source.len(), API_VERSION, &found).is_ok());
25//!
26//! // A guest built against another revision of the contract is refused
27//! // before its spans are even read.
28//! assert!(matches!(
29//! validate_comments(source.len(), API_VERSION + 1, &found),
30//! Err(ValidationError::ApiVersion { .. }),
31//! ));
32//! ```
33
34use ocomment_core::{ByteSpan, CommentKind};
35use serde::{Deserialize, Serialize};
36use thiserror::Error;
37
38/// The revision of this contract that host and guest must agree on.
39///
40/// It is bumped whenever the shape of a [`PluginComment`] or the rules in
41/// [`validate_comments`] change. A guest reports the version it was built
42/// against and the host refuses anything else, so a plugin compiled against
43/// an older SDK fails loudly instead of being misread.
44pub const API_VERSION: u32 = 1;
45
46/// One comment a plugin found.
47///
48/// A plugin reports where a comment is and what it is; it never decides
49/// whether the comment is removed. That stays with the host's policy, so one
50/// configuration governs built-in and plugin-scanned files alike.
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52pub struct PluginComment {
53 /// Where the comment's bytes are, delimiters included.
54 pub span: ByteSpan,
55 /// What the comment is, which is what the host's policy then judges.
56 pub kind: CommentKind,
57}
58
59/// Why a plugin's answer cannot be trusted.
60///
61/// Each variant is a way a guest could otherwise make the host remove bytes
62/// it should not, or spend unbounded work trying.
63#[derive(Clone, Debug, Error, Eq, PartialEq)]
64pub enum ValidationError {
65 /// The guest was built against a different revision of this contract.
66 #[error("plugin API version {received} is unsupported; host supports {supported}")]
67 ApiVersion {
68 /// The version the guest reported.
69 received: u32,
70 /// The only version this host accepts, [`API_VERSION`].
71 supported: u32,
72 },
73 /// A span is inverted or reaches past the end of the source.
74 #[error("plugin comment span is outside the {source_len}-byte source")]
75 OutOfBounds {
76 /// The length of the source the spans had to fit in.
77 source_len: usize,
78 },
79 /// A span starts before its predecessor ends, which no single-pass edit
80 /// could apply.
81 #[error("plugin spans are not strictly sorted and non-overlapping")]
82 Overlap,
83 /// A span covers no bytes, so it names no comment.
84 #[error("plugin comment spans must not be empty")]
85 EmptySpan,
86 /// More spans than the source could hold comments, which is a guest
87 /// spending the host's memory rather than reporting anything.
88 #[error("plugin returned more than the allowed {limit} spans")]
89 SpanLimit {
90 /// The most spans this source could have justified.
91 limit: usize,
92 },
93}
94
95/// Check everything a plugin returned before the host acts on any of it.
96///
97/// The version is checked first, so a guest built against another revision is
98/// refused before its spans are read at all. The spans must then each be
99/// non-empty, inside the source, and start no earlier than the previous one
100/// ended — the same contract
101/// [`transform_spans`](ocomment_core::transform_spans) enforces, checked here
102/// so a host can refuse a plugin's whole answer rather than a single span of
103/// it. The count is capped as well: no source can hold more comments than it
104/// has bytes, plus one.
105///
106/// # Errors
107///
108/// Returns the [`ValidationError`] for the first fault found. On any error
109/// the batch is refused whole; there is no partial acceptance.
110///
111/// # Examples
112///
113/// ```
114/// use ocomment_core::{ByteSpan, CommentKind};
115/// use ocomment_plugin_sdk::{API_VERSION, PluginComment, ValidationError, validate_comments};
116///
117/// let comment = |start, end| PluginComment {
118/// span: ByteSpan::new(start, end),
119/// kind: CommentKind::Line,
120/// };
121///
122/// assert!(validate_comments(10, API_VERSION, &[comment(0, 2), comment(2, 10)]).is_ok());
123/// assert_eq!(
124/// validate_comments(10, API_VERSION, &[comment(4, 7), comment(6, 8)]),
125/// Err(ValidationError::Overlap),
126/// );
127/// assert_eq!(
128/// validate_comments(10, API_VERSION, &[comment(9, 11)]),
129/// Err(ValidationError::OutOfBounds { source_len: 10 }),
130/// );
131/// ```
132pub fn validate_comments(
133 source_len: usize,
134 api_version: u32,
135 comments: &[PluginComment],
136) -> Result<(), ValidationError> {
137 if api_version != API_VERSION {
138 return Err(ValidationError::ApiVersion {
139 received: api_version,
140 supported: API_VERSION,
141 });
142 }
143 let limit = source_len.saturating_add(1).min(1_000_000);
144 if comments.len() > limit {
145 return Err(ValidationError::SpanLimit { limit });
146 }
147 let mut cursor = 0;
148 for (index, comment) in comments.iter().enumerate() {
149 if comment.span.start > comment.span.end || comment.span.end > source_len {
150 return Err(ValidationError::OutOfBounds { source_len });
151 }
152 if comment.span.is_empty() {
153 return Err(ValidationError::EmptySpan);
154 }
155 if index > 0 && comment.span.start < cursor {
156 return Err(ValidationError::Overlap);
157 }
158 cursor = comment.span.end;
159 }
160 Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn item(start: usize, end: usize) -> PluginComment {
168 PluginComment {
169 span: ByteSpan::new(start, end),
170 kind: CommentKind::Line,
171 }
172 }
173
174 #[test]
175 fn accepts_only_bounded_sorted_nonempty_spans() {
176 assert!(validate_comments(10, API_VERSION, &[item(0, 2), item(2, 10)]).is_ok());
177 assert_eq!(
178 validate_comments(10, API_VERSION, &[item(4, 7), item(6, 8)]),
179 Err(ValidationError::Overlap)
180 );
181 assert_eq!(
182 validate_comments(10, API_VERSION, &[item(3, 3)]),
183 Err(ValidationError::EmptySpan)
184 );
185 assert_eq!(
186 validate_comments(10, API_VERSION, &[item(9, 11)]),
187 Err(ValidationError::OutOfBounds { source_len: 10 })
188 );
189 }
190
191 #[test]
192 fn rejects_api_mismatch_before_reading_spans() {
193 assert!(matches!(
194 validate_comments(0, API_VERSION + 1, &[]),
195 Err(ValidationError::ApiVersion { .. })
196 ));
197 }
198}