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 pub fn upgrade(&self) -> Self {
164 if self.rows.is_some() {
165 return self.clone();
166 }
167 Self {
168 schema_version: 2,
169 segments: Vec::new(),
170 rows: Some(rows(&self.segments)),
171 ..self.clone()
172 }
173 }
174 pub fn capture(target: &RenderTarget, renderable: &dyn Renderable) -> Self {
175 let segments = target.segments(renderable);
176 let caps = target.capabilities();
177 Self {
178 schema_version: 1,
179 width: caps.width,
180 height: caps.height,
181 plain: segments
182 .iter()
183 .filter(|s| !s.control)
184 .map(|s| s.text.as_str())
185 .collect(),
186 ansi: target.console().segments_to_string(&segments),
187 segments: segments.iter().map(snapshot_segment).collect(),
188 rows: None,
189 regions: None,
190 }
191 }
192 pub fn to_json(&self) -> Result<String, SnapshotError> {
193 serde_json::to_string_pretty(self)
194 }
195 pub fn diff(&self, other: &Self) -> Option<String> {
204 if self.rows.is_some() || other.rows.is_some() {
205 let (a, b) = (self.upgrade(), other.upgrade());
206 let regions_differ = matches!(
207 (&a.regions, &b.regions),
208 (Some(x), Some(y)) if x != y
209 );
210 if (a.width, a.height, &a.plain, &a.rows) == (b.width, b.height, &b.plain, &b.rows)
211 && !regions_differ
212 {
213 return None;
214 }
215 if a.plain != b.plain {
216 return Some(
217 crate::diff::TextDiff::new(&a.plain, &b.plain).unified("self", "other"),
218 );
219 }
220 let (a_value, b_value) = (
221 serde_json::to_value(&a).ok()?,
222 serde_json::to_value(&b).ok()?,
223 );
224 let mut out = String::new();
225 let lines = crate::diff::DiffView::ansi(&a.ansi, &b.ansi).style_changed_lines();
226 if !lines.is_empty() {
227 let lines: Vec<String> = lines.iter().map(usize::to_string).collect();
228 out.push_str(&format!("style changed on line {}\n", lines.join(", ")));
229 }
230 let field = first_difference("rows", &a_value["rows"], &b_value["rows"])
231 .or_else(|| {
232 regions_differ
233 .then(|| {
234 first_difference("regions", &a_value["regions"], &b_value["regions"])
235 })
236 .flatten()
237 })
238 .or_else(|| first_difference("snapshot", &a_value, &b_value));
239 if let Some(field) = field {
240 out.push_str(&field);
241 }
242 return Some(out);
243 }
244 if self == other {
245 return None;
246 }
247 if self.plain != other.plain {
248 return Some(
249 crate::diff::TextDiff::new(&self.plain, &other.plain).unified("self", "other"),
250 );
251 }
252 if self.segments != other.segments {
253 let view = crate::diff::DiffView::ansi(&self.ansi, &other.ansi);
254 let lines = view.style_changed_lines();
255 let field = first_difference(
256 "segments",
257 &serde_json::to_value(&self.segments).ok()?,
258 &serde_json::to_value(&other.segments).ok()?,
259 );
260 let mut out = String::new();
261 if !lines.is_empty() {
262 let lines: Vec<String> = lines.iter().map(usize::to_string).collect();
263 out.push_str(&format!("style changed on line {}\n", lines.join(", ")));
264 }
265 if let Some(field) = field {
266 out.push_str(&field);
267 }
268 return (!out.is_empty()).then_some(out);
269 }
270 let a = serde_json::to_value(self).ok()?;
271 let b = serde_json::to_value(other).ok()?;
272 first_difference("snapshot", &a, &b)
273 }
274}
275fn first_difference(path: &str, a: &serde_json::Value, b: &serde_json::Value) -> Option<String> {
276 if a == b {
277 return None;
278 }
279 match (a, b) {
280 (serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
281 for (key, v) in a {
282 if let Some(diff) = first_difference(
283 &format!("{path}.{key}"),
284 v,
285 b.get(key).unwrap_or(&serde_json::Value::Null),
286 ) {
287 return Some(diff);
288 }
289 }
290 }
291 (serde_json::Value::Array(a), serde_json::Value::Array(b)) => {
292 for i in 0..a.len().max(b.len()) {
293 if let Some(diff) = first_difference(
294 &format!("{path}[{i}]"),
295 a.get(i).unwrap_or(&serde_json::Value::Null),
296 b.get(i).unwrap_or(&serde_json::Value::Null),
297 ) {
298 return Some(diff);
299 }
300 }
301 }
302 _ => {}
303 }
304 Some(format!("{path}: {a} -> {b}"))
305}
306fn rows(segments: &[SnapshotSegment]) -> Vec<Vec<SnapshotRun>> {
309 let mut rows: Vec<Vec<SnapshotRun>> = vec![Vec::new()];
310 for segment in segments.iter().filter(|s| !s.control) {
311 for (index, piece) in segment.text.split('\n').enumerate() {
312 if index > 0 {
313 rows.push(Vec::new());
314 }
315 if piece.is_empty() {
316 continue;
317 }
318 let run = SnapshotRun {
319 text: piece.to_owned(),
320 foreground: segment.foreground.clone(),
321 background: segment.background.clone(),
322 attributes: segment.attributes.clone(),
323 link: segment.link.clone(),
324 };
325 let row = rows.last_mut().expect("at least one row");
326 match row.last_mut() {
327 Some(last) if last.same_style(&run) => last.text.push_str(&run.text),
328 _ => row.push(run),
329 }
330 }
331 }
332 rows
333}
334fn snapshot_segment(s: &Segment) -> SnapshotSegment {
335 const ATTRS: [&str; 13] = [
336 "bold",
337 "dim",
338 "italic",
339 "underline",
340 "blink",
341 "blink2",
342 "reverse",
343 "conceal",
344 "strike",
345 "underline2",
346 "frame",
347 "encircle",
348 "overline",
349 ];
350 let theme = rich::terminal_theme::DEFAULT_TERMINAL_THEME;
351 SnapshotSegment {
352 text: s.text.clone(),
353 control: s.control,
354 foreground: s.style.as_ref().and_then(|s| s.color()).map(|c| {
355 theme
356 .resolve(c, true)
357 .hex()
358 .trim_start_matches('#')
359 .to_owned()
360 }),
361 background: s.style.as_ref().and_then(|s| s.bgcolor()).map(|c| {
362 theme
363 .resolve(c, false)
364 .hex()
365 .trim_start_matches('#')
366 .to_owned()
367 }),
368 attributes: s
369 .style
370 .as_ref()
371 .map(|s| {
372 ATTRS
373 .iter()
374 .enumerate()
375 .filter_map(|(i, n)| {
376 s.attr(i)
377 .map(|v| if v { (*n).into() } else { format!("not {n}") })
378 })
379 .collect()
380 })
381 .unwrap_or_default(),
382 link: s.style.as_ref().and_then(|s| s.link()).map(str::to_owned),
383 }
384}