feat: add PathAt code context query (#2232)
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 is used for custom paste scripts

- by pattern: e.g. `$root`
- by code: e.g. `{ root }`
- on conflict callback: e.g. `{ (dir: root, on-conflict: root + "/" +
random() + ".png") }`
This commit is contained in:
Myriad-Dreamin 2025-11-11 02:43:02 +08:00 committed by GitHub
parent c63162959d
commit c206933bf5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 645 additions and 13 deletions

1
Cargo.lock generated
View file

@ -4745,6 +4745,7 @@ dependencies = [
"typlite",
"typst",
"typst-assets",
"typst-library",
"typst-macros",
"typst-shim",
"typst-timing 0.14.0",

View file

@ -55,6 +55,7 @@ typst.workspace = true
typst-macros.workspace = true
typst-shim.workspace = true
typst-timing.workspace = true
typst-library.workspace = true
unscanny.workspace = true
walkdir.workspace = true
yaml-rust2.workspace = true

View file

@ -642,7 +642,7 @@ mod lint_tests {
let result = crate::diagnostics::DiagWorker::new(ctx).convert_all(result.iter());
let result = result
.into_iter()
.map(|(k, v)| (file_path_(&k), v))
.map(|(k, v)| (file_uri_(&k), v))
.collect::<BTreeMap<_, _>>();
assert_snapshot!(JsonRepr::new_redacted(result, &REDACT_LOC));
});

View file

@ -1,10 +1,21 @@
use std::ops::Deref;
use comemo::Track;
use serde::{Deserialize, Serialize};
use tinymist_analysis::analyze_expr;
use tinymist_world::ShadowApi;
use tinymist_project::{DiagnosticFormat, 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 typst::{
foundations::{Bytes, IntoValue, StyleChain},
text::TextElem,
};
use typst_shim::eval::{Eval, Vm};
use typst_shim::syntax::LinkedNodeExt;
use crate::{
@ -16,6 +27,38 @@ use crate::{
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum InteractCodeContextQuery {
/// (Experimental) Evaluate a path expression at a specific position in a
/// text document.
PathAt {
/// Code to evaluate. If the code starts with `{` and ends with `}`, it
/// will be evaluated as a code expression, otherwise it will be
/// evaluated as a path pattern.
///
/// ## Example
///
/// evaluate a path pattern, which could use following definitions:
///
/// ```plain
/// $root/x/$dir/../$name // is evaluated as
/// /path/to/root/x/dir/../main
/// ```
///
/// ## Example
///
/// evaluate a code expression, which could use following definitions:
/// - `root`: the root of the workspace
/// - `dir`: the directory of the current file
/// - `name`: the name of the current file
/// - `join(a, b, ...)`: join the arguments with the path separator
///
/// ```plain
/// { join(root, "x", dir, "y", name) } // is evaluated as
/// /path/to/root/x/dir/y/main
/// ```
code: String,
/// The extra `sys.inputs` for the code expression.
inputs: Dict,
},
/// Get the mode at a specific position in a text document.
ModeAt {
/// The position inside the text document.
@ -34,6 +77,8 @@ pub enum InteractCodeContextQuery {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum InteractCodeContextResponse {
/// Evaluate a path expression at a specific position in a text document.
PathAt(QueryResult<serde_json::Value>),
/// Get the mode at a specific position in a text document.
ModeAt {
/// The mode at the requested position.
@ -66,6 +111,10 @@ 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)?;
Some(InteractCodeContextResponse::PathAt(res))
}
InteractCodeContextQuery::ModeAt { position } => {
let cursor = ctx.to_typst_pos(position, &source)?;
let mode = Self::mode_at(&source, cursor)?;
@ -167,3 +216,248 @@ impl InteractCodeContextRequest {
}
}
}
fn eval_path_expr(
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}")
}
}
})
} else {
PathPattern::new(code)
.substitute(&entry)
.context_ut("failed to substitute path pattern")
.and_then(|path| {
serde_json::to_value(path.deref()).context_ut("failed to serialize path")
})
};
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)]
pub enum QueryResult<T> {
/// A successful result.
Success {
/// The value of the result.
value: T,
},
/// An error result.
Error {
/// The error message.
error: EcoString,
},
}
impl<T> QueryResult<T> {
/// Creates a successful result.
pub fn success(value: T) -> Self {
Self::Success { value }
}
/// Creates an error result.
pub fn error(error: EcoString) -> Self {
Self::Error { error }
}
}
impl<T, E: std::error::Error> From<Result<T, E>> for QueryResult<T> {
fn from(value: Result<T, E>) -> Self {
match value {
Ok(value) => QueryResult::success(value),
Err(error) => QueryResult::error(eco_format!("{error}")),
}
}
}
#[cfg(test)]
mod tests {
use typst::foundations::dict;
use super::*;
use crate::tests::*;
#[test]
fn test() {
snapshot_testing("code_context_path_at", &|ctx, path| {
let patterns = [
"$root/$dir/$name",
"$root/$name",
"$root/assets",
"$root/assets/$name",
r#"{ join(root, "x", dir, "y", name) }"#,
r#"{ join(root, 1) }"#,
r#"{ join(roo, 1) }"#,
];
let inp = [
dict! {
"x-path-context" => "vscode-paste",
"x-path-input-uri" => "https://huh.io/img.png",
"x-path-input-name" => "img.png",
},
dict! {
"x-path-context" => "vscode-paste",
"x-path-input-uri" => "https://huh.io/text.md",
"x-path-input-name" => "text.md",
},
];
let cases = patterns
.iter()
.map(|pat| (*pat, inp[0].clone()))
.chain(inp.iter().map(|inp| {
(
r#"{ import "/resolve.typ": resolve; resolve(join, root, dir, name) }"#,
inp.clone(),
)
}));
let result = cases
.map(|(code, inputs)| {
let request = InteractCodeContextRequest {
path: path.clone(),
query: vec![Some(InteractCodeContextQuery::PathAt {
code: code.to_string(),
inputs: inputs.clone(),
})],
};
json!({ "code": code, "inputs": inputs, "response": request.request(ctx) })
})
.collect::<Vec<_>>();
assert_snapshot!(JsonRepr::new_redacted(result, &REDACT_LOC));
});
}
}

