use std::fmt;
use std::sync::Arc;
use std::time::{Duration, Instant};
use rich::color::ColorSystem;
use rich::protocol::{CodeHighlighter, HighlightError, HighlightedCode};
use rich::syntax::Syntax;
use rich::Console;
const SOURCES: &[(&str, &str)] = &[
("empty", ""),
("a lone newline", "\n"),
("no final newline", "fn main() {\n let x = 1;\n}"),
("a final newline", "def f(x):\n return x + 1 # one\n"),
("CRLF line endings", "fn a() {}\r\nfn b() {}\r\n"),
("tabs", "if x {\n\treturn \"a\\tb\";\n}\n"),
(
"multi-byte text",
"let s = \"é 日本 🦀\"; // ünïcode\nprint(\"ok\")\n",
),
("blank lines", "\n\n\nx = 1\n\n"),
(
"an unterminated string",
"let s = \"never closed\nnext line\n",
),
];
const LANGUAGES: &[Option<&str>] = &[Some("rust"), Some("python"), Some("rs"), None];
const UNKNOWN_LANGUAGE: &str = "no-such-language-for-conformance";
const UNKNOWN_THEME: &str = "no-such-theme-for-conformance";
const SCALING_BUDGET: f64 = 40.0;
const TIMING_FLOOR: Duration = Duration::from_millis(2);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Failure {
pub check: &'static str,
pub detail: String,
}
impl fmt::Display for Failure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.check, self.detail)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConformanceError {
pub failures: Vec<Failure>,
}
impl fmt::Display for ConformanceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
f,
"the adapter failed {} conformance check(s):",
self.failures.len()
)?;
for failure in &self.failures {
writeln!(f, "- {failure}")?;
}
Ok(())
}
}
impl std::error::Error for ConformanceError {}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Options {
pub all_themes: bool,
pub scaling: bool,
}
impl Default for Options {
fn default() -> Self {
Options {
all_themes: false,
scaling: true,
}
}
}
pub fn check(highlighter: Arc<dyn CodeHighlighter>) -> Result<(), ConformanceError> {
check_with(highlighter, Options::default())
}
pub fn check_with(
highlighter: Arc<dyn CodeHighlighter>,
options: Options,
) -> Result<(), ConformanceError> {
let mut failures = Vec::new();
let mut fail = |check: &'static str, detail: String| failures.push(Failure { check, detail });
let themes = highlighter.themes();
let default = highlighter.default_theme().to_string();
if !themes.contains(&default) {
fail(
"themes",
format!("the default theme {default:?} is not in themes()"),
);
}
let mut chosen: Vec<String> = if options.all_themes {
themes.clone()
} else {
let mut chosen = vec![default.clone()];
if themes.iter().any(|t| t == "ansi_dark") {
chosen.push("ansi_dark".into());
}
chosen
};
chosen.dedup();
match highlighter.highlight("x = 1", Some("python"), UNKNOWN_THEME) {
Err(HighlightError::UnknownTheme(name)) if name == UNKNOWN_THEME => {}
Err(HighlightError::UnknownTheme(name)) => fail(
"unknown theme",
format!("UnknownTheme names {name:?}, not {UNKNOWN_THEME:?}"),
),
Err(other) => fail(
"unknown theme",
format!("expected UnknownTheme, got the error {other}"),
),
Ok(_) => fail(
"unknown theme",
format!("{UNKNOWN_THEME:?} highlighted instead of returning UnknownTheme"),
),
}
for theme in &chosen {
for (name, code) in SOURCES {
for language in LANGUAGES {
let context = format!("{name}, language {language:?}, theme {theme:?}");
match highlighter.highlight(code, *language, theme) {
Ok(highlighted) => check_shape(code, &highlighted, &context, &mut fail),
Err(error) => fail("highlights", format!("{context}: {error}")),
}
}
let unknown = highlighter.highlight(code, Some(UNKNOWN_LANGUAGE), theme);
let none = highlighter.highlight(code, None, theme);
match (unknown, none) {
(Ok(unknown), Ok(none)) if unknown != none => fail(
"unknown language",
format!(
"{name}, theme {theme:?}: an unknown language differs from no language"
),
),
(Err(error), _) => fail(
"unknown language",
format!("{name}, theme {theme:?}: an unknown language failed: {error}"),
),
_ => {}
}
}
}
let console = Console::builder()
.width(40)
.force_terminal(true)
.color_system(Some(ColorSystem::Truecolor))
.build();
for (name, code) in SOURCES {
let syntax = Syntax::new(*code, "rust")
.theme(default.clone())
.highlighter(highlighter.clone());
let rendered = console.render_to_string(&syntax);
if let Some(control) = stray_control(&rendered) {
fail(
"no control characters",
format!("{name}: the rendered output contains {control:?}"),
);
}
}
if options.scaling {
if let Some(detail) = scaling(highlighter.as_ref(), &default) {
fail("scaling", detail);
}
}
if failures.is_empty() {
Ok(())
} else {
Err(ConformanceError { failures })
}
}
fn check_shape(
code: &str,
highlighted: &HighlightedCode,
context: &str,
fail: &mut impl FnMut(&'static str, String),
) {
let lines: Vec<&str> = code.split('\n').collect();
if highlighted.lines.len() != lines.len() {
fail(
"line count",
format!(
"{context}: {} lines for {} elements of split('\\n')",
highlighted.lines.len(),
lines.len()
),
);
}
for (number, (line, text)) in highlighted.lines.iter().zip(&lines).enumerate() {
let mut end = 0;
for span in &line.spans {
let range = &span.range;
let problem = if range.start >= range.end {
Some("is empty or reversed")
} else if range.start < end {
Some("overlaps or precedes the previous span")
} else if range.end > text.len() {
Some("runs past the end of the line")
} else if !text.is_char_boundary(range.start) || !text.is_char_boundary(range.end) {
Some("splits a character")
} else {
None
};
if let Some(problem) = problem {
fail(
"spans",
format!("{context}: line {number}, span {range:?} {problem}"),
);
}
end = end.max(range.end);
}
}
}
fn stray_control(rendered: &str) -> Option<char> {
let mut chars = rendered.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.next() != Some('[') {
return Some(c);
}
let mut finished = false;
for c in chars.by_ref() {
if c == 'm' {
finished = true;
break;
}
if !(c.is_ascii_digit() || c == ';') {
return Some(c);
}
}
if !finished {
return Some('\u{1b}');
}
} else if c.is_control() && c != '\n' {
return Some(c);
}
}
None
}
fn scaling(highlighter: &dyn CodeHighlighter, theme: &str) -> Option<String> {
let block = "fn f(x: u32) -> u32 {\n // add one\n let s = \"text\";\n x + 1\n}\n";
let small = block.repeat(200);
let large = block.repeat(2_000);
let time = |code: &str| {
(0..3)
.map(|_| {
let started = Instant::now();
let _ = highlighter.highlight(code, Some("rust"), theme);
started.elapsed()
})
.min()
.unwrap_or_default()
};
let _ = time(&small); let small_time = time(&small).max(TIMING_FLOOR);
let large_time = time(&large);
let ratio = large_time.as_secs_f64() / small_time.as_secs_f64();
(ratio > SCALING_BUDGET).then(|| {
format!(
"10,000 lines took {ratio:.1}× as long as 1,000 ({large_time:?} against \
{small_time:?}); the budget is {SCALING_BUDGET}×"
)
})
}