typst-cli 0.15.0

The command line interface for Typst.
use std::fmt::Write;

use comemo::Track;
use ecow::{EcoString, eco_format};
use typst::World;
use typst::diag::{HintedStrResult, SourceDiagnostic, StrResult, Warned, bail, warning};
use typst::engine::Sink;
use typst::foundations::{
    Content, Context, IntoValue, LocatableSelector, Output, Repr, Scope,
};
use typst::introspection::{EmptyIntrospector, Introspector};
use typst::routines::SpanMode;
use typst::syntax::{Span, SyntaxMode};
use typst_bundle::Bundle;
use typst_eval::eval_string;
use typst_html::HtmlDocument;
use typst_layout::PagedDocument;

use crate::args::{Input, QueryCommand, Target};
use crate::compile::print_diagnostics;
use crate::set_failed;
use crate::world::SystemWorld;

/// Execute a query command.
pub fn query(command: &'static QueryCommand) -> HintedStrResult<()> {
    let mut world =
        SystemWorld::new(Some(&command.input), &command.world, &command.process)?;

    // Reset everything and ensure that the main file is present.
    world.reset();
    world.source(world.main()).map_err(|err| err.to_string())?;

    let Warned { output, mut warnings } = match command.target {
        Target::Paged => typst::compile::<PagedDocument>(&world)
            .map(|result| result.map(|output| Box::new(output) as Box<dyn Output>)),
        Target::Html => typst::compile::<HtmlDocument>(&world)
            .map(|result| result.map(|output| Box::new(output) as Box<dyn Output>)),
        Target::Bundle => typst::compile::<Bundle>(&world)
            .map(|result| result.map(|output| Box::new(output) as Box<dyn Output>)),
    };

    // Add deprecation warning.
    warnings.push(deprecation_warning(command));

    match output {
        // Retrieve and print query results.
        Ok(output) => {
            let data = retrieve(&world, command, output.introspector())?;
            let serialized = format(data, command)?;
            println!("{serialized}");
            print_diagnostics(&world, &[], &warnings, command.process.diagnostic_format)
                .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?;
        }

        // Print diagnostics.
        Err(errors) => {
            set_failed();
            print_diagnostics(
                &world,
                &errors,
                &warnings,
                command.process.diagnostic_format,
            )
            .map_err(|err| eco_format!("failed to print diagnostics ({err})"))?;
        }
    }

    Ok(())
}

/// Retrieve the matches for the selector.
fn retrieve(
    world: &dyn World,
    command: &QueryCommand,
    introspector: &dyn Introspector,
) -> HintedStrResult<Vec<Content>> {
    let selector = eval_string(
        world.track(),
        world.library(),
        // TODO: propagate warnings
        Sink::new().track_mut(),
        EmptyIntrospector.track(),
        Context::none().track(),
        &command.selector,
        SpanMode::Uniform(Span::detached()),
        SyntaxMode::Code,
        Scope::default(),
    )
    .map_err(|errors| {
        let mut message = EcoString::from("failed to evaluate selector");
        for (i, error) in errors.into_iter().enumerate() {
            message.push_str(if i == 0 { ": " } else { ", " });
            message.push_str(&error.message);
        }
        message
    })?
    .cast::<LocatableSelector>()?;

    Ok(introspector.query(&selector.0).into_iter().collect::<Vec<_>>())
}

/// Format the query result in the output format.
fn format(elements: Vec<Content>, command: &QueryCommand) -> StrResult<String> {
    if command.one && elements.len() != 1 {
        bail!("expected exactly one element, found {}", elements.len());
    }

    let mapped: Vec<_> = elements
        .into_iter()
        .filter_map(|c| match &command.field {
            Some(field) => c.get_by_name(field).ok(),
            _ => Some(c.into_value()),
        })
        .collect();

    if command.one {
        let Some(value) = mapped.first() else {
            bail!("no such field found for element");
        };
        crate::serialize(value, command.format, command.pretty)
    } else {
        crate::serialize(&mapped, command.format, command.pretty)
    }
}

/// Format the deprecation warning with the specific invocation of `typst eval` needed to replace `typst query`.
fn deprecation_warning(command: &QueryCommand) -> SourceDiagnostic {
    let query = {
        let mut buf = format!("query({})", command.selector);
        let access = |field: &str| {
            if typst::syntax::is_ident(field) {
                eco_format!(".{field}")
            } else {
                eco_format!(".at({})", field.repr())
            }
        };
        match (command.one, &command.field) {
            (false, None) => {}
            (false, Some(field)) => {
                write!(buf, ".map(it => it{})", access(field)).unwrap()
            }
            (true, None) => write!(buf, ".first()").unwrap(),
            (true, Some(field)) => write!(buf, ".first(){}", access(field)).unwrap(),
        }
        shell_escape::escape(buf.into())
    };

    let eval_command = match &command.input {
        Input::Path(path) => {
            eco_format!("typst eval {query} --in {}", path.display())
        }
        Input::Stdin => eco_format!("typst eval {query}"),
    };

    warning!(
        Span::detached(),
        "the `typst query` subcommand is deprecated";
        hint: "use `{eval_command}` instead";
    )
}