From 85c459d4c80290fdf6925f202710646a075e1bdd Mon Sep 17 00:00:00 2001 From: Myriad-Dreamin <35292584+Myriad-Dreamin@users.noreply.github.com> Date: Mon, 5 Aug 2024 02:14:03 +0800 Subject: [PATCH] feat: support vscode tasks for exporting query and pdfpc (#490) * feat: support vscode tasks for exporting query and pdfpc * test: update snapshot --- Cargo.lock | 1 + crates/tinymist-query/src/lib.rs | 14 +++ crates/tinymist/Cargo.toml | 1 + crates/tinymist/src/cmd.rs | 30 +++++++ crates/tinymist/src/server.rs | 1 + crates/tinymist/src/task/export.rs | 68 +++++++++++++++ docs/tinymist/feature/export.typ | 86 +++++++++++++++++++ .../print-state/.vscode/tasks.json | 29 +++++++ .../e2e-workspaces/print-state/effect.typ | 12 +++ .../e2e-workspaces/print-state/main.typ | 7 ++ editors/vscode/package.json | 61 ++++++++++++- editors/vscode/src/lsp.ts | 3 + editors/vscode/src/tasks.ts | 47 +++++++++- tests/e2e/main.rs | 4 +- 14 files changed, 354 insertions(+), 10 deletions(-) create mode 100644 editors/vscode/e2e-workspaces/print-state/.vscode/tasks.json create mode 100644 editors/vscode/e2e-workspaces/print-state/effect.typ create mode 100644 editors/vscode/e2e-workspaces/print-state/main.typ diff --git a/Cargo.lock b/Cargo.lock index 5872e3ea..4a4958f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3768,6 +3768,7 @@ dependencies = [ "reflexo", "serde", "serde_json", + "serde_yaml", "sync-lsp", "tinymist-assets", "tinymist-query", diff --git a/crates/tinymist-query/src/lib.rs b/crates/tinymist-query/src/lib.rs index 32b43531..f79c2389 100644 --- a/crates/tinymist-query/src/lib.rs +++ b/crates/tinymist-query/src/lib.rs @@ -153,6 +153,15 @@ mod polymorphic { Html {}, Markdown {}, Text {}, + Query { + format: String, + output_extension: Option, + strict: bool, + selector: String, + field: Option, + one: bool, + pretty: bool, + }, Svg { page: PageSelection, }, @@ -180,6 +189,11 @@ mod polymorphic { Self::Text { .. } => "txt", Self::Svg { .. } => "svg", Self::Png { .. } => "png", + Self::Query { + format, + output_extension, + .. + } => output_extension.as_deref().unwrap_or(format), } } } diff --git a/crates/tinymist/Cargo.toml b/crates/tinymist/Cargo.toml index a9244fbf..de7e3985 100644 --- a/crates/tinymist/Cargo.toml +++ b/crates/tinymist/Cargo.toml @@ -31,6 +31,7 @@ env_logger.workspace = true log.workspace = true serde.workspace = true serde_json.workspace = true +serde_yaml.workspace = true parking_lot.workspace = true paste.workspace = true diff --git a/crates/tinymist/src/cmd.rs b/crates/tinymist/src/cmd.rs index 0b5cdcab..fb88179a 100644 --- a/crates/tinymist/src/cmd.rs +++ b/crates/tinymist/src/cmd.rs @@ -26,6 +26,18 @@ struct ExportOpts { page: PageSelection, } +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct QueryOpts { + format: String, + output_extension: Option, + strict: Option, + pretty: Option, + selector: String, + field: Option, + one: Option, +} + /// Here are implemented the handlers for each command. impl LanguageState { /// Export the current document as PDF file(s). @@ -59,6 +71,24 @@ impl LanguageState { self.export(req_id, ExportKind::Text {}, args) } + /// Query the current document and export the result as JSON file(s). + pub fn export_query(&mut self, req_id: RequestId, mut args: Vec) -> ScheduledResult { + let opts = get_arg_or_default!(args[1] as QueryOpts); + self.export( + req_id, + ExportKind::Query { + format: opts.format, + output_extension: opts.output_extension, + strict: opts.strict.unwrap_or(true), + selector: opts.selector, + field: opts.field, + pretty: opts.pretty.unwrap_or(true), + one: opts.one.unwrap_or(false), + }, + args, + ) + } + /// Export the current document as Svg file(s). pub fn export_svg(&mut self, req_id: RequestId, mut args: Vec) -> ScheduledResult { let opts = get_arg_or_default!(args[1] as ExportOpts); diff --git a/crates/tinymist/src/server.rs b/crates/tinymist/src/server.rs index 3e6bace6..7c7a6f27 100644 --- a/crates/tinymist/src/server.rs +++ b/crates/tinymist/src/server.rs @@ -257,6 +257,7 @@ impl LanguageState { .with_command_("tinymist.exportText", State::export_text) .with_command_("tinymist.exportHtml", State::export_html) .with_command_("tinymist.exportMarkdown", State::export_markdown) + .with_command_("tinymist.exportQuery", State::export_query) .with_command("tinymist.doClearCache", State::clear_cache) .with_command("tinymist.pinMain", State::pin_document) .with_command("tinymist.focusMain", State::focus_document) diff --git a/crates/tinymist/src/task/export.rs b/crates/tinymist/src/task/export.rs index b8dd5968..663b057c 100644 --- a/crates/tinymist/src/task/export.rs +++ b/crates/tinymist/src/task/export.rs @@ -1,5 +1,6 @@ //! The actor that handles various document export, like PDF and SVG export. +use std::ops::Deref; use std::str::FromStr; use std::{path::PathBuf, sync::Arc}; @@ -8,6 +9,7 @@ use once_cell::sync::Lazy; use tinymist_query::{ExportKind, PageSelection}; use tokio::sync::mpsc; use typlite::Typlite; +use typst::foundations::IntoValue; use typst::{ foundations::Smart, layout::{Abs, Frame}, @@ -213,6 +215,39 @@ impl ExportConfig { // todo: timestamp world.now() typst_pdf::pdf(doc, Smart::Auto, timestamp) } + Query { + format, + output_extension: _, + strict, + selector, + field, + one, + pretty, + } => { + let elements = + typst_ts_compiler::query::retrieve(artifact.world.deref(), &selector, doc) + .map_err(|e| anyhow::anyhow!("failed to retrieve: {e}"))?; + if one && elements.len() != 1 { + bail!("expected exactly one element, found {}", elements.len()); + } + + let mapped: Vec<_> = elements + .into_iter() + .filter_map(|c| match &field { + Some(field) => c.get_by_name(field), + _ => Some(c.into_value()), + }) + .collect(); + + if one { + let Some(value) = mapped.first() else { + bail!("no such field found for element"); + }; + serialize(value, &format, strict, pretty).map(String::into_bytes)? + } else { + serialize(&mapped, &format, strict, pretty).map(String::into_bytes)? + } + } Html {} => typst_ts_svg_exporter::render_svg_html(doc).into_bytes(), Text {} => format!("{}", FullTextDigest(doc.clone())).into_bytes(), Markdown {} => { @@ -355,6 +390,39 @@ fn convert_datetime(date_time: chrono::DateTime) -> Option anyhow::Result { + Ok(match format { + "json" if pretty => serde_json::to_string_pretty(data)?, + "json" => serde_json::to_string(data)?, + "yaml" => serde_yaml::to_string(&data)?, + format if format == "txt" || !strict => { + use serde_json::Value::*; + let value = serde_json::to_value(data)?; + match value { + String(s) => s, + _ => { + let kind = match value { + Null => "null", + Bool(_) => "boolean", + Number(_) => "number", + String(_) => "string", + Array(_) => "array", + Object(_) => "object", + }; + bail!("expected a string value for format: {format}, got {kind}") + } + } + } + _ => bail!("unsupported format for query: {format}"), + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/docs/tinymist/feature/export.typ b/docs/tinymist/feature/export.typ index 3a6d01b9..79df8115 100644 --- a/docs/tinymist/feature/export.typ +++ b/docs/tinymist/feature/export.typ @@ -4,6 +4,84 @@ You can export your documents to various formats using the `export` feature. +== Export from Query Result + +=== Hello World Example (VSCode Tasks) + +You can export the result of a query as text using the `export` command. + +Given a code: + +```typ +#println("Hello World!") +#println("Hello World! Again...") +``` + +LSP should export the result of the query as text with the following content: + +```txt +Hello World! +Hello World! Again... +``` + +This requires the following configuration in your `tasks.json` file: + +```json +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Query as Text", + "type": "typst", + "command": "export", + "export": { + "format": "query", + "query.format": "txt", + "query.outputExtension": "out", + "query.field": "value", + "query.selector": "", + "query.one": true + } + }, + ] +} +``` + +See the #link("https://github.com/Myriad-Dreamin/tinymist/tree/main/editors/vscode/e2e-workspaces/print-state")[Sample Workspace: print-state] for more details. + +=== Pdfpc Example (VSCode Tasks) + +A more practical example is exporting the result of a query as a pdfpc file. You can use the following configuration in your `tasks.json` file to export the result of a query as a pdfpc file, which is adapted by #link("https://touying-typ.github.io/touying/")[Touying Slides]. + +```json +{ + "label": "Query as Pdfpc", + "type": "typst", + "command": "export", + "export": { + "format": "query", + "query.format": "json", + "query.outputExtension": "pdfpc", + "query.selector": "", + "query.field": "value", + "query.one": true + } +} +``` + +To simplify configuration, + +```json +{ + "label": "Query as Pdfpc", + "type": "typst", + "command": "export", + "export": { + "format": "pdfpc" + } +} +``` + == VSCode: Task Configuration You can configure tasks in your `tasks.json` file to "persist" the arguments for exporting documents. @@ -58,6 +136,14 @@ Example: "merged": true } }, + { + "label": "Query as Pdfpc", + "type": "typst", + "command": "export", + "export": { + "format": "pdfpc" + } + }, { "label": "Export as PNG and SVG", "type": "typst", diff --git a/editors/vscode/e2e-workspaces/print-state/.vscode/tasks.json b/editors/vscode/e2e-workspaces/print-state/.vscode/tasks.json new file mode 100644 index 00000000..1b83c779 --- /dev/null +++ b/editors/vscode/e2e-workspaces/print-state/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Query as Text", + "type": "typst", + "command": "export", + "export": { + "format": "query", + "query.format": "txt", + "query.outputExtension": "out", + "query.field": "value", + "query.selector": "", + "query.one": true + } + }, + { + "label": "Export PNG and SVG....", + "type": "typst", + "command": "export", + "export": { + "format": ["png", "svg"], + "png.ppi": 288, + "merged": true, + "merged.gap": "100pt" + } + } + ] +} diff --git a/editors/vscode/e2e-workspaces/print-state/effect.typ b/editors/vscode/e2e-workspaces/print-state/effect.typ new file mode 100644 index 00000000..5526a065 --- /dev/null +++ b/editors/vscode/e2e-workspaces/print-state/effect.typ @@ -0,0 +1,12 @@ + +#let print-state = state("print-effect", ()) +#let print(k, end: none) = print-state.update(it => it + (k, end)) +#let println = print.with(end: "\n") + +#let main = content => { + context [ + #let prints = print-state.final() + #metadata(prints.join()) + ] + content +} \ No newline at end of file diff --git a/editors/vscode/e2e-workspaces/print-state/main.typ b/editors/vscode/e2e-workspaces/print-state/main.typ new file mode 100644 index 00000000..735f8aef --- /dev/null +++ b/editors/vscode/e2e-workspaces/print-state/main.typ @@ -0,0 +1,7 @@ + +#import "effect.typ": * + +#show: main + +#println("Hello World!") +#println("Hello World! Again...") diff --git a/editors/vscode/package.json b/editors/vscode/package.json index e86d4f4f..a96ec8af 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -90,12 +90,22 @@ "enum": [ "pdf", "png", - "svg" + "svg", + "html", + "markdown", + "text", + "query", + "pdfpc" ], "enumDescriptions": [ "PDF", "PNG", - "SVG" + "SVG", + "HTML", + "Markdown", + "Plain Text", + "Query Result", + "Pdfpc (From Query)" ], "default": "pdf" }, @@ -111,7 +121,9 @@ "svg", "html", "markdown", - "text" + "text", + "query", + "pdfpc" ], "enumDescriptions": [ "PDF", @@ -119,7 +131,9 @@ "SVG", "HTML", "Markdown", - "Plain Text" + "Plain Text", + "Query Result", + "Pdfpc (From Query)" ], "default": "pdf" } @@ -192,6 +206,45 @@ "type": "string", "description": "The gap between the pages when merging **with absolute typst unit**. Affected formats: `png`", "default": "0pt" + }, + "query.format": { + "type": "string", + "description": "The format of the query output. Defaults to `json`.", + "default": "json", + "enum": [ + "json", + "yaml", + "txt" + ], + "enumDescriptions": [ + "JSON", + "YAML", + "Plain Text if the result is a string, otherwise raises an error. You may specific the field to use for the query with `query.field` and assert that there is only one result with `query.one`." + ] + }, + "query.outputExtension": { + "type": "string", + "description": "The extension of the query output. Inferring from `query.format` if not specified." + }, + "query.strict": { + "type": "boolean", + "description": "Whether to strictly check the query format. Defaults to `true`." + }, + "query.pretty": { + "type": "boolean", + "description": "Whether to pretty print the query output. Defaults to `true`." + }, + "query.selector": { + "type": "string", + "description": "The selector to use for the query. Must specified if `format`." + }, + "query.field": { + "type": "string", + "description": "The field to use for the query." + }, + "query.one": { + "type": "boolean", + "description": "Whether to only return one result. Defaults to `false`." } } } diff --git a/editors/vscode/src/lsp.ts b/editors/vscode/src/lsp.ts index 82363e05..8b385417 100644 --- a/editors/vscode/src/lsp.ts +++ b/editors/vscode/src/lsp.ts @@ -50,6 +50,9 @@ export const tinymist = { exportText(uri: string, extraOpts?: any) { return doExport("tinymist.exportText", uri, extraOpts); }, + exportQuery(uri: string, extraOpts?: any) { + return doExport("tinymist.exportQuery", uri, extraOpts); + }, }; function doExport(command: string, uri: string, extraOpts?: any): Promise { diff --git a/editors/vscode/src/tasks.ts b/editors/vscode/src/tasks.ts index 56ac1013..27c2b69a 100644 --- a/editors/vscode/src/tasks.ts +++ b/editors/vscode/src/tasks.ts @@ -13,7 +13,7 @@ export function activateTaskProvider(context: vscode.ExtensionContext): vscode.D return vscode.tasks.registerTaskProvider(TYPST_TASK_TYPE, provider); } -export type ExportFormat = "pdf" | "png" | "svg"; +export type ExportFormat = "pdf" | "png" | "svg" | "html" | "markdown" | "text" | "query" | "pdfpc"; export type TaskDefinition = vscode.TaskDefinition & { readonly type: typeof TYPST_TASK_TYPE; @@ -32,6 +32,13 @@ export type TaskDefinition = vscode.TaskDefinition & { "merged.gap"?: string; "png.merged.gap"?: string; "svg.merged.gap"?: string; + "query.format"?: string; + "query.outputExtension"?: string; + "query.strict"?: boolean; + "query.pretty"?: boolean; + "query.selector"?: string; + "query.field"?: string; + "query.one"?: boolean; }; }; @@ -121,6 +128,20 @@ export async function callTypstExportCommand(): Promise }, export: tinymist.exportText, }, + query: { + opts() { + return { + format: exportArgs["query.format"], + outputExtension: exportArgs["query.outputExtension"], + strict: exportArgs["query.strict"], + pretty: exportArgs["query.pretty"], + selector: exportArgs["query.selector"], + field: exportArgs["query.field"], + one: exportArgs["query.one"], + }; + }, + export: tinymist.exportQuery, + }, }; return Promise.resolve({ @@ -131,8 +152,17 @@ export async function callTypstExportCommand(): Promise try { await run(); - } catch (e) { - writeEmitter.fire("Typst export task failed: " + obj(e) + "\r\n"); + } catch (e: any) { + writeEmitter.fire( + "Typst export task failed: " + + obj({ + code: e.code, + message: e.message, + stack: e.stack, + error: e, + }) + + "\r\n", + ); } finally { closeEmitter.fire(0); } @@ -152,7 +182,16 @@ export async function callTypstExportCommand(): Promise return; } - for (const format of formats) { + for (let format of formats) { + if (format === "pdfpc") { + format = "query"; + exportArgs["query.format"] = "json"; + exportArgs["query.outputExtension"] = "pdfpc"; + exportArgs["query.selector"] = ""; + exportArgs["query.field"] = "value"; + exportArgs["query.one"] = true; + } + const provider = formatProvider[format]; if (!provider) { writeEmitter.fire("Unsupported export format: " + format + "\r\n"); diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs index 4cda4870..5a6f59d4 100644 --- a/tests/e2e/main.rs +++ b/tests/e2e/main.rs @@ -374,7 +374,7 @@ fn e2e() { }); let hash = replay_log(&tinymist_binary, &root.join("neovim")); - insta::assert_snapshot!(hash, @"siphash128_13:a54cfbafc7174bc8465fc0adb99aa28d"); + insta::assert_snapshot!(hash, @"siphash128_13:466ece1c48d23e59d3ff339a4c8ff461"); } { @@ -385,7 +385,7 @@ fn e2e() { }); let hash = replay_log(&tinymist_binary, &root.join("vscode")); - insta::assert_snapshot!(hash, @"siphash128_13:5c9f14019d55dc472177fff5f215fa40"); + insta::assert_snapshot!(hash, @"siphash128_13:5d7fb66abe6c73f204e5deb44ae6a343"); } }