Skip to content

Add typst eval CLI command#7362

Merged
laurmaedje merged 19 commits intotypst:mainfrom
TimerErTim:cli-eval
Nov 17, 2025
Merged

Add typst eval CLI command#7362
laurmaedje merged 19 commits intotypst:mainfrom
TimerErTim:cli-eval

Conversation

@TimerErTim
Copy link
Copy Markdown
Contributor

@TimerErTim TimerErTim commented Nov 12, 2025

Description

Deprecates query subcommand

Display a deprecation warning when the query subcommand is invoked and also in clap help.

No tests?

I have found no tests for individual CLI commands. So I decided to not design a new testsuite for raw CLI subcommands as that would be very much out of scope for this PR.

Closes #7344
Closes #7008

@MDLC01
Copy link
Copy Markdown
Collaborator

MDLC01 commented Nov 12, 2025

This would also close #7008

@TimerErTim TimerErTim force-pushed the cli-eval branch 2 times, most recently from 5d0b5cd to fd92022 Compare November 12, 2025 11:33
@TimerErTim TimerErTim marked this pull request as ready for review November 12, 2025 11:33
@laurmaedje laurmaedje changed the title Add typst eval cli command Add typst eval CLI command Nov 13, 2025
Copy link
Copy Markdown
Member

@laurmaedje laurmaedje left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR, it looks excellent!

I left a few minor comments, but the main thing to happen before merge is that we're confident in the API. What I posted in the issue was my initial idea, but upon playing around with your interpretation of it, a few questions have arisen.

First of all, I'm not sure it's desirable to interpret scope values as Typst code. I'd have expected the scope to always contain strings. Otherwise, it's not much different from just interpolating the variables into the main string. The idea was to offer a safe way to interpolate arbitrary strings. However, I'm not certain about this either. It feels somewhat redundant with sys.inputs. There have been various discussions about that one also supporting paths and bytes in some capacity (see #5382 (comment)) and those might crop up here, too. Since sys.inputs already works with typst eval out of the box, perhaps it's best to just rely on it and remove scope after all?