View file

@ -0,0 +1,17 @@
/// path: resolve.typ
#let resolve(join, root, dir, name) = {
let asset-dir = "assets"
if sys.inputs.x-path-input-uri.ends-with(".png") {
return (
file: join(root, "images", sys.inputs.x-path-input-name),
on-conflict: ```typc
import "/resolve.typ": on-conflict; on-conflict(join, root, dir, name)
```.text,
)
}
join(root, "assets", name)
};
-----
/// path: x_at_root.typ

View file

@ -0,0 +1 @@
/// path: the_dir/x_in_dir.typ

View file

@ -0,0 +1,136 @@
---
source: crates/tinymist-query/src/code_context.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_context_path_at/at_root.typ
---
[
{
"code": "$root/$dir/$name",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "x_at_root"
}
]
},
{
"code": "$root/$name",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "x_at_root"
}
]
},
{
"code": "$root/assets",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "assets"
}
]
},
{
"code": "$root/assets/$name",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "assets/x_at_root"
}
]
},
{
"code": "{ join(root, \"x\", dir, \"y\", name) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "x/y/x_at_root"
}
]
},
{
"code": "{ join(root, 1) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"error": "crates/tinymist-query/src/code_context.rs:275:21: failed to evaluate path expression: error: join argument is not a string: 1\n ┌─ /__redacted_path__.typ:1:0\n │\n1 │ { join(root, 1) }\n │ ^^^^^^^^^^^^^^^^^\n\n",
"kind": "pathAt"
}
]
},
{
"code": "{ join(roo, 1) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"error": "crates/tinymist-query/src/code_context.rs:275:21: failed to evaluate path expression: error: unknown variable: roo\n ┌─ /__redacted_path__.typ:1:0\n │\n1 │ { join(roo, 1) }\n │ ^^^^^^^^^^^^^^^^\n\n",
"kind": "pathAt"
}
]
},
{
"code": "{ import \"/resolve.typ\": resolve; resolve(join, root, dir, name) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": {
"file": "images/img.png",
"on-conflict": "import \"/resolve.typ\": on-conflict; on-conflict(join, root, dir, name)"
}
}
]
},
{
"code": "{ import \"/resolve.typ\": resolve; resolve(join, root, dir, name) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "text.md",
"x-path-input-uri": "https://huh.io/text.md"
},
"response": [
{
"kind": "pathAt",
"value": "assets/x_at_root"
}
]
}
]

