feat: move and compile tinymist crate for wasm32 target (#2027)
Some checks are pending
tinymist::auto_tag / auto-tag (push) Waiting to run
tinymist::ci / Duplicate Actions Detection (push) Waiting to run
tinymist::ci / Check Clippy, Formatting, Completion, Documentation, and Tests (Linux) (push) Waiting to run
tinymist::ci / Check Minimum Rust version and Tests (Windows) (push) Waiting to run
tinymist::ci / prepare-build (push) Waiting to run
tinymist::ci / announce (push) Blocked by required conditions
tinymist::ci / build (push) Blocked by required conditions
tinymist::gh_pages / build-gh-pages (push) Waiting to run

This commit is contained in:
Myriad-Dreamin 2025-08-11 13:14:26 +08:00 committed by GitHub
parent 79f68dc94d
commit ce5ab81760
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1558 additions and 1122 deletions

View file

@ -0,0 +1,150 @@
[package]
name = "tinymist-cli"
description = "An integrated language service for Typst."
categories = ["compilers", "command-line-utilities"]
keywords = ["cli", "lsp", "language", "typst"]
authors.workspace = true
version.workspace = true
license.workspace = true
edition.workspace = true
homepage.workspace = true
repository.workspace = true
rust-version.workspace = true
[[bin]]
name = "tinymist"
path = "src/main.rs"
doc = false
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
base64.workspace = true
chrono.workspace = true
clap.workspace = true
clap_builder.workspace = true
clap_complete.workspace = true
clap_complete_fig.workspace = true
clap_complete_nushell.workspace = true
clap_mangen.workspace = true
crossbeam-channel.workspace = true
codespan-reporting.workspace = true
comemo.workspace = true
dhat = { workspace = true, optional = true }
dirs.workspace = true
env_logger.workspace = true
futures.workspace = true
hyper.workspace = true
hyper-util = { workspace = true, features = [
"server",
"http1",
"http2",
"server-graceful",
"server-auto",
] }
http-body-util = "0.1.2"
hyper-tungstenite = { workspace = true, optional = true }
itertools.workspace = true
lsp-types.workspace = true
log.workspace = true
open.workspace = true
parking_lot.workspace = true
paste.workspace = true
rayon.workspace = true
reflexo.workspace = true
reflexo-typst = { workspace = true, features = ["system", "svg"] }
reflexo-vec2svg.workspace = true
rpds.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
strum.workspace = true
sync-ls = { workspace = true, features = ["lsp", "server", "system"] }
tinymist-assets = { workspace = true }
tinymist-query.workspace = true
tinymist-std.workspace = true
tinymist = { workspace = true, default-features = false, features = ["system"] }
tinymist-project = { workspace = true, features = ["lsp"] }
tinymist-render.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "io-std"] }
tokio-util.workspace = true
toml.workspace = true
ttf-parser.workspace = true
typlite = { workspace = true, default-features = false }
typst.workspace = true
typst-svg.workspace = true
typst-pdf.workspace = true
typst-render.workspace = true
typst-timing.workspace = true
typst-html.workspace = true
typst-shim.workspace = true
tinymist-preview = { workspace = true, optional = true }
typst-ansi-hl.workspace = true
tinymist-task.workspace = true
tinymist-debug.workspace = true
typstfmt.workspace = true
typstyle-core.workspace = true
unicode-script.workspace = true
walkdir.workspace = true
tinymist-l10n.workspace = true
dapts.workspace = true
[features]
default = [
"cli",
"pdf",
"l10n",
"lock",
"export",
"preview",
"trace",
"embed-fonts",
"no-content-hint",
"dap",
]
cli = ["sync-ls/clap", "clap/wrap_help"]
dhat-heap = ["dhat"]
# Embeds Typst's default fonts for
# - text (Linux Libertine),
# - math (New Computer Modern Math), and
# - code (Deja Vu Sans Mono)
# and additionally New Computer Modern for text
# into the binary.
embed-fonts = ["tinymist-project/fonts"]
pdf = ["tinymist-task/pdf"]
# Disable the default content hint.
# This requires modifying typst.
no-content-hint = [
"tinymist-task/no-content-hint",
"tinymist-project/no-content-hint",
"tinymist-preview/no-content-hint",
"typlite/no-content-hint",
"reflexo-typst/no-content-hint",
"reflexo-vec2svg/no-content-hint",
]
export = ["tinymist/export"]
lock = ["tinymist/lock"]
preview = ["tinymist/preview", "hyper-tungstenite"]
trace = ["tinymist/trace"]
dap = ["tinymist/dap"]
l10n = ["tinymist-assets/l10n"]
[dev-dependencies]
temp-env.workspace = true
[build-dependencies]
anyhow.workspace = true
cargo_metadata = "0.18.0"
vergen.workspace = true
[lints]
workspace = true

View file

