This commit is contained in:
Myriad-Dreamin 2025-11-12 11:07:36 +00:00 committed by GitHub
commit f673f7c580
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 445 additions and 148 deletions

View file

@ -1,23 +1,18 @@
use std::ops::Deref;
use comemo::Track;
use serde::{Deserialize, Serialize};
use tinymist_analysis::analyze_expr;
use tinymist_project::{DiagnosticFormat, PathPattern};
use tinymist_project::PathPattern;
use tinymist_std::error::prelude::*;
use tinymist_world::vfs::WorkspaceResolver;
use tinymist_world::{EntryReader, EntryState, ShadowApi, diag::print_diagnostics_to_string};
use typst::diag::{At, SourceResult};
use typst::foundations::{Args, Dict, NativeFunc, eco_format};
use typst::syntax::Span;
use typst::utils::LazyHash;
use tinymist_world::{EntryReader, ShadowApi};
use typst::foundations::{Dict, eco_format};
use typst::{
foundations::{Bytes, IntoValue, StyleChain},
text::TextElem,
};
use typst_shim::eval::{Eval, Vm};
use typst_shim::syntax::LinkedNodeExt;
use crate::hook::HookScript;
use crate::{
prelude::*,
syntax::{InterpretMode, interpret_mode_at},
@ -112,7 +107,7 @@ impl SemanticRequest for InteractCodeContextRequest {
for query in self.query {
responses.push(query.and_then(|query| match query {
InteractCodeContextQuery::PathAt { code, inputs: base } => {
let res = eval_path_expr(ctx, &code, base)?;
let res = eval_path(ctx, &code, base)?;
Some(InteractCodeContextResponse::PathAt(res))
}
InteractCodeContextQuery::ModeAt { position } => {
@ -217,65 +212,16 @@ impl InteractCodeContextRequest {
}
}
fn eval_path_expr(
fn eval_path(
ctx: &mut LocalContext,
code: &str,
inputs: Dict,
) -> Option<QueryResult<serde_json::Value>> {
let entry = ctx.world().entry_state();
let path = if code.starts_with("{") && code.ends_with("}") {
let id = entry
.select_in_workspace(Path::new("/__path__.typ"))
.main()?;
let inputs = make_sys(&entry, ctx.world().inputs(), inputs);
let (inputs, root, dir, name) = match inputs {
Some(EvalSysCtx {
inputs,
root,
dir,
name,
}) => (Some(inputs), Some(root), dir, Some(name)),
None => (None, None, None, None),
};
let mut world = ctx.world().task(tinymist_world::TaskInputs {
entry: None,
inputs,
});
// todo: bad performance
world.take_db();
let _ = world.map_shadow_by_id(id, Bytes::from_string(code.to_owned()));
tinymist_analysis::upstream::with_vm((&world as &dyn World).track(), |vm| {
define_val(vm, "join", Value::Func(join::data().into()));
for (key, value) in [("root", root), ("dir", dir), ("name", name)] {
if let Some(value) = value {
define_val(vm, key, value);
}
}
let mut expr = typst::syntax::parse_code(code);
let span = Span::from_range(id, 0..code.len());
expr.synthesize(span);
let expr = match expr.cast::<ast::Code>() {
Some(v) => v,
None => bail!(
"code is not a valid code expression: kind={:?}",
expr.kind()
),
};
match expr.eval(vm) {
Ok(value) => serde_json::to_value(value).context_ut("failed to serialize path"),
Err(e) => {
let res =
print_diagnostics_to_string(&world, e.iter(), DiagnosticFormat::Human);
let err = res.unwrap_or_else(|e| e);
bail!("failed to evaluate path expression: {err}")
}
}
})
crate::hook::eval_script(ctx.world(), HookScript::Code(code), inputs, &entry).and_then(
|(_, value)| serde_json::to_value(value).context_ut("failed to serialize path"),
)
} else {
PathPattern::new(code)
.substitute(&entry)
@ -287,85 +233,6 @@ fn eval_path_expr(
Some(path.into())
}
#[derive(Debug, Clone, Hash)]
struct EvalSysCtx {
inputs: Arc<LazyHash<Dict>>,
root: Value,
dir: Option<Value>,
name: Value,
}
#[comemo::memoize]
fn make_sys(entry: &EntryState, base: Arc<LazyHash<Dict>>, inputs: Dict) -> Option<EvalSysCtx> {
let root = entry.root();
let main = entry.main();
log::debug!("Check path {main:?} and root {root:?}");
let (root, main) = root.zip(main)?;
// Files in packages are not exported
if WorkspaceResolver::is_package_file(main) {
return None;
}
// Files without a path are not exported
let path = main.vpath().resolve(&root)?;
// todo: handle untitled path
if path.strip_prefix("/untitled").is_ok() {
return None;
}
let path = path.strip_prefix(&root).ok()?;
let dir = path.parent();
let file_name = path.file_name().unwrap_or_default();
let root = Value::Str(root.to_string_lossy().into());
let dir = dir.map(|d| Value::Str(d.to_string_lossy().into()));
let name = file_name.to_string_lossy();
let name = name.as_ref().strip_suffix(".typ").unwrap_or(name.as_ref());
let name = Value::Str(name.into());
let mut dict = base.as_ref().deref().clone();
for (key, value) in inputs {
dict.insert(key, value);
}
dict.insert("root".into(), root.clone());
if let Some(dir) = &dir {
dict.insert("dir".into(), dir.clone());
}
dict.insert("name".into(), name.clone());
Some(EvalSysCtx {
inputs: Arc::new(LazyHash::new(dict)),
root,
dir,
name,
})
}
fn define_val(vm: &mut Vm, name: &str, value: Value) {
let ident = SyntaxNode::leaf(SyntaxKind::Ident, name);
vm.define(ident.cast::<ast::Ident>().unwrap(), value);
}
#[typst_macros::func(title = "Join function")]
fn join(args: &mut Args) -> SourceResult<Value> {
let pos = args.take().to_pos();
let mut res = PathBuf::new();
for arg in pos {
match arg {
Value::Str(s) => res.push(s.as_str()),
_ => {
return Err(eco_format!("join argument is not a string: {arg:?}")).at(args.span);
}
};
}
Ok(Value::Str(res.to_string_lossy().into()))
}
/// A result of a query.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]

View file

@ -0,0 +1,149 @@
use std::time::Duration;
use anyhow::Context;
use ecow::eco_format;
use tinymist_project::{EntryReader, ExportTask, LspWorld};
use tinymist_std::error::prelude::*;
use typst::{
diag::{At, SourceResult, StrResult},
foundations::{Dict, Func, Str, Value},
syntax::Span,
};
use crate::hook::HookScript;
/// The state desc of an export script.
pub enum ExportState {
/// A debounce state.
Debounce {
/// The world to run the script in.
world: LspWorld,
/// The inner function to debounce.
inner: Func,
/// The duration to debounce.
duration: Duration,
/// The time the state was last checked.
checked: tinymist_std::time::Time,
},
/// A finished state.
Finished {
/// The tasks to run.
task: Vec<ExportTask>,
},
}
/// Runs an export script.
pub fn run_export_script(world: &LspWorld, code: &str, inputs: Dict) -> Result<ExportState> {
let result = super::eval_script(world, HookScript::Code(code), inputs, &world.entry_state())?;
check_script_res(result)
}
/// Determines the export of a state.
pub fn determine_export(state: ExportState) -> Result<ExportState> {
match state {
ExportState::Debounce {
world,
inner,
duration,
checked,
} => {
let now = tinymist_std::time::now();
if now
.duration_since(checked)
.context("failed to get duration since last checked")?
< duration
{
Ok(ExportState::Debounce {
world,
inner,
duration,
checked,
})
} else {
check_script_res(super::eval_script(
&world,
HookScript::Callback(inner),
Dict::default(),
&world.entry_state(),
)?)
}
}
ExportState::Finished { task } => Ok(ExportState::Finished { task }),
}
}
fn check_script_res((world, res): (LspWorld, Value)) -> Result<ExportState> {
match res {
Value::Dict(d) => {
let kind = match d.get("kind") {
Ok(Value::Str(kind)) => kind,
_ => bail!("expected result.kind to be a string"),
};
Ok(match kind.as_str() {
"debounce" => {
let inner = match d.get("inner") {
Ok(Value::Func(func)) => func.clone(),
_ => bail!("expected result.inner to be a function"),
};
let duration = match d.get("duration") {
Ok(Value::Int(duration)) => Duration::from_millis((*duration) as u64),
_ => bail!("expected result.duration to be a duration"),
};
ExportState::Debounce {
world,
inner,
duration,
checked: tinymist_std::time::now(),
}
}
_ => bail!("expected result.kind to be 'debounce'"),
})
}
_ => bail!("expected result to be a dictionary"),
}
}
#[typst_macros::func(title = "debounce function")]
pub(crate) fn debounce(span: Span, duration: Str, inner: Func) -> SourceResult<Dict> {
let duration = parse_time(duration.as_str()).at(span)?;
let mut res = Dict::default();
res.insert("inner".into(), Value::Func(inner.clone()));
res.insert("kind".into(), Value::Str("debounce".into()));
res.insert("duration".into(), Value::Int(duration.as_millis() as i64));
// let global = engine.world.library().global.scope();
// let sys = global.get("sys").unwrap().read().scope().unwrap();
// let inputs = sys.get("inputs").unwrap().read().clone();
// let last = match inputs {
// Value::Dict(dict) => dict.get("x-last").at(span)?.clone(),
// _ => return Err(eco_format!("expected sys.inputs to be a
// dict")).at(span), };
// let last_duration = match last {
// Value::Str(stamp) => Duration::from_millis(
// stamp
// .as_str()
// .parse::<u64>()
// .map_err(|e| eco_format!("expected sys.inputs.x-last to be a int,
// but {e}")) .at(span)?,
// ),
// _ => return Err(eco_format!("expected sys.inputs.x-last to be a
// int")).at(span), };
Ok(res)
}
fn parse_time(spec: &str) -> StrResult<Duration> {
let (digits, unit) = if let Some(digits) = spec.strip_suffix("ms") {
(digits, 1u64)
} else if let Some(digits) = spec.strip_suffix("s") {
(digits, 1000u64)
} else {
return Err("expected time spec like `5s` or `5ms`".into());
};
let digits = digits
.parse::<u64>()
.map_err(|e| eco_format!("expected time spec like `5s` or `5ms`, but {e}"))?;
Ok(Duration::from_millis(digits * unit))
}

View file

@ -0,0 +1,186 @@
//! Runs hook scripts for the server.
mod export;
use std::ops::Deref;
pub use export::*;
use tinymist_world::diag::print_diagnostics_to_string;
use tinymist_world::vfs::WorkspaceResolver;
use crate::prelude::*;
use comemo::Track;
use tinymist_project::LspWorld;
use tinymist_std::error::prelude::*;
use tinymist_world::{DiagnosticFormat, EntryState, ShadowApi};
use typst::World;
use typst::diag::{At, SourceResult};
use typst::foundations::{Args, Bytes, Context, Dict, Func, NativeFunc, eco_format};
use typst::syntax::Span;
use typst::syntax::SyntaxKind;
use typst::syntax::SyntaxNode;
use typst::syntax::ast;
use typst::utils::LazyHash;
use typst_shim::eval::{Eval, Vm};
/// The hook script.
pub enum HookScript<'a> {
/// A code script.
Code(&'a str),
/// A function callback.
Callback(Func),
}
/// Evaluates a hook script.
pub fn eval_script(
world: &LspWorld,
code: HookScript,
inputs: Dict,
entry: &EntryState,
) -> Result<(LspWorld, Value)> {
let id = entry
.select_in_workspace(Path::new("/__script__.typ"))
.main()
.expect("cannot create script file id");
let inputs = make_sys(entry, world.inputs(), inputs);
let (inputs, root, dir, name) = match inputs {
Some(EvalSysCtx {
inputs,
root,
dir,
name,
}) => (Some(inputs), Some(root), dir, Some(name)),
None => (None, None, None, None),
};
let mut world = world.task(tinymist_world::TaskInputs {
entry: None,
inputs,
});
if let HookScript::Code(code) = &code {
// todo: bad performance
world.take_db();
let _ = world.map_shadow_by_id(id, Bytes::from_string((*code).to_owned()));
}
let res = tinymist_analysis::upstream::with_vm((&world as &dyn World).track(), |vm| {
define_val(vm, "join", Value::Func(join::data().into()));
define_val(vm, "debounce", Value::Func(debounce::data().into()));
for (key, value) in [("root", root), ("dir", dir), ("name", name)] {
if let Some(value) = value {
define_val(vm, key, value);
}
}
let res = match code {
HookScript::Code(code) => {
let mut expr = typst::syntax::parse_code(code);
let span = Span::from_range(id, 0..code.len());
expr.synthesize(span);
let expr = match expr.cast::<ast::Code>() {
Some(v) => v,
None => bail!(
"code is not a valid code expression: kind={:?}",
expr.kind()
),
};
expr.eval(vm)
}
HookScript::Callback(callback) => callback.call(
&mut vm.engine,
Context::default().track(),
Vec::<Value>::default(),
),
};
match res {
Ok(value) => Ok(value),
Err(e) => {
let res = print_diagnostics_to_string(&world, e.iter(), DiagnosticFormat::Human);
let err = res.unwrap_or_else(|e| e);
bail!("failed to evaluate expression: {err}")
}
}
})?;
Ok((world, res))
}
#[derive(Debug, Clone, Hash)]
struct EvalSysCtx {
inputs: Arc<LazyHash<Dict>>,
root: Value,
dir: Option<Value>,
name: Value,
}
#[comemo::memoize]
fn make_sys(entry: &EntryState, base: Arc<LazyHash<Dict>>, inputs: Dict) -> Option<EvalSysCtx> {
let root = entry.root();
let main = entry.main();
log::debug!("Check path {main:?} and root {root:?}");
let (root, main) = root.zip(main)?;
// Files in packages are not exported
if WorkspaceResolver::is_package_file(main) {
return None;
}
// Files without a path are not exported
let path = main.vpath().resolve(&root)?;
// todo: handle untitled path
if path.strip_prefix("/untitled").is_ok() {
return None;
}
let path = path.strip_prefix(&root).ok()?;
let dir = path.parent();
let file_name = path.file_name().unwrap_or_default();
let root = Value::Str(root.to_string_lossy().into());
let dir = dir.map(|d| Value::Str(d.to_string_lossy().into()));
let name = file_name.to_string_lossy();
let name = name.as_ref().strip_suffix(".typ").unwrap_or(name.as_ref());
let name = Value::Str(name.into());
let mut dict = base.as_ref().deref().clone();
for (key, value) in inputs {
dict.insert(key, value);
}
dict.insert("root".into(), root.clone());
if let Some(dir) = &dir {
dict.insert("dir".into(), dir.clone());
}
dict.insert("name".into(), name.clone());
Some(EvalSysCtx {
inputs: Arc::new(LazyHash::new(dict)),
root,
dir,
name,
})
}
fn define_val(vm: &mut Vm, name: &str, value: Value) {
let ident = SyntaxNode::leaf(SyntaxKind::Ident, name);
vm.define(ident.cast::<ast::Ident>().unwrap(), value);
}
#[typst_macros::func(title = "Join function")]
fn join(args: &mut Args) -> SourceResult<Value> {
let pos = args.take().to_pos();
let mut res = PathBuf::new();
for arg in pos {
match arg {
Value::Str(s) => res.push(s.as_str()),
_ => {
return Err(eco_format!("join argument is not a string: {arg:?}")).at(args.span);
}
};
}
Ok(Value::Str(res.to_string_lossy().into()))
}

View file

@ -44,6 +44,7 @@ pub use workspace_label::*;
pub mod analysis;
pub mod docs;
pub mod hook;
pub mod index;
pub mod package;
pub mod syntax;

View file

@ -76,7 +76,7 @@ pub enum ProjectTask {
ExportTeX(ExportTeXTask),
/// An export Text task.
ExportText(ExportTextTask),
/// An query task.
/// A query task.
Query(QueryTask),
// todo: compatibility
// An export task of another type.

View file

@ -445,6 +445,89 @@ impl ProjectPreviewState {
}
}
struct CompileHooks {
pub(crate) export: ExportHook,
pub(crate) preview: PreviewHook,
pub(crate) diag: DiagHook,
pub(crate) word_count: WordCountHook,
}
impl CompileHooks {
pub fn needs_comile(&self, snap: &LspCompileSnapshot) -> bool {
self.export.needs_comile(snap)
|| self.preview.needs_comile(snap)
|| self.diag.needs_comile(snap)
|| self.word_count.needs_comile(snap)
}
}
struct ExportHook {
pub(crate) task: crate::task::ExportTask,
}
impl ExportHook {
fn needs_comile(&self, snap: &LspCompileSnapshot) -> bool {
let s = snap.signal;
let config = self.task.factory.task();
let when = config.task.when().unwrap_or(&TaskWhen::Never);
needs_compile(snap, when)
}
}
struct PreviewHook {
#[cfg(feature = "preview")]
pub(crate) preview: ProjectPreviewState,
pub(crate) when: TaskWhen,
}
impl PreviewHook {
fn needs_comile(&self, snap: &LspCompileSnapshot) -> bool {
needs_compile(snap, &self.when)
}
}
struct DiagHook {
pub(crate) editor_tx: EditorSender,
/// When to trigger the diag.
pub(crate) when: TaskWhen,
/// When to trigger the lint.
pub(crate) lint: TaskWhen,
}
impl DiagHook {
fn needs_comile(&self, snap: &LspCompileSnapshot) -> bool {
needs_compile(snap, &self.when) || needs_compile(snap, &self.lint)
}
}
struct WordCountHook {
count_words: bool,
}
impl WordCountHook {
fn needs_comile(&self, snap: &LspCompileSnapshot) -> bool {
let when = if self.count_words {
&TaskWhen::OnSave
} else {
&TaskWhen::Never
};
needs_compile(snap, when)
}
}
fn needs_compile(snap: &LspCompileSnapshot, when: &TaskWhen) -> bool {
let s = snap.signal;
match when {
TaskWhen::Never => false,
TaskWhen::Script => s.by_entry_update,
TaskWhen::OnType => s.by_mem_events,
TaskWhen::OnSave => s.by_fs_events,
// todo: respect doc
TaskWhen::OnDocumentHasTitle => s.by_fs_events, // && doc.info().title.is_some(),
}
}
/// The implementation of the compile handler.
pub struct CompileHandlerImpl {
/// The analysis data.
@ -659,6 +742,9 @@ impl CompileHandler<LspCompilerFeat, ProjectInsStateExt> for CompileHandlerImpl
s.ext.pending_reasons = CompileSignal::default();
s.ext.emitted_reasons = reason;
// todo: reason here.
let Some(compile_fn) = s.may_compile(&c.handler) else {
continue;
};

View file

@ -64,7 +64,7 @@ The following example demonstrates how to customize the paste behavior when past
Specifically, three script hooks will be supported:
- Hook on Paste: customize the paste behavior when pasting resources into the editing typst document.
- Hook on Watch: customize the watch behavior when a file change is detected in the workspaces.
- Hook on Export: customize the export behavior when a file change is detected in the workspaces.
- Hook on Generating Code Actions and Lenses: adding additional code actions by typst scripting.
= Customizing Paste Behavior
@ -109,11 +109,9 @@ If the result is a string, it will be treated as the `dir` field, i.e. `{ dir: <
More fields will be supported in the future. If you have any suggestions, please feel free to open an issue.
= Customizing Watch Behavior (Experimental)
= Customizing Export Behavior (Experimental)
*Note: this is not implemented yet in current version.*
You could configure `tinymist.onWatch` to customize the watch behavior. It will be executed when a file change is detected in the workspace.
You could configure `tinymist.onExport` to customize the export behavior. It will be executed when a file change is detected in the workspace.
For example, debouncing time:
@ -151,6 +149,8 @@ async function pdfWithGhostScript() {
Hint: you could create your own vscode extension to define such custom commands.
`tinymist.exportPdf` will be ignored if this configuration item is set.
= Providing Package-Specific Code Actions (Experimental)
*Note: this is not implemented yet in current version.*

View file

@ -1303,6 +1303,14 @@ zh = "自定义粘贴行为的脚本"
en = "The script to be executed when pasting resources into the editing typst document. If the script code starts with `{` and ends with `}`, it will be evaluated as a typst code expression, e.g. `$root/x/$dir/$name` evaluated as `/path/to/root/x/dir/main`, otherwise it will be evaluated as a path pattern, e.g. `{ join(root, \"x\", dir, if name.ends-with(\".png\") (\"imgs\"), name) }` evaluated as `/path/to/root/x/dir/imgs/main`. The extra valid definitions are `root`, `dir`, `name`, and `join`. To learn more about the paste script, please visit [Script Hooks](https://myriad-dreamin.github.io/tinymist/feature/script-hook.html). Hint: you could import `@local` packages in the paste script. Note: restarting the editor is required to change this setting."
zh = "将资源粘贴到正在编辑的 typst 文档时要执行的脚本。如果脚本代码以 `{` 开头并以 `}` 结尾,那么依 typst 代码表达式语法解释脚本,否则依路径模板解释脚本。路径模板例如 `$root/x/$dir/$name` 求解为 `/path/to/root/x/dir/main`typst 代码表达式例如 `{ join(root, \"x\", dir, if name.ends-with(\".png\") (\"imgs\"), name) }` 求解为 `/path/to/root/x/dir/imgs/main`。额外的有效定义有 `root`、`dir`、`name` 和 `join`。要了解有关粘贴脚本的更多信息,请访问 [Script Hooks](https://myriad-dreamin.github.io/tinymist/feature/script-hook.html)。提示:您可以在粘贴脚本中导入 `@local` 包。注意:更改此设置需要重新启动编辑器。"
[extension.tinymist.config.tinymist.onExport.title]
en = "The Script to Customize Export Behavior"
zh = "自定义导出行为的脚本"
[extension.tinymist.config.tinymist.onExport.desc]
en = "The script to be executed when perform export on change of typst document. `tinymist.exportPdf` will be ignored if this configuration item is set."
zh = "当修改 Typst 文档时用于确定导出行为的脚本。此配置不为空时,将忽略`tinymist.exportPdf`。"
[extension.tinymist.config.tinymist.renderDocs.title]
en = "(Experimental) Render Docs"
zh = "(实验性)渲染文档"