For mode, I'm also open whether it's actually pulling its weight then. (Should we remove scope, I don't feel quite as strong the urge for consistent with the eval function.)

What do you think?

Ok(StringInput::Stdin)
} else {
Ok(Input::Path(value.into()))
Ok(StringInput::String(value.to_string_lossy().into()))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we want to be lossy here. Is this the default behavior of clap when a string is required or does it error?

Copy link
Copy Markdown
Contributor Author

@TimerErTim TimerErTim Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clap’s StringValueParser checks whether the input is valid UTF-8, and if it isn’t, it displays the command’s help/usage message. This means there’s no lossy conversion—invalid input results in an explicit error, which appears to be the standard for Clap.

I will use the NonEmptyStringValueParser instead of the OsStringValueParser. This should solve the issue

@TimerErTim
Copy link
Copy Markdown
Contributor Author

Thank you for the review and the ideas.

--scope argument

I think having --scope values interpreted as Typst code is quite valuable. It allows simple and expressive usage such as:

typst eval x+y --scope x=2 --scope y=6
# 8

This also closely mirrors the familiar eval function inside Typst itself.

Against interpreting scope values as strings

If --scope values were interpreted as plain strings (which you can emulate using --scope 'x="2"'), the result would be:

# "26"

This means we would lose a lot of flexibility, especially when leveraging shell interpolation, e.g. --scope x=$(echo 1).

Additionally—and as you already mentioned—there would be almost no functional difference compared to --input, making --scope essentially redundant.

Even though I don’t currently have a practical use case for it, interpreting scope values as Typst code also enables injecting math contexts or markup content, allowing for things like:

typst eval x+y --scope 'x="2"' --scope y='$6$'
# {"func":"sequence","children":[{"func":"text","text":"2"},{"func":"equation","block":false,"body":{"func":"text","text":"6"}}]}

I have no idea why you would ever need this—but experience shows that users come up with all sorts of unanticipated (yet valid) uses.

Against dropping --scope entirely

Most of the points above apply here as well. Without --scope, we would lose a great deal of flexibility. With only --input, examples like the following become much harder to write:

typst eval 'if x == 0 { query(heading) } else { query(heading.where(level: x)) }' \
  --scope x=$(<some script yielding a level>) \
  --in ...
# headings of all levels or only specific levels

Without --scope, the same dynamic value would have to be interpolated multiple times or parsed manually from sys.input via JSON or similar. This adds complexity and reduces expressiveness.

--mode argument

From a practical standpoint, this argument can likely be omitted. Inside Typst itself it makes sense, since the evaluator actually renders math or content blocks. But from the CLI’s perspective, the output becomes something like:

{
  "func": "equation",
  "block": false,
  "body": {
    "func": "frac",
    "num": { ... },
    "denom": { ... }
  }
}

(example generated by the current implementation using
typst eval '#a/#b' --mode math --scope a='$2 times 3$' --scope b='6' --pretty)

This form is not particularly meaningful for most CLI use cases.

Alternative

If we remove the --mode argument and always evaluate in “code” mode, but a rare situation arises where a user actually does want the math-mode AST, they can simply wrap the expression:

typst eval '$#a/#b$' --scope a='$2 times 3$' --scope b='6' --pretty

This seems like a reasonable workaround, especially since such cases should be rare.

Downsides of having --mode

That said, keeping the argument incurs very little maintenance cost. The biggest issue is the ambiguity and conversion between args::SyntaxMode and syntax::SyntaxMode.

Depending on how significant you consider that friction, offering a --mode argument (defaulting to "code") could still be reasonable—especially since it mirrors the #eval(...) behavior from inside Typst, making the CLI more familiar for existing users.

TL;DR

--scope
+ Strong yes — significant flexibility, meaningful difference from --input, powerful shell integration.

--mode
~ Mild benefit, low maintenance cost — can reasonably go either way depending on project values and philosophy.

@laurmaedje
Copy link
Copy Markdown
Member

Thanks for the your detailed response. It's worth noting that the repetition could also be worked around like this:

typst eval "{ let x = $(echo 2); x + x + 2 }"

However, this easily makes for a code injection opportunity! I'm not sure what attack surface we're considering for typst eval yet. But having such an injection hazard is not great.

This is less bad with scope, but I think it's still somewhat problematic and could be a pitfall. Something like --scope x="$(cat user-input)" could be attacked with " + read("secret/file") + ". And even if you don't have an attacker, you'd have to deal with Typst's escape sequences in strings.

Of course, you can avoid this by using sys.inputs if you want a string. But the convenience of --scope will make it the first thing people reach for.

Considering that

  • sys.inputs avoids such security problems by default
  • you can still use eval(sys.inputs.x) explicitly within your code to emulate --scope

it might be a safer and better default. If you're expecting integers, I think it's also reasonable to be explicit about that and use int for parsing the inputs.

There is also the question of whether --scope would interact with --mode. (In the built-in eval function this is not relevant because the dictionary always contains values.)

@TimerErTim
Copy link
Copy Markdown
Contributor Author

Good point. I honestly did not think about potential attack surface on this one, but given your explanation, I agree that --input is enough and users can optin to risky input by using eval(sys.inputs.<whatever>).

The repition mitigation with { let x = eval(sys.inputs.x); x + x + 2 } works, but I feel like this is a lot of boilerplate for something we easily can have with the current design. Especially considering multiple --scope/--input arguments. I wonder if there could be a cleaner/less verbose/less boilerplaty way? 🤔

Summary

Given your arguments:

  • I agree with removing --scope in favor of the safer --inputs alternative.
  • Maybe there is a better way for defining variables in the scope of the evaluated statement?
    Your suggestion (let binding) seems to fit the Typst design philosophy perfectly though, so there may very well be no further room for improvement.
  • I assume the --mode argument is off the table then?

@TimerErTim
Copy link
Copy Markdown
Contributor Author

There is also the question of whether --scope would interact with --mode. (In the built-in eval function, this is not relevant because the dictionary always contains values.)

I think (and that is how I implemented it for now) --mode should only affect the received expression. --scope is unaffected by this and always evaluated as code. The reason for this is behaviour parity with the Typst #eval() function, where values in the scope Dict are always interpreted as code (unless put into math using $...$ or content [...] mode, same as in CLI).

@laurmaedje
Copy link
Copy Markdown
Member

I'd say lets remove both --scope and --mode then. The downsides of --scope seem significant and after all it's always easier to add something later if we reconsider it than to remove it.

@TimerErTim
Copy link
Copy Markdown
Contributor Author

Done

Copy link
Copy Markdown
Member

@laurmaedje laurmaedje left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a few minor changes myself. I also noticed one more thing just now and left two comments. Other than that, this looks ready to merge.

@laurmaedje laurmaedje added scripting About Typst's coding capabilities cli About Typst's command line interface. interface PRs that add to or change Typst's user-facing interface as opposed to internals or docs changes. labels Nov 17, 2025
@TimerErTim
Copy link
Copy Markdown
Contributor Author

Thanks a lot for the help along the way and the documentation/messages improvement from your side.

@laurmaedje laurmaedje enabled auto-merge November 17, 2025 22:44
@laurmaedje
Copy link
Copy Markdown
Member

Thank you, great work!

@laurmaedje laurmaedje added this pull request to the merge queue Nov 17, 2025
Merged via the queue into typst:main with commit 66f282e Nov 17, 2025
14 of 16 checks passed
ParaN3xus added a commit to ParaN3xus/tinymist that referenced this pull request Jan 16, 2026
ParaN3xus added a commit to ParaN3xus/typst.ts that referenced this pull request Jan 16, 2026
ParaN3xus added a commit to ParaN3xus/tinymist that referenced this pull request Jan 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli About Typst's command line interface. interface PRs that add to or change Typst's user-facing interface as opposed to internals or docs changes. scripting About Typst's coding capabilities

Projects

None yet

Development

Successfully merging this pull request may close these issues.

typst eval subcommand Return location information in typst query

3 participants