View file

@ -0,0 +1,133 @@
---
source: crates/tinymist-query/src/code_context.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/code_context_path_at/in_dir.typ
---
[
{
"code": "$root/$dir/$name",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "the_dir/x_in_dir"
}
]
},
{
"code": "$root/$name",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "x_in_dir"
}
]
},
{
"code": "$root/assets",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "assets"
}
]
},
{
"code": "$root/assets/$name",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "assets/x_in_dir"
}
]
},
{
"code": "{ join(root, \"x\", dir, \"y\", name) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"kind": "pathAt",
"value": "x/the_dir/y/x_in_dir"
}
]
},
{
"code": "{ join(root, 1) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"error": "crates/tinymist-query/src/code_context.rs:275:21: failed to evaluate path expression: error: join argument is not a string: 1\n ┌─ /__redacted_path__.typ:1:0\n │\n1 │ { join(root, 1) }\n │ ^^^^^^^^^^^^^^^^^\n\n",
"kind": "pathAt"
}
]
},
{
"code": "{ join(roo, 1) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"error": "crates/tinymist-query/src/code_context.rs:275:21: failed to evaluate path expression: error: unknown variable: roo\n ┌─ /__redacted_path__.typ:1:0\n │\n1 │ { join(roo, 1) }\n │ ^^^^^^^^^^^^^^^^\n\n",
"kind": "pathAt"
}
]
},
{
"code": "{ import \"/resolve.typ\": resolve; resolve(join, root, dir, name) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "img.png",
"x-path-input-uri": "https://huh.io/img.png"
},
"response": [
{
"error": "crates/tinymist-query/src/code_context.rs:275:21: failed to evaluate path expression: error: file not found (searched at /__redacted_path__.typ)\n ┌─ /__redacted_path__.typ:1:0\n │\n1 │ { import \"/resolve.typ\": resolve; resolve(join, root, dir, name) }\n │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n",
"kind": "pathAt"
}
]
},
{
"code": "{ import \"/resolve.typ\": resolve; resolve(join, root, dir, name) }",
"inputs": {
"x-path-context": "vscode-paste",
"x-path-input-name": "text.md",
"x-path-input-uri": "https://huh.io/text.md"
},
"response": [
{
"error": "crates/tinymist-query/src/code_context.rs:275:21: failed to evaluate path expression: error: file not found (searched at /__redacted_path__.typ)\n ┌─ /__redacted_path__.typ:1:0\n │\n1 │ { import \"/resolve.typ\": resolve; resolve(join, root, dir, name) }\n │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n",
"kind": "pathAt"
}
]
}
]

View file

