Skip to main content

mos_csl/
parser.rs

1//! Parse a CSL 1.0.2 style document into the typed [`Style`] AST.
2//!
3//! A read-only [`roxmltree`] DOM walk, dispatching on element local names.
4//! The `<style>` root must be in the CSL namespace or none
5//! (an unnamespaced root is tolerated; a foreign namespace is rejected).
6//!
7//! It models the structure and common attributes; unmodelled attributes are ignored,
8//! unknown rendering elements are a [`CslParseError`], and in-style `<locale>` blocks are retained
9//! as raw XML for a later locale slice.
10
11use std::collections::BTreeMap;
12
13use roxmltree::{Document, Node};
14
15use crate::error::{CslParseError, CslParseErrorKind};
16use crate::style::{
17    Bibliography, BibliographyOptions, Branch, Choose, Citation, CitationOptions, Common,
18    Conditions, DateElement, DatePart, Element, EtAl, Group, Info, InfoCategory, InfoContributor,
19    InfoLink, InheritableNameOptions, Label, Layout, LocaleBlock, Match, NameElement, NamePart,
20    Names, Number, SortKey, SortKeyOptions, SortTarget, Style, StyleClass, StyleOptions, Text,
21    TextSource,
22};
23
24/// The CSL XML namespace. A namespaced `<style>` root must use it.
25const CSL_NAMESPACE: &str = "http://purl.org/net/xbiblio/csl";
26
27/// Parse `input` as a CSL 1.0.2 style.
28///
29/// # Errors
30///
31/// Returns a [`CslParseError`] when `input` is not well-formed XML, the root is
32/// not `<style>`, the required `version`/`class` attributes are missing or
33/// invalid or unsupported, a `<macro>` lacks a `name`, a `<citation>`/
34/// `<bibliography>` lacks its `<layout>`, a `<text>` selects no source or too
35/// many sources, a `<choose>` has invalid branch order, or an unsupported
36/// rendering element is encountered.
37///
38/// # Examples
39///
40/// ```
41/// use mos_csl::{parse_style, StyleClass};
42///
43/// let style = parse_style(
44///     r#"<style version="1.0" class="in-text">
45///          <info><title>Demo</title></info>
46///          <citation><layout><text variable="title"/></layout></citation>
47///        </style>"#,
48/// )
49/// .expect("valid CSL");
50/// assert_eq!(style.class, StyleClass::InText);
51/// assert!(style.citation.is_some());
52/// ```
53pub fn parse_style(input: &str) -> Result<Style, CslParseError> {
54    let document = Document::parse(input).map_err(|error| {
55        let offset = text_pos_to_byte_offset(input, error.pos()).unwrap_or(0);
56        CslParseError::new(CslParseErrorKind::MalformedXml(error.to_string()), offset)
57    })?;
58    let root = document.root_element();
59    if root.tag_name().name() != "style" {
60        let name = root.tag_name().name().to_owned();
61        return Err(err_at(root, CslParseErrorKind::UnexpectedRoot(name)));
62    }
63    // A namespaced root must use the CSL namespace; an unnamespaced root is
64    // tolerated (hand-authored styles routinely omit it). A foreign namespace
65    // is rejected: element local names alone would otherwise accept non-CSL XML.
66    if let Some(namespace) = root.tag_name().namespace()
67        && namespace != CSL_NAMESPACE
68    {
69        return Err(err_at(
70            root,
71            CslParseErrorKind::ForeignNamespace(namespace.to_owned()),
72        ));
73    }
74
75    let version = root
76        .attribute("version")
77        .ok_or_else(|| err_at(root, CslParseErrorKind::MissingVersion))?;
78    // Accept the `1.0` schema major and any `1.0.x` point release; the version
79    // attribute of real CSL 1.0.x styles is almost always `1.0`, but tolerate an
80    // explicit patch suffix rather than reject otherwise-valid styles.
81    if version != "1.0" && !version.starts_with("1.0.") {
82        return Err(err_at(
83            root,
84            CslParseErrorKind::UnsupportedVersion(version.to_owned()),
85        ));
86    }
87    let version = version.to_owned();
88    let class = match root.attribute("class") {
89        Some("in-text") => StyleClass::InText,
90        Some("note") => StyleClass::Note,
91        Some(other) => {
92            return Err(err_at(
93                root,
94                CslParseErrorKind::UnknownClass(other.to_owned()),
95            ));
96        }
97        None => return Err(err_at(root, CslParseErrorKind::MissingClass)),
98    };
99    let default_locale = attr(root, "default-locale");
100
101    let mut info = Info::default();
102    let mut citation = None;
103    let mut bibliography = None;
104    let mut macros = BTreeMap::new();
105    let mut locales = Vec::new();
106
107    for child in child_elements(root) {
108        match child.tag_name().name() {
109            "info" => info = parse_info(child),
110            "citation" => citation = Some(parse_citation(child)?),
111            "bibliography" => bibliography = Some(parse_bibliography(child)?),
112            "macro" => {
113                let name = child
114                    .attribute("name")
115                    .ok_or_else(|| err_at(child, CslParseErrorKind::MissingMacroName))?;
116                macros.insert(name.to_owned(), parse_elements(child)?);
117            }
118            "locale" => locales.push(parse_locale(child, input)),
119            other => {
120                return Err(err_at(
121                    child,
122                    CslParseErrorKind::UnsupportedElement(other.to_owned()),
123                ));
124            }
125        }
126    }
127
128    Ok(Style {
129        class,
130        version,
131        default_locale,
132        options: parse_style_options(root),
133        info,
134        citation,
135        bibliography,
136        macros,
137        locales,
138    })
139}
140
141fn parse_info(node: Node<'_, '_>) -> Info {
142    let mut info = Info::default();
143    for child in child_elements(node) {
144        match child.tag_name().name() {
145            "id" => info.id = child.text().map(str::to_owned),
146            "title" => info.title = child.text().map(str::to_owned),
147            "link" => info.links.push(parse_info_link(child)),
148            "category" => info.categories.push(parse_info_category(child)),
149            "author" => info.authors.push(parse_info_contributor(child)),
150            "contributor" => info.contributors.push(parse_info_contributor(child)),
151            "updated" => info.updated = child.text().map(str::to_owned),
152            "issn" => {
153                if let Some(text) = child.text() {
154                    info.issn.push(text.to_owned());
155                }
156            }
157            // Other <info> children are ignored.
158            _ => {}
159        }
160    }
161    info
162}
163
164fn parse_info_link(node: Node<'_, '_>) -> InfoLink {
165    InfoLink {
166        rel: attr(node, "rel"),
167        href: attr(node, "href"),
168        media_type: attr(node, "type"),
169    }
170}
171
172fn parse_info_category(node: Node<'_, '_>) -> InfoCategory {
173    InfoCategory {
174        citation_format: attr(node, "citation-format"),
175        field: attr(node, "field"),
176    }
177}
178
179fn parse_info_contributor(node: Node<'_, '_>) -> InfoContributor {
180    let mut contributor = InfoContributor::default();
181    for child in child_elements(node) {
182        match child.tag_name().name() {
183            "name" => contributor.name = child.text().map(str::to_owned),
184            "uri" => contributor.uri = child.text().map(str::to_owned),
185            "email" => contributor.email = child.text().map(str::to_owned),
186            _ => {}
187        }
188    }
189    contributor
190}
191
192fn parse_locale(node: Node<'_, '_>, input: &str) -> LocaleBlock {
193    let xml = input
194        .get(node.range())
195        .map_or_else(String::new, str::to_owned);
196    LocaleBlock { xml }
197}
198
199fn parse_citation(node: Node<'_, '_>) -> Result<Citation, CslParseError> {
200    let (layout, sort) = parse_layout_and_sort(node)?;
201    Ok(Citation {
202        layout,
203        sort,
204        options: parse_citation_options(node),
205    })
206}
207
208fn parse_bibliography(node: Node<'_, '_>) -> Result<Bibliography, CslParseError> {
209    let (layout, sort) = parse_layout_and_sort(node)?;
210    Ok(Bibliography {
211        layout,
212        sort,
213        options: parse_bibliography_options(node),
214    })
215}
216
217fn parse_layout_and_sort(node: Node<'_, '_>) -> Result<(Layout, Vec<SortKey>), CslParseError> {
218    let mut layout = None;
219    let mut sort = Vec::new();
220    for child in child_elements(node) {
221        match child.tag_name().name() {
222            "layout" => layout = Some(parse_layout(child)?),
223            "sort" => sort = parse_sort(child),
224            other => {
225                return Err(err_at(
226                    child,
227                    CslParseErrorKind::UnsupportedElement(other.to_owned()),
228                ));
229            }
230        }
231    }
232    let layout = layout.ok_or_else(|| err_at(node, CslParseErrorKind::MissingLayout))?;
233    Ok((layout, sort))
234}
235
236fn parse_layout(node: Node<'_, '_>) -> Result<Layout, CslParseError> {
237    Ok(Layout {
238        elements: parse_elements(node)?,
239        common: parse_common(node),
240    })
241}
242
243fn parse_sort(node: Node<'_, '_>) -> Vec<SortKey> {
244    let mut keys = Vec::new();
245    for child in child_elements(node) {
246        if child.tag_name().name() == "key" {
247            let target = child.attribute("macro").map_or_else(
248                || SortTarget::Variable(attr(child, "variable").unwrap_or_default()),
249                |name| SortTarget::Macro(name.to_owned()),
250            );
251            keys.push(SortKey {
252                target,
253                descending: child.attribute("sort") == Some("descending"),
254                options: parse_sort_key_options(child),
255            });
256        }
257    }
258    keys
259}
260
261fn parse_elements(node: Node<'_, '_>) -> Result<Vec<Element>, CslParseError> {
262    let mut elements = Vec::new();
263    for child in child_elements(node) {
264        elements.push(parse_element(child)?);
265    }
266    Ok(elements)
267}
268
269fn parse_element(node: Node<'_, '_>) -> Result<Element, CslParseError> {
270    let element = match node.tag_name().name() {
271        "text" => Element::Text(parse_text(node)?),
272        "number" => Element::Number(parse_number(node)),
273        "date" => Element::Date(parse_date(node)),
274        "names" => Element::Names(Box::new(parse_names(node)?)),
275        "label" => Element::Label(parse_label(node)),
276        "group" => Element::Group(parse_group(node)?),
277        "choose" => Element::Choose(parse_choose(node)?),
278        other => {
279            return Err(err_at(
280                node,
281                CslParseErrorKind::UnsupportedElement(other.to_owned()),
282            ));
283        }
284    };
285    Ok(element)
286}
287
288fn parse_text(node: Node<'_, '_>) -> Result<Text, CslParseError> {
289    let source_count = [
290        node.attribute("variable"),
291        node.attribute("macro"),
292        node.attribute("term"),
293        node.attribute("value"),
294    ]
295    .into_iter()
296    .flatten()
297    .count();
298    if source_count > 1 {
299        return Err(err_at(node, CslParseErrorKind::TextWithMultipleSources));
300    }
301
302    let source = if let Some(variable) = node.attribute("variable") {
303        TextSource::Variable {
304            name: variable.to_owned(),
305            form: attr(node, "form"),
306        }
307    } else if let Some(name) = node.attribute("macro") {
308        TextSource::Macro(name.to_owned())
309    } else if let Some(term) = node.attribute("term") {
310        TextSource::Term {
311            name: term.to_owned(),
312            form: attr(node, "form"),
313            plural: bool_attr(node, "plural"),
314        }
315    } else if let Some(value) = node.attribute("value") {
316        TextSource::Value(value.to_owned())
317    } else {
318        return Err(err_at(node, CslParseErrorKind::TextWithoutSource));
319    };
320    Ok(Text {
321        source,
322        quotes: bool_attr(node, "quotes"),
323        strip_periods: bool_attr(node, "strip-periods"),
324        common: parse_common(node),
325    })
326}
327
328fn parse_number(node: Node<'_, '_>) -> Number {
329    Number {
330        variable: attr(node, "variable").unwrap_or_default(),
331        form: attr(node, "form"),
332        common: parse_common(node),
333    }
334}
335
336fn parse_date(node: Node<'_, '_>) -> DateElement {
337    let mut parts = Vec::new();
338    for child in child_elements(node) {
339        if child.tag_name().name() == "date-part" {
340            parts.push(DatePart {
341                name: attr(child, "name").unwrap_or_default(),
342                form: attr(child, "form"),
343                range_delimiter: attr(child, "range-delimiter"),
344                strip_periods: attr(child, "strip-periods"),
345                common: parse_common(child),
346            });
347        }
348    }
349    DateElement {
350        variable: attr(node, "variable").unwrap_or_default(),
351        form: attr(node, "form"),
352        date_parts: attr(node, "date-parts"),
353        parts,
354        common: parse_common(node),
355    }
356}
357
358fn parse_names(node: Node<'_, '_>) -> Result<Names, CslParseError> {
359    let variables = attr(node, "variable")
360        .unwrap_or_default()
361        .split_whitespace()
362        .map(str::to_owned)
363        .collect();
364    let mut name = None;
365    let mut et_al = None;
366    let mut label = None;
367    let mut substitute = Vec::new();
368    for child in child_elements(node) {
369        match child.tag_name().name() {
370            "name" => {
371                name = Some(parse_name_element(child));
372            }
373            "et-al" => {
374                et_al = Some(EtAl {
375                    term: attr(child, "term"),
376                    common: parse_common(child),
377                });
378            }
379            "label" => label = Some(parse_label(child)),
380            "substitute" => substitute = parse_elements(child)?,
381            other => {
382                return Err(err_at(
383                    child,
384                    CslParseErrorKind::UnsupportedElement(other.to_owned()),
385                ));
386            }
387        }
388    }
389    Ok(Names {
390        variables,
391        name,
392        et_al,
393        label,
394        substitute,
395        common: parse_common(node),
396    })
397}
398
399fn parse_name_element(node: Node<'_, '_>) -> NameElement {
400    let mut parts = Vec::new();
401    for child in child_elements(node) {
402        if child.tag_name().name() == "name-part" {
403            parts.push(NamePart {
404                name: attr(child, "name"),
405                common: parse_common(child),
406            });
407        }
408    }
409
410    NameElement {
411        form: attr(node, "form"),
412        options: parse_inheritable_name_options(node),
413        parts,
414        common: parse_common(node),
415    }
416}
417
418fn parse_label(node: Node<'_, '_>) -> Label {
419    Label {
420        variable: attr(node, "variable"),
421        form: attr(node, "form"),
422        plural: attr(node, "plural"),
423        strip_periods: attr(node, "strip-periods"),
424        common: parse_common(node),
425    }
426}
427
428fn parse_group(node: Node<'_, '_>) -> Result<Group, CslParseError> {
429    Ok(Group {
430        children: parse_elements(node)?,
431        common: parse_common(node),
432    })
433}
434
435fn parse_choose(node: Node<'_, '_>) -> Result<Choose, CslParseError> {
436    let mut branches = Vec::new();
437    let mut otherwise = Vec::new();
438    let mut seen_if = false;
439    let mut seen_else = false;
440    for child in child_elements(node) {
441        match child.tag_name().name() {
442            "if" => {
443                if seen_if || seen_else {
444                    return Err(err_at(child, CslParseErrorKind::InvalidChooseOrder));
445                }
446                seen_if = true;
447                branches.push(Branch {
448                    conditions: parse_conditions(child),
449                    children: parse_elements(child)?,
450                });
451            }
452            "else-if" => {
453                if !seen_if || seen_else {
454                    return Err(err_at(child, CslParseErrorKind::InvalidChooseOrder));
455                }
456                branches.push(Branch {
457                    conditions: parse_conditions(child),
458                    children: parse_elements(child)?,
459                });
460            }
461            "else" => {
462                if !seen_if || seen_else {
463                    return Err(err_at(child, CslParseErrorKind::InvalidChooseOrder));
464                }
465                seen_else = true;
466                otherwise = parse_elements(child)?;
467            }
468            other => {
469                return Err(err_at(
470                    child,
471                    CslParseErrorKind::UnsupportedElement(other.to_owned()),
472                ));
473            }
474        }
475    }
476    if !seen_if {
477        return Err(err_at(node, CslParseErrorKind::InvalidChooseOrder));
478    }
479    Ok(Choose {
480        branches,
481        otherwise,
482    })
483}
484
485fn parse_conditions(node: Node<'_, '_>) -> Conditions {
486    let match_mode = match node.attribute("match") {
487        Some("any") => Match::Any,
488        Some("none") => Match::None,
489        _ => Match::All,
490    };
491    Conditions {
492        match_mode,
493        kind: tokens(node, "type"),
494        variable: tokens(node, "variable"),
495        is_numeric: tokens(node, "is-numeric"),
496        is_uncertain_date: tokens(node, "is-uncertain-date"),
497        locator: tokens(node, "locator"),
498        position: tokens(node, "position"),
499        disambiguate: bool_attr(node, "disambiguate"),
500    }
501}
502
503fn parse_common(node: Node<'_, '_>) -> Common {
504    Common {
505        prefix: attr(node, "prefix"),
506        suffix: attr(node, "suffix"),
507        delimiter: attr(node, "delimiter"),
508        font_style: attr(node, "font-style"),
509        font_variant: attr(node, "font-variant"),
510        font_weight: attr(node, "font-weight"),
511        text_decoration: attr(node, "text-decoration"),
512        vertical_align: attr(node, "vertical-align"),
513        text_case: attr(node, "text-case"),
514        display: attr(node, "display"),
515    }
516}
517
518fn parse_style_options(node: Node<'_, '_>) -> StyleOptions {
519    StyleOptions {
520        page_range_format: attr(node, "page-range-format"),
521        demote_non_dropping_particle: attr(node, "demote-non-dropping-particle"),
522        initialize_with_hyphen: attr(node, "initialize-with-hyphen"),
523        names: parse_inheritable_name_options(node),
524    }
525}
526
527fn parse_citation_options(node: Node<'_, '_>) -> CitationOptions {
528    CitationOptions {
529        collapse: attr(node, "collapse"),
530        cite_group_delimiter: attr(node, "cite-group-delimiter"),
531        year_suffix_delimiter: attr(node, "year-suffix-delimiter"),
532        after_collapse_delimiter: attr(node, "after-collapse-delimiter"),
533        disambiguate_add_names: attr(node, "disambiguate-add-names"),
534        disambiguate_add_givenname: attr(node, "disambiguate-add-givenname"),
535        disambiguate_add_year_suffix: attr(node, "disambiguate-add-year-suffix"),
536        givenname_disambiguation_rule: attr(node, "givenname-disambiguation-rule"),
537        near_note_distance: attr(node, "near-note-distance"),
538        names: parse_inheritable_name_options(node),
539    }
540}
541
542fn parse_bibliography_options(node: Node<'_, '_>) -> BibliographyOptions {
543    BibliographyOptions {
544        hanging_indent: attr(node, "hanging-indent"),
545        second_field_align: attr(node, "second-field-align"),
546        line_spacing: attr(node, "line-spacing"),
547        entry_spacing: attr(node, "entry-spacing"),
548        subsequent_author_substitute: attr(node, "subsequent-author-substitute"),
549        subsequent_author_substitute_rule: attr(node, "subsequent-author-substitute-rule"),
550        names: parse_inheritable_name_options(node),
551    }
552}
553
554fn parse_sort_key_options(node: Node<'_, '_>) -> SortKeyOptions {
555    SortKeyOptions {
556        min: attr(node, "names-min"),
557        use_first: attr(node, "names-use-first"),
558        use_last: attr(node, "names-use-last"),
559    }
560}
561
562fn parse_inheritable_name_options(node: Node<'_, '_>) -> InheritableNameOptions {
563    InheritableNameOptions {
564        et_al_min: attr(node, "et-al-min"),
565        et_al_use_first: attr(node, "et-al-use-first"),
566        et_al_subsequent_min: attr(node, "et-al-subsequent-min"),
567        et_al_subsequent_use_first: attr(node, "et-al-subsequent-use-first"),
568        et_al_use_last: attr(node, "et-al-use-last"),
569        and: attr(node, "and"),
570        delimiter_precedes_et_al: attr(node, "delimiter-precedes-et-al"),
571        delimiter_precedes_last: attr(node, "delimiter-precedes-last"),
572        initialize: attr(node, "initialize"),
573        initialize_with: attr(node, "initialize-with"),
574        name_as_sort_order: attr(node, "name-as-sort-order"),
575        sort_separator: attr(node, "sort-separator"),
576    }
577}
578
579/// Element-only children of `node` (skips text, comments, and whitespace).
580fn child_elements<'a, 'input>(node: Node<'a, 'input>) -> impl Iterator<Item = Node<'a, 'input>> {
581    node.children().filter(Node::is_element)
582}
583
584/// An attribute as an owned `String`, if present.
585fn attr(node: Node<'_, '_>, name: &str) -> Option<String> {
586    node.attribute(name).map(str::to_owned)
587}
588
589/// A boolean attribute: `true` only when the value is exactly `"true"`.
590fn bool_attr(node: Node<'_, '_>, name: &str) -> bool {
591    node.attribute(name) == Some("true")
592}
593
594/// A whitespace-separated attribute split into owned tokens.
595fn tokens(node: Node<'_, '_>, name: &str) -> Vec<String> {
596    node.attribute(name)
597        .map(|value| value.split_whitespace().map(str::to_owned).collect())
598        .unwrap_or_default()
599}
600
601fn text_pos_to_byte_offset(input: &str, position: roxmltree::TextPos) -> Option<usize> {
602    let row = usize::try_from(position.row).ok()?;
603    let col = usize::try_from(position.col).ok()?;
604    if row == 0 || col == 0 {
605        return None;
606    }
607
608    let (line_start, line) = line_at(input, row)?;
609    let col_offset = column_to_byte_offset(line, col)?;
610    Some(line_start + col_offset)
611}
612
613fn line_at(input: &str, row: usize) -> Option<(usize, &str)> {
614    let mut line_start = 0;
615    for (line_index, line) in input.split_inclusive('\n').enumerate() {
616        if line_index + 1 == row {
617            let line_without_newline = line.strip_suffix('\n').map_or(line, |stripped| stripped);
618            return Some((line_start, line_without_newline));
619        }
620        line_start += line.len();
621    }
622
623    if row == 1 && input.is_empty() {
624        return Some((0, ""));
625    }
626    None
627}
628
629fn column_to_byte_offset(line: &str, col: usize) -> Option<usize> {
630    let target_chars = col.checked_sub(1)?;
631    let mut chars_seen = 0;
632    for (byte_offset, _) in line.char_indices() {
633        if chars_seen == target_chars {
634            return Some(byte_offset);
635        }
636        chars_seen += 1;
637    }
638
639    if chars_seen == target_chars {
640        Some(line.len())
641    } else {
642        None
643    }
644}
645
646/// Build an error anchored at a node's start byte offset.
647fn err_at(node: Node<'_, '_>, kind: CslParseErrorKind) -> CslParseError {
648    CslParseError::new(kind, node.range().start)
649}