#[cfg(any(feature = "ansi", feature = "html"))]
use std::fmt::Write as _;
use crate::HighlightStatus;
#[cfg(feature = "ansi")]
use crate::theme::RgbColor;
#[cfg(any(feature = "ansi", feature = "html"))]
use crate::{Error, HighlightedDocument, HighlightedLine, Result, Style, theme::SyntaxModifiers};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedOutput {
content: String,
status: HighlightStatus,
}
impl RenderedOutput {
pub fn as_str(&self) -> &str {
&self.content
}
pub fn into_string(self) -> String {
self.content
}
pub fn status(&self) -> HighlightStatus {
self.status
}
}
#[cfg(feature = "html")]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HtmlOptions {
pub include_wrapper: bool,
pub class: Option<String>,
pub include_scopes: bool,
}
#[cfg(feature = "html")]
impl Default for HtmlOptions {
fn default() -> Self {
Self {
include_wrapper: true,
class: Some("syntaxmate".to_owned()),
include_scopes: false,
}
}
}
#[cfg(feature = "html")]
pub fn render_html(
source: &str,
document: &HighlightedDocument,
options: &HtmlOptions,
) -> Result<RenderedOutput> {
let lines = validated_lines(source, document)?;
let mut output = String::with_capacity(source.len().saturating_mul(2));
if options.include_wrapper {
output.push_str("<pre");
if let Some(class) = options.class.as_deref() {
output.push_str(" class=\"");
escape_html_attribute(class, &mut output);
output.push('"');
}
output.push_str("><code>");
}
for (line_index, (text, line)) in lines.into_iter().enumerate() {
render_html_line(text, line, options, &mut output);
if line_index + 1 < document.lines().len() {
output.push('\n');
}
}
if options.include_wrapper {
output.push_str("</code></pre>");
}
Ok(RenderedOutput {
content: output,
status: document.status(),
})
}
#[cfg(feature = "html")]
fn render_html_line(
text: &str,
line: &HighlightedLine,
options: &HtmlOptions,
output: &mut String,
) {
let mut cursor = 0;
for span in line.spans() {
let range = span.range();
escape_html_text(&text[cursor..range.start], output);
output.push_str("<span");
write_html_style(span.style(), output);
if options.include_scopes {
output.push_str(" data-scopes=\"");
for (index, scope) in line.scope_names(span.scope_stack()).enumerate() {
if index != 0 {
output.push(' ');
}
escape_html_attribute(scope, output);
}
output.push('"');
}
output.push('>');
escape_html_text(&text[range.clone()], output);
output.push_str("</span>");
cursor = range.end;
}
escape_html_text(&text[cursor..], output);
}
#[cfg(feature = "html")]
fn write_html_style(style: Style, output: &mut String) {
if style == Style::default() {
return;
}
output.push_str(" style=\"");
if let Some(color) = style.foreground {
let _ = write!(
output,
"color:#{:02x}{:02x}{:02x};",
color.red, color.green, color.blue
);
}
if let Some(color) = style.background {
let _ = write!(
output,
"background-color:#{:02x}{:02x}{:02x};",
color.red, color.green, color.blue
);
}
if style.modifiers.contains(SyntaxModifiers::BOLD) {
output.push_str("font-weight:bold;");
}
if style.modifiers.contains(SyntaxModifiers::ITALIC) {
output.push_str("font-style:italic;");
}
if style.modifiers.contains(SyntaxModifiers::UNDERLINED)
|| style.modifiers.contains(SyntaxModifiers::CROSSED_OUT)
{
output.push_str("text-decoration:");
if style.modifiers.contains(SyntaxModifiers::UNDERLINED) {
output.push_str("underline");
}
if style.modifiers.contains(SyntaxModifiers::CROSSED_OUT) {
if style.modifiers.contains(SyntaxModifiers::UNDERLINED) {
output.push(' ');
}
output.push_str("line-through");
}
output.push(';');
}
output.push('"');
}
#[cfg(feature = "html")]
fn escape_html_text(text: &str, output: &mut String) {
for character in text.chars() {
match character {
'&' => output.push_str("&"),
'<' => output.push_str("<"),
'>' => output.push_str(">"),
'"' => output.push_str("""),
'\'' => output.push_str("'"),
character => output.push(character),
}
}
}
#[cfg(feature = "html")]
fn escape_html_attribute(text: &str, output: &mut String) {
for character in text.chars() {
match character {
'&' => output.push_str("&"),
'<' => output.push_str("<"),
'>' => output.push_str(">"),
'"' => output.push_str("""),
'\'' => output.push_str("'"),
'\0' => output.push('\u{fffd}'),
character if character.is_control() => {
let _ = write!(output, "&#x{:x};", u32::from(character));
}
character => output.push(character),
}
}
}
#[cfg(feature = "ansi")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AnsiOptions {
pub colors: bool,
pub sanitize_control_characters: bool,
}
#[cfg(feature = "ansi")]
impl Default for AnsiOptions {
fn default() -> Self {
Self {
colors: true,
sanitize_control_characters: true,
}
}
}
#[cfg(feature = "ansi")]
pub fn render_ansi(
source: &str,
document: &HighlightedDocument,
options: &AnsiOptions,
) -> Result<RenderedOutput> {
let lines = validated_lines(source, document)?;
let mut output = String::with_capacity(source.len().saturating_mul(2));
for (line_index, (text, line)) in lines.into_iter().enumerate() {
render_ansi_line(text, line, options, &mut output);
if line_index + 1 < document.lines().len() {
output.push('\n');
}
}
Ok(RenderedOutput {
content: output,
status: document.status(),
})
}
#[cfg(feature = "ansi")]
fn render_ansi_line(
text: &str,
line: &HighlightedLine,
options: &AnsiOptions,
output: &mut String,
) {
let mut cursor = 0;
let mut active_style = Style::default();
for span in line.spans() {
let range = span.range();
write_ansi_source(&text[cursor..range.start], options, output);
let style = if options.colors {
span.style()
} else {
Style::default()
};
if style != active_style {
if active_style != Style::default() {
output.push_str("\x1b[0m");
}
write_ansi_style(style, output);
active_style = style;
}
write_ansi_source(&text[range.clone()], options, output);
cursor = range.end;
}
if active_style != Style::default() {
output.push_str("\x1b[0m");
}
write_ansi_source(&text[cursor..], options, output);
}
#[cfg(feature = "ansi")]
fn write_ansi_style(style: Style, output: &mut String) {
let mut codes = Vec::with_capacity(6);
if style.modifiers.contains(SyntaxModifiers::BOLD) {
codes.push("1".to_owned());
}
if style.modifiers.contains(SyntaxModifiers::ITALIC) {
codes.push("3".to_owned());
}
if style.modifiers.contains(SyntaxModifiers::UNDERLINED) {
codes.push("4".to_owned());
}
if style.modifiers.contains(SyntaxModifiers::CROSSED_OUT) {
codes.push("9".to_owned());
}
if let Some(color) = style.foreground {
codes.push(ansi_color("38", color));
}
if let Some(color) = style.background {
codes.push(ansi_color("48", color));
}
if !codes.is_empty() {
output.push_str("\x1b[");
output.push_str(&codes.join(";"));
output.push('m');
}
}
#[cfg(feature = "ansi")]
fn ansi_color(prefix: &str, color: RgbColor) -> String {
format!("{prefix};2;{};{};{}", color.red, color.green, color.blue)
}
#[cfg(feature = "ansi")]
fn write_ansi_source(text: &str, options: &AnsiOptions, output: &mut String) {
if !options.sanitize_control_characters {
output.push_str(text);
return;
}
for character in text.chars() {
if character == '\t' || !character.is_control() {
output.push(character);
continue;
}
match character {
'\0'..='\x1f' => {
let picture = char::from_u32(0x2400 + u32::from(character)).unwrap_or('\u{fffd}');
output.push(picture);
}
'\x7f' => output.push('\u{2421}'),
_ => {
let _ = write!(output, "\\u{{{:x}}}", u32::from(character));
}
}
}
}
#[cfg(any(feature = "ansi", feature = "html"))]
fn validated_lines<'a>(
source: &'a str,
document: &'a HighlightedDocument,
) -> Result<Vec<(&'a str, &'a HighlightedLine)>> {
let source_lines = crate::engine::line::LineChunks::new(source)
.map(|line| line.text)
.collect::<Vec<_>>();
if source_lines.len() != document.lines().len() {
return Err(Error::Render(format!(
"source has {} logical lines but the highlighted document has {}",
source_lines.len(),
document.lines().len()
)));
}
source_lines
.into_iter()
.zip(document.lines())
.enumerate()
.map(|(line_index, (text, line))| {
let mut cursor = 0;
for span in line.spans() {
let range = span.range();
if range.start < cursor
|| range.start > range.end
|| range.end > text.len()
|| !text.is_char_boundary(range.start)
|| !text.is_char_boundary(range.end)
{
return Err(Error::Render(format!(
"invalid highlighted byte range {range:?} on line {line_index}"
)));
}
cursor = range.end;
}
Ok((text, line))
})
.collect()
}
#[cfg(all(
test,
feature = "ansi",
feature = "html",
feature = "bundled-grammars",
feature = "bundled-themes"
))]
mod tests {
use super::*;
use crate::Highlighter;
#[test]
fn html_escapes_source_and_can_expose_exact_scopes() {
let source = "fn main() { println!(\"<script>&\"); }\n";
let mut highlighter = Highlighter::bundled().unwrap();
let document = highlighter
.highlight("rust", source, "github-dark")
.unwrap();
let output = render_html(
source,
&document,
&HtmlOptions {
class: Some("syntaxmate\" data-injected=\"no".to_owned()),
include_scopes: true,
..HtmlOptions::default()
},
)
.unwrap();
assert!(
output
.as_str()
.starts_with("<pre class=\"syntaxmate" data-injected="no\"><code>")
);
assert!(!output.as_str().contains(" data-injected=\"no\""));
assert!(output.as_str().contains("<script>&"));
assert!(!output.as_str().contains("<script>"));
assert!(output.as_str().contains("data-scopes=\""));
assert!(output.as_str().ends_with("</code></pre>"));
assert!(output.status().is_complete());
}
#[test]
fn ansi_sanitizes_source_escape_sequences() {
let source = "let value = \"\x1b[31m\";";
let mut highlighter = Highlighter::bundled().unwrap();
let document = highlighter
.highlight("rust", source, "github-dark")
.unwrap();
let output = render_ansi(source, &document, &AnsiOptions::default()).unwrap();
assert!(output.as_str().contains('␛'));
assert!(!output.as_str().contains("\x1b[31m"));
assert!(output.as_str().contains("\x1b["));
}
#[test]
fn renderers_reject_a_document_from_different_source() {
let mut highlighter = Highlighter::bundled().unwrap();
let document = highlighter
.highlight("rust", "let x = 1;", "github-dark")
.unwrap();
let error = render_html("different\nshape", &document, &HtmlOptions::default())
.expect_err("line mismatch must fail");
assert!(matches!(error, Error::Render(_)));
}
}