mirror of
https://github.com/Myriad-Dreamin/tinymist.git
synced 2025-11-24 05:06:41 +00:00
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
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:
parent
79f68dc94d
commit
ce5ab81760
56 changed files with 1558 additions and 1122 deletions
150
crates/tinymist-cli/Cargo.toml
Normal file
150
crates/tinymist-cli/Cargo.toml
Normal 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
|
||||
22
crates/tinymist-cli/README.md
Normal file
22
crates/tinymist-cli/README.md
Normal 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).
|
||||
238
crates/tinymist-cli/src/args.rs
Normal file
238
crates/tinymist-cli/src/args.rs
Normal 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,
|
||||
}
|
||||
88
crates/tinymist-cli/src/compile.rs
Normal file
88
crates/tinymist-cli/src/compile.rs
Normal 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(())
|
||||
}
|
||||
225
crates/tinymist-cli/src/generate_script.rs
Normal file
225
crates/tinymist-cli/src/generate_script.rs
Normal 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)
|
||||
}
|
||||
21
crates/tinymist-cli/src/lib.rs
Normal file
21
crates/tinymist-cli/src/lib.rs
Normal 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::*;
|
||||
499
crates/tinymist-cli/src/main.rs
Normal file
499
crates/tinymist-cli/src/main.rs
Normal 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
|
||||
}
|
||||
170
crates/tinymist-cli/src/preview.rs
Normal file
170
crates/tinymist-cli/src/preview.rs
Normal 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(())
|
||||
}
|
||||
144
crates/tinymist-cli/src/testing-log.typ
Normal file
144
crates/tinymist-cli/src/testing-log.typ
Normal 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
|
||||
}
|
||||
627
crates/tinymist-cli/src/testing.rs
Normal file
627
crates/tinymist-cli/src/testing.rs
Normal 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}");
|
||||
}
|
||||
7
crates/tinymist-cli/src/utils.rs
Normal file
7
crates/tinymist-cli/src/utils.rs
Normal 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);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue