Introduction
Knot is a literate programming system for Typst. It lets you
embed executable R and Python code directly inside .knot documents, which compile
to .typ files that Typst renders into PDF (or any other format Typst supports).
If you have used RMarkdown or Quarto, the idea will feel familiar. The differences are in the details — and the details matter.
The Literate Programming Idea
Literate programming, as coined by Donald Knuth, is the practice of writing programs and their explanations as a single document. The source of truth is the document — not the code, not the prose, but both together.
In practice this means:
- Your analysis, your methodology, and your conclusions live in the same file as the code that produces them.
- The document is always reproducible: running it again produces exactly the same output.
- There is no "copy the number from the script into the report" step. The number is the report.
Knot follows this philosophy strictly. A .knot document is a Typst document with
executable code blocks. Nothing more, nothing less.
Why Typst?
Typst is a modern typesetting system designed from the ground up to be fast and programmable. Where LaTeX compilation can take seconds to minutes on a large document, Typst compiles in milliseconds. Where LaTeX error messages are famously cryptic, Typst's are precise and helpful.
This speed matters for literate programming. When you fix a typo, you want to see the result immediately — not wait for a full recompile. Knot exploits Typst's speed at every level:
- Static content (headings, prose, equations) updates in milliseconds.
- Cached chunks (code whose output hasn't changed) appear instantly in the preview.
- Changed chunks stream into the preview one by one as they finish executing, without waiting for the entire document to recompile.
The result is a writing experience where the preview feels live, not batched.
The Execution Model
Understanding how Knot executes code is essential to using it effectively.
One interpreter per language per file
Each .knot file gets its own R interpreter and its own Python interpreter.
Variables defined in one file are not visible in another. If your project has
a chapter1.knot and a chapter2.knot, they are completely isolated — each starts
from a fresh environment.
This isolation is intentional. It enforces modularity and prevents subtle
cross-file dependencies that are hard to debug. If chapter2.knot needs a value
computed in chapter1.knot, it must read it explicitly (from a file, a database,
or a shared data format).
Linear execution within a file
Within a single file, code chunks of the same language execute sequentially, in document order. The R chunk on line 50 sees all variables defined by R chunks above it. The Python chunk on line 200 sees all Python variables defined above it.
R and Python are independent — they do not share a namespace. But within each language, state accumulates from top to bottom, exactly as if you had run the file as a script.
file: analysis.knot
[R chunk 1] x <- 1:100 ← defines x
[R chunk 2] mean(x) ← sees x ✓
[Python 1] y = [1, 2, 3] ← defines y (Python namespace)
[R chunk 3] sd(x) ← still sees x ✓
[Python 2] sum(y) ← sees y ✓, cannot see x ✗
R and Python run in parallel when multiple languages are present in the same file — the R chain and the Python chain are independent and can execute simultaneously.
The Cache and Invalidation
Re-executing every chunk on every save would be too slow for long documents. Knot caches the output of every chunk and only re-executes chunks whose inputs have changed.
Chained hashing
Each chunk's cache key is a SHA-256 hash of:
- The chunk's source code
- Its options (
#|frontmatter) - The hash of the previous chunk in the same language chain
The chaining is the critical part. If you edit chunk 3, its hash changes. Because chunk 4's hash includes chunk 3's hash, chunk 4's hash also changes — even if chunk 4's own code is identical. And so does chunk 5's, chunk 6's, and so on.
chunk 1 (unchanged) hash: a1b2c3…
chunk 2 (unchanged) hash: f(code₂, a1b2c3…) = 9d8e7f…
chunk 3 (EDITED) hash: f(code₃', 9d8e7f…) = 3c4d5e… ← changed
chunk 4 (unchanged) hash: f(code₄, 3c4d5e…) = 7f8a9b… ← also changed!
chunk 5 (unchanged) hash: f(code₅, 7f8a9b…) = 2e3f4a… ← also changed!
This cascade is not a bug — it is the correct behaviour. Chunk 4 may depend on a variable modified by chunk 3. Knot cannot know for certain whether it does, so it re-executes everything downstream. Reproducibility is guaranteed.
Environment snapshots
Re-executing chunk 4 requires that the R (or Python) environment be in the same state it was in just before chunk 4 last ran. Knot achieves this through environment snapshots.
Before executing each chunk, Knot saves a snapshot of the interpreter's state (the set of live objects and their values). When a downstream chunk must be re-executed, Knot restores the snapshot from just before that chunk and then runs the chunk — without having to re-execute all the upstream chunks.
This means that if only chunk 5 changes in a 20-chunk document, Knot restores the snapshot from before chunk 5 and executes only chunk 5. The other 19 chunks are served from cache.
The Freeze Contract
The snapshot trade-off
Snapshots make incremental recompilation fast, but they have a cost: each snapshot captures the entire interpreter environment at that point — every variable, every object. If your document loads a multi-gigabyte dataset or trains a model, that object is included in every subsequent snapshot. With twenty chunks after the training step, you end up with twenty copies of the model on disk, and restoring any one of those snapshots means reading the full file back into memory.
This is the core trade-off:
| Default (no freeze) | With freeze | |
|---|---|---|
| Snapshots | Heavy — large objects included in each one | Light — large objects excluded |
| Disk usage | Grows with number of downstream chunks | Object stored once |
| Snapshot restore | One large file to read | Small snapshot + separate object load |
| Constraint | None | Object must not be mutated downstream |
How freeze works
```{r}
#| freeze: [model, training_data]
model <- train(training_data)
```
When you declare freeze: [model, training_data], Knot:
- Serialises each named object into content-addressed storage
(
.knot_cache/objects/{hash}.ext) immediately after the chunk executes. - Excludes those objects from all subsequent snapshots, keeping them lightweight.
- Reloads the objects separately after every snapshot restore — so the interpreter always has them available.
The objects are still serialised and deserialised; the gain is that snapshots themselves stay small and fast to write and read, and the objects live on disk only once regardless of how many chunks follow.
The immutability contract
In exchange, Knot computes an xxHash64 fingerprint of each frozen object immediately after declaration. After every subsequent chunk in the same language chain that must re-execute, Knot recomputes the fingerprints and compares them against the stored values. If they differ — meaning some downstream code accidentally modified them — Knot marks the violation and suspends execution of the rest of the chain, surfacing the error in the preview and in VS Code diagnostics.
This gives you a compile-time contract: "these objects must not change after this point." If they do, you find out immediately, not after you have published the document.
xxHash64 was chosen for its speed: it can fingerprint hundreds of megabytes per second, making it practical even for large in-memory objects.
When to use freeze
Use freeze when an object is large and immutable after its creation chunk —
a trained model, a loaded dataset, a precomputed matrix. Do not use it for objects
that downstream chunks are expected to modify.
The Preview Experience
The VS Code extension brings all of the above together into a fluid writing experience:
-
You type — the preview updates instantly with the current cached output for all unchanged chunks. The chunk you edited shows a thick amber dotted border; downstream chunks invalidated by the hash cascade show a thin amber dashed border.
-
You save — Knot immediately assembles a Phase 0 preview (cache hits in full, pending chunks shown with an orange border). This appears in under 50 ms.
-
Chunks execute — as each chunk finishes, its result streams into the preview in real time. You see results appear one by one, not all at once at the end.
-
Sync — clicking in the PDF scrolls to the corresponding source line. Moving your cursor in the source scrolls the PDF. Forward and backward sync work bidirectionally.
The goal is to make the feedback loop short enough that you think of writing and computing as a single activity, not two separate phases.
Comparison with RMarkdown and Quarto
RMarkdown and Quarto are exceptional tools. They have shaped the practice of reproducible research over many years and inspired much of what Knot tries to do. If you are already happy with one of them, there is no reason to switch.
Knot is a young project. It does not have the maturity, the ecosystem, or the community of either. What it offers instead is a narrow but deliberate bet: Typst as the only typesetting target, with reproducibility as a first-class constraint.
What RMarkdown and Quarto do better
- Maturity and ecosystem. Thousands of packages, templates, and extensions have been built around knitr and Quarto. Knot has none of that yet.
- Output formats. Quarto targets HTML, Word, presentations, websites, books, and more. Knot only produces Typst documents.
- Language support. Quarto supports R, Python, Julia, Observable, and others. Knot supports R and Python.
- Typesetting flexibility. If you need LaTeX — for a journal template, a
specific package, or a workflow that requires
.texoutput — RMarkdown and Quarto are the right tools. Typst is still young and not accepted everywhere. - Speed of a first full run. Knot's incremental model shines on reruns, but the first compilation has the same cost as any other tool. On large documents, Typst's own compilation is fast; the bottleneck is code execution, which is comparable across systems.
Where Knot makes a different choice
- The notebook/render split. RMarkdown and Quarto have two distinct modes. In the notebook (interactive) session, execution is non-linear: you can run chunks out of order, redefine variables, and accumulate state across runs. At render time, the document is executed linearly from a fresh environment. This split is a frequent source of frustration: code that worked interactively breaks at render because it silently depended on state that no longer exists. Knot has only one mode. Every compilation is a linear execution from a clean state, with the cache as the only shortcut. There is no gap between "works in the session" and "works in the document".
- Typst instead of LaTeX. Typst's syntax is clean, its compilation is fast, and its layout model is modern. For users who do not need LaTeX compatibility, it removes a significant source of friction.
- Live preview with per-chunk streaming. Cached chunks appear instantly; only invalidated chunks rerun. The preview updates progressively rather than waiting for the full document.
| Knot | RMarkdown | Quarto | |
|---|---|---|---|
| Typesetting engine | Typst only | LaTeX / HTML | LaTeX / HTML / others |
| Maturity | Early | Mature | Mature |
| Supported languages | R, Python | R (+ reticulate) | R, Python, Julia, others |
| Output formats | PDF (via Typst) | Many | Many |
| Execution order | Always linear | Linear at render, non-linear interactively | Linear at render, non-linear interactively |
| Caching | Chained SHA-256 | knitr cache (per chunk) | Freeze / cache |
| Live preview | Streaming, per-chunk | None / slow | Limited |
| Bidirectional sync | Yes | No | Partial |
Installation
Quick install
curl -sSf https://raw.githubusercontent.com/knot-literate-programming/knot/master/install.sh | bash
This script:
- Detects your platform (macOS arm64/x86_64, Linux x86_64/arm64).
- Downloads the prebuilt
knotandknot-lspbinaries from the latest release. - Installs them to
~/.local/bin(override with--prefix DIR). - Installs the VS Code extension if the
codecommand is available. - Checks that all prerequisites are present and tells you what is missing.
Prerequisites
Required
| Tool | Purpose | Install |
|---|---|---|
| Typst | Compiles .typ → PDF | See below |
| Tinymist | Powers the VS Code live preview | See below |
Typst can be installed with most package managers:
# macOS
brew install typst
# cargo
cargo install --locked typst-cli
Tinymist is the Typst language server. Download the binary for your platform
from the Tinymist releases page
and place it somewhere in your PATH.
Note: The Tinymist VS Code extension bundles its own binary, but Knot's LSP spawns a separate Tinymist subprocess and needs the binary available in
PATHindependently.
Per language (install what you use)
| Tool | Purpose | Install |
|---|---|---|
| R | Execute R chunks | CRAN |
| Air | Format R code in VS Code | See Air docs |
| Python 3 | Execute Python chunks | python.org, conda, pyenv… |
| Ruff | Format Python code in VS Code | pip install ruff |
You only need the tools for the languages you actually use. If your document has no R chunks, you do not need R.
Build from source
If there is no prebuilt binary for your platform, or if you want to work from the latest development version:
git clone https://github.com/knot-literate-programming/knot.git
cd knot
# Install the CLI and LSP
cargo install --path crates/knot-cli
cargo install --path crates/knot-lsp
# Install the VS Code extension
bash scripts/install-vscode-dev.sh
You need Rust 1.80+ and Node.js 20+.
Verifying the installation
knot --version
knot-lsp --version
typst --version
tinymist --version
All four commands should print a version string without errors.
Your First Project
Create a project
knot init my-report
cd my-report
This creates:
my-report/
├── knot.toml ← project configuration
├── main.knot ← your document
└── lib/
└── knot.typ ← Typst helpers (imported by main.knot)
Open in VS Code
code .
Open main.knot. The Knot extension activates automatically. Click
Start Preview in the status bar (or press the Knot button in the editor
toolbar) to open the live PDF preview.
Write your first document
Replace the contents of main.knot with:
#import "lib/knot.typ": *
#show: knot-init
= My First Report
```{r}
x <- c(2, 4, 6, 8, 10)
summary(x)
```
The mean of `x` is `{r} mean(x)` and its standard deviation is `{r} round(sd(x), 2)`.
```{python}
import math
values = [1, 4, 9, 16, 25]
print(f"Sum of squares: {sum(values)}")
```
Save the file. The preview updates within a second.
Compile to PDF
knot build # writes my-report.pdf
Or use watch mode, which rebuilds automatically on every save:
knot watch # rebuilds on save + opens typst watch for PDF
What just happened
When you saved main.knot, Knot ran a three-pass pipeline:
-
Plan — parsed the document, computed a SHA-256 hash for each chunk, and decided which chunks needed to execute (all of them, since this is the first run).
-
Execute — ran the R chunks sequentially in an R subprocess, and the Python chunk in a Python subprocess. The two languages ran in parallel. Results were written to the cache.
-
Assemble — interleaved the chunk outputs with the surrounding Typst source and wrote
main.typ.
On the next save, if you only change the prose, none of the chunks re-execute — their cached outputs are reused instantly.
Next steps
- Document Structure — learn the full
.knotformat. - Chunk Options — control what is shown, how figures are sized, and more.
- VS Code — preview, sync, formatting, and diagnostics.
Document Structure
A .knot file is a valid Typst document with two additions: code chunks and
inline expressions. Everything else — headings, paragraphs, equations, figures,
references — is standard Typst.
The required import
Every .knot document must start with:
#import "lib/knot.typ": *
#show: knot-init
knot-init installs the Typst functions (code-chunk, etc.) that Knot's output
relies on. Without it, the compiled .typ will not render correctly.
Code chunks
A code chunk is a fenced block with a language tag in braces:
```{r}
x <- 1:10
mean(x)
```
```{python}
import numpy as np
np.mean([1, 2, 3])
```
The language tag (r, python) determines which interpreter runs the block.
Options are specified as YAML comments at the top of the block, prefixed with #|:
```{r}
#| label: summary-stats
#| echo: false
#| fig-width: 6
x <- rnorm(100)
hist(x)
typst(current_plot())
```
See Chunk Options for the complete reference.
Inline expressions
An inline expression evaluates a short snippet and inserts the result into the surrounding prose:
The average is `{r} mean(x)` and the count is `{python} len(values)`.
Inline expressions share the same namespace as code chunks — x defined in an
R chunk above is available in a later {r} inline expression.
Inline expressions should return a simple scalar (a number, a string, a boolean). For complex objects, use a code chunk instead.
Multi-file projects
Large projects can split content across multiple .knot files. Declare them in
knot.toml:
[document]
main = "main.knot"
includes = ["chapter1.knot", "chapter2.knot"]
Each file gets its own isolated R and Python environment. Variables defined in
chapter1.knot are not visible in chapter2.knot. If you need to share data
between files, write it to disk (e.g., an RDS file, a CSV, a pickle) in one file
and read it in the next.
The main file must contain a /* KNOT-INJECT-CHAPTERS */ placeholder where the
compiled include files will be inserted:
#import "lib/knot.typ": *
#show: knot-init
= My Book
/* KNOT-INJECT-CHAPTERS */
Code Chunks
This chapter covers the mechanics of code chunks. For the complete list of options, see Chunk Options Reference. For output types, see the Output chapters.
Syntax
A code chunk is a fenced Markdown code block whose language tag is wrapped in braces:
```{r}
x <- 1:10
mean(x)
```
The braces distinguish executable chunks ({r}) from static code blocks (```r),
which Knot passes through as-is to Typst.
Controlling what is shown
The show option controls what appears in the compiled document:
| Value | Code block shown | Output shown |
|---|---|---|
"both" (default) | Yes | Yes |
"code" | Yes | No |
"output" | No | Yes |
"none" | No | No |
```{r}
#| show: "output"
x <- rnorm(1000)
hist(x, col = "steelblue", main = "Distribution of x")
typst(current_plot())
```
Skipping execution
Set eval: false to include a chunk in the document without running it:
```{r}
#| eval: false
# This code is shown but not executed
very_slow_function()
```
Disabling the cache
By default Knot caches every chunk. Set cache: false to force re-execution on
every compile, regardless of whether the code has changed:
```{r}
#| cache: false
# Always re-executes (e.g. for live data, random seeds, timestamps)
Sys.time()
```
Labels and cross-references
A labeled chunk becomes a referenceable Typst figure when it also has a caption:
```{r}
#| label: fig-histogram
#| fig-cap: Distribution of simulated data
#| show: "output"
hist(rnorm(500), col = "steelblue")
typst(current_plot())
```
As shown in @fig-histogram, the distribution is approximately normal.
Without a caption, the label is still emitted as a Typst label but no #figure
wrapper is added.
Execution order and state
Chunks of the same language execute in document order. State accumulates:
```{r}
x <- 42 # x is now defined
```
Some prose in between.
```{r}
x * 2 # outputs 84
```
If you delete or reorder chunks, the cache is invalidated for everything downstream. See The Cache and Invalidation.
Inline Expressions
An inline expression evaluates a short code snippet and inserts the result directly into the surrounding text.
Syntax
The mean is `{r} mean(x)` and the p-value is `{python} round(p, 4)`.
The result is inserted as plain text — no code block, no output label.
What can be returned
An inline expression should return a scalar: a single number, string, or boolean. Knot formats the result as follows:
- R: strips the
[1]prefix that R normally prepends.[1] 3.14becomes3.14. Quoted strings have their quotes removed.[1] "Alice"becomesAlice. - Python: inserts whatever
print()would output for the value.
Short vectors are accepted and rendered verbatim (e.g. [1] 1 2 3 4 5), but
complex or multi-line outputs produce an error. Use a code chunk with show: "output"
for those.
Shared state with chunks
Inline expressions share the namespace of their language. An {r} expression
sees all variables defined by R chunks above it in the same file:
```{r}
model <- lm(mpg ~ wt, data = mtcars)
coef_wt <- coef(model)["wt"]
```
A one-unit increase in weight is associated with a
`{r} round(coef_wt, 2)` change in fuel efficiency.
Caching
Inline expressions are cached with the same chained-hash mechanism as chunks. If the R or Python state that an expression depends on changes, the expression re-evaluates automatically.
Chunk Options Reference
Options are written as YAML comments at the top of a chunk, one per line,
prefixed with #|:
```{r}
#| label: my-chunk
#| echo: false
#| fig-width: 6
plot(1:10)
typst(current_plot())
```
Options can also be set globally in knot.toml under [chunk-defaults],
[r-chunks], or [python-chunks]. Per-chunk options always override defaults.
Execution control
| Option | Type | Default | Description |
|---|---|---|---|
eval | bool | true | If false, the chunk is not executed and produces no output. |
cache | bool | true | If false, the chunk always re-executes even if its hash has not changed. |
freeze | list | [] | Object names whose xxHash64 fingerprint must not change after this chunk. See The Freeze Contract. |
Display control
| Option | Type | Default | Description |
|---|---|---|---|
show | string | "both" | What to display: "both", "code", "output", "none". |
echo | bool | true | Alias for show: "output" when false. Kept for compatibility. |
Labelling and captions
| Option | Type | Default | Description |
|---|---|---|---|
label | string | — | Chunk identifier. Used as a Typst label (<label>) for cross-referencing. |
fig-cap | string | — | Caption for the figure wrapper (enables Typst #figure). |
Figure sizing
| Option | Type | Default | Description |
|---|---|---|---|
fig-width | number | 6 | Figure width in inches. |
fig-height | number | 4 | Figure height in inches. |
fig-dpi | number | 150 | Resolution in dots per inch (raster formats). |
fig-format | string | "svg" | Output format: "svg" or "png". |
Warnings
| Option | Type | Default | Description |
|---|---|---|---|
warning | bool | true | Whether to capture and display R/Python warnings. |
warning-pos | string | "below" | Where to show warnings: "above" or "below" the output. |
Layout
| Option | Type | Default | Description |
|---|---|---|---|
layout | string | "vertical" | How to arrange code and output: "vertical" or "horizontal". |
Code styling (codly)
Options prefixed with codly- are passed directly to the
codly Typst package for syntax
highlighting customisation:
```{r}
#| codly-stroke: 2pt + red
#| codly-lang-radius: 8pt
x <- 1
```
Refer to the codly documentation for the full list of available options.
Dependencies
| Option | Type | Default | Description |
|---|---|---|---|
depends | list | [] | File paths that, when modified, invalidate this chunk's cache. Useful for chunks that read external files. |
Example:
```{r}
#| depends: [data/raw.csv]
data <- read.csv("data/raw.csv")
```
Text Output
When a chunk produces text output (printed values, cat(), print(), etc.),
Knot captures it and renders it as a code block below the source code.
R
In R, the last expression in a chunk is automatically printed:
```{r}
summary(mtcars$mpg)
```
Output:
Min. 1st Qu. Median Mean 3rd Qu. Max.
10.40 15.43 19.20 20.09 22.80 33.90
Use cat() or print() for explicit output. Multiple print calls produce
multiple output lines.
Python
In Python, only explicit print() calls produce output:
```{python}
import statistics
data = [1, 2, 3, 4, 5]
print(f"Mean: {statistics.mean(data)}")
print(f"Stdev: {statistics.stdev(data):.2f}")
```
Suppressing output
Use show: "code" to show the code without its output, or show: "none" to
silently execute a chunk (useful for setup chunks that only define variables).
```{r}
#| show: "none"
library(ggplot2)
theme_set(theme_minimal())
```
Warnings
R and Python warnings are captured separately from standard output. By default
they appear below the output block. Control this with warning and warning-pos:
```{r}
#| warning: false
log(-1) ← warning suppressed
```
Plots and Figures
Knot captures plots from R and Python and embeds them in the document as SVG or PNG images.
R
Use typst(current_plot()) after your plotting code to capture the current
graphics device:
```{r}
#| fig-width: 7
#| fig-height: 4
#| show: "output"
plot(mtcars$wt, mtcars$mpg, pch = 19, col = "steelblue",
xlab = "Weight", ylab = "MPG")
abline(lm(mpg ~ wt, data = mtcars), col = "red")
typst(current_plot())
```
typst() is a helper function loaded into every R session by Knot. It saves the
current plot to a file in the cache and tells Knot to embed it.
ggplot2
Use typst(p) to plot the ggplot object p:
```{r}
library(ggplot2)
p <- ggplot(mtcars, aes(wt, mpg)) +
geom_point() +
geom_smooth(method = "lm")
typst(p)
```
Python (Matplotlib)
```{python}
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0, 2 * np.pi, 100)
plt.plot(x, np.sin(x))
plt.title("Sine wave")
typst(current_plot())
```
typst and current_plot are automatically available in every Python session.
Figure options
| Option | Default | Description |
|---|---|---|
fig-width | 6 | Width in inches |
fig-height | 4 | Height in inches |
fig-dpi | 150 | Resolution (PNG only) |
fig-format | "svg" | "svg" or "png" |
SVG is recommended for most plots — it scales perfectly at any zoom level and produces smaller files. Use PNG for plots with many thousands of points where SVG becomes slow to render.
Captions and cross-references
```{r}
#| label: fig-scatter
#| fig-cap: Weight vs fuel efficiency in the mtcars dataset.
#| show: "output"
plot(mtcars$wt, mtcars$mpg, pch = 19)
typst(current_plot())
```
@fig-scatter shows a clear negative relationship.
DataFrames and Tables
Knot automatically converts R data frames and Python pandas DataFrames into Typst tables.
R
Pass a data frame to typst():
```{r}
#| show: "output"
typst(head(mtcars, 5))
```
This produces a formatted Typst table with column headers. The table respects Typst's standard table styling.
Python
```{python}
import pandas as pd
df = pd.DataFrame({
"name": ["Alice", "Bob", "Carol"],
"score": [92, 85, 97],
"grade": ["A", "B", "A+"]
})
typst(df)
```
Combining a table and a plot
A chunk can emit both a table and a plot. Use typst() twice:
```{r}
#| show: "output"
df <- aggregate(mpg ~ cyl, data = mtcars, mean)
typst(df)
barplot(df$mpg, names.arg = df$cyl, ylab = "Mean MPG", xlab = "Cylinders")
typst(current_plot())
```
knot.toml
knot.toml is the project configuration file. Knot searches for it by walking
up from the current directory. Place it at the project root.
Minimal configuration
[document]
main = "main.knot"
Full reference
[document]
# Main document file (required)
main = "main.knot"
# Additional .knot files compiled before main and injected at
# /* KNOT-INJECT-CHAPTERS */ in main.knot
includes = ["chapter1.knot", "chapter2.knot"]
[execution]
# Abort chunk execution after this many seconds (default: 30)
timeout_secs = 60
[chunk-defaults]
# Default options applied to every chunk in every language.
# All chunk options are valid here.
echo = true
warning = true
fig-width = 6
fig-height = 4
fig-format = "svg"
[r-chunks]
# Defaults applied to R chunks only (override [chunk-defaults]).
warning = false
[python-chunks]
# Defaults applied to Python chunks only (override [chunk-defaults]).
fig-dpi = 200
[codly]
# Options passed to the codly Typst package for syntax highlighting.
# These apply globally to all code blocks.
# Refer to the codly documentation for available keys.
# Example:
# zebra-fill = "luma(250)"
Option precedence
From lowest to highest priority:
- Built-in Knot defaults
[chunk-defaults]inknot.toml[r-chunks]or[python-chunks]inknot.toml- Per-chunk
#|options in the.knotfile
VS Code
The Knot VS Code extension provides a complete editing environment for .knot
files: live preview, bidirectional sync, completion, diagnostics, and formatting.
Starting the preview
Open a .knot file and click Start Preview in the status bar, or use the
command palette (Ctrl+Shift+P / Cmd+Shift+P) and run Knot: Start Preview.
A browser window opens with a live, streaming preview of your document.
The preview lifecycle
Understanding what happens when you interact with the document helps you make the most of the preview:
While typing (before saving)
The preview updates instantly using only cached results. Modified chunks — those whose code you have changed since the last compile — show an amber border:
- Amber (strong border): the chunk you edited directly.
- Amber (thin border): chunks downstream of the edit whose cache is invalidated by the chained hash cascade (they haven't been re-executed yet, but they will be when you save).
No code executes while you type. The preview is pure Typst — immediate.
On save
-
Phase 0 (< 50 ms): Knot assembles a preview using cached outputs for unchanged chunks, and orange placeholders for chunks that need to re-execute. This appears before any code runs.
-
Streaming execution: Each chunk executes in sequence. As each one finishes, its result replaces the orange placeholder in real time. You see results appear one by one, not all at once.
-
Final: once all chunks have executed, diagnostics are refreshed.
What the border styles mean
| Style | Meaning |
|---|---|
| None | Output is current (cache hit or just executed) |
| 5 pt amber dotted | Chunk you edited directly — awaiting save |
| 1 pt amber dashed | Downstream hash-cascade invalidation — awaiting save |
| 2 pt orange solid | Compile in progress — chunk queued for execution |
| White semi-transparent overlay | Inert — execution suspended due to an upstream error |
These styles are defined in lib/knot.typ via the knot-state-styles dictionary
and can be customised per project:
// In lib/knot.typ — override any entry to change the preview appearance
#let knot-state-styles = (
pending: (stroke: 2pt + rgb("#f97316")),
modified: (stroke: (thickness: 5pt, paint: rgb("#fcd34d"), dash: "densely-dotted")),
"modified-cascade": (stroke: (thickness: 1pt, paint: rgb("#fcd34d"), dash: "dashed")),
inert: (overlay-fill: white.transparentize(40%)),
)
These styles appear only in the live preview — they never show up in the final PDF.
Bidirectional sync
Forward sync (source → PDF): move your cursor in the .knot editor. The PDF
preview scrolls to the corresponding position automatically.
Backward sync (PDF → source): click anywhere in the PDF preview. VS Code opens
the corresponding line in the .knot source file.
Completion and hover
- Chunk options: type
#|inside a chunk to see completions for all available options. Hover over an option name to read its documentation. - Typst symbols: completion and hover for Typst functions and variables is provided by Tinymist (proxied through Knot's LSP).
- R and Python: hover over identifiers in code chunks to see type information
(provided by Tinymist for the virtual
.typrepresentation).
Formatting
Save with formatting enabled (editor.formatOnSave: true) to format:
Each formatter is invoked only if the corresponding binary is available in PATH.
Knot reconstructs the .knot file from the formatted parts without touching the
other sections.
Diagnostics
Errors appear in the Problems panel and as inline squiggles:
- Parse errors: malformed
#|options, unknown option names. - Runtime errors: R or Python exceptions from the last compile. These persist in the cache so they are visible even before you trigger a new compile.
- Typst errors: forwarded from Tinymist.
Extension settings
| Setting | Default | Description |
|---|---|---|
knot.tinymistPath | (PATH) | Path to the tinymist binary if not in PATH. |
knot.airPath | (PATH) | Path to the air binary if not in PATH. |
knot.ruffPath | (PATH) | Path to the ruff binary if not in PATH. |
CLI Reference
The knot command-line tool manages compilation, watching, and project
initialisation. Run knot --help or knot <command> --help for the full
list of flags.
knot init
knot init <name>
Creates a new project directory <name> with a knot.toml, a main.knot
template, and the lib/knot.typ helper file.
knot build
knot build
Compiles the project to a .typ file, then calls typst compile to produce a
PDF. Reads knot.toml from the current directory or any parent directory.
knot watch
knot watch [--preview]
Watches all .knot files in the project for changes. On every save:
- Re-compiles changed chunks (using the cache for unchanged ones).
- Writes the updated
.typfile. - The background
typst watchprocess picks up the new.typand regenerates the PDF automatically.
With --preview, uses tinymist preview instead of typst watch, opening a
browser preview.
For a richer live preview experience (streaming, per-chunk updates, sync), use the VS Code extension instead.
knot compile
knot compile <file.knot>
Compiles a single .knot file to a .typ file (no PDF). Useful for debugging
or scripting.
knot clean
knot clean
Removes the .knot_cache/ directory and all generated .typ files. The next
knot build or knot watch will re-execute all chunks from scratch.
knot format
knot format <file.knot>
knot format <file.knot> --check
Formats a .knot file using Air (R), Ruff (Python), and Tinymist (Typst).
With --check, exits with a non-zero status if the file would be reformatted
(useful in CI).
knot jump-to-source
knot jump-to-source <main.typ> <line> [--open]
Maps a line number in the compiled .typ file back to the corresponding line in
the .knot source. Prints file:line to stdout.
With --open, opens VS Code at that position via code --goto.
Used internally by the VS Code extension for backward sync (PDF → source).
knot jump-to-typ
knot jump-to-typ <main.typ> <file.knot> <line>
Maps a line number in a .knot source file to the corresponding line in the
compiled .typ file. Prints the line number to stdout.
Used internally by the VS Code extension for forward sync (source → PDF).
Architecture Overview
Knot is structured as a Rust workspace with four components:
knot/
├── crates/
│ ├── knot-core/ # Engine: parser, compiler, cache, executors
│ ├── knot-cli/ # knot command-line tool
│ └── knot-lsp/ # Language Server (Tinymist proxy + Knot overlays)
└── editors/
└── vscode/ # VS Code extension (TypeScript)
Component responsibilities
knot-core
The heart of the system. Everything that makes Knot work lives here:
- Parser (
parser/): Winnow combinator parser that turns.knotfiles into an AST ofNodevalues — prose, code chunks, and inline expressions. - Compiler (
compiler/): Three-pass pipeline (plan → execute → assemble). - Cache (
cache/): SHA-256 addressed persistent cache in.knot_cache/. - Executors (
executors/): Persistent R and Python subprocesses. - Backend (
backend.rs): RendersNodes into.typtext. - Project (
project.rs): Top-level API —compile_project_full,compile_project_phase0, etc.
knot-core has no tokio dependency. Concurrency is std::thread::scope.
knot-lsp
An LSP server that wraps Tinymist (the official Typst LSP) and adds Knot-specific capabilities:
- Forwards most LSP requests to a Tinymist subprocess after mapping
.knotcoordinates to virtual.typcoordinates. - Adds chunk-option completion, hover docs, hybrid formatting, and diagnostics.
- Manages streaming preview via
knot/startPreviewandknot/syncForward.
knot-cli
A thin binary over knot-core. Most commands delegate directly to project-level
functions (compile_project_full, etc.).
editors/vscode
A VS Code extension written in TypeScript. It communicates with knot-lsp via the
Language Server Protocol and adds editor UI (status bar, preview lifecycle,
auto-redirect from .typ to .knot).
Data flow: a save event
Here is the full path a didSave event takes from VS Code to a rendered PDF:
[VS Code]
didSave
│
▼
[knot-lsp — server_impl.rs::did_save]
├── increment compile_generation
├── spawn do_compile(generation)
│
└── [do_compile]
├── Phase 0: compile_project_phase0 (instant, orange placeholders)
│ └── apply_update → textDocument/didChange → Tinymist
│
├── Streaming: compile_project_full(path, Some(callback))
│ └── for each chunk executed:
│ apply_update → textDocument/didChange → Tinymist
│
└── Final: apply_update + refresh_diagnostics
→ textDocument/didChange → Tinymist
→ publishDiagnostics → VS Code
[Tinymist subprocess]
textDocument/didChange
→ recompile .typ
→ push updated PDF to browser preview
Key types
In knot-core
#![allow(unused)] fn main() { // A parsed document node enum Node { Prose(String), CodeChunk { language, options, code, … }, Inline { language, expr, … }, } // How much of the chunk's output to include enum Show { Both, Code, Output, None } // What execution work is needed enum ExecutionNeed { Skip, // eval: false CacheHit(ExecutionAttempt), // hash matched cache MustExecute, // must re-run } // The result of running (or attempting to run) a chunk enum ExecutionAttempt { Success(ExecutionOutput), RuntimeError(RuntimeError), } // Visual state used in .typ output enum ChunkExecutionState { Ready, // cache hit or just executed Inert, // suspended: upstream error Pending, // compilation in progress (orange) Modified, // direct edit, pre-save (amber, thick) ModifiedCascade, // hash-cascade, pre-save (amber, thin) } }
In knot-lsp
#![allow(unused)] fn main() { // Whether Tinymist has received didOpen for the virtual .typ enum TinymistOverlay { Inactive, Active { next_version: u64 }, } }
The Three-Pass Pipeline
The compiler lives in crates/knot-core/src/compiler/. Understanding the three
passes is the prerequisite for almost any change to how Knot executes code.
Pass 1 — Planning (pipeline.rs)
Input: parsed Vec<Node> + cache
Output: Vec<PlannedNode> — every node annotated with ExecutionNeed
Planning does four things:
-
Resolve options for each chunk: merge global defaults, language defaults, and per-chunk
#|options into aResolvedChunkOptions. -
Compute a chained SHA-256 hash for each chunk in each language chain. The hash of chunk N covers:
- the chunk's source code
- its resolved options
- the hash of chunk N-1 in the same language
Because hashes chain, editing chunk 3 changes the hash of chunk 4, 5, 6, … even if their code is unchanged. This guarantees downstream re-execution.
-
Classify each chunk:
Skip—eval: falseoptionCacheHit(attempt)— hash matched a cache entryMustExecute— hash not in cache (new or changed)
-
Apply Phase0Mode when assembling the partial document (for live preview):
Phase0Mode::Pending(duringdo_compile): allMustExecutechunks getChunkExecutionState::Pending(orange border).Phase0Mode::Modified(duringdo_phase0_only): the firstMustExecuteper language chain getsModified(amber thick), subsequent ones getModifiedCascade(amber thin) — distinguishing direct edits from hash-cascade invalidations.
Pass 2 — Execution (execution.rs)
Input: Vec<PlannedNode> (only MustExecute nodes are processed)
Output: Vec<ExecutedNode> — results written to cache
Execution uses std::thread::scope to run R and Python chains in parallel:
group_by_language(planned_nodes)
├── R chain → thread A → run_language_chain(r_nodes)
└── Python chain → thread B → run_language_chain(python_nodes)
Within each run_language_chain:
- Iterate over
MustExecutenodes in document order. - For each node, call the executor (
RExecutororPythonExecutor). - Write the result to cache (success or error).
- If the result is an error, all subsequent nodes in the chain become
Inert(interpreter state is uncertain). - If
freezeobjects are declared,check_freeze_contractis called after each subsequentMustExecutenode — a hash mismatch also cascadesInert.
The ExecutorManager uses a take/put-back pattern so executors can be moved
into threads without lifetime issues.
SnapshotManager saves and restores interpreter state (the R/Python environment
just before each chunk). This allows re-executing chunk 5 in a 20-chunk document
without re-running chunks 1-4.
Pass 3 — Assembly (mod.rs)
Input: original Vec<Node> + execution results + cache
Output: a .typ string
Assembly interleaves prose and code-chunk outputs in document order. For each
node it calls format_node() in backend.rs, which:
- For prose: emits the text as-is (it is already valid Typst).
- For code chunks: calls
format_chunk()to wrap the output in the appropriate#code-chunk(...)call with the right state flags and options. - Embeds
#KNOT-SYNCmarkers for bidirectional source ↔ PDF navigation.
Streaming (two-phase API)
The compiler exposes a two-phase API on the Compiler struct for live preview:
#![allow(unused)] fn main() { // Phase 0: planning only (no code executed), returns partial .typ immediately fn plan_and_partial( &self, nodes: Vec<Node>, mode: Phase0Mode, ) -> Result<(Vec<PlannedNode>, Arc<Mutex<Cache>>, String)> // Phase 1: execution + streaming fn execute_and_assemble_streaming( &self, planned: Vec<PlannedNode>, cache: Arc<Mutex<Cache>>, progress: Option<Sender<ProgressEvent>>, ) -> Result<String> }
ProgressEvent carries the doc_idx of the completed node plus its
ExecutedNode. The LSP uses these to rebuild the .typ string and push
incremental updates to Tinymist after each chunk completes.
Entry points
For most purposes you will use the project-level API in project.rs:
#![allow(unused)] fn main() { // One-shot compilation (used by knot build / knot watch) pub fn compile_project_full( root: &Path, on_progress: Option<Box<dyn Fn(String) + Send>>, ) -> Result<ProjectOutput> // Phase 0 only (instant, used by LSP on didChange) pub fn compile_project_phase0(root: &Path, mode: Phase0Mode) -> Result<ProjectOutput> // Phase 0 with unsaved buffer (typing-time updates) pub fn compile_project_phase0_unsaved( root: &Path, unsaved_path: &Path, content: &str, mode: Phase0Mode, ) -> Result<ProjectOutput> }
Language Executors
Both language executors live in crates/knot-core/src/executors/ and follow
the same pattern. Understanding this pattern is the prerequisite for adding a
new language.
How an executor works
-
Spawn a subprocess running a persistent interpreter (R or Python). The interpreter loads an embedded helper script on startup (
resources/typst.Rorresources/typst.py), which defines thetypst()andcurrent_plot()functions. -
Before each execution, set environment variables in the child process:
Variable Purpose KNOT_METADATA_FILEPath to the side-channel JSON temp file KNOT_CACHE_DIRCache directory (for saving plot files) KNOT_FIG_WIDTHFigure width in inches KNOT_FIG_HEIGHTFigure height in inches KNOT_FIG_DPIDots per inch (for raster formats) KNOT_FIG_FORMATOutput format: svgorpngThese must be set in the child process environment, not the Rust process. In R:
Sys.setenv(KNOT_METADATA_FILE = ...). In Python:os.environ["KNOT_METADATA_FILE"] = .... -
Send the user code to the interpreter's stdin, followed by a sentinel.
-
Read stdout/stderr until the sentinel appears.
-
Read the side-channel JSON (
KNOT_METADATA_FILE) for rich output — plot file paths, DataFrame data, etc. -
Return an
ExecutionAttempt:Success(ExecutionOutput)orRuntimeError(RuntimeError).
The side-channel
The side-channel (executors/side_channel.rs) is a temporary JSON file that
lets the language runtime pass structured metadata back to Rust without
shell-escaping issues. After each chunk:
- The helper script writes to the JSON file if
typst()orcurrent_plot()was called. - The Rust executor reads and clears the file.
- The result becomes part of
ExecutionOutput.outputs.
The SnapshotManager
SnapshotManager (compiler/snapshot_manager.rs) wraps an executor and adds
environment snapshotting:
save_snapshot(chunk_hash)— serialises the interpreter environment to a file in.knot_cache/snapshots/.restore_snapshot(chunk_hash)— restores a previously saved environment.
This is what allows re-executing chunk 5 without re-running chunks 1–4: Knot restores the snapshot from just before chunk 5 ran, then re-executes chunk 5.
Adding a new language executor
Here is the checklist. All steps are in crates/knot-core/.
1. Add a helper script
Create resources/<lang>/typst.<ext> with at least a typst() function.
The function should:
- Check if
KNOT_METADATA_FILEis set. - Serialise its argument (text, DataFrame, or plot) to the side-channel.
- Fall back to printing if the variable is not set.
2. Create the executor
Create src/executors/<lang>/ with:
mod.rs— re-exportsexecution.rs— implementsExecutor:
#![allow(unused)] fn main() { pub trait Executor: Send { fn execute( &mut self, code: &str, options: &GraphicsOptions, cache_dir: &Path, ) -> Result<ExecutionAttempt>; fn save_environment(&mut self, path: &Path) -> Result<()>; fn restore_environment(&mut self, path: &Path) -> Result<()>; } }
3. Register the language
- Add a variant to
Languageinexecutors/mod.rs. - Add a case to
ExecutorManager::spawninexecutors/manager.rs. - Add a case to
group_by_languageincompiler/execution.rs.
4. Add parser support
- Add the language identifier to the chunk fence parser in
parser/winnow_parser.rs(thelanguagecombinator).
5. Add options
If your language needs language-specific default options, add a section to
ChunkOptions in parser/options.rs and to knot.toml parsing in
config.rs.
6. Write tests
Add snapshot tests in executors/<lang>/ and integration tests (marked
#[ignore] unless R/Python-free) in a tests/ module.
Adding a Chunk Option
Chunk options are the #| key: value lines at the top of a code chunk. They
control evaluation, display, figure dimensions, and more. This page shows how
to add a new one end-to-end.
We will add a hypothetical center-output: true option as a worked example.
Step 1 — Declare the option in ChunkOptions
crates/knot-core/src/parser/options.rs contains the ChunkOptions struct
(the raw parsed options) and ResolvedChunkOptions (after merging defaults).
Add your field to both:
#![allow(unused)] fn main() { // In ChunkOptions (raw, all Option<T>) pub center_output: Option<bool>, // In ResolvedChunkOptions (resolved, concrete type with a default) pub center_output: bool, }
Then update ResolvedChunkOptions::resolve() to merge from the option chain
(global defaults → language defaults → per-chunk):
#![allow(unused)] fn main() { center_output: per_chunk.center_output .or(lang_default.center_output) .or(global_default.center_output) .unwrap_or(false), }
Step 2 — Parse the YAML key
The parser in parser/winnow_parser.rs reads #| lines as YAML strings.
Option names use kebab-case in the source and are mapped to the ChunkOptions
fields in the apply_option function:
#![allow(unused)] fn main() { "center-output" => { options.center_output = Some( value.parse::<bool>().map_err(|_| ParseError::InvalidOptionValue { key: "center-output", value: value.to_string(), })? ); } }
Step 3 — Register metadata (drives completion + hover)
OptionMetadata in crates/knot-core/src/defaults.rs drives both the LSP
completion list and hover documentation. Add an entry:
#![allow(unused)] fn main() { OptionMetadata { name: "center-output", kind: OptionKind::Bool, default: "false", description: "Center the output block horizontally in the document.", }, }
Step 4 — Use the option in the backend
backend.rs renders each chunk node to Typst. In format_chunk(), pass the
new option as a parameter to the #code-chunk(...) call:
#![allow(unused)] fn main() { if options.center_output { lines.push(" center-output: true,".to_string()); } }
Step 5 — Handle it in lib/knot.typ
If the option affects rendering, add the corresponding logic to the
code-chunk function in lib/knot.typ. For center-output:
#let code-chunk(
// … existing params …
center-output: false,
body,
) = {
// …
if center-output {
align(center, output-block)
} else {
output-block
}
}
Step 6 — Add a snapshot test
Add a test case in crates/knot-core/src/backend.rs:
#![allow(unused)] fn main() { #[test] fn test_format_chunk_center_output() { let backend = Backend::new(BackendOptions::default()); let result = backend.format_chunk(&Node::CodeChunk { // … with center_output: true … }); assert_snapshot!(result); } }
Run INSTA_UPDATE=always cargo test -p knot-core to generate the snapshot.
Step 7 — Expose in knot.toml
If the option should be configurable globally or per-language, add it to
config.rs in the ChunkDefaults struct and handle it in load_config().
Document it in docs/book/src/chunk-options.md and
docs/book/src/configuration.md.
The Language Server
knot-lsp is a proxy LSP server. It sits between VS Code and Tinymist
(the official Typst Language Server), intercepting requests, translating
coordinates, and injecting Knot-specific features.
Architecture
[VS Code]
│ LSP (stdio)
▼
[knot-lsp]
├── Own handlers (completion, hover, formatting, diagnostics, preview)
│
└── [Tinymist subprocess]
│ LSP (stdio, internal)
▼
Typst analysis, symbol resolution, preview rendering
The key insight: Knot compiles .knot → virtual .typ. The editor works on
.knot files, but Tinymist only understands .typ. The LSP bridges this gap.
Coordinate translation
position_mapper.rs maintains a mapping between .knot line numbers and
virtual .typ line numbers. This mapping is rebuilt on every compile.
For every LSP request that carries a position (definition, hover, completion,
formatting), knot-lsp:
- Checks if the document is a
.knotfile. - Maps the
.knotposition to the corresponding.typposition. - Forwards the request to Tinymist with the translated position.
- Maps the response positions back to
.knotcoordinates.
The mapping is exposed by knot-core via compile_project_full's
ProjectOutput.source_map.
Own handlers
These features are handled entirely by knot-lsp without forwarding to
Tinymist:
| Handler | File | What it does |
|---|---|---|
| Completion | handlers/completion.rs | #| triggers chunk-option completion |
| Hover | handlers/hover.rs | Hover over option names shows docs from OptionMetadata |
| Formatting | handlers/formatting.rs | Air (R) + Ruff (Python) + Tinymist (Typst) |
| Diagnostics | diagnostics.rs | Merges parse errors + runtime errors from cache |
| Symbols | symbols.rs | Document symbols for the .knot file |
Preview lifecycle
Starting the preview
knot/startPreview (a custom LSP method) triggers:
compile_project_phase0— instant, no code runs.apply_update— sendstextDocument/didOpen(v=1) to Tinymist.tinymist.doStartPreview— starts a preview task on our Tinymist subprocess. This returns atask_idand astatic_server_port.- The port is stored in
ServerState.preview_info. - The response tells VS Code the URL to open:
http://127.0.0.1:{port}.
Why use our own Tinymist subprocess? The VS Code extension has its own Tinymist instance, but we cannot obtain its preview task ID. Our subprocess is under our control, so we can obtain both the task ID (needed for forward sync) and the static server port.
Compilation on save
did_save in server_impl.rs:
- Increments
compile_generation(stale-guard). - Spawns
do_compile(generation)in a background thread.
do_compile:
- Phase 0 (instant):
compile_project_phase0(Phase0Mode::Pending)→ orange placeholders →apply_update→ Tinymist sees the.typchange. - Streaming:
compile_project_full(path, Some(callback))— for each chunk that finishes,apply_update→ Tinymist. - Final: last
apply_update+refresh_diagnostics.
At every apply_update call, the generation is checked — if a newer didSave
has arrived, the in-flight compile is silently abandoned.
Phase 0 on change
did_change triggers do_phase0_only (not do_compile):
compile_project_phase0_unsaved(content, Phase0Mode::Modified)— uses the in-memory buffer so the preview updates while the user types.apply_update→ Tinymist.
This is what produces the amber borders while typing: modified chunks are
rendered with state flags is-modified or is-modified-cascade, which the
knot-state-styles in lib/knot.typ renders as amber borders.
Sync
Forward sync (source → PDF)
knot/syncForward receives the cursor's .knot line, maps it to a .typ
line via PositionMapper, then calls tinymist.scrollPreview on our
subprocess with the task_id from preview_info.
Backward sync (PDF → source)
Tinymist sends a window/showDocument notification when the user clicks in
the PDF. handle_tinymist_show_document in server_impl.rs:
- Receives the
.typfile path + line. - Maps the line back to a
.knotfile + line usingcompile_project_full's source map. - Sends
window/showDocumentto VS Code with the.knotcoordinates.
Adding a new LSP feature
- If it needs Tinymist: intercept the request in
proxy.rs, map coordinates withPositionMapper, forward, map the response back. - If it is Knot-specific: add a handler in
handlers/, register it inserver_impl.rs's request dispatch, and add any state toServerStateinstate.rs. - If it is a custom method (like
knot/startPreview): add a match arm in the custom-method dispatcher inserver_impl.rs.
Testing
Running the tests
# All unit tests (no R/Python required)
cargo test --workspace --exclude knot-cli
# Single crate
cargo test -p knot-core
# Include integration tests (requires R and Python installed)
cargo test --workspace
knot-cli is excluded by default because its integration tests run full
compilations and require live R and Python installations.
Snapshot tests
knot-core's backend tests use insta for snapshot testing.
All snapshots live in crates/knot-core/src/snapshots/ and
crates/knot-core/src/compiler/snapshots/.
Running and updating
# Run snapshot tests normally
cargo test -p knot-core
# Regenerate all snapshots after intentional changes
INSTA_UPDATE=always cargo test -p knot-core
# Review pending snapshot changes interactively
cargo insta review
When you add a new test using assert_snapshot!:
- Write the test with the
assert_snapshot!call. - Run
INSTA_UPDATE=always cargo test -p knot-coreonce. - Verify the generated
.snapfile looks correct. - Commit both the test and the
.snapfile.
What to snapshot test
- Any function in
backend.rsthat produces.typtext. - Any assembler output in
compiler/mod.rs. - Parser output for representative inputs.
Integration tests
Integration tests in knot-cli/tests/ compile actual .knot documents and
verify the output. They are marked #[ignore] so they do not run in CI (which
does not have R or Python). Run them manually:
cargo test -p knot-cli -- --ignored
Test organisation conventions
- Test functions are named
<thing>_should_<expectation_when_condition>, for exampleformat_chunk_should_omit_stroke_when_no_border_option. - Each test has one assertion (or one
assert_snapshot!call). - Tests that require external tools are
#[ignore]with a comment explaining the dependency.
Current coverage gaps
The most critical gap (tracked as Technical Debt A in the master plan) is that
the R and Python executor code paths are covered only by #[ignore] tests.
The execution pipeline itself — pipeline.rs, execution.rs, freeze.rs —
is exercised only by integration tests.
If you add new execution logic, consider whether it can be tested with a mock
executor that returns fixed ExecutionAttempt values without spawning a
real interpreter.
Contributing
Development setup
Prerequisites
| Tool | Version | Purpose |
|---|---|---|
| Rust | 1.80+ | Build all crates |
| Node.js | 20+ | Build the VS Code extension |
| Typst | latest | Document rendering in tests |
| Tinymist | latest | Needed by the LSP |
| R | 4.0+ | Run R executor tests |
| Python | 3.8+ | Run Python executor tests |
R and Python are optional for unit tests but required for integration tests.
Clone and build
git clone https://github.com/knot-literate-programming/knot.git
cd knot
cargo build --release
Binaries: target/release/knot and target/release/knot-lsp.
Build the VS Code extension
cd editors/vscode
npm install
npm run compile
Install a development build into VS Code:
bash scripts/install-vscode-dev.sh
This packages the extension into a .vsix file and installs it via
code --install-extension. Restart VS Code to activate.
Before opening a PR
Run the same checks as CI, in this order:
# 1. Check & Lint
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
# 2. Tests (excluding knot-cli integration tests)
cargo test --workspace --exclude knot-cli
# 3. VS Code extension
cd editors/vscode && npm ci && npm run compile
All three must pass with no warnings before pushing.
Commit conventions
Knot uses Conventional Commits:
feat: short description of the new feature
fix: description of the bug that was fixed
docs: documentation change
refactor: code change without behaviour change
test: add or change tests
chore: build system, dependencies, CI
Scope is optional but helpful for larger PRs:
feat(lsp): add Go to Definition for Typst symbols
fix(executor): set KNOT_FIG_FORMAT env var in Python process
Project structure recap
crates/
knot-core/
src/
parser/ # Winnow-based .knot parser
compiler/ # Three-pass pipeline
pipeline.rs # Pass 1: planning + hashing
execution.rs # Pass 2: parallel execution
mod.rs # Pass 3: assembly + two-phase API
freeze.rs # Freeze contract checking
snapshot_manager.rs
node_output.rs
options.rs
sync.rs # #KNOT-SYNC markers
executors/ # R and Python subprocesses
cache/ # SHA-256 addressed persistent cache
backend.rs # Node → .typ text rendering
project.rs # Top-level compile_project_* API
config.rs # knot.toml parsing
knot-cli/
src/main.rs # CLI command dispatch
knot-lsp/
src/
server_impl.rs # Request/notification handlers
state.rs # ServerState (Arc<RwLock<>>)
proxy.rs # Forward to Tinymist
position_mapper.rs
diagnostics.rs
handlers/ # Completion, hover, formatting
sync.rs # Forward/backward sync
editors/
vscode/
src/
extension.ts # Activation, preview lifecycle
projectExplorer.ts
resources/
typst.R # Embedded R helper (typst(), current_plot())
typst.py # Embedded Python helper
Adding a new language executor
See Language Executors for the complete checklist.
Adding a chunk option
See Adding a Chunk Option for the complete walkthrough.
Releasing
Releases are automated via cargo-dist. Pushing a version tag triggers GitHub
Actions to build binaries for all supported platforms and publish a GitHub
Release with the binaries and the VS Code .vsix.
# Bump version, tag, and push (triggers CI + release)
git tag v0.4.0
git push origin v0.4.0