Using It as a Library

FantASM is a Rust library as well as a command. A program can assemble in-process and get the bytes and the diagnostics back as values, without shelling out and parsing text.

This page is for Rust developers embedding the assembler. Everything else in this documentation is for people writing Z80.

Dependency

FantASM is not on crates.io. publish = false, it being distributed as signed binaries, so depend on it by path or by git:

[dependencies]
fantasm = { git = "https://codeberg.org/TwistedRaven/fantasm" }

There is no semantic-version promise across those. The crate is versioned for its releases, not for its API.

Default features bring in a lot. nex, sna, lsp and update-check are all on by default, and lsp pulls in tower-lsp and an async runtime. If you only want to assemble:

fantasm = { git = "…", default-features = false }

Assemble Call

Syntax: asm.assemble_from_str(<source>, <name>) — asm.assemble(<path>)

assemble_from_str assembles source under the file name name, and assemble does the same from a file, resolving include and incbin against the include path. The bytes are on the emitter afterwards.

use fantasm::assembler::{Assembler, AssemblerOptions};

let mut asm = Assembler::new();
asm.enable_console(false);
asm.device("zxnext");        // implies Z80N; enable_z80n(true) for it on another machine

let outcome = asm.assemble_from_str("    org $8000\nmain:\n    ld a,1\n    ret\n", "demo.asm");

let bytes = asm.emitter.bank.as_slice().to_vec();

The machine is device since 2.0, on this surface as well as the command line: Assembler::device() in place of Assembler::target(), and Options::device in place of Options::target. target still means an image the project builds — target_name, Options::targets — so the two are no longer one word.

verbosity(level) sets how much is printed: 0 nothing, 1 the progress log, 2 what the build decided. enable_console(bool) is unchanged and still means one level, which is what it always meant.

⚠ Call enable_console(false) first. Without it the assembler prints progress and messages to stdout, which is fatal for anything speaking a protocol there — a language server, or a tool whose output is piped.

Diagnostics

This is the part that catches people. The Result carries one error; the collected set lives on the assembler.

let _ = asm.assemble_from_str(source, "demo.asm");   // both consumers ignore this
for e in &asm.errors   { /* … */ }
for w in &asm.warnings { /* … */ }
asm.errorsEvery error the build found. Two undefined names give two entries.
asm.warningsWarnings, and they accumulate.

The Result’s single error is the first one; it is there so ? works, not because it is all there was.

W1049 unused label needs asking for. display_unused() is the library equivalent of --find-unused, and it appends to warnings, so call it before you collect or the borrow checker will say so for you.

Each Diagnostic carries level, error_code, message, hint, location (file, line, column) and related_locations. Nothing needs parsing out of a string — see Diagnostics for what the codes mean.

⚠ warnings is not only warnings. !message output (Message, 1056) is in the same list, and so is the shebang notice (Info, 1057). Filter on level, never on the code:

use fantasm::assembler::ErrorLevel;

// `display_unused` takes &mut, so ask for the unused-label sweep before borrowing the list.
asm.display_unused();

let real: Vec<_> = asm.warnings.iter()
    .filter(|w| matches!(w.level, ErrorLevel::Warning | ErrorLevel::Error))
    .collect();

Test Results

Syntax: asm.execute_tests()

execute_tests runs the !test blocks and answers with one error counting the failures, so the same rule applies: the detail is on the assembler.

let _ = asm.execute_tests();
asm.test_counts.passed;              // and .failed
for f in &asm.test_failures { /* an ordinary Diagnostic */ }

A failing assertion is E1091, positioned at the assertion’s own line. Anything else that went wrong while the body assembled keeps the code and hint it would have had in the program — see Testing.

Debug Information

Syntax: asm.sld_file(<path>) — asm.save_sld()

Source-to-address records are on asm.run.sld_records as values, so you need not read the file back. sld_file nominates where a file would go, and save_sld writes it.

Nominating a file does not write it. The library writes a .sld when save_sld() is called and not before, so a caller that only wants the records in memory need not clean a file up, and one that wants the file has to ask:

let scratch = std::env::temp_dir().join("mine.sld");
asm.sld_file(scratch.to_string_lossy().as_ref());
// … assemble …
let traces: Vec<_> = asm.run.sld_records.iter().filter(|r| r.record_type == "T").collect();
asm.save_sld()?;                        // only now does the file exist
let _ = std::fs::remove_file(&scratch);

⚠ The records are only populated if you nominate a file. sld_file() is what turns recording on; without it sld_records is empty.

record_type is an enum — Trace, Comment, and the rest — rather than the letter the file carries. A !assert reaches the records as a Comment, carrying its text verbatim, and the file column is whatever name the source was assembled under: a compiler assembling generated text gets its own generated file name there, which is not a file a debugger can open.

T records map a source line to an address; L records are symbols. See Debugging a Build for the format.

Tokeniser

fantasm::assembler::tokens::{Token, TokenReader} is the lexer the assembler uses, and can be run without assembling anything — which is how an editor gets highlighting that agrees with the assembler rather than approximating it with regular expressions.

Public Surface

Assembler, AssemblerOptions, Diagnostic, ErrorLevel and TokenReader are the whole of what embedding takes: a build run in-process, its diagnostics read as values, and the tokeniser used on its own for highlighting.

Reaching further in is possible, since much of the crate is pub, but nothing outside those five promises to stay put.

Further Reading

← Documentation