Skip to main content

flams_stex/
lib.rs

1//#![feature(lazy_type_alias)]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3
4mod dependencies;
5mod latex;
6pub mod math;
7pub mod quickparse;
8mod rustex;
9
10use crate::dependencies::STeXDependency;
11use either::Either;
12use eyre::Context;
13use flams_ftml::FTML_CONTENT;
14use flams_math_archives::{
15    artifacts::{FileArtifact, FileOrString, FtmlString},
16    backend::{AnyBackend, GlobalBackend, LocalBackend},
17    build_target,
18    formats::{BuildResult, BuildSpec},
19    manager::ArchiveOrGroup,
20    source_format, Archive, LocalArchive, MathArchive,
21};
22use flams_system::FlamsExtension;
23use flams_utils::vecmap::VecSet;
24use ftml_uris::{ArchiveId, DocumentUri, UriWithArchive, UriWithPath};
25pub use rustex::{OutputCont, RusTeX};
26use std::path::{Path, PathBuf};
27
28flams_system::register_exension!(FlamsExtension {
29    name: "stex",
30    on_start: || {
31        RusTeX::initialize();
32        math::RusTeXMath::initialize();
33    },
34    on_build_result: |_, _, _, _| (),
35    on_reload: || ()
36});
37
38source_format!(STEX {
39    name: "stex",
40    description: "(Semantically annotated) LaTeX",
41    targets: &[
42        PDFLATEX_FIRST.id(),
43        PDFLATEX.id(),
44        RUSTEX.id(),
45        FTML_CONTENT.id(),
46        ftml_solver::CHECK.id()
47    ],
48    file_extensions: &["tex", "ltx"],
49    dependencies: dependencies::get_deps
50});
51
52build_target!(PDFLATEX_FIRST {
53    name: "pdflatex_first",
54    description: "Run pdflatex and bibtex/biber/index once",
55    // yields AUX
56    run: pdflatex_first
57});
58
59build_target!(PDFLATEX {
60    name: "pdflatex",
61    description: "Run pdflatex a second time",
62    // yields PDF
63    // requires AUX
64    run: pdflatex_second
65});
66
67build_target!(RUSTEX {
68    name: "rustex",
69    description: "Run RusTeX tex->html only",
70    // yields: FTML,
71    // requires AUX
72    run: rustex
73});
74
75// build_result!(aux @ "LaTeX aux/bbl/toc files, as generated by pdflatex+bibtex/biber/mkindex");
76
77pub struct PDF(PathBuf);
78impl FileArtifact for PDF {
79    fn kind(&self) -> &'static str {
80        "pdf"
81    }
82    fn as_any(&self) -> &dyn std::any::Any {
83        self as _
84    }
85    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
86        self as _
87    }
88    fn source(&self) -> &Path {
89        &self.0
90    }
91}
92
93#[allow(clippy::needless_pass_by_value)]
94fn pdflatex_first(task: BuildSpec) -> BuildResult {
95    let Either::Left(path) = task.source else {
96        return BuildResult {
97            log: FileOrString::Str("Needs a physical file".to_string().into_boxed_str()),
98            result: Err(Vec::new()),
99        };
100    };
101    latex::clean(path);
102    let log = path.with_extension("log");
103    let mh = task
104        .backend
105        .mathhubs()
106        .map(|p| p.display().to_string())
107        .collect::<Vec<_>>()
108        .join(",");
109    let ret = latex::pdflatex_and_bib(path, [("STEX_WRITESMS", "true"), ("MATHHUB", &mh)]);
110    if ret.is_ok() {
111        BuildResult {
112            log: FileOrString::File(log),
113            result: Ok(Some(Box::new(PDF(path.with_extension("pdf"))) as _)),
114        }
115    } else {
116        BuildResult {
117            log: FileOrString::File(log),
118            result: Err(Vec::new()),
119        }
120    }
121}
122
123#[allow(clippy::needless_pass_by_value)]
124fn pdflatex_second(task: BuildSpec) -> BuildResult {
125    let Either::Left(path) = task.source else {
126        return BuildResult {
127            log: FileOrString::Str("Needs a physical file".to_string().into_boxed_str()),
128            result: Err(Vec::new()),
129        };
130    };
131    let log = path.with_extension("log");
132    let mh = task
133        .backend
134        .mathhubs()
135        .map(|p| p.display().to_string())
136        .collect::<Vec<_>>()
137        .join(",");
138    let ret = latex::pdflatex(path, [("STEX_USESMS", "true"), ("MATHHUB", &mh)]);
139    if ret.is_ok() {
140        BuildResult {
141            log: FileOrString::File(log),
142            result: Ok(Some(Box::new(PDF(path.with_extension("pdf"))) as _)),
143        }
144    } else {
145        BuildResult {
146            log: FileOrString::File(log),
147            result: Err(Vec::new()),
148        }
149    }
150}
151
152#[allow(clippy::needless_pass_by_value)]
153fn rustex(task: BuildSpec) -> BuildResult {
154    // TODO make work with string as well
155    let Either::Left(path) = task.source else {
156        return BuildResult {
157            log: FileOrString::Str("Needs a physical file".to_string().into_boxed_str()),
158            result: Err(Vec::new()),
159        };
160    };
161    let out = path.with_extension("rlog");
162    let ocl = out.clone();
163    let mh = task
164        .backend
165        .mathhubs()
166        .map(|p| p.display().to_string())
167        .collect::<Vec<_>>()
168        .join(",");
169    let run = move || {
170        //println!("Running rustex");
171        RusTeX::get()
172            .map_err(|()| "Could not initialize RusTeX".to_string())
173            .and_then(|e| {
174                std::panic::catch_unwind(move || {
175                    e.run_with_envs(
176                        path,
177                        false,
178                        [
179                            ("STEX_USESMS".to_string(), "true".to_string()),
180                            ("MATHHUB".to_string(), mh),
181                        ],
182                        Some(&ocl),
183                    )
184                })
185                .map_err(|e| {
186                    #[allow(clippy::option_if_let_else)]
187                    if let Some(s) = e.downcast_ref::<&str>() {
188                        (*s).to_string()
189                    } else if let Ok(s) = e.downcast::<String>() {
190                        *s
191                    } else {
192                        "Unknown RusTeX error".to_string()
193                    }
194                })
195            })
196    };
197    #[cfg(debug_assertions)]
198    let ret = {
199        std::thread::scope(move |s| {
200            std::thread::Builder::new()
201                .stack_size(16 * 1024 * 1024)
202                .spawn_scoped(s, run)
203                .expect("foo")
204                .join()
205                .expect("foo")
206        })
207    };
208    #[cfg(not(debug_assertions))]
209    let ret = { run() };
210    //println!("Finished; evaling result");
211    match ret {
212        Err(s) => BuildResult {
213            log: FileOrString::Str(s.into_boxed_str()),
214            result: Err(Vec::new()),
215        },
216        Ok(Err(_)) => BuildResult {
217            log: FileOrString::File(out),
218            result: Err(Vec::new()),
219        },
220        Ok(Ok(s)) => {
221            latex::clean(path);
222            BuildResult {
223                log: FileOrString::File(out),
224                result: Ok(Some(Box::new(FtmlString(s.into_boxed_str())) as _)),
225            }
226        }
227    }
228}
229
230static OPTIONS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(||
231    // SAFETY: known to be well-formed
232    unsafe{ regex::Regex::new(
233        r"\\(?<cmd>documentclass|usepackage|RequirePackage)(?<opts>\[[^\]]*\])?\{(?<name>notesslides|stex|hwexam|problem)\}"
234    ).unwrap_unchecked() });
235
236#[allow(clippy::trivial_regex)]
237static LIBS: std::sync::LazyLock<regex::Regex> = std::sync::LazyLock::new(||
238    // SAFETY: known to be well-formed
239    unsafe{ regex::Regex::new(r"\\libinput\{").unwrap_unchecked() });
240
241macro_rules! err {
242    ($fmt:expr) => {return Err(eyre::eyre!($fmt))};
243    ($fmt:expr, $($args:tt)*) => {return Err(eyre::eyre!($fmt,$($args)*))};
244    ($e:expr => $fmt:expr) => { $e.wrap_err($fmt)?};
245    ($e:expr => $fmt:expr, $($args:tt)*) => { $e.wrap_err_with(|| format!($fmt,$($args)*))?};
246}
247
248/// # Errors
249#[allow(clippy::too_many_lines)]
250pub fn export_standalone(doc: &DocumentUri, file: &Path, target_dir: &Path) -> eyre::Result<()> {
251    use std::fmt::Write;
252    if !file.extension().is_some_and(|e| e == "tex") {
253        err!("Not a .tex file: {}", file.display());
254    }
255
256    // safe, because we earlier checked that it has extension .tex => it has a file name
257    let file_name = unsafe { file.file_name().unwrap_unchecked() };
258
259    let mh_path = target_dir.join("mathhub");
260    err!(
261        std::fs::create_dir_all(&mh_path) =>
262        "Invalid target directory: {}",
263        mh_path.display()
264    );
265    let archive = doc.archive_id();
266
267    let mh = flams_math_archives::mathhub::mathhubs()
268        .iter()
269        .map(|p| p.display().to_string())
270        .collect::<Vec<_>>()
271        .join(",");
272    let Ok(()) = latex::pdflatex_and_bib(file, [("STEX_WRITESMS", "true"), ("MATHHUB", &mh)])
273    else {
274        err!(
275            "failed to build {}\nCheck .log file for details",
276            file.display()
277        );
278    };
279
280    let sms = file.with_extension("sms");
281    let sms_target = target_dir.join(file_name).with_extension("sms");
282    err!(std::fs::copy(&sms, &sms_target) => "Failed to copy file {}",sms.display() );
283
284    let orig_txt = err!(
285        std::fs::read_to_string(file) =>
286        "failed to open file {}",
287        file.display()
288    );
289    let Some(begin) = orig_txt.find("\\begin{document}") else {
290        err!("No \\begin{{document}} found!")
291    };
292    let mut txt = orig_txt[..begin].to_string();
293    //orig_txt.truncate(begin);
294    let rel_path = doc.path().map_or_else(
295        || file_name.display().to_string(),
296        |p| format!("{p}/{}", file_name.display()),
297    );
298    err!(
299        write!(
300            txt,
301            "\n\\begin{{document}}\n  \\inputref[{archive}]{{{rel_path}}}\n\\end{{document}}"
302        ) =>
303        "impossible",
304    );
305
306    let mut matched = false;
307    let txt = OPTIONS.replace(&txt, |cap: &regex::Captures<'_>| {
308        matched = true;
309        // This is safe, because the named groups are necessary components of the regex, so a match
310        // entails they are defined.
311        let (cmd, name) = unsafe {
312            (
313                cap.name("cmd").unwrap_unchecked().as_str(),
314                cap.name("name").unwrap_unchecked().as_str(),
315            )
316        };
317        cap.name("opts").map_or_else(
318            || format!("\\{cmd}[mathhub=./mathhub,usesms]{{{name}}}"),
319            |opts| {
320                format!(
321                    "\\{cmd}[{},mathhub=./mathhub,usesms]{{{name}}}",
322                    &opts.as_str()[1..opts.as_str().len() - 1]
323                )
324            },
325        )
326    });
327    if !matched {
328        err!(
329            "No sTeX \\documentclass or \\package found in {}",
330            file.display()
331        );
332    }
333    let rep = format!("\\libinput[{archive}]{{");
334    let txt = LIBS.replace_all(&txt, &rep);
335
336    let tex_target = target_dir.join(file_name);
337    err!(std::fs::write(&tex_target, txt.as_bytes()) => "Failed to write to file {}",tex_target.display());
338
339    copy("stex.sty", &target_dir)?;
340    copy("stex-logo.sty", &target_dir)?;
341    copy("stex-backend-pdflatex.cfg", &target_dir)?;
342    copy("stex-highlighting.sty", &target_dir)?;
343    copy("stexthm.sty", &target_dir)?;
344    // stex-compat?
345
346    let mut todos = vec![(orig_txt, file.to_owned(), doc.clone())];
347    let mut archives = VecSet(Vec::with_capacity(4));
348    while let Some((txt, f, d)) = todos.pop() {
349        if !archives.0.contains(d.archive_id()) {
350            archives.0.push(d.archive_id().clone());
351            do_archive(d.archive_id(), &mh_path)?;
352        }
353        // by construction, the files in todos have a file name
354        let name = unsafe { f.file_name().unwrap_unchecked() };
355        let target_file = d.path().map_or_else(
356            || mh_path.join(d.archive_id().to_string()).join("source"),
357            |p| {
358                mh_path
359                    .join(d.archive_id().to_string())
360                    .join("source")
361                    .join(p.to_string())
362            },
363        );
364        err!(std::fs::create_dir_all(&target_file) => "Failed to create directory {}",target_file.display());
365        let target_file = target_file.join(name);
366        err!(std::fs::copy(&f, target_file) => "Failed to copy file {}",f.display());
367        for dep in dependencies::parse_deps(&txt, &f, &d, &AnyBackend::Global) {
368            match dep {
369                STeXDependency::Inputref { archive, filepath } => {
370                    let archive = archive.as_ref().unwrap_or(&d.path.archive.id);
371                    let Some((d, f)) = GlobalBackend.with_local_archive(archive, |a| {
372                        a.and_then(|a| {
373                            let f = a.source_dir().join(&*filepath);
374                            let d = DocumentUri::from_archive_relpath(a.uri().clone(), &*filepath)
375                                .ok()?;
376                            Some((d, f))
377                        })
378                    }) else {
379                        err!("Could not find document for file {}", f.display())
380                    };
381                    let txt = err!(
382                        std::fs::read_to_string(&f) =>
383                        "failed to open file {}",
384                        f.display()
385                    );
386                    todos.push((txt, f, d));
387                }
388                STeXDependency::Img { archive, filepath } => {
389                    let archive = archive.as_ref().unwrap_or(&d.path.archive.id);
390                    let Some(source) = GlobalBackend.with_local_archive(archive, |a| {
391                        a.map(|a| a.path().join("source").join(&*filepath))
392                    }) else {
393                        err!("Could not find image file {}", f.display())
394                    };
395                    let img_target = mh_path
396                        .join(archive.to_string())
397                        .join("source")
398                        .join(&*filepath);
399                    if !source.exists() {
400                        err!("img file not found: {}", source.display())
401                    }
402                    // safe, because file exists and is not root
403                    let parent = unsafe { img_target.parent().unwrap_unchecked() };
404                    err!(std::fs::create_dir_all(&parent) => "Error creating directory {}",parent.display());
405                    err!(std::fs::copy(&source,&img_target) => "Error copying {}",img_target.display());
406                }
407                STeXDependency::ImportModule { .. }
408                | STeXDependency::UseModule { .. }
409                | STeXDependency::Module { .. } => (),
410            }
411        }
412    }
413
414    Ok(())
415}
416
417fn copy(name: &str, to: &Path) -> eyre::Result<()> {
418    let Some(sty) = tex_engine::engine::filesystem::kpathsea::KPATHSEA.which(name) else {
419        err!("No {name} found")
420    };
421    let sty_target = to.join(name);
422    err!(std::fs::copy(sty, sty_target) => "Failed to copy {}",name);
423    Ok(())
424}
425
426fn do_archive(id: &ArchiveId, target: &Path) -> eyre::Result<()> {
427    GlobalBackend.with_tree(|t| {
428        let mut steps = id.steps();
429        let Some(mut current) = steps.next() else {
430            err!("empty archive ID");
431        };
432        let mut ls = &t.top;
433        loop {
434            let Some(a) = ls.iter().find(|a| a.id().last() == current) else {
435                err!("archive not found: {id}");
436            };
437            match a {
438                ArchiveOrGroup::Archive(_) => {
439                    if steps.next().is_some() {
440                        err!("archive not found: {id}");
441                    }
442                    let Some(Archive::Local(a)) = t.get(id) else {
443                        err!("Not a local archive: {id}")
444                    };
445                    return do_manifest(a, target);
446                }
447                ArchiveOrGroup::Group(g) => {
448                    let Some(next) = steps.next() else {
449                        err!("archive not found: {id}");
450                    };
451                    current = next;
452                    ls = &g.children;
453                    if let Some(ArchiveOrGroup::Archive(a)) =
454                        g.children.iter().find(|a| a.id().is_meta())
455                    {
456                        let Some(Archive::Local(a)) = t.get(a) else {
457                            err!("archive not found: {a}");
458                        };
459                        do_manifest(a, target)?;
460                    }
461                }
462            }
463        }
464    })
465}
466
467fn do_manifest(a: &LocalArchive, target: &Path) -> eyre::Result<()> {
468    let archive_target = target.join(a.id().to_string());
469    let manifest_target = archive_target.join("META-INF/MANIFEST.MF");
470    if manifest_target.exists() {
471        return Ok(());
472    }
473    let manifest_source = a.path().join("META-INF/MANIFEST.MF");
474    if !manifest_source.exists() {
475        err!(
476            "MANIFEST.MF of {} not found (at {})",
477            a.id(),
478            manifest_source.display()
479        );
480    }
481    // safe, because by construction, file has a parent
482    let meta_inf = unsafe { manifest_target.parent().unwrap_unchecked() };
483    err!(std::fs::create_dir_all(&meta_inf) => "Failed to create directory {}",meta_inf.display());
484    err!(std::fs::copy(&manifest_source, &manifest_target) => "failed to copy {} to {}",manifest_source.display(),manifest_target.display());
485
486    let lib_source = a.path().join("lib");
487    if lib_source.exists() {
488        let lib_target = archive_target.join("lib");
489        flams_utils::fs::copy_dir_all(&lib_source, &lib_target)?;
490    }
491    Ok(())
492}
493/*
494#[cfg(test)]
495#[rstest::rstest]
496fn standalone_test() {
497    tracing_subscriber::fmt().init();
498    flams_system::settings::Settings::initialize(flams_system::settings::SettingsSpec::default());
499    flams_system::backend::GlobalBackend::initialize();
500
501    let target_dir = Path::new("/home/jazzpirate/temp/test");
502    let doc = "https://mathhub.info?a=Papers/24-cicm-views-in-alea&d=paper&l=en"
503        .parse()
504        .unwrap();
505    let file =
506        Path::new("/home/jazzpirate/work/MathHub/Papers/24-cicm-views-in-alea/source/paper.tex");
507    export_standalone(&doc, &file, target_dir).unwrap()
508}
509 */
510
511/*
512#[cfg(test)]
513#[rstest::rstest]
514fn test() {
515    fn print<T>() {
516        tracing::info!(
517            "Size of {}:{}",
518            std::any::type_name::<T>(),
519            std::mem::size_of::<T>()
520        )
521    }
522    tracing_subscriber::fmt().init();
523    print::<ArchiveId>();
524    print::<flams_ontology::uris::BaseUri>();
525    print::<flams_ontology::uris::ArchiveUri>();
526    print::<flams_ontology::uris::PathURI>();
527    print::<flams_ontology::uris::ModuleUri>();
528    print::<flams_ontology::uris::DocumentUri>();
529    print::<flams_ontology::uris::SymbolUri>();
530    print::<flams_ontology::uris::DocumentElementUri>();
531}
532 */