@ -0,0 +1,22 @@
# tinymist
This crate provides a CLI that starts services for [Typst](https://typst.app/) [taɪpst]. It provides:
+ `tinymist probe`: Do nothing, which just probes that the binary is working.
+ `tinymist lsp`: A language server following the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/).
+ `tinymist preview`: A preview server for Typst.
+ `tinymist --help` Learn more about the CLI.
## Usage
See [Features: Command Line Interface](https://myriad-dreamin.github.io/tinymist/feature/cli.html).
## Documentation
See [Crate Docs](https://myriad-dreamin.github.io/tinymist/rs/tinymist/index.html).
Also see [Developer Guide: Tinymist LSP](https://myriad-dreamin.github.io/tinymist/module/lsp.html).
## Contributing
See [CONTRIBUTING.md](https://github.com/Myriad-Dreamin/tinymist/blob/main/CONTRIBUTING.md).

View file

@ -0,0 +1,238 @@
use std::path::Path;
use sync_ls::transport::MirrorArgs;
use tinymist::project::DocCommands;
use tinymist::LONG_VERSION;
use tinymist::{CompileFontArgs, CompileOnceArgs};
#[cfg(feature = "preview")]
use tinymist::tool::preview::PreviewArgs;
#[cfg(feature = "preview")]
use tinymist_project::DocNewArgs;
#[cfg(feature = "preview")]
use tinymist_task::TaskWhen;
use crate::compile::CompileArgs;
use crate::generate_script::GenerateScriptArgs;
use crate::testing::TestArgs;
#[derive(Debug, Clone, clap::Parser)]
#[clap(name = "tinymist", author, version, about, long_version(LONG_VERSION.as_str()))]
pub struct CliArguments {
/// Mode of the binary
#[clap(subcommand)]
pub command: Option<Commands>,
}
#[derive(Debug, Clone, clap::Subcommand)]
#[clap(rename_all = "kebab-case")]
pub enum Commands {
/// Probes existence (Nop run)
Probe,
/// Generates completion script to stdout
Completion(ShellCompletionArgs),
/// Runs language server
Lsp(LspArgs),
/// Runs debug adapter
Dap(DapArgs),
/// Runs language server for tracing some typst program.
#[clap(hide(true))]
TraceLsp(TraceLspArgs),
/// Runs preview server
#[cfg(feature = "preview")]
Preview(tinymist::tool::preview::PreviewCliArgs),
/// Execute a document and collect coverage
#[clap(hide(true))] // still in development
Cov(CompileOnceArgs),
/// Test a document and gives summary
Test(TestArgs),
/// Runs compile command like `typst-cli compile`
Compile(CompileArgs),
/// Generates build script for compilation
#[clap(hide(true))] // still in development
GenerateScript(GenerateScriptArgs),
/// Runs language query
#[clap(hide(true))] // still in development
#[clap(subcommand)]
Query(QueryCommands),
/// Runs documents
#[clap(hide(true))] // still in development
#[clap(subcommand)]
Doc(DocCommands),
/// Runs tasks
#[clap(hide(true))] // still in development
#[clap(subcommand)]
Task(TaskCommands),
}
impl Default for Commands {
fn default() -> Self {
Self::Lsp(LspArgs::default())
}
}
#[derive(Debug, Clone, clap::Parser)]
pub struct ShellCompletionArgs {
/// The shell to generate the completion script for. If not provided, it
/// will be inferred from the environment.
#[clap(value_enum)]
pub shell: Option<Shell>,
}
#[allow(clippy::enum_variant_names)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)]
#[clap(rename_all = "lowercase")]
pub enum Shell {
Bash,
Elvish,
Fig,
Fish,
PowerShell,
Zsh,
Nushell,
}
impl Shell {
pub fn from_env() -> Option<Self> {
if let Some(env_shell) = std::env::var_os("SHELL") {
let name = Path::new(&env_shell).file_stem()?.to_str()?;
match name {
"bash" => Some(Shell::Bash),
"zsh" => Some(Shell::Zsh),
"fig" => Some(Shell::Fig),
"fish" => Some(Shell::Fish),
"elvish" => Some(Shell::Elvish),
"powershell" | "powershell_ise" => Some(Shell::PowerShell),
"nushell" => Some(Shell::Nushell),
_ => None,
}
} else if cfg!(windows) {
Some(Shell::PowerShell)
} else {
None
}
}
}
impl clap_complete::Generator for Shell {
fn file_name(&self, name: &str) -> String {
use clap_complete::shells::{Bash, Elvish, Fish, PowerShell, Zsh};
use clap_complete_fig::Fig;
use clap_complete_nushell::Nushell;
match self {
Shell::Bash => Bash.file_name(name),
Shell::Elvish => Elvish.file_name(name),
Shell::Fig => Fig.file_name(name),
Shell::Fish => Fish.file_name(name),
Shell::PowerShell => PowerShell.file_name(name),
Shell::Zsh => Zsh.file_name(name),
Shell::Nushell => Nushell.file_name(name),
}
}
fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::Write) {
use clap_complete::shells::{Bash, Elvish, Fish, PowerShell, Zsh};
use clap_complete_fig::Fig;
use clap_complete_nushell::Nushell;
match self {
Shell::Bash => Bash.generate(cmd, buf),
Shell::Elvish => Elvish.generate(cmd, buf),
Shell::Fig => Fig.generate(cmd, buf),
Shell::Fish => Fish.generate(cmd, buf),
Shell::PowerShell => PowerShell.generate(cmd, buf),
Shell::Zsh => Zsh.generate(cmd, buf),
Shell::Nushell => Nushell.generate(cmd, buf),
}
}
}
#[derive(Debug, Clone, Default, clap::Parser)]
pub struct TraceLspArgs {
#[clap(long, default_value = "false")]
pub persist: bool,
// lsp or http
#[clap(long, default_value = "lsp")]
pub rpc_kind: String,
#[clap(flatten)]
pub mirror: MirrorArgs,
#[clap(flatten)]
pub compile: CompileOnceArgs,
}
#[derive(Debug, Clone, Default, clap::Parser)]
pub struct LspArgs {
#[clap(flatten)]
pub mirror: MirrorArgs,
#[clap(flatten)]
pub font: CompileFontArgs,
}
pub type DapArgs = LspArgs;
#[derive(Debug, Clone, clap::Subcommand)]
#[clap(rename_all = "camelCase")]
pub enum QueryCommands {
/// Get the documentation for a specific package.
PackageDocs(PackageDocsArgs),
/// Check a specific package.
CheckPackage(PackageDocsArgs),
}
#[derive(Debug, Clone, clap::Parser)]
pub struct PackageDocsArgs {
/// The path of the package to request docs for.
#[clap(long)]
pub path: Option<String>,
/// The package of the package to request docs for.
#[clap(long)]
pub id: String,
/// The output path for the requested docs.
#[clap(short, long)]
pub output: String,
// /// The format of requested docs.
// #[clap(long)]
// pub format: Option<QueryDocsFormat>,
}
#[derive(Debug, Clone, Default, clap::ValueEnum)]
#[clap(rename_all = "camelCase")]
pub enum QueryDocsFormat {
#[default]
Json,
Markdown,
}
/// Project task commands.
#[derive(Debug, Clone, clap::Subcommand)]
#[clap(rename_all = "kebab-case")]
pub enum TaskCommands {
/// Declare a preview task.
#[cfg(feature = "preview")]
Preview(TaskPreviewArgs),
}
/// Declare an lsp task.
#[derive(Debug, Clone, clap::Parser)]
#[cfg(feature = "preview")]
pub struct TaskPreviewArgs {
/// Argument to identify a project.
#[clap(flatten)]
pub declare: DocNewArgs,
/// Name a task.
#[clap(long = "task")]
pub task_name: Option<String>,
/// When to run the task
#[arg(long = "when")]
pub when: Option<TaskWhen>,
/// Preview arguments
#[clap(flatten)]
pub preview: PreviewArgs,
}

View file

@ -0,0 +1,88 @@
//! Project management tools.
use std::path::PathBuf;
use reflexo::ImmutPath;
use reflexo_typst::WorldComputeGraph;
use tinymist_std::error::prelude::*;
use tinymist::project::*;
use tinymist::world::system::print_diagnostics;
use tinymist::ExportTask;
/// Arguments for project compilation.
#[derive(Debug, Clone, clap::Parser)]
pub struct CompileArgs {
/// Inherits the compile task arguments.
#[clap(flatten)]
pub compile: TaskCompileArgs,
/// Saves the compilation arguments to the lock file.
#[clap(long)]
pub save_lock: bool,
/// Specifies the path to the lock file. If the path is
/// set, the lock file will be saved.
#[clap(long)]
pub lockfile: Option<PathBuf>,
}
/// Runs project compilation(s)
pub async fn compile_main(args: CompileArgs) -> Result<()> {
let cwd = std::env::current_dir().context("cannot get cwd")?;
// todo: respect the name of the lock file
// Saves the lock file if the flags are set
let save_lock = args.save_lock || args.lockfile.is_some();
let lock_dir: ImmutPath = if let Some(lockfile) = args.lockfile {
let lockfile = if lockfile.is_absolute() {
lockfile
} else {
cwd.join(lockfile)
};
lockfile
.parent()
.context("lock file must have a parent directory")?
.into()
} else {
cwd.as_path().into()
};
// Identifies the input and output
let input = args.compile.declare.to_input((&cwd, &lock_dir));
let output = args.compile.to_task(input.id.clone(), &cwd)?;
if save_lock {
LockFile::update(&lock_dir, |state| {
state.replace_document(input.relative_to(&lock_dir));
state.replace_task(output.clone());
Ok(())
})?;
}
// Prepares for the compilation
let universe = (input, lock_dir.clone()).resolve()?;
let world = universe.snapshot();
let graph = WorldComputeGraph::from_world(world);
// Compiles the project
let is_html = matches!(output.task, ProjectTask::ExportHtml(..));
let compiled = CompiledArtifact::from_graph(graph, is_html);
let diag = compiled.diagnostics();
print_diagnostics(compiled.world(), diag, DiagnosticFormat::Human)
.context_ut("print diagnostics")?;
if compiled.has_errors() {
// todo: we should process case of compile error in fn main function
std::process::exit(1);
}
// Exports the compiled project
let lock_dir = save_lock.then_some(lock_dir);
ExportTask::do_export(output.task, compiled, lock_dir).await?;
Ok(())
}

View file

@ -0,0 +1,225 @@
//! Project management tools.
use std::{borrow::Cow, path::Path};
use clap_complete::Shell;
use reflexo::path::unix_slash;
use tinymist::project::*;
use tinymist_std::{bail, error::prelude::*};
/// Arguments for generating a build script.
#[derive(Debug, Clone, clap::Parser)]
pub struct GenerateScriptArgs {
/// The shell to generate the completion script for. If not provided, it
/// will be inferred from the environment.
#[clap(value_enum)]
pub shell: Option<Shell>,
/// The path to the output script.
#[clap(short, long)]
pub output: Option<String>,
}
/// Generates a build script for compilation
pub fn generate_script_main(args: GenerateScriptArgs) -> Result<()> {
let Some(shell) = args.shell.or_else(Shell::from_env) else {
bail!("could not infer shell");
};
let output = Path::new(args.output.as_deref().unwrap_or("build"));
let output = match shell {
Shell::Bash | Shell::Zsh | Shell::Elvish | Shell::Fish => output.with_extension("sh"),
Shell::PowerShell => output.with_extension("ps1"),
_ => bail!("unsupported shell: {shell:?}"),
};
let script = match shell {
Shell::Bash | Shell::Zsh | Shell::PowerShell => shell_build_script(shell)?,
_ => bail!("unsupported shell: {shell:?}"),
};
std::fs::write(output, script).context("write script")?;
Ok(())
}
/// Generates a build script for shell-like shells
fn shell_build_script(shell: Shell) -> Result<String> {
let mut output = String::new();
match shell {
Shell::Bash => {
output.push_str("#!/usr/bin/env bash\n\n");
}
Shell::Zsh => {
output.push_str("#!/usr/bin/env zsh\n\n");
}
Shell::PowerShell => {}
_ => {}
}
let lock_dir = std::env::current_dir().context("current directory")?;
let lock = LockFile::read(&lock_dir)?;
struct CmdBuilder(Vec<Cow<'static, str>>);
impl CmdBuilder {
fn new() -> Self {
Self(vec![])
}
fn extend(&mut self, args: impl IntoIterator<Item = impl Into<Cow<'static, str>>>) {
for arg in args {
self.0.push(arg.into());
}
}
fn push(&mut self, arg: impl Into<Cow<'static, str>>) {
self.0.push(arg.into());
}
fn build(self) -> String {
self.0.join(" ")
}
}
let quote_escape = |s: &str| s.replace("'", r#"'"'"'"#);
let quote = |s: &str| format!("'{}'", s.replace("'", r#"'"'"'"#));
let path_of = |p: &ResourcePath, loc: &str| {
let Some(path) = p.to_rel_path(&lock_dir) else {
log::error!("could not resolve path for {loc}, path: {p:?}");
return String::default();
};
quote(&unix_slash(&path))
};
let base_cmd: Vec<&str> = vec!["tinymist", "compile", "--save-lock"];
for task in lock.task.iter() {
let Some(input) = lock.get_document(&task.document) else {
log::warn!(
"could not find document for task {:?}, whose document is {:?}",
task.id,
task.doc_id()
);
continue;
};
// todo: preview/query commands
let Some(export) = task.task.as_export() else {
continue;
};
let mut cmd = CmdBuilder::new();
cmd.extend(base_cmd.iter().copied());
cmd.push("--task");
cmd.push(quote(&task.id.to_string()));
cmd.push(path_of(&input.main, "main"));
if let Some(root) = &input.root {
cmd.push("--root");
cmd.push(path_of(root, "root"));
}
for (k, v) in &input.inputs {
cmd.push(format!(
r#"--input='{}={}'"#,
quote_escape(k),
quote_escape(v)
));
}
for p in &input.font_paths {
cmd.push("--font-path");
cmd.push(path_of(p, "font-path"));
}
if !input.system_fonts {
cmd.push("--ignore-system-fonts");
}
if let Some(p) = &input.package_path {
cmd.push("--package-path");
cmd.push(path_of(p, "package-path"));
}
if let Some(p) = &input.package_cache_path {
cmd.push("--package-cache-path");
cmd.push(path_of(p, "package-cache-path"));
}
if let Some(p) = &export.output {
cmd.push("--output");
cmd.push(quote(&p.to_string()));
}
for t in &export.transform {
match t {
ExportTransform::Pretty { .. } => {
cmd.push("--pretty");
}
ExportTransform::Pages { ranges } => {
for r in ranges {
cmd.push("--pages");
cmd.push(r.to_string());
}
}
// todo: export me
ExportTransform::Merge { .. } | ExportTransform::Script { .. } => {}
}
}
match &task.task {
ProjectTask::Preview(..) | ProjectTask::Query(..) => {}
ProjectTask::ExportPdf(task) => {
cmd.push("--format=pdf");
for s in &task.pdf_standards {
cmd.push("--pdf-standard");
let s = serde_json::to_string(s).context("pdf standard")?;
cmd.push(s);
}
if let Some(output) = &task.creation_timestamp {
cmd.push("--creation-timestamp");
cmd.push(output.to_string());
}
}
ProjectTask::ExportSvg(..) => {
cmd.push("--format=svg");
}
ProjectTask::ExportSvgHtml(..) => {
cmd.push("--format=svg_html");
}
ProjectTask::ExportMd(..) => {
cmd.push("--format=md");
}
ProjectTask::ExportTeX(..) => {
cmd.push("--format=tex");
}
ProjectTask::ExportPng(..) => {
cmd.push("--format=png");
}
ProjectTask::ExportText(..) => {
cmd.push("--format=txt");
}
ProjectTask::ExportHtml(..) => {
cmd.push("--format=html");
}
}
let ext = task.task.extension();
output.push_str(&format!(
"# From {} to {} ({ext})\n",
task.doc_id(),
task.id
));
output.push_str(&cmd.build());
output.push('\n');
}
Ok(output)
}

View file

@ -0,0 +1,21 @@
//! # tinymist
//!
//! This crate provides a CLI that starts services for [Typst](https://typst.app/). It provides:
//! + `tinymist lsp`: A language server following the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/).
//! + `tinymist preview`: A preview server for Typst.
//!
//! ## Usage
//!
//! See [Features: Command Line Interface](https://myriad-dreamin.github.io/tinymist/feature/cli.html).
//!
//! ## Documentation
//!
//! See [Crate Docs](https://myriad-dreamin.github.io/tinymist/rs/tinymist/index.html).
//!
//! Also see [Developer Guide: Tinymist LSP](https://myriad-dreamin.github.io/tinymist/module/lsp.html).
//!
//! ## Contributing
//!
//! See [CONTRIBUTING.md](https://github.com/Myriad-Dreamin/tinymist/blob/main/CONTRIBUTING.md).
pub use tinymist::*;

View file

@ -0,0 +1,499 @@
#![doc = include_str!("../README.md")]
mod args;
#[cfg(feature = "export")]
mod compile;
mod generate_script;
#[cfg(feature = "preview")]
mod preview;
mod testing;
mod utils;
use core::fmt;
use std::collections::HashMap;
use std::io;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::sync::{Arc, LazyLock};
use clap::Parser;
use clap_builder::CommandFactory;
use clap_complete::generate;
use futures::future::MaybeDone;
use parking_lot::Mutex;
use reflexo::ImmutPath;
use reflexo_typst::package::PackageSpec;
use sync_ls::transport::{with_stdio_transport, MirrorArgs};
use sync_ls::{
internal_error, DapBuilder, DapMessage, GetMessageKind, LsHook, LspBuilder, LspClientRoot,
LspMessage, LspResult, Message, RequestId, TConnectionTx,
};
use tinymist::world::TaskInputs;
use tinymist::LONG_VERSION;
use tinymist::{Config, RegularInit, ServerState, SuperInit, UserActionTask};
use tinymist_project::EntryResolver;
use tinymist_query::package::PackageInfo;
use tinymist_std::hash::{FxBuildHasher, FxHashMap};
use tinymist_std::{bail, error::prelude::*};
use typst::ecow::EcoString;
#[cfg(feature = "l10n")]
use tinymist_l10n::{load_translations, set_translations};
#[cfg(feature = "preview")]
use tinymist_project::LockFile;
#[cfg(feature = "preview")]
use tinymist_task::Id;
use crate::args::*;
#[cfg(feature = "export")]
use crate::compile::compile_main;
use crate::generate_script::generate_script_main;
#[cfg(feature = "preview")]
use crate::preview::preview_main;
use crate::testing::{coverage_main, test_main};
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
/// The runtimes used by the application.
pub struct Runtimes {
/// The tokio runtime.
pub tokio_runtime: tokio::runtime::Runtime,
}
impl Default for Runtimes {
fn default() -> Self {
let tokio_runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
Self { tokio_runtime }
}
}
static RUNTIMES: LazyLock<Runtimes> = LazyLock::new(Runtimes::default);
/// The main entry point.
fn main() -> Result<()> {
#[cfg(feature = "dhat-heap")]
let _profiler = dhat::Profiler::new_heap();
// Parses command line arguments
let args = CliArguments::parse();
// Probes soon to avoid other initializations causing errors
if matches!(args.command, Some(Commands::Probe)) {
return Ok(());
}
// Loads translations
#[cfg(feature = "l10n")]
set_translations(load_translations(tinymist_assets::L10N_DATA)?);
// Starts logging
let _ = {
let is_transient_cmd = matches!(args.command, Some(Commands::Compile(..)));
let is_test_no_verbose =
matches!(&args.command, Some(Commands::Test(test)) if !test.verbose);
use log::LevelFilter::*;
let base_no_info = is_transient_cmd || is_test_no_verbose;
let base_level = if base_no_info { Warn } else { Info };
let preview_level = if is_test_no_verbose { Warn } else { Debug };
let diag_level = if is_test_no_verbose { Warn } else { Info };
env_logger::builder()
.filter_module("tinymist", base_level)
.filter_module("tinymist_preview", preview_level)
.filter_module("typlite", base_level)
.filter_module("reflexo", base_level)
.filter_module("sync_ls", base_level)
.filter_module("reflexo_typst2vec::pass::span2vec", Error)
.filter_module("reflexo_typst::diag::console", diag_level)
.try_init()
};
match args.command.unwrap_or_default() {
Commands::Completion(args) => completion(args),
Commands::Cov(args) => coverage_main(args),
Commands::Test(args) => RUNTIMES.tokio_runtime.block_on(test_main(args)),
#[cfg(feature = "export")]
Commands::Compile(args) => RUNTIMES.tokio_runtime.block_on(compile_main(args)),
Commands::GenerateScript(args) => generate_script_main(args),
Commands::Query(query_cmds) => query_main(query_cmds),
Commands::Lsp(args) => lsp_main(args),
#[cfg(feature = "dap")]
Commands::Dap(args) => dap_main(args),
Commands::TraceLsp(args) => trace_lsp_main(args),
#[cfg(feature = "preview")]
Commands::Preview(args) => RUNTIMES.tokio_runtime.block_on(preview_main(args)),
Commands::Doc(args) => project_main(args),
Commands::Task(args) => task_main(args),
Commands::Probe => Ok(()),
}
}
/// Generates completion script to stdout.
pub fn completion(args: ShellCompletionArgs) -> Result<()> {
let Some(shell) = args.shell.or_else(Shell::from_env) else {
tinymist_std::bail!("could not infer shell");
};
let mut cmd = CliArguments::command();
generate(shell, &mut cmd, "tinymist", &mut io::stdout());
Ok(())
}
/// The main entry point for the language server.
pub fn lsp_main(args: LspArgs) -> Result<()> {
let pairs = LONG_VERSION.trim().split('\n');
let pairs = pairs
.map(|e| e.splitn(2, ":").map(|e| e.trim()).collect::<Vec<_>>())
.collect::<Vec<_>>();
log::info!("tinymist version information: {pairs:?}");
log::info!("starting language server: {args:?}");
let is_replay = !args.mirror.replay.is_empty();
with_stdio_transport::<LspMessage>(args.mirror.clone(), |conn| {
let client = client_root(conn.sender);
ServerState::install_lsp(LspBuilder::new(
RegularInit {
client: client.weak().to_typed(),
font_opts: args.font,
exec_cmds: Vec::new(),
},
client.weak(),
))
.build()
.start(conn.receiver, is_replay)
})?;
log::info!("language server did shut down");
Ok(())
}
/// The main entry point for the language server.
#[cfg(feature = "dap")]
pub fn dap_main(args: DapArgs) -> Result<()> {
let pairs = LONG_VERSION.trim().split('\n');
let pairs = pairs
.map(|e| e.splitn(2, ":").map(|e| e.trim()).collect::<Vec<_>>())
.collect::<Vec<_>>();
log::info!("tinymist version information: {pairs:?}");
log::info!("starting debug adaptor: {args:?}");
let is_replay = !args.mirror.replay.is_empty();
with_stdio_transport::<DapMessage>(args.mirror.clone(), |conn| {
let client = client_root(conn.sender);
ServerState::install_dap(DapBuilder::new(
tinymist::DapRegularInit {
client: client.weak().to_typed(),
font_opts: args.font,
},
client.weak(),
))
.build()
.start(conn.receiver, is_replay)
})?;
log::info!("language server did shut down");
Ok(())
}
/// The main entry point for the compiler.
pub fn trace_lsp_main(args: TraceLspArgs) -> Result<()> {
let inputs = args.compile.resolve_inputs();
let mut input = PathBuf::from(match args.compile.input {
Some(value) => value,
None => Err(anyhow::anyhow!("provide a valid path"))?,
});
let mut root_path = args.compile.root.unwrap_or(PathBuf::from("."));
if root_path.is_relative() {
root_path = std::env::current_dir().context("cwd")?.join(root_path);
}
if input.is_relative() {
input = std::env::current_dir().context("cwd")?.join(input);
}
if !input.starts_with(&root_path) {
bail!("input file is not within the root path: {input:?} not in {root_path:?}");
}
with_stdio_transport::<LspMessage>(args.mirror.clone(), |conn| {
let client_root = client_root(conn.sender);
let client = client_root.weak();
let roots = vec![ImmutPath::from(root_path)];
let config = Config {
entry_resolver: EntryResolver {
roots,
..EntryResolver::default()
},
font_opts: args.compile.font,
..Config::default()
};
let mut service = ServerState::install_lsp(LspBuilder::new(
SuperInit {
client: client.to_typed(),
exec_cmds: Vec::new(),
config,
err: None,
},
client.clone(),
))
.build();
let resp = service.ready(()).unwrap();
let MaybeDone::Done(resp) = resp else {
anyhow::bail!("internal error: not sync init")
};
resp.unwrap();
// todo: persist
let request_received = reflexo::time::Instant::now();
let req_id: RequestId = 0.into();
client.register_request("tinymistExt/documentProfiling", &req_id, request_received);
let state = service.state_mut().unwrap();
let entry = state.entry_resolver().resolve(Some(input.as_path().into()));
let snap = state.snapshot().unwrap();
RUNTIMES.tokio_runtime.block_on(async {
let w = snap.world().clone().task(TaskInputs {
entry: Some(entry),
inputs,
});
UserActionTask::trace_main(client, state, &w, args.rpc_kind, req_id).await
});
Ok(())
})?;
Ok(())
}
/// The main entry point for language server queries.
pub fn query_main(cmds: QueryCommands) -> Result<()> {
use tinymist_project::package::PackageRegistry;
with_stdio_transport::<LspMessage>(MirrorArgs::default(), |conn| {
let client_root = client_root(conn.sender);
let client = client_root.weak();
// todo: roots, inputs, font_opts
let config = Config::default();
let mut service = ServerState::install_lsp(LspBuilder::new(
SuperInit {
client: client.to_typed(),
exec_cmds: Vec::new(),
config,
err: None,
},
client.clone(),
))
.build();
let resp = service.ready(()).unwrap();
let MaybeDone::Done(resp) = resp else {
anyhow::bail!("internal error: not sync init")
};
resp.unwrap();
let state = service.state_mut().unwrap();
let snap = state.snapshot().unwrap();
let res = RUNTIMES.tokio_runtime.block_on(async move {
match cmds {
QueryCommands::PackageDocs(args) => {
let pkg = PackageSpec::from_str(&args.id).unwrap();
let path = args.path.map(PathBuf::from);
let path = path
.unwrap_or_else(|| snap.registry().resolve(&pkg).unwrap().as_ref().into());
let res = state
.resource_package_docs_(PackageInfo {
path,
namespace: pkg.namespace,
name: pkg.name,
version: pkg.version.to_string(),
})?
.await?;
let output_path = Path::new(&args.output);
std::fs::write(output_path, res).map_err(internal_error)?;
}
QueryCommands::CheckPackage(args) => {
let pkg = PackageSpec::from_str(&args.id).unwrap();
let path = args.path.map(PathBuf::from);
let path = path
.unwrap_or_else(|| snap.registry().resolve(&pkg).unwrap().as_ref().into());
state
.check_package(PackageInfo {
path,
namespace: pkg.namespace,
name: pkg.name,
version: pkg.version.to_string(),
})?
.await?;
}
};
LspResult::Ok(())
});
res.map_err(|e| anyhow::anyhow!("{e:?}"))
})?;
Ok(())
}
#[cfg(feature = "preview")]
trait LockFileExt {
fn preview(&mut self, doc_id: Id, args: &TaskPreviewArgs) -> Result<Id>;
}
#[cfg(feature = "preview")]
impl LockFileExt for LockFile {
fn preview(&mut self, doc_id: Id, args: &TaskPreviewArgs) -> Result<Id> {
use tinymist_task::{ApplyProjectTask, PreviewTask, ProjectTask, TaskWhen};
let task_id = args
.task_name
.as_ref()
.map(|t| Id::new(t.clone()))
.unwrap_or(doc_id.clone());
let when = args.when.clone().unwrap_or(TaskWhen::OnType);
let task = ProjectTask::Preview(PreviewTask { when });
let task = ApplyProjectTask {
id: task_id.clone(),
document: doc_id,
task,
};
self.replace_task(task);
Ok(task_id)
}
}
/// Project document commands' main
#[cfg(feature = "lock")]
pub fn project_main(args: tinymist_project::DocCommands) -> Result<()> {
use tinymist_project::DocCommands;
let cwd = std::env::current_dir().context("cannot get cwd")?;
LockFile::update(&cwd, |state| {
let ctx: (&Path, &Path) = (&cwd, &cwd);
match args {
DocCommands::New(args) => {
state.replace_document(args.to_input(ctx));
}
DocCommands::Configure(args) => {
use tinymist_project::ProjectRoute;
let id: Id = args.id.id(ctx);
state.route.push(ProjectRoute {
id: id.clone(),
priority: args.priority,
});
}
}
Ok(())
})
}
/// Project task commands' main
#[cfg(feature = "lock")]
pub fn task_main(args: TaskCommands) -> Result<()> {
let cwd = std::env::current_dir().context("cannot get cwd")?;
LockFile::update(&cwd, |state| {
let _ = state;
match args {
#[cfg(feature = "preview")]
TaskCommands::Preview(args) => {
let ctx: (&Path, &Path) = (&cwd, &cwd);
let input = args.declare.to_input(ctx);
let id = input.id.clone();
state.replace_document(input);
let _ = state.preview(id, &args);
Ok(())
}
}
})
}
/// Creates a new language server host.
fn client_root<M: TryFrom<Message, Error = anyhow::Error> + GetMessageKind>(
sender: TConnectionTx<M>,
) -> LspClientRoot {
LspClientRoot::new(RUNTIMES.tokio_runtime.handle().clone(), sender)
.with_hook(Arc::new(TypstLsHook::default()))
}
#[derive(Default)]
struct TypstLsHook(Mutex<FxHashMap<RequestId, typst_timing::TimingScope>>);
impl fmt::Debug for TypstLsHook {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TypstLsHook").finish()
}
}
impl LsHook for TypstLsHook {
fn start_request(&self, req_id: &RequestId, method: &str) {
().start_request(req_id, method);
if let Some(scope) = typst_timing::TimingScope::new(static_str(method)) {
let mut map = self.0.lock();
map.insert(req_id.clone(), scope);
}
}
fn stop_request(&self, req_id: &RequestId, method: &str, received_at: std::time::Instant) {
().stop_request(req_id, method, received_at);
if let Some(scope) = self.0.lock().remove(req_id) {
let _ = scope;
}
}
fn start_notification(&self, method: &str) {
().start_notification(method);
}
fn stop_notification(
&self,
method: &str,
received_at: std::time::Instant,
result: LspResult<()>,
) {
().stop_notification(method, received_at, result);
}
}
fn static_str(s: &str) -> &'static str {
static STRS: Mutex<FxHashMap<EcoString, &'static str>> =
Mutex::new(HashMap::with_hasher(FxBuildHasher));
let mut strs = STRS.lock();
if let Some(&s) = strs.get(s) {
return s;
}
let static_ref: &'static str = String::from(s).leak();
strs.insert(static_ref.into(), static_ref);
static_ref
}

View file

@ -0,0 +1,170 @@
use std::sync::Arc;
use futures::{SinkExt, StreamExt};
use hyper_tungstenite::tungstenite::Message;
use tinymist::{
project::ProjectPreviewState,
tool::{
preview::{bind_streams, make_http_server, PreviewCliArgs, ProjectPreviewHandler},
project::{start_project, ProjectOpts, StartProjectResult},
},
};
use tinymist_assets::TYPST_PREVIEW_HTML;
use tinymist_preview::{
frontend_html, ControlPlaneMessage, ControlPlaneTx, PreviewBuilder, PreviewConfig,
};
use tinymist_project::WorldProvider;
use tinymist_std::error::prelude::*;
use tokio::sync::mpsc;
use crate::utils::exit_on_ctrl_c;
/// Entry point of the preview tool.
pub async fn preview_main(args: PreviewCliArgs) -> Result<()> {
log::info!("Arguments: {args:#?}");
let handle = tokio::runtime::Handle::current();
let config = args.preview.config(&PreviewConfig::default());
#[cfg(feature = "open")]
let open_in_browser = args.open_in_browser(true);
let static_file_host =
if args.static_file_host == args.data_plane_host || !args.static_file_host.is_empty() {
Some(args.static_file_host)
} else {
None
};
exit_on_ctrl_c();
let verse = args.compile.resolve()?;
let previewer = PreviewBuilder::new(config);
let (service, handle) = {
let preview_state = ProjectPreviewState::default();
let opts = ProjectOpts {
handle: Some(handle),
preview: preview_state.clone(),
..ProjectOpts::default()
};
let StartProjectResult {
service,
intr_tx,
mut editor_rx,
} = start_project(verse, Some(opts), |compiler, intr, next| {
next(compiler, intr)
});
// Consume editor_rx
tokio::spawn(async move { while editor_rx.recv().await.is_some() {} });
let id = service.compiler.primary.id.clone();
let registered = preview_state.register(&id, previewer.compile_watcher(args.task_id));
if !registered {
tinymist_std::bail!("failed to register preview");
}
let handle: Arc<ProjectPreviewHandler> = Arc::new(ProjectPreviewHandler {
project_id: id,
client: Box::new(intr_tx),
});
(service, handle)
};
let (lsp_tx, mut lsp_rx) = ControlPlaneTx::new(true);
let control_plane_server_handle = tokio::spawn(async move {
let (control_sock_tx, mut control_sock_rx) = mpsc::unbounded_channel();
let srv =
make_http_server(String::default(), args.control_plane_host, control_sock_tx).await;
log::info!("Control panel server listening on: {}", srv.addr);
let control_websocket = control_sock_rx.recv().await.unwrap();
let ws = control_websocket.await.unwrap();
tokio::pin!(ws);
loop {
tokio::select! {
Some(resp) = lsp_rx.resp_rx.recv() => {
let r = ws
.send(Message::Text(serde_json::to_string(&resp).unwrap()))
.await;
let Err(err) = r else {
continue;
};
log::warn!("failed to send response to editor {err:?}");
break;
}
msg = ws.next() => {
let msg = match msg {
Some(Ok(Message::Text(msg))) => Some(msg),
Some(Ok(msg)) => {
log::error!("unsupported message: {msg:?}");
break;
}
Some(Err(e)) => {
log::error!("failed to receive message: {e}");
break;
}
_ => None,
};
if let Some(msg) = msg {
let Ok(msg) = serde_json::from_str::<ControlPlaneMessage>(&msg) else {
log::warn!("failed to parse control plane request: {msg:?}");
break;
};
lsp_rx.ctl_tx.send(msg).unwrap();
} else {
// todo: inform the editor that the connection is closed.
break;
}
}
}
}
let _ = srv.shutdown_tx.send(());
let _ = srv.join.await;
});
let (websocket_tx, websocket_rx) = mpsc::unbounded_channel();
let mut previewer = previewer.build(lsp_tx, handle.clone()).await;
tokio::spawn(service.run());
bind_streams(&mut previewer, websocket_rx);
let frontend_html = frontend_html(TYPST_PREVIEW_HTML, args.preview.preview_mode, "/");
let static_server = if let Some(static_file_host) = static_file_host {
log::warn!("--static-file-host is deprecated, which will be removed in the future. Use --data-plane-host instead.");
let html = frontend_html.clone();
Some(make_http_server(html, static_file_host, websocket_tx.clone()).await)
} else {
None
};
let srv = make_http_server(frontend_html, args.data_plane_host, websocket_tx).await;
log::info!("Data plane server listening on: {}", srv.addr);
let static_server_addr = static_server.as_ref().map(|s| s.addr).unwrap_or(srv.addr);
log::info!("Static file server listening on: {static_server_addr}");
#[cfg(feature = "open")]
if open_in_browser {
open::that_detached(format!("http://{static_server_addr}"))
.log_error("failed to open browser for preview");
}
let _ = tokio::join!(previewer.join(), srv.join, control_plane_server_handle);
// Assert that the static server's lifetime is longer than the previewer.
let _s = static_server;
Ok(())
}

View file

@ -0,0 +1,144 @@
#let total-tests = state("total-tests", (0, 0))
#let test-set = state("test-set", (:))
#let example-set = state("example-set", (:))
#let ref-paths = state("ref-paths", (:))
#let reset() = {
test-set.update(it => {
for test in it.keys() {
it.insert(test, "stale")
}
it
})
example-set.update(it => {
for test in it.keys() {
it.insert(test, "stale")
}
it
})
}
#let running-tests(tests, examples) = {
total-tests.update(it => (tests, examples))
}
#let running-test(test) = {
test-set.update(it => {
it.insert(test, "running")
it
})
}
#let passed-test(test) = {
test-set.update(it => {
it.insert(test, "passed")
it
})
}
#let failed-test(test) = {
test-set.update(it => {
it.insert(test, "failed")
it
})
}
#let running-example(example) = {
example-set.update(it => {
it.insert(example, "running")
it
})
}
#let passed-example(example) = {
example-set.update(it => {
if it.at(example) == "running" {
it.insert(example, "passed")
}
it
})
}
#let failed-example(example) = {
example-set.update(it => {
it.insert(example, "failed")
it
})
}
#let mismatch-example(example, hint) = {
example-set.update(it => {
it.insert(example, "failed")
it
})
ref-paths.update(it => {
it.insert(example, hint)
it
})
}
#let main(it) = {
context {
let tests = test-set.final()
let examples = example-set.final()
let ref-paths = ref-paths.final()
let (total-tests, total-examples) = total-tests.final()
[
Running #total-tests tests, #total-examples examples.
]
let tests = tests.pairs().sorted()
for (test, status) in tests [
#if status == "stale" {
continue
}
+ Test(#text(fill: blue.darken(10%), test)): #if status == "running" [
#text(fill: yellow)[Running]
] else if status == "passed" [
#text(fill: green.darken(30%))[Passed]
] else [
#text(fill: red)[Failed]
]
]
let examples = examples.pairs().sorted()
for (example, status) in examples [
#if status == "stale" {
continue
}
+ Example(#text(fill: blue.darken(10%), example)): #if status == "running" [
#text(fill: yellow)[Running]
] else if status == "passed" [
#text(fill: green.darken(30%))[Passed]
] else [
#link(
label("hint-" + example),
text(fill: red)[Failed]
)
]
]
set page(height: auto)
for (example, hint) in ref-paths.pairs() {
page[
== Hint #text(fill: blue.darken(10%), example) #label("hint-" + example)
#text(fill: red)[compare image at #text(fill: blue.darken(10%), "/" + hint)]
#grid(
align: center,
columns: (1fr, 1fr),
[Ref], [Got],
image("/" + hint), image("/" + hint.slice(0, hint.len() - 4) + ".tmp.png"),
)
]
}
}
it
}

