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
SnapshotsHeavy — large objects included in each oneLight — large objects excluded
Disk usageGrows with number of downstream chunksObject stored once
Snapshot restoreOne large file to readSmall snapshot + separate object load
ConstraintNoneObject 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:

  1. Serialises each named object into content-addressed storage (.knot_cache/objects/{hash}.ext) immediately after the chunk executes.
  2. Excludes those objects from all subsequent snapshots, keeping them lightweight.
  3. 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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 .tex output — 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.
KnotRMarkdownQuarto
Typesetting engineTypst onlyLaTeX / HTMLLaTeX / HTML / others
MaturityEarlyMatureMature
Supported languagesR, PythonR (+ reticulate)R, Python, Julia, others
Output formatsPDF (via Typst)ManyMany
Execution orderAlways linearLinear at render, non-linear interactivelyLinear at render, non-linear interactively
CachingChained SHA-256knitr cache (per chunk)Freeze / cache
Live previewStreaming, per-chunkNone / slowLimited
Bidirectional syncYesNoPartial

Installation

Quick install

curl -sSf https://raw.githubusercontent.com/knot-literate-programming/knot/master/install.sh | bash

This script:

  1. Detects your platform (macOS arm64/x86_64, Linux x86_64/arm64).
  2. Downloads the prebuilt knot and knot-lsp binaries from the latest release.
  3. Installs them to ~/.local/bin (override with --prefix DIR).
  4. Installs the VS Code extension if the code command is available.
  5. Checks that all prerequisites are present and tells you what is missing.

Prerequisites

Required

ToolPurposeInstall
TypstCompiles .typ → PDFSee below
TinymistPowers the VS Code live previewSee 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 PATH independently.

Per language (install what you use)

ToolPurposeInstall
RExecute R chunksCRAN
AirFormat R code in VS CodeSee Air docs
Python 3Execute Python chunkspython.org, conda, pyenv…
RuffFormat Python code in VS Codepip 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:

  1. 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).

  2. 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.

  3. 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

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:

