diff --git a/Cargo.lock b/Cargo.lock index ac4c3a1d1..9547e3ac1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,6 +56,19 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "biblatex" +version = "0.6.3" +source = "git+https://github.com/typst/biblatex#932ad283dd45dd88d4fa14dc5b9bda7a270ba027" +dependencies = [ + "chrono", + "numerals", + "paste", + "strum", + "unicode-normalization", + "unscanny", +] + [[package]] name = "bincode" version = "1.3.3" @@ -304,6 +317,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "displaydoc" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf95dc3f046b9da4f2d51833c0d3547d8564ef6910f5c1ed130306a75b92886" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ecow" version = "0.1.0" @@ -363,6 +387,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -408,6 +441,26 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hayagriva" +version = "0.1.1" +source = "git+https://github.com/typst/hayagriva#992389b23f9765198ee8d3f1818d6dbdc8f46b60" +dependencies = [ + "biblatex", + "chrono", + "isolang", + "lazy_static", + "linked-hash-map", + "paste", + "regex", + "strum", + "thiserror", + "unic-langid", + "unicode-segmentation", + "url", + "yaml-rust", +] + [[package]] name = "heck" version = "0.4.1" @@ -452,6 +505,16 @@ dependencies = [ "cxx-build", ] +[[package]] +name = "idna" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ddfc70884202db2244c223200c204c2bda1bc6e0998d11b5e024d657209e6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "if_chain" version = "1.0.2" @@ -523,6 +586,15 @@ dependencies = [ "libc", ] +[[package]] +name = "isolang" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b64fd6448ee8a45ce6e4365c58e4fa7d8740cba2ed70db3e9ab4879ebd93eaaa" +dependencies = [ + "phf", +] + [[package]] name = "itoa" version = "0.4.8" @@ -729,12 +801,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "numerals" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25be21376a772d15f97ae789845340a9651d3c4246ff5ebb6a2b35f9c37bd31" + [[package]] name = "once_cell" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" +[[package]] +name = "paste" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" + [[package]] name = "pdf-writer" version = "0.6.0" @@ -746,6 +830,30 @@ dependencies = [ "ryu", ] +[[package]] +name = "percent-encoding" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.4.2" @@ -917,6 +1025,12 @@ dependencies = [ "xmlparser", ] +[[package]] +name = "rustversion" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f3208ce4d8448b3f3e7d168a73f5e0c43a61e32930de3bceeccedb388b6bf06" + [[package]] name = "rustybuzz" version = "0.5.1" @@ -1033,6 +1147,28 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subsetter" version = "0.1.0" @@ -1141,6 +1277,30 @@ dependencies = [ "safe_arch", ] +[[package]] +name = "tinystr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ac3f5b6856e931e15e07b478e98c8045239829a65f9156d4fa7e7788197a5ef" +dependencies = [ + "displaydoc", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "ttf-parser" version = "0.15.2" @@ -1246,6 +1406,7 @@ dependencies = [ "comemo", "csv", "ecow", + "hayagriva", "hypher", "kurbo", "lipsum", @@ -1292,6 +1453,24 @@ dependencies = [ "walkdir", ] +[[package]] +name = "unic-langid" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398f9ad7239db44fd0f80fe068d12ff22d78354080332a5077dc6f52f14dcf2f" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35bfd2f2b8796545b55d7d3fd3e89a0613f68a0d1c8bc28cb7ff96b411a35ff" +dependencies = [ + "tinystr", +] + [[package]] name = "unicase" version = "2.6.0" @@ -1336,6 +1515,15 @@ name = "unicode-math-class" version = "0.1.0" source = "git+https://github.com/typst/unicode-math-class#a7ac7dd75cd79ab2e0bdb629036cb913371608d2" +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-script" version = "0.5.5" @@ -1372,6 +1560,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9df2af067a7953e9c3831320f35c1cc0600c30d44d9f7a12b01db1cd88d6b47" +[[package]] +name = "url" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + [[package]] name = "usvg" version = "0.22.0" diff --git a/assets/files/bad.bib b/assets/files/bad.bib new file mode 100644 index 000000000..41b4d63df --- /dev/null +++ b/assets/files/bad.bib @@ -0,0 +1,6 @@ +@article{arrgh, + title = {An‐arrgh‐chy: The Law and Economics of Pirate Organization}, + author = {Leeson, Peter T.}, + crossref = {polecon}, + date = {19XXX-XX-XX}, +} diff --git a/assets/files/invalid-utf8.txt b/assets/files/bad.txt similarity index 100% rename from assets/files/invalid-utf8.txt rename to assets/files/bad.txt diff --git a/assets/files/works.bib b/assets/files/works.bib new file mode 100644 index 000000000..72f06db1d --- /dev/null +++ b/assets/files/works.bib @@ -0,0 +1,32 @@ +@article{stupid, + title={At-scale impact of the {Net Wok}: A culinarically holistic investigation of distributed dumplings}, + author={Astley, Rick and Morris, Linda}, + journal={Armenian Journal of Proceedings}, + volume={61}, + pages={192--219}, + year={2020}, + publisher={Automattic Inc.} +} + +@www{issue201, + title={Use of ids field creates unstable references}, + author={{cfr42}}, + url={https://github.com/plk/biblatex/issues/201}, + date={2014-02-02/2014-02-07}, + ids={unstable, github} +} + +@article{arrgh, + title={The Pirate Organization}, + author={Leeson, Peter T.}, +} + +@article{quark, + title={The Quark Organization}, + author={Leeson, Peter T.}, +} + +@misc{cannonfodder, + title={An Insight into Bibliographical Distress}, + author={Aldrin, Buzz} +} diff --git a/library/Cargo.toml b/library/Cargo.toml index b532883be..3c1f79d45 100644 --- a/library/Cargo.toml +++ b/library/Cargo.toml @@ -14,6 +14,7 @@ typst = { path = ".." } comemo = { git = "https://github.com/typst/comemo" } csv = "1" ecow = "0.1" +hayagriva = { git = "https://github.com/typst/hayagriva" } hypher = "0.1" kurbo = "0.8" lipsum = { git = "https://github.com/reknih/lipsum" } diff --git a/library/src/lib.rs b/library/src/lib.rs index 5b114d9b3..c4a421d21 100644 --- a/library/src/lib.rs +++ b/library/src/lib.rs @@ -89,6 +89,8 @@ fn global(math: Module, calc: Module) -> Module { global.define("outline", meta::OutlineNode::id()); global.define("heading", meta::HeadingNode::id()); global.define("figure", meta::FigureNode::id()); + global.define("cite", meta::CiteNode::id()); + global.define("bibliography", meta::BibliographyNode::id()); global.define("numbering", meta::numbering); // Symbols. @@ -179,7 +181,7 @@ fn items() -> LangItems { raw: |text, lang, block| { let mut node = text::RawNode::new(text).with_block(block); if let Some(lang) = lang { - node = node.with_lang(Some(lang)); + node.push_lang(Some(lang)); } node.pack() }, @@ -194,6 +196,7 @@ fn items() -> LangItems { } node.pack() }, + bibliography_keys: meta::BibliographyNode::keys, heading: |level, title| meta::HeadingNode::new(title).with_level(level).pack(), list_item: |body| layout::ListItem::new(body).pack(), enum_item: |number, body| { diff --git a/library/src/meta/bibliography.rs b/library/src/meta/bibliography.rs new file mode 100644 index 000000000..a01f9eee1 --- /dev/null +++ b/library/src/meta/bibliography.rs @@ -0,0 +1,472 @@ +use std::collections::HashMap; +use std::ffi::OsStr; +use std::path::Path; +use std::sync::Arc; + +use ecow::EcoVec; +use hayagriva::io::{BibLaTeXError, YamlBibliographyError}; +use hayagriva::style::{self, Citation, Database, DisplayString, Formatting}; +use typst::font::{FontStyle, FontWeight}; + +use super::LocalName; +use crate::layout::{GridNode, ParNode, Sizing, TrackSizings, VNode}; +use crate::meta::HeadingNode; +use crate::prelude::*; +use crate::text::{Hyphenate, TextNode}; + +/// A bibliography / reference listing. +/// +/// Display: Bibliography +/// Category: meta +#[node(Locatable, Synthesize, Show, LocalName)] +pub struct BibliographyNode { + /// Path to a Hayagriva `.yml` or BibLaTeX `.bib` file. + #[required] + #[parse( + let Spanned { v: path, span } = + args.expect::>("path to bibliography file")?; + let path: EcoString = vm.locate(&path).at(span)?.to_string_lossy().into(); + let _ = load(vm.world(), &path).at(span)?; + path + )] + pub path: EcoString, + + /// The title of the bibliography. + /// + /// - When set to `{auto}`, an appropriate title for the [text + /// language]($func/text.lang) will be used. This is the default. + /// - When set to `{none}`, the bibliography will not have a title. + /// - A custom title can be set by passing content. + #[default(Some(Smart::Auto))] + pub title: Option>, + + /// The bibliography style. + #[default(BibliographyStyle::Ieee)] + pub style: BibliographyStyle, +} + +impl BibliographyNode { + /// Find the document's bibliography. + pub fn find(introspector: Tracked) -> StrResult { + let mut iter = introspector.locate(Selector::node::()).into_iter(); + let Some((_, node)) = iter.next() else { + return Err("the document does not contain a bibliography".into()); + }; + + if iter.next().is_some() { + Err("multiple bibliographies are not supported")?; + } + + Ok(node.to::().unwrap().clone()) + } + + /// Whether the bibliography contains the given key. + pub fn has(vt: &Vt, key: &str) -> bool { + vt.introspector + .locate(Selector::node::()) + .into_iter() + .flat_map(|(_, node)| load(vt.world(), &node.to::().unwrap().path())) + .flatten() + .any(|entry| entry.key() == key) + } + + /// Find all bibliography keys. + pub fn keys( + world: Tracked, + introspector: Tracked, + ) -> Vec<(EcoString, Option)> { + Self::find(introspector) + .and_then(|node| load(world, &node.path())) + .into_iter() + .flatten() + .map(|entry| { + let key = entry.key().into(); + let detail = + entry.title().map(|title| title.canonical.value.as_str().into()); + (key, detail) + }) + .collect() + } +} + +impl Synthesize for BibliographyNode { + fn synthesize(&mut self, _: &Vt, styles: StyleChain) { + self.push_style(self.style(styles)); + } +} + +impl Show for BibliographyNode { + fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult { + const COLUMN_GUTTER: Em = Em::new(0.65); + const ROW_GUTTER: Em = Em::new(1.0); + const INDENT: Em = Em::new(1.5); + + let works = match Works::new(vt) { + Ok(works) => works, + Err(error) => { + if vt.locatable() { + bail!(self.span(), error) + } else { + return Ok(TextNode::packed("bibliography")); + } + } + }; + + let mut seq = vec![]; + if let Some(title) = self.title(styles) { + let title = title.clone().unwrap_or_else(|| { + TextNode::packed(self.local_name(TextNode::lang_in(styles))) + }); + + seq.push( + HeadingNode::new(title) + .with_level(NonZeroUsize::new(1).unwrap()) + .with_numbering(None) + .pack(), + ); + } + + if works.references.iter().any(|(prefix, _)| prefix.is_some()) { + let mut cells = vec![]; + for (prefix, reference) in &works.references { + cells.push(prefix.clone().unwrap_or_default()); + cells.push(reference.clone()); + } + + seq.push( + GridNode::new(cells) + .with_columns(TrackSizings(vec![Sizing::Auto; 2])) + .with_column_gutter(TrackSizings(vec![COLUMN_GUTTER.into()])) + .with_row_gutter(TrackSizings(vec![ROW_GUTTER.into()])) + .pack(), + ); + } else { + let mut entries = vec![]; + for (i, (_, reference)) in works.references.iter().enumerate() { + if i > 0 { + entries.push(VNode::new(ROW_GUTTER.into()).with_weakness(1).pack()); + } + entries.push(reference.clone()); + } + + seq.push( + Content::sequence(entries) + .styled(ParNode::set_hanging_indent(INDENT.into())), + ); + } + + Ok(Content::sequence(seq)) + } +} + +impl LocalName for BibliographyNode { + fn local_name(&self, lang: Lang) -> &'static str { + match lang { + Lang::GERMAN => "Bibliographie", + Lang::ENGLISH | _ => "Bibliography", + } + } +} + +/// A bibliography style. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum BibliographyStyle { + /// Follows guidance of the American Psychological Association. Based on the + /// 7th edition of the APA Publication Manual. + Apa, + /// The Chicago Author Date style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 15. + AuthorDate, + /// The style of the Institute of Electrical and Electronics Engineers. + /// Based on the 2018 IEEE Reference Guide. + Ieee, + /// Follows guidance of the Modern Language Association. Based on the 8th + /// edition of the MLA Handbook. + Mla, +} + +impl BibliographyStyle { + /// The default citation style for this bibliography style. + pub fn default_citation_style(self) -> CitationStyle { + match self { + Self::Apa => CitationStyle::AuthorDate, + Self::AuthorDate => CitationStyle::AuthorDate, + Self::Ieee => CitationStyle::Numerical, + Self::Mla => CitationStyle::AuthorDate, + } + } +} + +/// A citation of another work. +/// +/// Display: Citation +/// Category: meta +#[node(Locatable, Synthesize, Show)] +pub struct CiteNode { + /// The citation key. + #[required] + pub key: EcoString, + + /// A supplement for the citation such as page or chapter number. + #[positional] + pub supplement: Option, + + /// The citation style. + /// + /// When set to `{auto}`, automatically picks the preferred citation style + /// for the bibliography's style. + pub style: Smart, +} + +impl Synthesize for CiteNode { + fn synthesize(&mut self, _: &Vt, styles: StyleChain) { + self.push_supplement(self.supplement(styles)); + self.push_style(self.style(styles)); + } +} + +impl Show for CiteNode { + fn show(&self, vt: &mut Vt, _: StyleChain) -> SourceResult { + let id = self.0.stable_id().unwrap(); + let works = match Works::new(vt) { + Ok(works) => works, + Err(error) => { + if vt.locatable() { + bail!(self.span(), error) + } else { + return Ok(TextNode::packed("citation")); + } + } + }; + + let Some(citation) = works.citations.get(&id).cloned() else { + return Ok(TextNode::packed("citation")); + }; + + citation + .ok_or("bibliography does not contain this key") + .at(self.span()) + } +} + +/// A citation style. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum CitationStyle { + /// IEEE-style numerical reference markers. + Numerical, + /// A simple alphanumerical style. For example, the output could be Rass97 + /// or MKG+21. + Alphanumerical, + /// The Chicago Author Date style. Based on the 17th edition of the Chicago + /// Manual of Style, Chapter 15. + AuthorDate, + /// A Chicago-like author-title format. Results could look like this: + /// Prokopov, “It Is Fast or It Is Wrong”. + AuthorTitle, + /// Citations that just consist of the entry keys. + Keys, +} + +/// Fully formatted citations and references. +pub struct Works { + citations: HashMap>, + references: Vec<(Option, Content)>, +} + +impl Works { + /// Prepare all things need to cite a work or format a bibliography. + pub fn new(vt: &Vt) -> StrResult> { + let bibliography = BibliographyNode::find(vt.introspector)?; + let style = bibliography.style(StyleChain::default()); + let citations = vt + .locate_node::() + .map(|(id, node)| { + ( + id, + node.key(), + node.supplement(StyleChain::default()), + node.style(StyleChain::default()) + .unwrap_or(style.default_citation_style()), + ) + }) + .collect(); + Ok(create(vt.world(), &bibliography.path(), style, citations)) + } +} + +/// Generate all citations and the whole bibliography. +#[comemo::memoize] +fn create( + world: Tracked, + path: &str, + style: BibliographyStyle, + citations: Vec<(StableId, EcoString, Option, CitationStyle)>, +) -> Arc { + let entries = load(world, path).unwrap(); + + let mut db = Database::new(); + let mut preliminary = vec![]; + + for (id, key, supplement, style) in citations { + let entry = entries.iter().find(|entry| entry.key() == key); + if let Some(entry) = &entry { + db.push(entry); + } + preliminary.push((id, entry, supplement, style)); + } + + let mut current = CitationStyle::Numerical; + let mut citation_style: Box = + Box::new(style::Numerical::new()); + + let citations = preliminary + .into_iter() + .map(|(id, result, supplement, style)| { + let formatted = result.map(|entry| { + if style != current { + current = style; + citation_style = match style { + CitationStyle::Numerical => Box::new(style::Numerical::new()), + CitationStyle::Alphanumerical => { + Box::new(style::Alphanumerical::new()) + } + CitationStyle::AuthorDate => { + Box::new(style::ChicagoAuthorDate::new()) + } + CitationStyle::AuthorTitle => Box::new(style::AuthorTitle::new()), + CitationStyle::Keys => Box::new(style::Keys::new()), + }; + } + + let citation = db.citation( + &mut *citation_style, + &[Citation { + entry, + supplement: supplement.is_some().then(|| SUPPLEMENT), + }], + ); + let bracketed = citation.display.with_default_brackets(&*citation_style); + format_display_string(&bracketed, supplement) + }); + (id, formatted) + }) + .collect(); + + let bibliography_style: Box = match style { + BibliographyStyle::Apa => Box::new(style::Apa::new()), + BibliographyStyle::AuthorDate => Box::new(style::ChicagoAuthorDate::new()), + BibliographyStyle::Ieee => Box::new(style::Ieee::new()), + BibliographyStyle::Mla => Box::new(style::Mla::new()), + }; + + let references = db + .bibliography(&*bibliography_style, None) + .into_iter() + .map(|reference| { + let prefix = reference.prefix.map(|prefix| { + let bracketed = prefix.with_default_brackets(&*citation_style); + format_display_string(&bracketed, None) + }); + let reference = format_display_string(&reference.display, None); + (prefix, reference) + }) + .collect(); + + Arc::new(Works { citations, references }) +} + +/// Load bibliography entries from a path. +#[comemo::memoize] +fn load(world: Tracked, path: &str) -> StrResult> { + let path = Path::new(path); + let buffer = world.file(path)?; + let src = std::str::from_utf8(&buffer).map_err(|_| "file is not valid utf-8")?; + let ext = path.extension().and_then(OsStr::to_str).unwrap_or_default(); + let entries = match ext.to_lowercase().as_str() { + "yml" => hayagriva::io::from_yaml_str(src).map_err(format_hayagriva_error)?, + "bib" => hayagriva::io::from_biblatex_str(src).map_err(|err| { + err.into_iter() + .next() + .map(|error| format_biblatex_error(src, error)) + .unwrap_or_else(|| "failed to parse biblatex file".into()) + })?, + _ => return Err("unknown bibliography format".into()), + }; + Ok(entries.into_iter().collect()) +} + +/// Format a Hayagriva loading error. +fn format_hayagriva_error(error: YamlBibliographyError) -> EcoString { + eco_format!("{error}") +} + +/// Format a BibLaTeX loading error. +fn format_biblatex_error(src: &str, error: BibLaTeXError) -> EcoString { + let (span, msg) = match error { + BibLaTeXError::Parse(error) => (error.span, error.kind.to_string()), + BibLaTeXError::Type(error) => (error.span, error.kind.to_string()), + }; + let line = src.get(..span.start).unwrap_or_default().lines().count(); + eco_format!("failed to parse biblatex file: {msg} in line {line}") +} + +/// Hayagriva only supports strings, but we have a content supplement. To deal +/// with this, we pass this string to hayagriva instead of our content, find it +/// in the output and replace it with the content. +const SUPPLEMENT: &str = "cdc579c45cf3d648905c142c7082683f"; + +/// Format a display string into content. +fn format_display_string( + string: &DisplayString, + mut supplement: Option, +) -> Content { + let mut stops: Vec<_> = string + .formatting + .iter() + .flat_map(|(range, _)| [range.start, range.end]) + .collect(); + + if let Some(i) = string.value.find(SUPPLEMENT) { + stops.push(i); + stops.push(i + SUPPLEMENT.len()); + } + + stops.sort(); + stops.dedup(); + stops.push(string.value.len()); + + let mut start = 0; + let mut seq = vec![]; + for stop in stops { + let segment = string.value.get(start..stop).unwrap_or_default(); + if segment.is_empty() { + continue; + } + + let mut styles = StyleMap::new(); + for (range, fmt) in &string.formatting { + if !range.contains(&start) { + continue; + } + + styles.set(match fmt { + Formatting::Bold => TextNode::set_weight(FontWeight::BOLD), + Formatting::Italic => TextNode::set_style(FontStyle::Italic), + Formatting::NoHyphenation => { + TextNode::set_hyphenate(Hyphenate(Smart::Custom(false))) + } + }); + } + + let content = if segment == SUPPLEMENT && supplement.is_some() { + supplement.take().unwrap_or_default() + } else { + TextNode::packed(segment) + }; + + seq.push(content.styled_with_map(styles)); + start = stop; + } + + Content::sequence(seq) +} diff --git a/library/src/meta/heading.rs b/library/src/meta/heading.rs index 4d1b87e65..48f6e2296 100644 --- a/library/src/meta/heading.rs +++ b/library/src/meta/heading.rs @@ -145,7 +145,7 @@ impl Finalize for HeadingNode { } } -/// Counters through headings with different levels. +/// Counts through headings with different levels. pub struct HeadingCounter(Vec); impl HeadingCounter { diff --git a/library/src/meta/mod.rs b/library/src/meta/mod.rs index 3cde2b8e0..ba74dac05 100644 --- a/library/src/meta/mod.rs +++ b/library/src/meta/mod.rs @@ -1,5 +1,6 @@ //! Interaction between document parts. +mod bibliography; mod document; mod figure; mod heading; @@ -8,6 +9,7 @@ mod numbering; mod outline; mod reference; +pub use self::bibliography::*; pub use self::document::*; pub use self::figure::*; pub use self::heading::*; diff --git a/library/src/meta/numbering.rs b/library/src/meta/numbering.rs index d71fb2339..c59766c80 100644 --- a/library/src/meta/numbering.rs +++ b/library/src/meta/numbering.rs @@ -65,7 +65,7 @@ pub fn numbering( numbering.apply(vm.world(), &numbers)? } -/// How to number an enumeration. +/// How to number a sequence of things. #[derive(Debug, Clone, Hash)] pub enum Numbering { /// A pattern with prefix, numbering, lower / upper case and suffix. diff --git a/library/src/meta/reference.rs b/library/src/meta/reference.rs index f63c7e4c7..b4cfa049f 100644 --- a/library/src/meta/reference.rs +++ b/library/src/meta/reference.rs @@ -1,4 +1,4 @@ -use super::{FigureNode, HeadingNode, LocalName, Numbering}; +use super::{BibliographyNode, CiteNode, FigureNode, HeadingNode, LocalName, Numbering}; use crate::prelude::*; use crate::text::TextNode; @@ -35,7 +35,7 @@ use crate::text::TextNode; /// /// Display: Reference /// Category: meta -#[node(Synthesize, Show)] +#[node(Show)] pub struct RefNode { /// The target label that should be referenced. #[required] @@ -60,27 +60,33 @@ pub struct RefNode { /// In @intro, we see how to turn /// Sections into Chapters. /// ``` - - /// All elements with the target label in the document. - #[synthesized] - pub matches: Vec, pub supplement: Smart>, } -impl Synthesize for RefNode { - fn synthesize(&mut self, vt: &Vt, _: StyleChain) { - let matches = vt - .locate(Selector::Label(self.target())) - .map(|(_, node)| node.clone()) - .collect(); - - self.push_matches(matches); - } -} - impl Show for RefNode { fn show(&self, vt: &mut Vt, styles: StyleChain) -> SourceResult { - let matches = self.matches(); + let target = self.target(); + let supplement = self.supplement(styles); + + let matches: Vec<_> = vt + .locate(Selector::Label(self.target())) + .map(|(_, node)| node.clone()) + .collect(); + + if !vt.locatable() || BibliographyNode::has(vt, &target.0) { + if !matches.is_empty() { + bail!(self.span(), "label occurs in the document and its bibliography"); + } + + return Ok(CiteNode::new(target.0) + .with_supplement(match supplement { + Smart::Custom(Some(Supplement::Content(content))) => Some(content), + _ => None, + }) + .pack() + .spanned(self.span())); + } + let [target] = matches.as_slice() else { if vt.locatable() { bail!(self.span(), if matches.is_empty() { diff --git a/src/eval/library.rs b/src/eval/library.rs index d3f7547d6..1240d9bb2 100644 --- a/src/eval/library.rs +++ b/src/eval/library.rs @@ -2,6 +2,7 @@ use std::fmt::{self, Debug, Formatter}; use std::hash::{Hash, Hasher}; use std::num::NonZeroUsize; +use comemo::Tracked; use ecow::EcoString; use once_cell::sync::OnceCell; @@ -9,8 +10,9 @@ use super::Module; use crate::diag::SourceResult; use crate::doc::Document; use crate::geom::{Abs, Dir}; -use crate::model::{Content, Label, NodeId, StyleChain, StyleMap, Vt}; +use crate::model::{Content, Introspector, Label, NodeId, StyleChain, StyleMap, Vt}; use crate::util::hash128; +use crate::World; /// Definition of Typst's standard library. #[derive(Debug, Clone, Hash)] @@ -61,6 +63,11 @@ pub struct LangItems { pub link: fn(url: EcoString) -> Content, /// A reference: `@target`, `@target[..]`. pub reference: fn(target: Label, supplement: Option) -> Content, + /// The keys contained in the bibliography and short descriptions of them. + pub bibliography_keys: fn( + world: Tracked, + introspector: Tracked, + ) -> Vec<(EcoString, Option)>, /// A section heading: `= Introduction`. pub heading: fn(level: NonZeroUsize, body: Content) -> Content, /// An item in a bullet list: `- ...`. diff --git a/src/ide/analyze.rs b/src/ide/analyze.rs index 3c46cca15..7338ba57c 100644 --- a/src/ide/analyze.rs +++ b/src/ide/analyze.rs @@ -1,8 +1,11 @@ use std::path::PathBuf; use comemo::Track; +use ecow::EcoString; +use crate::doc::Frame; use crate::eval::{eval, Module, Route, Tracer, Value}; +use crate::model::{Introspector, Label}; use crate::syntax::{ast, LinkedNode, Source, SyntaxKind}; use crate::util::PathExt; use crate::World; @@ -64,3 +67,37 @@ pub fn analyze_import( let source = world.source(id); eval(world.track(), route.track(), tracer.track_mut(), source).ok() } + +/// Find all labels and details for them. +pub fn analyze_labels( + world: &(dyn World + 'static), + frames: &[Frame], +) -> (Vec<(Label, Option)>, usize) { + let mut output = vec![]; + let mut introspector = Introspector::new(); + let items = &world.library().items; + introspector.update(frames); + + // Labels in the document. + for node in introspector.iter() { + let Some(label) = node.label() else { continue }; + let details = node + .field("caption") + .or_else(|| node.field("body")) + .and_then(|field| match field { + Value::Content(content) => Some(content), + _ => None, + }) + .and_then(|content| (items.text_str)(content)); + output.push((label.clone(), details)); + } + + let split = output.len(); + + // Bibliography keys. + for (key, detail) in (items.bibliography_keys)(world.track(), introspector.track()) { + output.push((Label(key), detail)); + } + + (output, split) +} diff --git a/src/ide/complete.rs b/src/ide/complete.rs index de6f2b736..665901605 100644 --- a/src/ide/complete.rs +++ b/src/ide/complete.rs @@ -4,7 +4,9 @@ use ecow::{eco_format, EcoString}; use if_chain::if_chain; use unscanny::Scanner; +use super::analyze::analyze_labels; use super::{analyze_expr, analyze_import, plain_docs_sentence, summarize_font_family}; +use crate::doc::Frame; use crate::eval::{methods_on, CastInfo, Library, Scope, Value}; use crate::syntax::{ ast, is_id_continue, is_id_start, is_ident, LinkedNode, Source, SyntaxKind, @@ -21,11 +23,12 @@ use crate::World; /// control and space or something similar. pub fn autocomplete( world: &(dyn World + 'static), + frames: &[Frame], source: &Source, cursor: usize, explicit: bool, ) -> Option<(usize, Vec)> { - let mut ctx = CompletionContext::new(world, source, cursor, explicit)?; + let mut ctx = CompletionContext::new(world, frames, source, cursor, explicit)?; let _ = complete_comments(&mut ctx) || complete_field_accesses(&mut ctx) @@ -78,7 +81,10 @@ fn complete_comments(ctx: &mut CompletionContext) -> bool { /// Complete in markup mode. fn complete_markup(ctx: &mut CompletionContext) -> bool { // Bail if we aren't even in markup. - if !matches!(ctx.leaf.parent_kind(), None | Some(SyntaxKind::Markup)) { + if !matches!( + ctx.leaf.parent_kind(), + None | Some(SyntaxKind::Markup) | Some(SyntaxKind::Ref) + ) { return false; } @@ -96,6 +102,13 @@ fn complete_markup(ctx: &mut CompletionContext) -> bool { return true; } + // Start of an reference: "@|" or "@he|". + if ctx.leaf.kind() == SyntaxKind::RefMarker { + ctx.from = ctx.leaf.offset() + 1; + ctx.label_completions(); + return true; + } + // Behind a half-completed binding: "#let x = |". if_chain! { if let Some(prev) = ctx.leaf.prev_leaf(); @@ -850,6 +863,7 @@ fn code_completions(ctx: &mut CompletionContext, hashtag: bool) { /// Context for autocompletion. struct CompletionContext<'a> { world: &'a (dyn World + 'static), + frames: &'a [Frame], library: &'a Library, source: &'a Source, global: &'a Scope, @@ -869,6 +883,7 @@ impl<'a> CompletionContext<'a> { /// Create a new autocompletion context. fn new( world: &'a (dyn World + 'static), + frames: &'a [Frame], source: &'a Source, cursor: usize, explicit: bool, @@ -878,6 +893,7 @@ impl<'a> CompletionContext<'a> { let leaf = LinkedNode::new(source.root()).leaf_at(cursor)?; Some(Self { world, + frames, library, source, global: &library.global.scope(), @@ -955,6 +971,18 @@ impl<'a> CompletionContext<'a> { } } + /// Add completions for all labels. + fn label_completions(&mut self) { + for (label, detail) in analyze_labels(self.world, self.frames).0 { + self.completions.push(Completion { + kind: CompletionKind::Constant, + label: label.0, + apply: None, + detail, + }); + } + } + /// Add a completion for a specific value. fn value_completion( &mut self, diff --git a/src/ide/mod.rs b/src/ide/mod.rs index bee959cdf..38bede0bb 100644 --- a/src/ide/mod.rs +++ b/src/ide/mod.rs @@ -6,6 +6,7 @@ mod highlight; mod jump; mod tooltip; +pub use self::analyze::analyze_labels; pub use self::complete::*; pub use self::highlight::*; pub use self::jump::*; @@ -13,15 +14,17 @@ pub use self::tooltip::*; use std::fmt::Write; +use ecow::{eco_format, EcoString}; + use self::analyze::*; use crate::font::{FontInfo, FontStyle}; /// Extract the first sentence of plain text of a piece of documentation. /// /// Removes Markdown formatting. -fn plain_docs_sentence(docs: &str) -> String { +fn plain_docs_sentence(docs: &str) -> EcoString { let mut s = unscanny::Scanner::new(docs); - let mut output = String::new(); + let mut output = EcoString::new(); let mut link = false; while let Some(c) = s.eat() { match c { @@ -62,7 +65,7 @@ fn plain_docs_sentence(docs: &str) -> String { } /// Create a short description of a font family. -fn summarize_font_family<'a>(variants: impl Iterator) -> String { +fn summarize_font_family<'a>(variants: impl Iterator) -> EcoString { let mut infos: Vec<_> = variants.collect(); infos.sort_by_key(|info| info.variant); @@ -78,7 +81,7 @@ fn summarize_font_family<'a>(variants: impl Iterator) -> St let count = infos.len(); let s = if count == 1 { "" } else { "s" }; - let mut detail = format!("{count} variant{s}."); + let mut detail = eco_format!("{count} variant{s}."); if min_weight == max_weight { write!(detail, " Weight {min_weight}.").unwrap(); diff --git a/src/ide/tooltip.rs b/src/ide/tooltip.rs index a32dfb0b2..0b37b7ca5 100644 --- a/src/ide/tooltip.rs +++ b/src/ide/tooltip.rs @@ -1,20 +1,22 @@ use std::fmt::Write; -use ecow::EcoString; +use ecow::{eco_format, EcoString}; use if_chain::if_chain; +use super::analyze::analyze_labels; use super::{analyze_expr, plain_docs_sentence, summarize_font_family}; +use crate::doc::Frame; use crate::eval::{CastInfo, Tracer, Value}; use crate::geom::{round_2, Length, Numeric}; -use crate::syntax::ast; -use crate::syntax::{LinkedNode, Source, SyntaxKind}; +use crate::syntax::{ast, LinkedNode, Source, SyntaxKind}; use crate::util::pretty_comma_list; use crate::World; /// Describe the item under the cursor. pub fn tooltip( world: &(dyn World + 'static), + frames: &[Frame], source: &Source, cursor: usize, ) -> Option { @@ -22,6 +24,7 @@ pub fn tooltip( named_param_tooltip(world, &leaf) .or_else(|| font_tooltip(world, &leaf)) + .or_else(|| ref_tooltip(world, frames, &leaf)) .or_else(|| expr_tooltip(world, &leaf)) } @@ -29,9 +32,9 @@ pub fn tooltip( #[derive(Debug, Clone)] pub enum Tooltip { /// A string of text. - Text(String), + Text(EcoString), /// A string of Typst code. - Code(String), + Code(EcoString), } /// Tooltip for a hovered expression. @@ -55,7 +58,7 @@ fn expr_tooltip(world: &(dyn World + 'static), leaf: &LinkedNode) -> Option Option Option { +fn length_tooltip(length: Length) -> Option { length.em.is_zero().then(|| { - format!( + Tooltip::Code(eco_format!( "{}pt = {}mm = {}cm = {}in", round_2(length.abs.to_pt()), round_2(length.abs.to_mm()), round_2(length.abs.to_cm()), round_2(length.abs.to_inches()) - ) + )) }) } +/// Tooltip for a hovered reference. +fn ref_tooltip( + world: &(dyn World + 'static), + frames: &[Frame], + leaf: &LinkedNode, +) -> Option { + if leaf.kind() != SyntaxKind::RefMarker { + return None; + } + + let target = leaf.text().trim_start_matches('@'); + for (label, detail) in analyze_labels(world, frames).0 { + if label.0 == target { + return Some(Tooltip::Text(detail?.into())); + } + } + + None +} + /// Tooltips for components of a named parameter. fn named_param_tooltip( world: &(dyn World + 'static), diff --git a/src/model/typeset.rs b/src/model/typeset.rs index 8719ea0c1..fe4332883 100644 --- a/src/model/typeset.rs +++ b/src/model/typeset.rs @@ -35,7 +35,7 @@ pub fn typeset(world: Tracked, content: &Content) -> SourceResult= 5 || introspector.update(&document) { + if iter >= 5 || introspector.update(&document.pages) { break; } } @@ -49,13 +49,10 @@ pub fn typeset(world: Tracked, content: &Content) -> SourceResult { /// The compilation environment. - #[doc(hidden)] pub world: Tracked<'a, dyn World>, /// Provides stable identities to nodes. - #[doc(hidden)] pub provider: TrackedMut<'a, StabilityProvider>, /// Provides access to information about the document. - #[doc(hidden)] pub introspector: Tracked<'a, Introspector>, } @@ -127,7 +124,6 @@ impl StabilityProvider { } /// Provides access to information about the document. -#[doc(hidden)] pub struct Introspector { init: bool, nodes: Vec<(StableId, Content)>, @@ -136,7 +132,7 @@ pub struct Introspector { impl Introspector { /// Create a new introspector. - fn new() -> Self { + pub fn new() -> Self { Self { init: false, nodes: vec![], @@ -146,10 +142,10 @@ impl Introspector { /// Update the information given new frames and return whether we can stop /// layouting. - fn update(&mut self, document: &Document) -> bool { + pub fn update(&mut self, frames: &[Frame]) -> bool { self.nodes.clear(); - for (i, frame) in document.pages.iter().enumerate() { + for (i, frame) in frames.iter().enumerate() { let page = NonZeroUsize::new(1 + i).unwrap(); self.extract(frame, page, Transform::identity()); } @@ -171,6 +167,11 @@ impl Introspector { true } + /// Iterate over all nodes. + pub fn iter(&self) -> impl Iterator { + self.nodes.iter().map(|(_, node)| node) + } + /// Extract metadata from a frame. fn extract(&mut self, frame: &Frame, page: NonZeroUsize, ts: Transform) { for (pos, element) in frame.elements() { @@ -199,12 +200,12 @@ impl Introspector { #[comemo::track] impl Introspector { /// Whether this introspector is not yet initialized. - fn init(&self) -> bool { + pub fn init(&self) -> bool { self.init } /// Locate all metadata matches for the given selector. - fn locate(&self, selector: Selector) -> Vec<(StableId, &Content)> { + pub fn locate(&self, selector: Selector) -> Vec<(StableId, &Content)> { let nodes = self.locate_impl(&selector); let mut queries = self.queries.borrow_mut(); if !queries.iter().any(|(prev, _)| prev == &selector) { diff --git a/tests/ref/meta/bibliography.png b/tests/ref/meta/bibliography.png new file mode 100644 index 000000000..3ff542d16 Binary files /dev/null and b/tests/ref/meta/bibliography.png differ diff --git a/tests/typ/compute/data.typ b/tests/typ/compute/data.typ index d80d4857b..43746e188 100644 --- a/tests/typ/compute/data.typ +++ b/tests/typ/compute/data.typ @@ -11,8 +11,8 @@ #let data = read("/missing.txt") --- -// Error: 18-37 file is not valid utf-8 -#let data = read("/invalid-utf8.txt") +// Error: 18-28 file is not valid utf-8 +#let data = read("/bad.txt") --- // Test reading CSV data. diff --git a/tests/typ/meta/bibliography.typ b/tests/typ/meta/bibliography.typ new file mode 100644 index 000000000..2e2ddd358 --- /dev/null +++ b/tests/typ/meta/bibliography.typ @@ -0,0 +1,27 @@ +// Test citations and bibliographies. + +--- +// Error: 15-25 failed to parse biblatex file: wrong number of digits in line 5 +#bibliography("/bad.bib") + +--- +// Test ambigious reference. += Introduction +// Error: 1-7 label occurs in the document and its bibliography +@arrgh +#bibliography("/works.bib") + +--- +#set page(width: 200pt) += Details +See also #cite("arrgh", [p. 22]), @arrgh[p. 4], and @cannonfodder[p. 5]. +#bibliography("/works.bib") + +--- +// Test unconventional order. +#set page(width: 200pt) +#bibliography("/works.bib", title: [Works to be cited], style: "author-date") +#line(length: 100%) +The net-work is a creature of its own. @stupid +This is close to piratery! @arrgh +And quark! @quark