The Language Server
FantASM is also a language server, so an editor can show you errors as you type, jump to a label’s definition, rename one across the project, and tell you what an instruction costs in T-states. It is the same binary — no separate download, and no second copy of the assembler that might disagree with the one building your program.
fantasm --lspStartup
Syntax: fantasm --lsp
--lsp starts the server, which speaks JSON-RPC over stdin and stdout, as every LSP client expects to talk to one.
It is handled before the banner would be printed, and logging goes to stderr, so stdout carries protocol and nothing else. --nologo is harmless but does nothing here. An editor starts this for you; you never run it by hand except to check the binary works.
Capabilities
| Diagnostics | Errors and warnings, as you type |
| Go to definition | Jump to where a label, constant or macro is defined |
| Find references | Every place a name is used |
| Hover | What a name is worth, or what an instruction costs |
| Completion | Instructions, directives, and your own names |
| Workspace symbols | Search every symbol in the project |
| Document symbols | This file’s outline, with local labels under the routine they belong to |
| Document highlight | Every use of the name under the cursor, in this file |
| Rename | A name and its uses, across the project |
| Call hierarchy | What reaches a routine, and what it reaches |
| Code actions | A quick fix where a diagnostic’s hint names a replacement |
| Document links | An INCLUDE’s file name, clickable |
| Folding ranges | MACRO, STRUCT, ENUM, MODULE, INTERFACE, REP and !test bodies |
| Inlay hints | A constant’s value beside the name |
| Signature help | The operand forms an instruction accepts |
| Document formatting | Whitespace, to what your editor sends |
Diagnostics
The file is assembled on every keystroke and the result reported in place — the same errors and warnings fantasm build produces, from the same code.
A diagnostic lands in the file it came from. An error inside an included file is reported against that file, at its own line, not against the include line that pulled it in.
Only errors and warnings are marked. Build tracing — the Info: lines fantasm -v prints, such as which file an include resolved to — never reaches the editor, since there is no way to ask for it there and a mark on correct code is worse than no mark at all.
!message and a print in a !rhai block are yours rather than the assembler’s, so they still arrive, in the language server’s output pane with the file and line they came from. They do not underline your source.
Hover
A label or constant answers with its value and where it was defined:
Constant: MY_CONST = 100 ($64)
test.asm, line 2An instruction answers with its timing, including the forms that have two answers:
Instruction: JR
Timing: 12/7 T-states (taken/not taken)Instruction: LDIR
Timing: 21/16 T-states (repeated/final)That is the same timing data assert_cycles checks against in Testing.
Completion
Instructions and directives are offered from a fixed list; labels, constants and macros come from your source as it stands. . triggers a completion, so typing Gfx. offers that module’s members — see Modules.
Signature Help
Triggered by a space or a comma, so it appears as you start an operand and again after each one.
Rename
Renames a name and every use of it, across the project rather than the open file. Your editor asks first, and a position that is not a name it can rename says so instead of offering an edit that would do nothing.
A local label is renamed within its own routine. .loop under draw and .loop under clear are different labels, and renaming one leaves the other alone. Writing jp draw.loop from elsewhere is a use of the first.
Call Hierarchy
What reaches a routine, and what that routine reaches. Your editor draws it as a tree you can open downwards.
It is a reference hierarchy, and each entry says so. The graph records that a name was reached from a scope rather than how — a call, a jp, an address loaded into hl, and a name in a db are the same edge. Read it as what mentions this, which is what makes it useful for finding out whether anything still does.
Code Actions
Where a diagnostic’s hint names a replacement, it is offered as a quick fix. E1003 on a name a module cannot see suggests the @NAME form; a deprecated spelling (W1103) offers the one that replaced it.
Nothing is offered for a diagnostic whose hint is advice rather than a replacement, since there would be nothing to apply.
Document Links, Folding and Inlay Hints
An INCLUDE’s file name is a link, resolved the same way the assembler resolves it — so a link that does not open is the same missing file the build would report.
MACRO, STRUCT, ENUM, MODULE, INTERFACE, REP and !test bodies fold. Folding reads the text rather than the assembled program, so a block inside a false IFDEF still folds.
A constant’s value is shown beside its name as an inlay hint.
Formatting
Whole documents, and whitespace only. Your editor’s Format Document command lays the file out, and Format on Save answers the same edits before the file is written — the server waits to be asked, so nothing at the client end needs configuring beyond turning it on.
What your editor sends is the whole of what is honoured:
| setting | |
|---|---|
tab_size | how wide a tab is |
insert_spaces | whether an indent is written with spaces or tabs |
trim_trailing_whitespace | remove whitespace at the end of a line |
insert_final_newline | end the file with a newline |
trim_final_newlines | remove blank lines after the last one |
In VSCodium and VS Code those are editor.tabSize, editor.insertSpaces, and the three files.* settings of the same names. Turn format-on-save on with editor.formatOnSave.
An indent is re-rendered rather than re-decided. The indent a line already has is measured and written again with the character and width you asked for. Nothing decides how deep a line ought to be, so a MACRO body is laid out exactly as you left it and a hand-aligned table survives.
What it deliberately does not do: no casing, so LD and ld are left as you wrote them; no column alignment, so a tab between operands is untouched. And nothing is read from fantasm.toml — there is no [format] table, and a project cannot yet state a house style.
A line that does not parse is left exactly as it is, and so is one ending with a string literal still open. A file being edited is unterminated more often than not, and the whitespace after an unclosed quote belongs to the string rather than to the layout. Line endings are never changed either: trimming stops at the \r, so a CRLF file stays one.
Column 0
FantASM decides what a line means by whether it starts at column 0, so the formatter never creates an indent where there was none, and never removes the last of one where there was some.
⚠ The second is the direction that would bite. A label reaching an indent is E1093 and stops the build, which you would notice. A macro name reaching column 0 defines a label instead of expanding — W1094, a warning, exit code unchanged, and the output silently shorter.
VSCodium and VS Code
Install 0x1DE from Open VSX (twistedraven.0x1de). It starts the language server for you and adds the rest of the environment around it:
| Syntax highlighting | A TextMate grammar and semantic tokens for the fantasm language |
| Snippets | For .asm and .inc files |
| Build task | Task type fantasm, taking a file and an optional target |
| Problem matcher | Named fantasm, so build errors land in the Problems panel |
| Debugging | Breakpoints and stepping under Bizmuth — see Debugging a Build |
Three commands: Assemble (FantASM), Run on Bizmuth, and Restart FantASM Language Server.
Settings, if the defaults do not suit:
| Setting | Default |
|---|---|
0x1de.fantasm.serverPath | fantasm — an absolute path if it is not on your PATH |
0x1de.fantasm.serverArgs | ["--lsp", "--nologo"] |
0x1de.fantasm.target | zxnext — passed as -t by the build task |
Both spellings there are the ones 2.0 retired. -t still works and warns once (W1103), so an extension-driven build now carries a deprecation line until 0x1DE moves to -d. The initializationOptions key stays target deliberately — it is the protocol the extension already speaks, and renaming it would break every client at once.
The extension is BSD-3-Clause, and its source is at codeberg.org/TwistedRaven/0x1DE.
Other Editors
Any LSP client will do. The command is fantasm --lsp, and the file extensions are whatever your project uses — .asm, .inc, .z80.
Neovim’s built-in client, as an example:
vim.lsp.start({
name = 'fantasm',
cmd = { 'fantasm', '--lsp' },
root_dir = vim.fs.dirname(vim.fs.find({ 'fantasm.toml', '.git' }, { upward = true })[1]),
init_options = { target = 'zxnext' },
})init_options is optional where your fantasm.toml sets device — see Honoured Settings. Without either, the server assembles as a bare zx48, and a Next project shows E1016 Z80N instructions are not enabled on every nextreg and mul while the build succeeds on the same lines. What the client sends is read once at startup, so changing it means restarting the server; the project file is re-read as you type.
Format-on-save is the client’s to arrange. In Neovim it is one autocommand:
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = { '*.asm', '*.inc', '*.z80' },
callback = function() vim.lsp.buf.format() end,
})⚠ vim.lsp.buf.format() sends tab_size and insert_spaces and none of the other three, taking them from the buffer’s shiftwidth and expandtab. Trailing whitespace and the final newline are then left alone — set them in your editor if you want them, rather than expecting the server to.
You get diagnostics, hover, completion, rename, call hierarchy and the rest — everything in the table above your client implements — but none of the highlighting, snippets or debugging, which are the extension’s rather than the server’s.
Project Discovery
The project root is the nearest directory at or above the open file containing fantasm.toml, .git or Cargo.toml. Nearest wins, so a fantasm.toml inside a checkout beats the .git above it.
include and incbin are then searched for in two places: the open file’s own directory, and that project root.
It is worked out per file, so a tree holding several projects gives each file the project it is in. Open hello/src/hello.asm and then console/src/console.asm and the second is searched under console/, whichever you opened first.
If nothing above a file is marked as a project, the folder your editor was opened on is used instead.
Honoured Settings
fantasm.toml is read, and its [assembler] section applies to what the editor shows you. include_dirs is added to the two directories above, so a project keeping headers in a sibling directory resolves them in the editor as it does on the command line. device, z80n, cspect, case_insensitive and origin each change what a diagnostic says, and all of them arrive. So does [defines].
A [[target]] answers for the file named by its main. Two sources in one directory can belong to different targets and get different settings, which is why this is worked out per file rather than sent once by your editor. A file that is no target’s main — a header, or anything reached only through an include — takes [assembler] alone.
Anything your editor sends wins. A target in initializationOptions overrides the project file’s, the same way -d on the command line does. That key keeps its name: it is the protocol the extension already speaks, and renaming it would break every client at once.
Answering File
The language server’s output pane names the file that answered, once per document rather than per keystroke:
boot.asm → /home/you/SpectNext/console/fantasm.toml (target console); searching …The bracket says which of four things happened: (target console) for a target’s main, (target console, included by src/main.asm) for a file that target reaches, (on its include path) for one a project finds through include_dirs without any target naming it, and (no target claims it) for one sitting under a project file nothing reaches it from.
Worth looking at when a diagnostic surprises you — no fantasm.toml above it and the wrong fantasm.toml produce the same symptom otherwise. A file that will not parse is reported there as a warning with the reason, and the editor falls back to defaults rather than going quiet.
Errors in an Included File
A file a target reaches is analysed as part of that target. Open a fragment that uses constants its parent defines, or continues from an org set elsewhere, and you see what the build sees — not E1003 for names that are perfectly fine. The output pane names the unit that answered:
palette.inc → /home/you/SpectNext/console/fantasm.toml (target console, included by src/main.asm)A file no target reaches is assembled on its own, which is the one case where the editor can still show you a name the build would supply. A header nothing includes yet, or a file outside every project, is in that position; the output pane says so.
Limitations
Formatting a selection is not offered, only a whole document. Aligning columns is a property of a block rather than of a line, and a range that cuts one in half has no answer, so the capability is left undeclared rather than answering badly.
Otherwise, the server advertises exactly the capabilities in the table above, and an editor offering a command outside that list will find nothing behind it.
⚠ Not every editor implements every capability it is offered. Call hierarchy works in VSCodium and is absent from Zed’s UI, for example — the server answers either way, and what you see is the client’s decision.
Further Reading
- Diagnostics — reading the messages the editor is showing you.
- The Project File — what
fantasm.tomlconfigures for the build, if not for the editor.