IPU Assembly — VS Code extension
Editing support for IPU VLIW assembly (.asm, .asm.j2): highlighting,
completion, hover, navigation, rename, formatting and assembler diagnostics,
plus Run/Debug/Test buttons and an IPU sidebar for the kernel library.
Anything that runs a process needs an IPU checkout: the nearest Bazel workspace
above the file holding src/tools/ipu-as-py, with the open folder at, inside,
or up to three levels above it; switching checkouts is picked up live.
Elsewhere a .asm is only coloured.
Where its knowledge comes from
| Knowledge |
Source |
When |
Token shapes, comments, ; / ;;, labels |
asm_grammar.lark terminals |
extension build |
| Instructions, their slots, slots per word, operands, docs |
instruction_spec.py |
extension build |
What each register is; CR0/CR1 fixed values |
ipu_common.registers, ipu_emu.ipu_config |
extension build |
| What each operand type accepts (registers, enums, ranges) |
the assembler's token classes, completion_domain() |
extension build |
| Which names ignore case |
probe programs run through the assembler |
extension build |
| Kernels, families and their Bazel targets |
ipu_app.bzl (:kernel_targets) |
runtime, from the workspace |
| Cases, options, operations |
the kernel registry (:kernel_manifest) |
runtime, from the workspace |
| Which kernel handles a computation |
query --json |
runtime, from the workspace |
| Whether a program assembles |
ipu-as check |
runtime, from the workspace |
None of it is hand-written. Build-time knowledge is bundled as data/isa.json;
runtime knowledge comes from the open checkout, so branch-only kernels show up.
Generated, not written
The TextMate grammar in syntaxes/ is generated by ipu_as/gen_vscode.py from
two sources, and it is worth being precise about which is which:
- Structure — comments,
; / ;;, :, the identifier shape — from
get_parser().terminals via pattern.to_regexp(). No token text is restated.
- Vocabulary — which identifiers are instructions or registers — from
instruction_spec.py and the register enums.
The vocabulary cannot come from the parser: asm_grammar.lark has a single
catch-all TOKEN terminal, so the parser genuinely does not distinguish BKPT
from lr0 from my_label. Neither layer is written by hand; both are
enumerated.
This matters because the previous, hand-written grammar drifted from the
language: it coloured BEQ lr0, cr0, +1;; as valid (commas are a lex error) and
failed to recognise end: in BKPT;; end: BKPT;; as a label. Nothing detected
either, because nothing compared the grammar to the parser. That is what the
agreement test below now does.
Edits reflect automatically on the next build:
| Change |
Effect |
A terminal's value in asm_grammar.lark |
picked up automatically |
An instruction added to instruction_spec.py |
picked up automatically |
A new terminal named COMMENT_* / STRING_* / PUNCT_* / WS_* |
scoped by convention |
| A new terminal with an unrecognised name |
build fails, naming the terminal |
The last row is the one manual step, and it is deliberate: nothing can guess
what colour a brand-new lexical concept should be, so the build stops at the
moment of the change rather than quietly leaving it unhighlighted.
Regenerate
bazel run //vscode-ipu-asm:gen_vscode
syntaxes/ipu-asm.tmLanguage.json, language-configuration.json and
data/isa.json are build outputs and are not committed.
The agreement test
test/agreement.js tokenizes a corpus twice — once with the Lark parser, once
with the exact engine VS Code ships (vscode-textmate + vscode-oniguruma) —
and fails on any disagreement about a token's boundaries or role. It runs in
.github/workflows/vscode-extension.yml.
The Jinja layer is checked on the raw files: every {# … #}, {% … %} and
{{ … }} (even in an assembly comment) must carry its Jinja scope and nothing
else may; the regions are cross-checked against Jinja's own lexer.
Kernels aside, test/corpus/edge_cases.asm covers constructs they happen not to
use (mid-line labels, labels spelled like mnemonics, every numeric base). It
must keep assembling; a change that breaks it is a real grammar regression.
The other tests
| Test |
Checks |
test/checker.js |
which checker runs, when the prebuilt one is stale, and how a process that runs too long is stopped |
test/project.js |
which IPU checkout a file or an open folder belongs to, if any |
test/language.js <isa.json> [parser-tokens.json] |
completion and navigation logic, and agreement with the parser on the corpus |
test/runs.js [manifest.json] |
the run, test, debug, benchmark and query commands, against the real manifest |
test/rewrites.js + check_rewrites |
renaming each label and Jinja symbol, and formatting, leaves the assembled binary unchanged |
test/integration/run.js |
the extension in a real VS Code, then in a parent folder, another project and a pre-manifest checkout |
All run in the workflow. The integration test needs @vscode/test-electron
(installed without saving) and a display (xvfb-run -a on a server); set
IPU_TEST_CHECK_COMMAND to a checker argv to include checker-backed tests.
Publishing and versions
Every push to master that touches the extension or its inputs republishes it.
The patch number is the workflow run number, applied on the runner only —
the version in package.json is just the major/minor base.
Nothing is written back to the repository. A bumped package.json committed to
master would land under this workflow's own paths filter and retrigger it,
so the version is set at build time and thrown away.
Marketplace publishing needs a VSCE_PAT repository secret: an Azure DevOps
personal access token scoped to Marketplace → Manage, issued for all
accessible organizations. Without it the Marketplace step logs a note and skips
— a missing secret should not fail an otherwise good build — and the GitHub
release below still happens.
Install
Every push to master that changes the extension or the sources its language
data is generated from republishes the vscode-latest prerelease:
curl -LO https://github.com/rechefe/ipu-emulator/releases/download/vscode-latest/ipu-asm.vsix
code --install-extension ipu-asm.vsix
Features
- Hover: an instruction's reference, a register's role, a Jinja name's value.
- Completion: at an instruction start, the mnemonics that still fit the
word (slots filled as
CompoundInst._fill_instructions does), each bringing
its operands as tab stops named as the instruction spec names them; at an
operand, what its type accepts, labels and fitting Jinja names, with an
operand hint.
A typed space opens it only at an operand, so Enter still ends a line.
Templates are read as one rendering ({% if %} branches are alternatives).
- Values inline:
{{ lr_row }} shows the literal value in force there
(lr1), as Jinja scopes it.
- Unused names faded: a label nothing branches to, or a
set nothing
reads. A name written anywhere else in the file counts as used.
- Navigation and rename for labels and Jinja
set / macro / for names
and parameters, scoped as Jinja scopes them. Rename refuses a clash or a
change that the assembler reports as a new problem (without the assembler it
proceeds on the lexical check and says so). Outline and folding too.
- Formatting: a label at column 0 on its own line, instructions one indent
in, one
;; word per line; nothing but that whitespace changes, and every
kernel is already formatted. Typing indents after a label, splits code after
a typed ;; and moves a label to column 0 on :.
- Buttons on a kernel's
.asm: Run (bazel run <target> -- --case default),
Debug (the same with --config=debug), Test (bazel test <target>) and
Benchmark if it has one. They run in a terminal named after the kernel,
reused only when its shell is idle.
- Cases, in the sidebar (a case's pencil, a kernel's +): a form with a
field per option, typed as the runner parses it, to run or debug a case with other values, edit a registry case, or add one that
starts from a registry case. Saved cases go to
ipuAsm.cases; cases.py is
never written.
- IPU sidebar: family, kernel and case with the same actions, and a query
that asks the registry which kernel handles an operation. Both read
//src/tools/ipu-apps:kernel_manifest, which joins Bazel's targets with the
registry's cases and refuses if they disagree. A checkout's manifest is first
built when something needs it (the sidebar showing it, a kernel's .asm open,
a command), then reloads when kernels or build files change. With several checkouts, each row acts on its own. While
it is open, it selects the kernel whose .asm is in the editor.
Settings
| Setting |
Default |
Purpose |
ipuAsm.checkCommand |
Bazel |
the checker, as an argv (e.g. a virtualenv's ipu-as check --json --input) |
ipuAsm.manifestCommand |
Bazel |
the command that prints the kernel manifest |
ipuAsm.queryCommand |
Bazel |
the query command; --json, the operation and parameters are appended |
ipuAsm.bazelFlags |
none |
extra flags for Run, Debug, Test and Benchmark, e.g. --define=ipu_proto=1 |
ipuAsm.cases |
none |
cases saved from the form, by kernel: { "tall": { "base": "default", "options": { "rows": 64 } } } |
ipuAsm.timeoutSeconds |
300 |
the longest the checker, the manifest or a query may run before it is stopped; 0 for no limit |
ipuAsm.wordSeparation |
space |
how ;; words are set apart (a label, comment or opening Jinja tag stays with its word): space (an empty CodeLens row; file unchanged), emptyLine (written by formatting), line (colour ipuAsm.wordSeparator) or none |
ipuAsm.diagnostics |
true |
on/off switches, one per feature: the assembler's errors as you type |
ipuAsm.hover |
true |
hover on instructions, registers and Jinja names |
ipuAsm.completion |
true |
completion |
ipuAsm.operandHints |
true |
operand hints (signature help) |
ipuAsm.jinjaValueHints |
true |
a set name's literal value after each {{ name }}: {{ lr_row }} lr1 |
ipuAsm.unusedNames |
true |
fading unused labels and set names |
ipuAsm.navigation |
true |
Go to Definition, Find References and Rename |
ipuAsm.outline |
true |
the Outline and breadcrumbs |
ipuAsm.folding |
true |
folding of comments and Jinja blocks |
ipuAsm.formatting |
true |
Format Document and format on type |
ipuAsm.kernelButtons |
true |
Run, Debug, Test and Benchmark buttons on a kernel's .asm |
ipuAsm.sidebar |
true |
the IPU sidebar |
Diagnostics
The grammar colours tokens; it cannot say a program is wrong. A TextMate rule
assigns scopes to spans and has no notion of what the grammar expects next, and
validity is not a lexical property — whether a comma is legal depends on the
parser's state, not on the characters. BEQ lr0, cr0, +1;; would be coloured
perfectly normally.
So extension.js runs the real assembler and turns its errors into squiggles,
covering all three stages:
|
Example |
Reported at |
| template |
{% for x in %} |
the template line |
| parse |
BEQ lr0, cr0, +1;; |
the comma |
| encode |
BGT lr0 lr1;;, mac.ee r0;; |
the mnemonic |
Templates render in Jinja's sandbox, as the assembler does, because a file is
checked as soon as it opens; the extension is also disabled in Restricted Mode.
A template that expects harness values should give them a | default(...).
It shells out to ipu-as check --json rather than speaking LSP, which keeps the
extension dependency-free — no vscode-languageclient, no bundled
node_modules, and the vsix stays a few kilobytes.
By default the extension builds //src/tools/ipu-as-py:ipu-as in the background
on a checkout's first check, then runs the resulting binary directly. This avoids
Bazel's client startup and workspace lock while typing. Before every check it
compares the binary with the assembler sources; if the binary is missing or the
sources are newer, that check falls back to bazel run, which rebuilds it, and
later checks use the binary directly again.
test/checker.js covers this selection and freshness rule and runs in
.github/workflows/vscode-extension.yml.
To bypass Bazel entirely, point ipuAsm.checkCommand at a virtualenv instead:
"ipuAsm.checkCommand": [".venv/bin/ipu-as", "check", "--json", "--input"]
The file path is appended to whatever you configure.
When diagnostics are not running
A broken toolchain must not paint the file red — that would be a diagnostic
about the environment masquerading as one about the code. But it must not be
silent either: "checker unavailable" and "your code is fine" look identical, and
silence is the worse failure.
So a status bar item reports which state you are in — $(check) IPU when
diagnostics are live, $(warning) IPU: checks off when they are not. Clicking
it, or running IPU Assembly: Why are diagnostics not running?, gives the
reason.
The most common reason: ipu-as check is added by the same change as this
extension, so a workspace on a branch predating it has no check subcommand
and diagnostics cannot run there. Highlighting still works — that is
self-contained in the vsix.
ipu-as check is a normal CLI too: it exits 1 on problems and prints
file:line:col: error: …, so it works in a pre-commit hook or CI.
Positions inside Jinja templates
The parser only ever sees rendered text, so a byte offset in what it parsed has
no general relationship to the file on screen — loops repeat lines, conditionals
drop them, whitespace control joins them.
Rather than guess afterwards by matching line content, the checker renders the
template a second time with each line tagged by its own line number. The tags
are # comments, so they are invisible to the parser, and Jinja carries them
through whatever it does to the text — so a diagnostic names the source line
exactly, including inside a loop body or below a block of {%- set -%}
aliases. The tagged render is used only when it is equivalent to the real one.
Where a construct cannot be tagged safely, or an error carries no position at
all, the diagnostic says so with (position approximate) rather than implying
byte accuracy.