use super::{
compose_diff_word_decorations, compose_line_decorations, compose_rainbow_decorations,
Decoration, DiffWordKind, LineSelection, RainbowRange,
};
use crate::annotations::{compose_annotations, Annotation};
use crate::diff_words::DiffWordRange;
use crate::events::HighlightEvent;
use crate::highlights::HIGHLIGHT_NAMES;
use serde::Deserialize;
use std::fs;
use std::ops::RangeInclusive;
use std::path::PathBuf;
#[derive(Debug, Deserialize)]
struct Manifest {
cases: Vec<Case>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Case {
name: String,
source: String,
events: Vec<SyntaxEvent>,
#[serde(default)]
annotations: Vec<CaseAnnotation>,
#[serde(default)]
rainbow_ranges: Vec<CaseRainbowRange>,
#[serde(default)]
diff_word_ranges: Vec<CaseDiffWordRange>,
highlight_lines: Vec<LineSpec>,
expected: String,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
enum SyntaxEvent {
Start { scope: String, language: String },
Source { start: usize, end: usize },
End,
}
#[derive(Debug, Deserialize)]
struct CaseAnnotation {
start: usize,
end: usize,
data: String,
}
#[derive(Debug, Deserialize)]
struct CaseRainbowRange {
start: usize,
end: usize,
depth: usize,
}
#[derive(Debug, Deserialize)]
struct CaseDiffWordRange {
start: usize,
end: usize,
kind: String,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum LineSpec {
Single(usize),
Range([usize; 2]),
}
fn manifest() -> Manifest {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/decoration-composition.json");
serde_json::from_str(&fs::read_to_string(&path).expect("read decoration-composition.json"))
.expect("parse decoration-composition.json")
}
fn scope_index(name: &str) -> usize {
HIGHLIGHT_NAMES
.iter()
.position(|candidate| *candidate == name)
.unwrap_or_else(|| panic!("`{name}` is not a highlight scope"))
}
fn selection(lines: &[LineSpec]) -> LineSelection {
let ranges: Vec<RangeInclusive<usize>> = lines
.iter()
.map(|line| match line {
LineSpec::Single(line) => *line..=*line,
LineSpec::Range([start, end]) => *start..=*end,
})
.collect();
LineSelection::new(&ranges, &[])
}
fn syntax_events(case: &Case) -> Vec<HighlightEvent<'static, ()>> {
case.events
.iter()
.map(|event| match event {
SyntaxEvent::Start { scope, language } => HighlightEvent::Start {
scope_index: scope_index(scope),
language: language.clone(),
},
SyntaxEvent::Source { start, end } => HighlightEvent::Source {
start: *start,
end: *end,
},
SyntaxEvent::End => HighlightEvent::End,
})
.collect()
}
fn annotations(case: &Case) -> Vec<Annotation<String>> {
case.annotations
.iter()
.map(|annotation| {
Annotation::new(annotation.start..annotation.end, annotation.data.clone())
.expect("fixture annotations are well-formed")
})
.collect()
}
fn compose<'a>(
case: &'a Case,
annotations: &'a [Annotation<String>],
) -> Vec<HighlightEvent<'a, String>> {
let ranges = case
.rainbow_ranges
.iter()
.map(|range| RainbowRange {
start: range.start,
end: range.end,
depth: range.depth,
})
.collect::<Vec<_>>();
let diff_words = case
.diff_word_ranges
.iter()
.map(|range| DiffWordRange {
start: range.start,
end: range.end,
kind: match range.kind.as_str() {
"removed" => DiffWordKind::Removed,
"added" => DiffWordKind::Added,
other => panic!("unknown diff word kind {other:?}"),
},
})
.collect::<Vec<_>>();
let events = compose_diff_word_decorations(&case.source, &syntax_events(case), &diff_words);
let events = compose_rainbow_decorations(&case.source, &events, &ranges);
let events = compose_annotations(&case.source, &events, annotations)
.expect("fixture annotations resolve");
compose_line_decorations(&case.source, &events, &selection(&case.highlight_lines))
}
fn notation<T: std::fmt::Display>(events: &[HighlightEvent<'_, T>]) -> String {
let mut decorations = Vec::new();
events
.iter()
.map(|event| match event {
HighlightEvent::Start { scope_index, .. } => {
format!("S:{}", HIGHLIGHT_NAMES[*scope_index])
}
HighlightEvent::Source { start, end } => format!("T:{start}-{end}"),
HighlightEvent::End => "E".to_string(),
HighlightEvent::AnnotationStart { range, data } => {
format!("A+{}@{}-{}", data, range.start, range.end)
}
HighlightEvent::AnnotationEnd => "A-".to_string(),
HighlightEvent::DecorationStart { decoration } => {
decorations.push(*decoration);
match decoration {
Decoration::Line {
number,
highlighted,
} => format!("L+{number}{}", if *highlighted { "*" } else { "" }),
Decoration::RainbowBracket { depth } => format!("R+{depth}"),
Decoration::DiffWord { kind } => format!("W+{}", kind.as_str()),
}
}
HighlightEvent::DecorationEnd => match decorations.pop() {
Some(Decoration::Line { .. }) => "L-".to_string(),
Some(Decoration::RainbowBracket { .. }) => "R-".to_string(),
Some(Decoration::DiffWord { .. }) => "W-".to_string(),
None => "D-".to_string(),
},
})
.collect::<Vec<_>>()
.join(" ")
}
#[test]
fn the_corpus_covers_the_shapes_composition_has_to_get_right() {
let manifest = manifest();
assert!(
manifest.cases.len() >= 36,
"corpus shrank to {} cases",
manifest.cases.len()
);
for required in [
"empty/no-events",
"lines/trailing-newline-opens-one-more",
"lines/blank-line-in-the-middle",
"scope/closed-and-reopened-across-a-newline",
"scope/ends-at-newline",
"scope/continues-in-next-source-event",
"scope/inner-ends-at-newline-outer-continues",
"scope/ends-at-final-newline",
"scope/unbalanced-start-at-final-newline",
"scope/reopens-before-explicit-empty-scope",
"scope/ends-at-crlf-before-blank-line",
"annotation/ends-at-newline",
"annotation/point-after-range-ending-at-newline",
"rainbow/ends-at-newline",
"layers/all-end-at-newline",
"scope/unbalanced-start-closes-before-the-last-line-ends",
"annotation/closed-and-reopened-across-a-newline",
"rainbow/crosses-source-and-syntax-boundaries",
"rainbow/composes-with-annotations-and-lines",
"diffword/crosses-source-and-syntax-boundaries",
"diffword/composes-with-rainbow-annotations-and-lines",
"utf8/multibyte-lines",
"highlight/overlapping-ranges-merge",
"highlight/range-beyond-the-document",
"highlight/blank-line",
] {
assert!(
manifest.cases.iter().any(|case| case.name == required),
"the corpus lost its `{required}` case"
);
}
}
#[test]
fn rust_produces_the_expected_stream() {
for case in &manifest().cases {
let annotations = annotations(case);
let composed = compose(case, &annotations);
let rendered = notation(&composed);
assert_eq!(
rendered, case.expected,
"{}: composition changed",
case.name
);
let recomposed =
compose_line_decorations(&case.source, &composed, &selection(&case.highlight_lines));
assert_eq!(notation(&recomposed), rendered, "{}: recomposed", case.name);
}
}
#[test]
fn composition_preserves_the_source() {
for case in &manifest().cases {
let annotations = annotations(case);
let rendered: String = compose(case, &annotations)
.iter()
.filter_map(|event| match event {
HighlightEvent::Source { start, end } => Some(&case.source[*start..*end]),
_ => None,
})
.collect();
let covered: String = case
.events
.iter()
.filter_map(|event| match event {
SyntaxEvent::Source { start, end } => Some(&case.source[*start..*end]),
_ => None,
})
.collect();
assert_eq!(rendered, covered, "{}: source changed", case.name);
}
}
#[test]
fn every_line_is_balanced() {
for case in &manifest().cases {
let annotations = annotations(case);
let mut depth = 0usize;
let mut lines = 0usize;
let mut decorations = Vec::new();
for event in compose(case, &annotations) {
match event {
HighlightEvent::DecorationStart { decoration } => {
decorations.push(decoration);
match decoration {
Decoration::Line { .. } => {
assert_eq!(depth, 0, "{}: a line opened inside a scope", case.name);
lines += 1;
}
Decoration::RainbowBracket { .. } | Decoration::DiffWord { .. } => {
depth += 1;
}
}
}
HighlightEvent::DecorationEnd => match decorations.pop() {
Some(Decoration::Line { .. }) => {
assert_eq!(depth, 0, "{}: a line closed inside a scope", case.name);
}
Some(Decoration::RainbowBracket { .. } | Decoration::DiffWord { .. }) => {
depth = depth.checked_sub(1).expect("unbalanced span decoration");
}
None => panic!("{}: unmatched decoration end", case.name),
},
HighlightEvent::Start { .. } | HighlightEvent::AnnotationStart { .. } => depth += 1,
HighlightEvent::End | HighlightEvent::AnnotationEnd => {
depth = depth.checked_sub(1).expect("unbalanced closing event");
}
HighlightEvent::Source { .. } => {}
}
}
assert!(lines >= 1, "{}: produced no lines", case.name);
}
}