ValueCode block shownOutput shown
"both" (default)YesYes
"code"YesNo
"output"NoYes
"none"NoNo
```{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.14 becomes 3.14. Quoted strings have their quotes removed. [1] "Alice" becomes Alice.
  • 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

OptionTypeDefaultDescription
evalbooltrueIf false, the chunk is not executed and produces no output.
cachebooltrueIf false, the chunk always re-executes even if its hash has not changed.
freezelist[]Object names whose xxHash64 fingerprint must not change after this chunk. See The Freeze Contract.

Display control

OptionTypeDefaultDescription
showstring"both"What to display: "both", "code", "output", "none".
echobooltrueAlias for show: "output" when false. Kept for compatibility.

Labelling and captions

OptionTypeDefaultDescription
labelstringChunk identifier. Used as a Typst label (<label>) for cross-referencing.
fig-capstringCaption for the figure wrapper (enables Typst #figure).

Figure sizing

OptionTypeDefaultDescription
fig-widthnumber6Figure width in inches.
fig-heightnumber4Figure height in inches.
fig-dpinumber150Resolution in dots per inch (raster formats).
fig-formatstring"svg"Output format: "svg" or "png".

Warnings

OptionTypeDefaultDescription
warningbooltrueWhether to capture and display R/Python warnings.
warning-posstring"below"Where to show warnings: "above" or "below" the output.

Layout

OptionTypeDefaultDescription
layoutstring"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

OptionTypeDefaultDescription
dependslist[]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

OptionDefaultDescription
fig-width6Width in inches
fig-height4Height in inches
fig-dpi150Resolution (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:

  1. Built-in Knot defaults
  2. [chunk-defaults] in knot.toml
  3. [r-chunks] or [python-chunks] in knot.toml
  4. Per-chunk #| options in the .knot file

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

  1. 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.

  2. 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.

  3. Final: once all chunks have executed, diagnostics are refreshed.

What the border styles mean

StyleMeaning
NoneOutput is current (cache hit or just executed)
5 pt amber dottedChunk you edited directly — awaiting save
1 pt amber dashedDownstream hash-cascade invalidation — awaiting save
2 pt orange solidCompile in progress — chunk queued for execution
White semi-transparent overlayInert — 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 .typ representation).

Formatting

Save with formatting enabled (editor.formatOnSave: true) to format:

  • R code with Air
  • Python code with Ruff
  • Typst source with Tinymist's built-in formatter

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

SettingDefaultDescription
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:

  1. Re-compiles changed chunks (using the cache for unchanged ones).
  2. Writes the updated .typ file.
  3. The background typst watch process picks up the new .typ and 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 .knot files into an AST of Node values — 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): Renders Nodes into .typ text.
  • 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 .knot coordinates to virtual .typ coordinates.
  • Adds chunk-option completion, hover docs, hybrid formatting, and diagnostics.
  • Manages streaming preview via knot/startPreview and knot/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:

  1. Resolve options for each chunk: merge global defaults, language defaults, and per-chunk #| options into a ResolvedChunkOptions.

  2. 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.

  3. Classify each chunk:

    • Skipeval: false option
    • CacheHit(attempt) — hash matched a cache entry
    • MustExecute — hash not in cache (new or changed)
  4. Apply Phase0Mode when assembling the partial document (for live preview):

    • Phase0Mode::Pending (during do_compile): all MustExecute chunks get ChunkExecutionState::Pending (orange border).
    • Phase0Mode::Modified (during do_phase0_only): the first MustExecute per language chain gets Modified (amber thick), subsequent ones get ModifiedCascade (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:

  1. Iterate over MustExecute nodes in document order.
  2. For each node, call the executor (RExecutor or PythonExecutor).
  3. Write the result to cache (success or error).
  4. If the result is an error, all subsequent nodes in the chain become Inert (interpreter state is uncertain).
  5. If freeze objects are declared, check_freeze_contract is called after each subsequent MustExecute node — a hash mismatch also cascades Inert.

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-SYNC markers 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

  1. Spawn a subprocess running a persistent interpreter (R or Python). The interpreter loads an embedded helper script on startup (resources/typst.R or resources/typst.py), which defines the typst() and current_plot() functions.

  2. Before each execution, set environment variables in the child process:

    VariablePurpose
    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: svg or png

    These 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"] = ....

  3. Send the user code to the interpreter's stdin, followed by a sentinel.

  4. Read stdout/stderr until the sentinel appears.

  5. Read the side-channel JSON (KNOT_METADATA_FILE) for rich output — plot file paths, DataFrame data, etc.

  6. Return an ExecutionAttempt: Success(ExecutionOutput) or RuntimeError(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() or current_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:

  1. Check if KNOT_METADATA_FILE is set.
  2. Serialise its argument (text, DataFrame, or plot) to the side-channel.
  3. Fall back to printing if the variable is not set.

2. Create the executor

Create src/executors/<lang>/ with:

  • mod.rs — re-exports
  • execution.rs — implements Executor:
#![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 Language in executors/mod.rs.
  • Add a case to ExecutorManager::spawn in executors/manager.rs.
  • Add a case to group_by_language in compiler/execution.rs.

4. Add parser support

  • Add the language identifier to the chunk fence parser in parser/winnow_parser.rs (the language combinator).

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:

  1. Checks if the document is a .knot file.
  2. Maps the .knot position to the corresponding .typ position.
  3. Forwards the request to Tinymist with the translated position.
  4. Maps the response positions back to .knot coordinates.

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:

HandlerFileWhat it does
Completionhandlers/completion.rs#| triggers chunk-option completion
Hoverhandlers/hover.rsHover over option names shows docs from OptionMetadata
Formattinghandlers/formatting.rsAir (R) + Ruff (Python) + Tinymist (Typst)
Diagnosticsdiagnostics.rsMerges parse errors + runtime errors from cache
Symbolssymbols.rsDocument symbols for the .knot file

Preview lifecycle

Starting the preview

knot/startPreview (a custom LSP method) triggers:

  1. compile_project_phase0 — instant, no code runs.
  2. apply_update — sends textDocument/didOpen (v=1) to Tinymist.
  3. tinymist.doStartPreview — starts a preview task on our Tinymist subprocess. This returns a task_id and a static_server_port.
  4. The port is stored in ServerState.preview_info.
  5. 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:

  1. Increments compile_generation (stale-guard).
  2. Spawns do_compile(generation) in a background thread.

do_compile:

  1. Phase 0 (instant): compile_project_phase0(Phase0Mode::Pending) → orange placeholders → apply_update → Tinymist sees the .typ change.
  2. Streaming: compile_project_full(path, Some(callback)) — for each chunk that finishes, apply_update → Tinymist.
  3. 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):

  1. compile_project_phase0_unsaved(content, Phase0Mode::Modified) — uses the in-memory buffer so the preview updates while the user types.
  2. 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:

  1. Receives the .typ file path + line.
  2. Maps the line back to a .knot file + line using compile_project_full's source map.
  3. Sends window/showDocument to VS Code with the .knot coordinates.

Adding a new LSP feature

  1. If it needs Tinymist: intercept the request in proxy.rs, map coordinates with PositionMapper, forward, map the response back.
  2. If it is Knot-specific: add a handler in handlers/, register it in server_impl.rs's request dispatch, and add any state to ServerState in state.rs.
  3. If it is a custom method (like knot/startPreview): add a match arm in the custom-method dispatcher in server_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!:

  1. Write the test with the assert_snapshot! call.
  2. Run INSTA_UPDATE=always cargo test -p knot-core once.
  3. Verify the generated .snap file looks correct.
  4. Commit both the test and the .snap file.

What to snapshot test

  • Any function in backend.rs that produces .typ text.
  • 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 example format_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

ToolVersionPurpose
Rust1.80+Build all crates
Node.js20+Build the VS Code extension
TypstlatestDocument rendering in tests
TinymistlatestNeeded by the LSP
R4.0+Run R executor tests
Python3.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