@ -191,7 +191,7 @@ mod tests {
let mut result = result.map(|v| {
v.into_iter()
.map(|loc| {
let fp = file_path(loc.uri.as_str());
let fp = file_uri(loc.uri.as_str());
format!(
"{fp}@{}:{}:{}:{}",
loc.range.start.line,

View file

@ -329,6 +329,7 @@ pub static REDACT_LOC: LazyLock<RedactFields> = LazyLock::new(|| {
RedactFields::from_iter([
"location",
"contents",
"file",
"uri",
"oldUri",
"newUri",
@ -419,6 +420,30 @@ impl Redact for RedactFields {
for (_, val) in map.iter_mut() {
*val = self.redact(val.clone());
}
if let Some(kind) = map.get("kind")
&& matches!(kind.as_str(), Some("pathAt"))
{
if let Some(value) = map.get("value")
&& let Value::String(s) = value
{
let v = file_path_(Path::new(s)).into();
map.insert("value".to_owned(), v);
}
if let Some(error) = map.get("error") {
let error = error.as_str().unwrap();
static REG: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(/dummy-root/|C:\\dummy-root\\).*?\.typ"#).unwrap()
});
let error = REG.replace_all(error, "/__redacted_path__.typ").replace(
"crates\\tinymist-query\\src\\code_context.rs",
"crates/tinymist-query/src/code_context.rs",
);
map.insert("error".to_owned(), Value::String(error));
}
}
for key in self.0.iter().copied() {
let Some(t) = map.remove(key) else {
continue;
@ -430,12 +455,18 @@ impl Redact for RedactFields {
map.insert(
key.to_owned(),
Value::Object(
obj.iter().map(|(k, v)| (file_path(k), v.clone())).collect(),
obj.iter().map(|(k, v)| (file_uri(k), v.clone())).collect(),
),
);
}
"file" => {
map.insert(
key.to_owned(),
file_path_(Path::new(t.as_str().unwrap())).into(),
);
}
"uri" | "target" | "oldUri" | "newUri" | "targetUri" => {
map.insert(key.to_owned(), file_path(t.as_str().unwrap()).into());
map.insert(key.to_owned(), file_uri(t.as_str().unwrap()).into());
}
"range"
| "selectionRange"
@ -465,20 +496,24 @@ impl Redact for RedactFields {
}
}
pub(crate) fn file_path(uri: &str) -> String {
file_path_(&lsp_types::Url::parse(uri).unwrap())
pub(crate) fn file_uri(uri: &str) -> String {
file_uri_(&lsp_types::Url::parse(uri).unwrap())
}
pub(crate) fn file_path_(uri: &lsp_types::Url) -> String {
pub(crate) fn file_uri_(uri: &lsp_types::Url) -> String {
let uri = uri.to_file_path().unwrap();
file_path_(&uri)
}
pub(crate) fn file_path_(path: &Path) -> String {
let root = if cfg!(windows) {
PathBuf::from("C:\\dummy-root")
} else {
PathBuf::from("/dummy-root")
};
let uri = uri.to_file_path().unwrap();
let abs_path = Path::new(&uri).strip_prefix(root).map(|p| p.to_owned());
let rel_path = abs_path
.unwrap_or_else(|_| Path::new("-").join(Path::new(&uri).iter().next_back().unwrap()));
let abs_path = path.strip_prefix(root).map(|p| p.to_owned());
let rel_path =
abs_path.unwrap_or_else(|_| Path::new("-").join(path.iter().next_back().unwrap()));
unix_slash(&rel_path)
}

View file

@ -395,6 +395,19 @@ impl<T, E: std::fmt::Display> WithContextUntyped<T> for Result<T, E> {
}
}
impl<T> WithContextUntyped<T> for Option<T> {
fn context_ut(self, loc: &'static str) -> Result<T> {
self.ok_or_else(|| Error::new(loc, ErrKind::None, None))
}
fn with_context_ut<F>(self, loc: &'static str, f: F) -> Result<T>
where
F: FnOnce() -> Option<Box<[(&'static str, String)]>>,
{
self.ok_or_else(|| Error::new(loc, ErrKind::None, f()))
}
}
/// The error prelude.
pub mod prelude {

View file

@ -177,6 +177,7 @@ impl PathPattern {
let w = root.to_string_lossy();
let f = file_name.to_string_lossy();
let f = f.as_ref().strip_suffix(".typ").unwrap_or(f.as_ref());
// replace all $root
let mut path = self.0.replace("$root", &w);
@ -184,7 +185,7 @@ impl PathPattern {
let d = dir.to_string_lossy();
path = path.replace("$dir", &d);
}
path = path.replace("$name", &f);
path = path.replace("$name", f);
Some(Path::new(path.as_str()).clean().into())
}