Thanks for picking this up. This document covers the developer loop, project layout, and concrete recipes for the most common changes you'll want to make.
If you haven't already, skim docs/architecture.md first — it explains the pipeline that the recipes below plug into.
- .NET 9 SDK (
dotnet --version≥ 9.0). - An editor that understands C# 13 + nullable reference types. Rider, VS, or VS Code + C# Dev Kit all work.
- The repo is plain Git; no submodules, no LFS.
# One-shot build (Debug).
dotnet build
# Run the entire test suite. ~1–2 s; runs in parallel.
dotnet test
# Run a single test project for fast iteration.
dotnet test test/ArcadeBasic.Parser.Tests
# Invoke the CLI on a source file.
dotnet run --project src/ArcadeBasic.Cli -- run examples/hello.bas
# Interactive REPL — fastest loop for trying out a one-liner.
dotnet run --project src/ArcadeBasic.Cli -- repl
# Inspect intermediate stages.
dotnet run --project src/ArcadeBasic.Cli -- lex examples/factorial.bas
dotnet run --project src/ArcadeBasic.Cli -- parse examples/factorial.bas
dotnet run --project src/ArcadeBasic.Cli -- analyze examples/factorial.bas
# AOT-publish.
dotnet publish src/ArcadeBasic.Cli -c Release -r <rid>
# → src/ArcadeBasic.Cli/bin/Release/net9.0/<rid>/publish/arcade-basicRelease builds set TreatWarningsAsErrors=true, so anything that builds cleanly in Debug must also be warning-free for CI.
src/
ArcadeBasic.Core/ # SourceFile, Position, Diagnostic
ArcadeBasic.Lexer/ # Tokens, keyword table
ArcadeBasic.Parser/ # Recursive-descent parser, AST (record class hierarchy)
ArcadeBasic.Sema/ # Two-pass analyzer, Scope, Symbol, SemanticInfo
ArcadeBasic.Runtime/ # Value, ActivationRecord, FlowControl, BuiltinImpls, BasicFile
ArcadeBasic.Interpreter/ # Tree-walking interpreter (feature-complete)
ArcadeBasic.Bytecode/ # Opcode enum, Chunk, BytecodeSerializer
ArcadeBasic.Compiler/ # AST → bytecode lowering (feature-parity with interpreter)
ArcadeBasic.Vm/ # Stack-based bytecode VM
ArcadeBasic.Cli/ # Command dispatch + self-extracting AOT stub
test/
ArcadeBasic.{Lexer,Parser,Sema,Interpreter,Vm,Runtime,Conformance}.Tests/
examples/ # Sample programs runnable via `arcade-basic run`
testdata/{conformance,golden}/
docs/
architecture.md # Pipeline + per-project tour
conformance.md # ISO 10279 deviations
Directory.Build.props sets Nullable=enable, LangVersion=13, AnalysisLevel=latest, and InternalsVisibleTo from each source project to its sibling test project. So sema/interpreter internals are visible from tests without having to make them public.
The library projects multi-target net9.0;netstandard2.1. The CLI is single-target net9.0 (it uses APIs that only exist on .NET 6+). Test projects are single-target net9.0.
When adding code to a library project, prefer APIs that exist on both targets. If you need a net9.0-only API, either:
- Wrap the call in
#if NET5_0_OR_GREATER(or a more specific version guard) with a netstandard fallback, or - Push the call out into
ArcadeBasic.Cli, where it's free to use anything.
The repo-root Polyfill.cs is included automatically into any non-net5.0+ build (see Directory.Build.props). It supplies IsExternalInit, RequiredMemberAttribute, CompilerFeatureRequiredAttribute, SetsRequiredMembersAttribute, and ReferenceEqualityComparer. Add to it if you find another required/init/record-related feature that doesn't compile on netstandard.
Two places to touch:
-
src/ArcadeBasic.Sema/Builtins.cs— register the name, return type, and argument types. TheBuiltinSignaturerecords min/max arity and each argument's expected type tag (Numeric/String/Any).yield return new BuiltinSymbol("ATAN2", IsString: false, new BuiltinSignature(2, 2, [BuiltinArgType.Numeric, BuiltinArgType.Numeric]));
-
src/ArcadeBasic.Runtime/BuiltinImpls.cs— add the runtime implementation. The dictionary key is the name (case-insensitive lookup); the value isValue[] -> Value.t["ATAN2"] = args => Num(FromDouble(Math.Atan2(ToDouble(args[0]), ToDouble(args[1]))));
-
Test in
test/ArcadeBasic.Interpreter.Tests/InterpreterTests.cs. Each test typically runs a tiny.bassnippet viaRun("PRINT ATAN2(1, 1)")and asserts on the trimmed stdout.
That's it — no parser changes needed. The parser already recognises Identifier(args) as a generic call form; sema dispatches based on what the name resolves to.
This is the heaviest extension. Six touch points:
src/ArcadeBasic.Lexer/TokenKind.cs— add a new enum entry, e.g.KwSwap.src/ArcadeBasic.Lexer/Keywords.cs— wire the spelling to the token kind.src/ArcadeBasic.Parser/Ast/Stmt.cs— define a newsealed record class SwapStmt(...) : Stmt(Span).src/ArcadeBasic.Parser/BasicParser.cs— add aParse*method and add its dispatch entry inParseStatement:TokenKind.KwSwap => ParseSwap(),
src/ArcadeBasic.Interpreter/BasicInterpreter.cs(ExecStmtImplswitch) and possiblyBasicInterpreter.Statements.cs— add anExecSwaphandler returningFlowControl.- Tests under
test/ArcadeBasic.Parser.Tests(round-trip the AST) andtest/ArcadeBasic.Interpreter.Tests(observe the effect).
If you want VM coverage, add an Opcode.Swap (already exists for the stack op — pick a different name for your statement), extend BasicCompiler.cs to lower the AST node, and extend BasicVm.cs to dispatch.
src/ArcadeBasic.Bytecode/Opcode.cs— add the enum entry in the appropriate section (stack / arithmetic / control flow / etc.).src/ArcadeBasic.Vm/BasicVm.cs— add acase Opcode.X:to the dispatch loop. Push/pop operands explicitly; the VM's stack isStack<Value>.src/ArcadeBasic.Compiler/BasicCompiler.cs— emit the opcode from the relevant AST lowering path.- Test in
test/ArcadeBasic.Vm.Tests/. Tests compile a snippet, run it on the VM, and assert on captured output.
If the opcode takes operands, encode them with the existing LEB128 helpers in BytecodeSerializer.cs and document the encoding in the comment beside the enum value.
- Drop the
.basfile inexamples/. - Add a row to the table in
examples/README.md. - If it's a notable program (Star Trek / Lunar Lander tier), also add a one-liner to the "Running the example programs" section of the top-level
README.md.
Programs must work via arcade-basic run. If they also work on the VM, mark them ✓ in the matrix; otherwise leave the VM column as —.
lex <file>prints the token stream — useful when a parser error doesn't match what you typed.parse <file>pretty-prints the AST — useful when sema or the interpreter does something surprising.analyze <file>shows the program-scope symbol table, DATA pool, and line label map.replis the fastest loop for trying a one-line conjecture without touching disk. Multi-line blocks (FOR/DO/IF/SUB/...) are accepted;.listshows the accumulated session,.clearresets it.- All diagnostics carry a stable code (
FB0xxx). Grepsrc/ArcadeBasic.Sema/Analyzer.csfor the code to find where it's raised. - For interpreter behaviour, sprinkle
Console.Error.WriteLine(...)inExecStmtImplor the relevantExec*helper. Interpreter output goes to a configurableTextWriter, but stderr is separate and won't pollute test goldens. - Most tests use
Run(source)helpers that pipe stdin and capture stdout — seeInterpreterTests.csfor the pattern.
- C# 13, nullable enabled, implicit usings. Don't
using System;— it's already there. - Records over classes for data carriers (AST nodes, values, symbols).
sealed record classfor closed hierarchies. - Pattern matching over
if/ischains. Most dispatch is aswitchexpression or pattern-matchingswitchstatement. - No comments that restate the code. Comments explain why, not what. The codebase aims for self-explanatory names and lets surprising decisions earn their
//line. - Tests use xUnit + FluentAssertions + Verify. Goldens go through Verify; one-off assertions use
output.Should().Contain(...)etc. - Diagnostic codes are stable. When you add an error path, assign a new
FB0xxxconstant in the appropriate file and grep the codebase to make sure it's unique. - Public surface stays small.
internalby default, with[InternalsVisibleTo]for tests handled inDirectory.Build.props.
For a behaviour question, the most useful issue includes:
- The minimal
.bassnippet that reproduces the problem. - What
dotnet run -- run <file>actually prints. - What you expected, with a citation to ISO 10279 if it's a spec-conformance question.
Pull requests for features should land tests in the same PR. Pull requests for bug fixes should include a regression test that fails before the fix and passes after. The example programs in examples/ are integration coverage: ArcadeBasic.Conformance.Tests runs each one through both engines and asserts the tree-walker and the bytecode VM agree byte-for-byte (deterministic examples) and that every example compiles on the VM, and CI smoke-runs them through both run and vm. If you add a statement or builtin, add an example or extend an existing one so that parity net covers it.