1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
//! The [`Backend`] trait: a render target for a parsed [`Document`].
//!
//! Each output format (HTML, DOCX, JSON, ...) lives in its own crate and
//! implements this trait. Third parties can add formats the same way without
//! touching the core — that's the whole point of keeping this trait here and
//! depending only on the document model.
use crate::ast::Document;
use crate::attributes::Attributes;
use crate::error::Result;
pub trait Backend {
/// Stable identifier used on the CLI (`--to <name>`).
fn name(&self) -> &'static str;
/// Conventional file extension for this backend's output.
fn extension(&self) -> &'static str;
/// Render a document to bytes (text formats return UTF-8 bytes).
fn convert(&self, doc: &Document) -> Result<Vec<u8>>;
}
/// A converter's work at the start of conversion. Ruby's `convert_document`
/// builds the head (reading a custom stylesheet, which can also activate the
/// URI cache) after converting the document title and before the header
/// outline and the body convert, so its reads and warnings precede those of
/// the body's images. Core converts the body while parsing, so a converter
/// that reads resources in its head passes this hook to
/// [`parse_resource_with_start`](crate::parse_resource_with_start) or
/// [`parse_with_start`](crate::parse_with_start); core calls it once at that
/// point and reports the returned warnings as conversion diagnostics there.
pub trait ConversionStart {
/// Called with the document's attributes as conversion starts (the
/// header attributes, like Ruby's converter sees them). Returns warnings.
fn start(&mut self, attributes: &Attributes) -> Vec<String>;
/// Called before [`start`](Self::start) with Ruby's base directory of the
/// document ([`crate::Document::base_dir`]), from which a converter
/// resolves the assets it reads as conversion starts (a stylesheet).
fn base_dir(&mut self, dir: Option<&str>) {
let _ = dir;
}
/// Ruby's `highlight` substitution: a `source` listing whose custom
/// `subs` keep `specialcharacters` is highlighted by a server-side syntax
/// highlighter in their place, and the later stages run over its HTML
/// (`highlight_source`). Core extracts and restores the callouts; the
/// converter returns the highlighted HTML and, when it wraps the code
/// (a line number table), the offset where the code starts. `None`
/// keeps Ruby's `sub_source` (special characters, then callouts).
fn highlight(&mut self, request: &HighlightRequest<'_>) -> Option<Highlighted> {
let _ = request;
None
}
}
/// What a [`ConversionStart::highlight`] call highlights.
#[derive(Debug)]
pub struct HighlightRequest<'a> {
/// The text the earlier stages produced, without callout marks.
pub source: &'a str,
/// The listing's named attributes (`language`, `linenums`, `start`,
/// `highlight` …) and options.
pub named: &'a std::collections::BTreeMap<String, String>,
pub options: &'a [String],
/// The document attributes where the listing converts.
pub attributes: &'a Attributes,
}
/// The highlighted HTML of a [`HighlightRequest`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Highlighted {
pub html: String,
/// Where the code starts in `html` (Ruby's `source_offset`), if not 0.
pub source_offset: Option<usize>,
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Recorder(Vec<(Option<String>, Option<String>)>);
impl ConversionStart for Recorder {
fn start(&mut self, attributes: &Attributes) -> Vec<String> {
self.0.push((
attributes.get("stylesheet").map(str::to_owned),
attributes.get("body-entry").map(str::to_owned),
));
vec!["head warning".into()]
}
}
#[test]
fn conversion_start_runs_once_with_the_header_attributes() {
// Once per document (not for an AsciiDoc cell's nested document),
// with the header attributes: a body entry is not set yet.
let source =
"= T\n:stylesheet: a.css\n\n:body-entry: x\n\n|===\na|\n= Inner\n\ncell\n|===\n";
let mut recorder = Recorder::default();
let result = crate::parse_with_start(source, &Default::default(), &mut recorder);
assert_eq!(recorder.0, [(Some("a.css".to_owned()), None)]);
// Its warnings are conversion diagnostics without a source cursor.
let warning = result
.diagnostics
.iter()
.find(|d| d.message == "head warning")
.expect("start warning");
assert_eq!(warning.severity, crate::Severity::Warning);
assert_eq!(
warning.report.location,
crate::DiagnosticLocation::Unlocated
);
// The document is the same as without the hook.
assert_eq!(
result.document,
crate::parse_with_options(source, &Default::default()).document
);
}
/// Wraps every word in a span, like a highlighter, and records requests.
#[derive(Default)]
struct Spans(Vec<(String, Option<String>, Option<String>)>);
impl ConversionStart for Spans {
fn start(&mut self, _: &Attributes) -> Vec<String> {
Vec::new()
}
fn highlight(&mut self, request: &HighlightRequest<'_>) -> Option<Highlighted> {
self.0.push((
request.source.to_owned(),
request.named.get("language").cloned(),
request.attributes.get("x").map(str::to_owned),
));
let escaped = request
.source
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
Some(Highlighted {
html: format!("<span class=\"k\">{escaped}</span>"),
source_offset: None,
})
}
}
fn listing_parts(result: &crate::ParseResult) -> Vec<crate::AttributePart> {
let crate::BlockKind::Listing {
inlines: Some(inlines),
..
} = &result.document.blocks[0].kind
else {
panic!("listing with substituted content");
};
let [crate::Inline::Substituted { parts, .. }] = inlines.as_slice() else {
panic!("one substituted node");
};
parts.clone()
}
#[test]
fn highlight_replaces_special_characters_of_a_source_listing() {
use crate::AttributePart as P;
// `attributes+` prepends the stage: the attribute expands first.
let source = ":x: <v>\n\n[source,java,subs=\"attributes+\"]\n----\na < {x} // <1>\n----\n";
let mut spans = Spans::default();
let result = crate::parse_with_start(source, &Default::default(), &mut spans);
// The earlier attributes stage feeds the highlighter Ruby's string
// (the stored value is escaped), without the callout mark.
assert_eq!(
spans.0,
[(
"a < <v> \n".to_owned(),
Some("java".to_owned()),
Some("<v>".to_owned())
)]
);
let parts = listing_parts(&result);
// Tags are raw, text decoded; the mark follows its line.
assert_eq!(parts[0], P::Raw("<span class=\"k\">".into()));
assert_eq!(parts[1], P::Text("a < <v> ".into()));
assert!(
matches!(&parts[2], P::Callout(c) if c.number == "1" && c.guard.as_deref() == Some("// ") && c.id.as_deref() == Some("CO1-1"))
);
}
#[test]
fn without_a_highlighter_custom_subs_substitute_special_characters() {
use crate::AttributePart as P;
// A converter that does not highlight (and plain parsing) keeps
// Ruby's `sub_source`; a non-source listing is never highlighted.
let source = "[source,java,subs=\"+attributes\"]\n----\na < b\n----\n";
let plain = crate::parse_with_options(source, &Default::default());
assert_eq!(listing_parts(&plain), [P::Text("a < b".into())]);
let mut recorder = Recorder::default();
let hooked = crate::parse_with_start(source, &Default::default(), &mut recorder);
assert_eq!(hooked.document, plain.document);
let mut spans = Spans::default();
let listing = "[listing,subs=\"+attributes\"]\n----\na < b\n----\n";
crate::parse_with_start(listing, &Default::default(), &mut spans);
assert!(spans.0.is_empty());
}
}