View file

@ -0,0 +1,627 @@
//! Testing utilities
use core::fmt;
use std::collections::HashSet;
use std::io::Write;
use std::path::Path;
use std::sync::{atomic::AtomicBool, Arc};
use itertools::Either;
use parking_lot::Mutex;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use reflexo::ImmutPath;
use reflexo_typst::{vfs::FileId, TypstDocument, TypstHtmlDocument};
use tinymist_debug::CoverageResult;
use tinymist_project::world::{system::print_diagnostics, DiagnosticFormat};
use tinymist_query::analysis::Analysis;
use tinymist_query::syntax::{cast_include_expr, find_source_by_expr, node_ancestors};
use tinymist_query::testing::{TestCaseKind, TestSuites};
use tinymist_std::{bail, error::prelude::*, fs::paths::write_atomic, typst::TypstPagedDocument};
use typst::diag::{Severity, SourceDiagnostic};
use typst::ecow::EcoVec;
use typst::foundations::{Context, Label};
use typst::syntax::{ast, LinkedNode, Source, Span};
use typst::{utils::PicoStr, World};
use typst_shim::eval::TypstEngine;
use tinymist::project::*;
use tinymist::tool::project::{start_project, StartProjectResult};
use tinymist::world::{with_main, SourceWorld};
use crate::utils::exit_on_ctrl_c;
const TEST_EVICT_MAX_AGE: usize = 30;
const PREFIX_LEN: usize = 7;
/// Runs coverage test on a document
pub fn coverage_main(args: CompileOnceArgs) -> Result<()> {
// Prepares for the compilation
let universe = args.resolve()?;
let world = universe.snapshot();
let result = Ok(()).and_then(|_| -> Result<()> {
let res = tinymist_debug::collect_coverage::<TypstPagedDocument, _>(&world)?;
let cov_path = Path::new("target/coverage.json");
let res = serde_json::to_string(&res.to_json(&world)).context("coverage")?;
std::fs::create_dir_all(cov_path.parent().context("parent")?).context("create coverage")?;
std::fs::write(cov_path, res).context("write coverage")?;
Ok(())
});
print_diag_or_error(&world, result)
}
/// Testing arguments
#[derive(Debug, Clone, clap::Parser)]
pub struct TestArgs {
/// The argument to compile once.
#[clap(flatten)]
pub compile: CompileOnceArgs,
/// Configuration for testing
#[clap(flatten)]
pub config: TestConfigArgs,
/// Whether to run in watch mode.
#[clap(long)]
pub watch: bool,
/// Whether to render the dashboard.
#[clap(long)]
pub dashboard: bool,
/// Whether not to render the dashboard.
#[clap(long)]
pub no_dashboard: bool,
/// Whether to log verbose information.
#[clap(long)]
pub verbose: bool,
}
/// Testing config arguments
#[derive(Debug, Clone, clap::Parser)]
pub struct TestConfigArgs {
/// Whether to update the reference images.
#[clap(long)]
pub update: bool,
/// The argument to export to PNG.
#[clap(flatten)]
pub png: PngExportArgs,
/// Whether to collect coverage.
#[clap(long)]
pub coverage: bool,
/// Style of printing coverage.
#[clap(long, default_value = "short")]
pub print_coverage: PrintCovStyle,
}
/// Style of printing coverage.
#[derive(Debug, Clone, clap::Parser, clap::ValueEnum)]
pub enum PrintCovStyle {
/// Don't print the coverage.
Never,
/// Prints the coverage in a short format.
Short,
/// Prints the coverage in a full format.
Full,
}
macro_rules! test_log {
($level:ident,$prefix:expr, $($arg:tt)*) => {
msg(Level::$level, $prefix, format_args!($($arg)*))
};
}
macro_rules! test_info { ($( $arg:tt )*) => { test_log!(Info, $($arg)*) }; }
macro_rules! test_error { ($( $arg:tt )*) => { test_log!(Error, $($arg)*) }; }
macro_rules! log_info { ($( $arg:tt )*) => { test_log!(Info, "Info", $($arg)*) }; }
macro_rules! log_hint { ($( $arg:tt )*) => { test_log!(Hint, "Hint", $($arg)*) }; }
const LOG_PRELUDE: &str = "#import \"/target/testing-log.typ\": *\n#show: main";
/// Runs tests on a document
pub async fn test_main(args: TestArgs) -> Result<()> {
exit_on_ctrl_c();
// Prepares for the compilation
let verse = args.compile.resolve()?;
let root = verse.entry_state().root().map(Ok);
let root = root
.unwrap_or_else(|| std::env::current_dir().map(|p| p.into()))
.context("cannot find root")?;
std::fs::create_dir_all(Path::new("target")).context("create target dir")?;
let dashboard = (!args.no_dashboard) && (args.dashboard || args.watch);
let dashboard_path = "target/dashboard.typ";
let out_file = if dashboard {
test_info!("Info", "Dashboard is available at {dashboard_path}");
write_atomic("target/testing-log.typ", include_str!("testing-log.typ"))
.context("write log template")?;
let mut out_file = std::fs::File::create(dashboard_path).context("create log file")?;
writeln!(out_file, "{LOG_PRELUDE}").context("write log")?;
Some(Arc::new(Mutex::new(out_file)))
} else {
None
};
let config = TestContext {
root,
args: args.config,
out_file,
analysis: Analysis::default(),
};
if !args.watch {
let snap = verse.snapshot();
return match test_once(&snap, &config) {
Ok(true) => Ok(()),
Ok(false) | Err(..) => std::process::exit(1),
};
}
let ctx = Arc::new(Mutex::new(config.clone()));
let repl_ctx = ctx.clone();
let mut is_first = true;
let StartProjectResult {
service,
mut editor_rx,
intr_tx,
} = start_project(verse, None, move |c, mut i, next| {
if let Interrupt::Compiled(artifact) = &mut i {
let mut config = ctx.lock();
let instant = std::time::Instant::now();
// todo: well term support
// Clear the screen and then move the cursor to the top left corner.
eprintln!("\x1B[2J\x1B[1;1H");
if is_first {
is_first = false;
} else {
log_info!("Runs testing again...");
}
// Sets is_compiling to track dependencies
let mut world = artifact.snap.world.clone();
world.set_is_compiling(true);
let res = test_once(&world, &config);
world.set_is_compiling(false);
if let Err(err) = res {
test_error!("Fatal:", "{err}");
}
log_info!("Tests finished in {:?}", instant.elapsed());
if dashboard {
log_hint!("Dashboard is available at {dashboard_path}");
}
log_hint!("Press 'h' for help");
config.args.update = false;
}
next(c, i)
});
let proj_id = service.compiler.primary.id.clone();
tokio::spawn(async move {
let mut line = String::new();
loop {
line.clear();
std::io::stdin().read_line(&mut line).unwrap();
match line.trim() {
"r" => {
let _ = intr_tx.send(Interrupt::Compile(proj_id.clone()));
}
"u" => {
let mut repl_ctx = repl_ctx.lock();
repl_ctx.args.update = true;
let _ = intr_tx.send(Interrupt::Compile(proj_id.clone()));
}
"h" => eprintln!("h/r/u/c/q: help/run/update/quit"),
"q" => std::process::exit(0),
line => eprintln!("Unknown command: {line}"),
}
}
});
// Consume service and editor_rx
tokio::spawn(async move { while editor_rx.recv().await.is_some() {} });
service.run().await;
Ok(())
}
fn test_once(world: &LspWorld, ctx: &TestContext) -> Result<bool> {
let mut actx = ctx.analysis.enter(world.clone());
let doc = typst::compile::<TypstPagedDocument>(&actx.world).output?;
let suites =
tinymist_query::testing::test_suites(&mut actx, &TypstDocument::from(Arc::new(doc)))
.context("failed to discover tests")?;
log_info!(
"Found {} tests and {} examples",
suites.tests.len(),
suites.examples.len()
);
let result = if ctx.args.coverage {
let (cov, result) = tinymist_debug::with_cov(world, |world| {
let suites = suites.recheck(world);
let runner = TestRunner::new(ctx, world, &suites);
let result = print_diag_or_error(world, runner.run());
comemo::evict(TEST_EVICT_MAX_AGE);
result
});
ctx.handle_cov(world, cov?)?;
result
} else {
let suites = suites.recheck(world);
let runner = TestRunner::new(ctx, world, &suites);
comemo::evict(TEST_EVICT_MAX_AGE);
runner.run()
};
let passed = print_diag_or_error(world, result);
if matches!(passed, Ok(true)) {
log_info!("All test cases passed...");
} else {
test_error!("Fatal:", "Some test cases failed...");
}
passed
}
#[derive(Clone)]
struct TestContext {
analysis: Analysis,
root: ImmutPath,
args: TestConfigArgs,
out_file: Option<Arc<Mutex<std::fs::File>>>,
}
impl TestContext {
pub fn handle_cov(&self, world: &LspWorld, cov: CoverageResult) -> Result<()> {
let cov_path = Path::new("target/coverage.json");
let res = serde_json::to_string(&cov.to_json(world)).context("coverage")?;
write_atomic(cov_path, res).context("write coverage")?;
log_info!("Written coverage to {} ...", cov_path.display());
const COV_PREFIX: &str = " \x1b[1;32mCov\x1b[0m ";
match self.args.print_coverage {
PrintCovStyle::Never => {}
PrintCovStyle::Short => {
eprintln!("{}", cov.summarize(true, COV_PREFIX))
}
PrintCovStyle::Full => {
eprintln!("{}", cov.summarize(false, COV_PREFIX))
}
}
Ok(())
}
}
struct TestRunner<'a> {
ctx: &'a TestContext,
world: &'a dyn SourceWorld,
suites: &'a TestSuites,
diagnostics: Mutex<Vec<EcoVec<SourceDiagnostic>>>,
examples: Mutex<HashSet<String>>,
failed: AtomicBool,
}
impl<'a> TestRunner<'a> {
fn new(ctx: &'a TestContext, world: &'a dyn SourceWorld, suites: &'a TestSuites) -> Self {
Self {
ctx,
world,
suites,
diagnostics: Mutex::new(Vec::new()),
examples: Mutex::new(HashSet::new()),
failed: AtomicBool::new(false),
}
}
fn put_log(&self, args: fmt::Arguments) {
if let Some(file) = &self.ctx.out_file {
writeln!(file.lock(), "{args}").unwrap();
}
}
fn running(&self, kind: &str, name: &str) {
test_info!("Running", "{kind}({name})");
self.put_log(format_args!("#running-{kind}({name:?})"));
}
fn mark_failed(&self, kind: &str, name: &str, args: impl fmt::Display) {
test_log!(Error, "Failed", "{kind}({name}): {args}");
self.put_log(format_args!("#failed-{kind}({name:?})"));
self.failed.store(true, std::sync::atomic::Ordering::SeqCst);
}
fn mark_passed(&self, kind: &str, name: &str) {
test_info!("Passed", "{kind}({name})");
self.put_log(format_args!("#passed-{kind}({name:?})"));
}
fn failed_example(&self, name: &str, args: impl fmt::Display) {
self.mark_failed("example", name, args);
}
fn failed_test(&self, name: &str, args: impl fmt::Display) {
self.mark_failed("test", name, args);
}
/// Runs the tests and returns whether all tests passed.
fn run(self) -> Result<bool> {
self.put_log(format_args!(
"#reset();\n#running-tests({}, {})",
self.suites.tests.len(),
self.suites.examples.len()
));
let examples = self.suites.examples.par_iter().map(Either::Left);
let tests = self.suites.tests.par_iter().map(Either::Right);
examples.chain(tests).for_each(|case| {
let test = match case {
Either::Left(test) => {
self.run_example(test);
return;
}
Either::Right(test) => test,
};
let name = &test.name;
let func = &test.function;
let world = with_main(self.world.as_world(), test.location);
let mut engine = TypstEngine::new(&world);
// Executes the function
match test.kind {
TestCaseKind::Test | TestCaseKind::Bench => {
self.running("test", name);
if let Err(err) = engine.call(func, Context::default()) {
self.diagnostics.lock().push(err);
self.failed_test(name, format_args!("call error"));
} else {
self.mark_passed("test", name);
}
}
TestCaseKind::Panic => {
self.running("test", name);
match engine.call(func, Context::default()) {
Ok(..) => {
self.failed_test(name, "exited normally, expected panic");
}
Err(err) => {
let all_panic = err.iter().all(|p| p.message.contains("panic"));
if !all_panic {
self.diagnostics.lock().push(err);
self.failed_test(name, "exited with error, expected panic");
} else {
self.mark_passed("test", name);
}
}
}
}
TestCaseKind::Example => {
match get_example_file(&world, name, test.location, func.span()) {
Ok(example) => self.run_example(&example),
Err(err) => self.failed_test(name, format_args!("not found: {err}")),
};
}
}
});
{
let diagnostics = self.diagnostics.into_inner();
if !diagnostics.is_empty() {
let diagnostics = diagnostics.into_iter().flatten().collect::<EcoVec<_>>();
let any_error = diagnostics.iter().any(|d| d.severity == Severity::Error);
if any_error {
Err(diagnostics)?
} else {
print_diagnostics(self.world, diagnostics.iter(), DiagnosticFormat::Human)
.context_ut("print diagnostics")?;
}
}
}
Ok(!self.failed.load(std::sync::atomic::Ordering::SeqCst))
}
fn run_example(&self, test: &Source) {
let id = test.id().vpath().as_rooted_path().with_extension("");
let name = id.file_name().and_then(|s| s.to_str()).unwrap_or_default();
self.running("example", name);
if !self.examples.lock().insert(name.to_string()) {
self.failed_example(name, "duplicate");
return;
}
let world = with_main(self.world.as_world(), test.id());
let mut has_err = false;
let (has_err_, doc) = self.build_example::<TypstPagedDocument>(&world);
has_err |= has_err_ || self.render_paged(name, doc.as_ref());
if self.can_html(doc.as_ref()) {
let (has_err_, doc) = self.build_example::<TypstHtmlDocument>(&world);
has_err |= has_err_ || self.render_html(name, doc.as_ref());
}
if has_err {
self.failed_example(name, "has error");
} else {
self.mark_passed("example", name);
}
}
fn build_example<T: typst::Document>(&self, world: &dyn World) -> (bool, Option<T>) {
let result = typst::compile::<T>(world);
if !result.warnings.is_empty() {
self.diagnostics.lock().push(result.warnings);
}
match result.output {
Ok(v) => (false, Some(v)),
Err(e) => {
self.diagnostics.lock().push(e);
(true, None)
}
}
}
fn render_paged(&self, example: &str, doc: Option<&TypstPagedDocument>) -> bool {
let Some(doc) = doc else {
return false;
};
let ppp = self.ctx.args.png.ppi / 72.0;
let pixmap = typst_render::render_merged(doc, ppp, Default::default(), None);
let output = pixmap.encode_png().context_ut("cannot encode pixmap");
let output = output.and_then(|output| self.update_example(example, &output, "paged"));
self.check_result(example, output, "paged")
}
fn render_html(&self, example: &str, doc: Option<&TypstHtmlDocument>) -> bool {
let Some(doc) = doc else {
return false;
};
let output = match typst_html::html(doc) {
Ok(output) => self.update_example(example, output.as_bytes(), "html"),
Err(err) => {
self.diagnostics.lock().push(err);
Err(error_once!("render error"))
}
};
self.check_result(example, output, "html")
}
fn check_result(&self, example: &str, res: Result<()>, kind: &str) -> bool {
if let Err(err) = res {
self.failed_example(example, format_args!("cannot render {kind}: {err}"));
true
} else {
false
}
}
fn update_example(&self, example: &str, data: &[u8], kind: &str) -> Result<()> {
let ext = if kind == "paged" { "png" } else { "html" };
let refs_path = self.ctx.root.join("refs");
let path = refs_path.join(kind).join(example).with_extension(ext);
let tmp_path = &path.with_extension(format!("tmp.{ext}"));
let hash_path = &path.with_extension("hash");
let hash = &format!("siphash128_13:{:x}", tinymist_std::hash::hash128(&data));
let existing_hash = if std::fs::exists(hash_path).context("exists hash ref")? {
Some(std::fs::read(hash_path).context("read hash ref")?)
} else {
None
};
let equal = existing_hash.map(|existing| existing.as_slice() == hash.as_bytes());
match (self.ctx.args.update, equal) {
// Doesn't exist, create it
(_, None) => {}
(_, Some(true)) => log_info!("example({example}): {kind} matches"),
(true, Some(false)) => log_info!("example({example}): ref {kind}"),
(false, Some(false)) => {
write_atomic(tmp_path, data).context("write tmp ref")?;
self.failed_example(example, format_args!("mismatch {kind}"));
log_hint!("example({example}): compare {kind} at {}", path.display());
match path.strip_prefix(&self.ctx.root) {
Ok(p) => self.put_log(format_args!("#mismatch-example({example:?}, {p:?})")),
Err(_) => self.put_log(format_args!("#mismatch-example({example:?}, none)")),
};
return Ok(());
}
}
if std::fs::exists(tmp_path).context("exists tmp")? {
std::fs::remove_file(tmp_path).context("remove tmp")?;
}
if matches!(equal, Some(true)) {
return Ok(());
}
std::fs::create_dir_all(path.parent().context("parent")?).context("create ref")?;
write_atomic(path, data).context("write ref")?;
write_atomic(hash_path, hash).context("write hash ref")?;
Ok(())
}
fn can_html(&self, doc: Option<&TypstPagedDocument>) -> bool {
let Some(doc) = doc else {
return false;
};
let label = Label::new(PicoStr::intern("test-html-example"));
// todo: error multiple times
doc.introspector.query_label(label).is_ok()
}
}
fn get_example_file(world: &dyn World, name: &str, id: FileId, span: Span) -> Result<Source> {
let source = world.source(id).context_ut("cannot find file")?;
let node = LinkedNode::new(source.root());
let leaf = node.find(span).context("cannot find example function")?;
let function = node_ancestors(&leaf)
.find(|n| n.is::<ast::Closure>())
.context("cannot find example function")?;
let closure = function.cast::<ast::Closure>().unwrap();
if closure.params().children().count() != 0 {
bail!("example function must not have parameters");
}
let included =
cast_include_expr(name, closure.body()).context("cannot find example function")?;
find_source_by_expr(world, id, included).context("cannot find example file")
}
fn print_diag_or_error<T>(world: &impl SourceWorld, result: Result<T>) -> Result<T> {
match result {
Ok(v) => Ok(v),
Err(err) => {
if let Some(diagnostics) = err.diagnostics() {
print_diagnostics(world, diagnostics.iter(), DiagnosticFormat::Human)
.context_ut("print diagnostics")?;
bail!("");
}
Err(err)
}
}
}
enum Level {
Error,
Info,
Hint,
}
fn msg(level: Level, prefix: &str, msg: fmt::Arguments) {
let color = match level {
Level::Error => "\x1b[1;31m",
Level::Info => "\x1b[1;32m",
Level::Hint => "\x1b[1;36m",
};
let reset = "\x1b[0m";
eprintln!("{color}{prefix:>PREFIX_LEN$}{reset} {msg}");
}

View file

@ -0,0 +1,7 @@
pub fn exit_on_ctrl_c() {
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
log::info!("Ctrl-C received, exiting");
std::process::exit(0);
});
}