1#[cfg(feature = "syntax")]
3pub mod conformance;
4
5use crate::target::RenderTarget;
6use rich::protocol::RenderEnvironment;
7use rich::{Renderable, Segment};
8use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
12pub struct SnapshotSegment {
13 pub text: String,
14 pub control: bool,
15 pub foreground: Option<String>,
16 pub background: Option<String>,
17 pub attributes: Vec<String>,
18 pub link: Option<String>,
19}
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub struct SnapshotRun {
25 pub text: String,
26 pub foreground: Option<String>,
27 pub background: Option<String>,
28 pub attributes: Vec<String>,
29 pub link: Option<String>,
30}
31impl SnapshotRun {
32 fn same_style(&self, other: &SnapshotRun) -> bool {
33 (
34 &self.foreground,
35 &self.background,
36 &self.attributes,
37 &self.link,
38 ) == (
39 &other.foreground,
40 &other.background,
41 &other.attributes,
42 &other.link,
43 )
44 }
45}
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51pub struct SnapshotRegion {
52 pub role: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub level: Option<u8>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub row: Option<usize>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub column: Option<usize>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub label: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub link: Option<String>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub parent: Option<usize>,
65 pub spans: Vec<[usize; 3]>,
66}
67impl SnapshotRegion {
68 fn from_region(region: &crate::frame::Region) -> Self {
69 use rich::protocol::RegionRole;
70 let (level, row, column) = match region.role {
71 RegionRole::Heading { level } => (Some(level), None, None),
72 RegionRole::TableCell { row, column } => (None, Some(row), Some(column)),
73 RegionRole::TableHeader { column } | RegionRole::TableFooter { column } => {
74 (None, None, Some(column))
75 }
76 _ => (None, None, None),
77 };
78 SnapshotRegion {
79 role: crate::frame::role_name(®ion.role),
80 level,
81 row,
82 column,
83 label: region.label.clone(),
84 link: region.link.clone(),
85 parent: region.parent,
86 spans: region
87 .spans
88 .iter()
89 .map(|span| [span.row, span.columns.start, span.columns.end])
90 .collect(),
91 }
92 }
93}
94#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
104pub struct RenderSnapshot {
105 pub schema_version: u32,
106 pub width: usize,
107 pub height: usize,
108 pub plain: String,
109 pub ansi: String,
110 pub segments: Vec<SnapshotSegment>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub rows: Option<Vec<Vec<SnapshotRun>>>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub regions: Option<Vec<SnapshotRegion>>,
115}
116pub type SnapshotError = serde_json::Error;
117impl RenderSnapshot {
118 pub fn capture_frame(target: &RenderTarget, renderable: &dyn Renderable) -> Self {
120 let segments = target.segments(renderable);
121 let frame = crate::frame::Frame::from_segments(&segments);
122 Self::from_frame(target, &segments, &frame)
123 }
124 fn from_frame(
127 target: &RenderTarget,
128 segments: &[Segment],
129 frame: &crate::frame::Frame,
130 ) -> Self {
131 let caps = target.capabilities();
132 let snapshot: Vec<SnapshotSegment> = segments.iter().map(snapshot_segment).collect();
133 Self {
134 schema_version: 2,
135 width: caps.width,
136 height: caps.height,
137 plain: frame.plain(),
138 ansi: frame.to_ansi_merged(&target.console()),
139 segments: Vec::new(),
140 rows: Some(rows(&snapshot)),
141 regions: None,
142 }
143 }
144 pub fn capture_regions(target: &RenderTarget, renderable: &dyn Renderable) -> Self {
149 let (segments, frame) = target.segments_and_frame_with_regions(renderable);
150 let regions = frame
151 .regions()
152 .iter()
153 .map(SnapshotRegion::from_region)
154 .collect();
155 Self {
156 schema_version: 3,
157 regions: Some(regions),
158 ..Self::from_frame(target, &segments, &frame)
159 }
160 }
161 #[must_use]
164 pub fn upgrade(&self) -> Self {
165 if self.rows.is_some() {
166 return self.clone();
167 }
168 Self {
169 schema_version: 2,
170 segments: Vec::new(),
171 rows: Some(rows(&self.segments)),
172 ..self.clone()
173 }
174 }
175 pub fn capture(target: &RenderTarget, renderable: &dyn Renderable) -> Self {
176 let segments = target.segments(renderable);
177 let caps = target.capabilities();
178 Self {
179 schema_version: 1,
180 width: caps.width,
181 height: caps.height,
182 plain: segments
183 .iter()
184 .filter(|s| !s.control)
185 .map(|s| s.text.as_str())
186 .collect(),
187 ansi: target.console().segments_to_string(&segments),
188 segments: segments.iter().map(snapshot_segment).collect(),
189 rows: None,
190 regions: None,
191 }
192 }
193 pub fn to_json(&self) -> Result<String, SnapshotError> {
194 serde_json::to_string_pretty(self)
195 }
196 pub fn diff(&self, other: &Self) -> Option<String> {
205 if self.rows.is_some() || other.rows.is_some() {
206 let (a, b) = (self.upgrade(), other.upgrade());
207 let regions_differ = matches!(
208 (&a.regions, &b.regions),
209 (Some(x), Some(y)) if x != y
210 );
211 if (a.width, a.height, &a.plain, &a.rows) == (b.width, b.height, &b.plain, &b.rows)
212 && !regions_differ
213 {
214 return None;
215 }
216 if a.plain != b.plain {
217 return Some(
218 crate::diff::TextDiff::new(&a.plain, &b.plain).unified("self", "other"),
219 );
220 }
221 let (a_value, b_value) = (
222 serde_json::to_value(&a).ok()?,
223 serde_json::to_value(&b).ok()?,
224 );
225 let mut out = String::new();
226 let lines = crate::diff::DiffView::ansi(&a.ansi, &b.ansi).style_changed_lines();
227 if !lines.is_empty() {
228 let lines: Vec<String> = lines.iter().map(usize::to_string).collect();
229 out.push_str(&format!("style changed on line {}\n", lines.join(", ")));
230 }
231 let field = first_difference("rows", &a_value["rows"], &b_value["rows"])
232 .or_else(|| {
233 regions_differ
234 .then(|| {
235 first_difference("regions", &a_value["regions"], &b_value["regions"])
236 })
237 .flatten()
238 })
239 .or_else(|| first_difference("snapshot", &a_value, &b_value));
240 if let Some(field) = field {
241 out.push_str(&field);
242 }
243 return Some(out);
244 }
245 if self == other {
246 return None;
247 }
248 if self.plain != other.plain {
249 return Some(
250 crate::diff::TextDiff::new(&self.plain, &other.plain).unified("self", "other"),
251 );
252 }
253 if self.segments != other.segments {
254 let view = crate::diff::DiffView::ansi(&self.ansi, &other.ansi);
255 let lines = view.style_changed_lines();
256 let field = first_difference(
257 "segments",
258 &serde_json::to_value(&self.segments).ok()?,
259 &serde_json::to_value(&other.segments).ok()?,
260 );
261 let mut out = String::new();
262 if !lines.is_empty() {
263 let lines: Vec<String> = lines.iter().map(usize::to_string).collect();
264 out.push_str(&format!("style changed on line {}\n", lines.join(", ")));
265 }
266 if let Some(field) = field {
267 out.push_str(&field);
268 }
269 return (!out.is_empty()).then_some(out);
270 }
271 let a = serde_json::to_value(self).ok()?;
272 let b = serde_json::to_value(other).ok()?;
273 first_difference("snapshot", &a, &b)
274 }
275}
276fn first_difference(path: &str, a: &serde_json::Value, b: &serde_json::Value) -> Option<String> {
277 if a == b {
278 return None;
279 }
280 match (a, b) {
281 (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
282 for (key, v) in a {
283 if let Some(diff) = first_difference(
284 &format!("{path}.{key}"),
285 v,
286 b.get(key).unwrap_or(&serde_json::Value::Null),
287 ) {
288 return Some(diff);
289 }
290 }
291 }
292 (serde_json::Value::Array(a), serde_json::Value::Array(b)) => {
293 for i in 0..a.len().max(b.len()) {
294 if let Some(diff) = first_difference(
295 &format!("{path}[{i}]"),
296 a.get(i).unwrap_or(&serde_json::Value::Null),
297 b.get(i).unwrap_or(&serde_json::Value::Null),
298 ) {
299 return Some(diff);
300 }
301 }
302 }
303 _ => {}
304 }
305 Some(format!("{path}: {a} -> {b}"))
306}
307fn rows(segments: &[SnapshotSegment]) -> Vec<Vec<SnapshotRun>> {
310 let mut rows: Vec<Vec<SnapshotRun>> = vec![Vec::new()];
311 for segment in segments.iter().filter(|s| !s.control) {
312 for (index, piece) in segment.text.split('\n').enumerate() {
313 if index > 0 {
314 rows.push(Vec::new());
315 }
316 if piece.is_empty() {
317 continue;
318 }
319 let run = SnapshotRun {
320 text: piece.to_owned(),
321 foreground: segment.foreground.clone(),
322 background: segment.background.clone(),
323 attributes: segment.attributes.clone(),
324 link: segment.link.clone(),
325 };
326 let row = rows.last_mut().expect("at least one row");
327 match row.last_mut() {
328 Some(last) if last.same_style(&run) => last.text.push_str(&run.text),
329 _ => row.push(run),
330 }
331 }
332 }
333 rows
334}
335fn snapshot_segment(s: &Segment) -> SnapshotSegment {
336 const ATTRS: [&str; 13] = [
337 "bold",
338 "dim",
339 "italic",
340 "underline",
341 "blink",
342 "blink2",
343 "reverse",
344 "conceal",
345 "strike",
346 "underline2",
347 "frame",
348 "encircle",
349 "overline",
350 ];
351 let theme = rich::terminal_theme::DEFAULT_TERMINAL_THEME;
352 SnapshotSegment {
353 text: s.text.to_string(),
354 control: s.control,
355 foreground: s.style.as_ref().and_then(|s| s.color()).map(|c| {
356 theme
357 .resolve(c, true)
358 .hex()
359 .trim_start_matches('#')
360 .to_owned()
361 }),
362 background: s.style.as_ref().and_then(|s| s.bgcolor()).map(|c| {
363 theme
364 .resolve(c, false)
365 .hex()
366 .trim_start_matches('#')
367 .to_owned()
368 }),
369 attributes: s
370 .style
371 .as_ref()
372 .map(|s| {
373 ATTRS
374 .iter()
375 .enumerate()
376 .filter_map(|(i, n)| {
377 s.attr(i)
378 .map(|v| if v { (*n).into() } else { format!("not {n}") })
379 })
380 .collect()
381 })
382 .unwrap_or_default(),
383 link: s.style.as_ref().and_then(|s| s.link()).map(str::to_owned),
384 }
385}