Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/) - while
the project is in `0.x`, breaking changes may land in MINOR releases; PATCH
releases are reserved for bug fixes only.

## [Unreleased]

- **Fixed FE-5:** *Lazy file read doesn't catch bad file encoding* Now forces UTF-8 file encoding
- **Fixed FE-9:** *Adjacent operators produce syntax errors* Relaxed strictness of the operator parser to accomodate cramped code.
- **Fixed CG-12:** *Globals exhausting ZP slots they never occupied* Added early out in ZP building if the operand is both a variable and a global.

## [1.7.1] 2026-07-24

### Fixed:
Expand Down
8 changes: 6 additions & 2 deletions c02-as/src/generator.c
Original file line number Diff line number Diff line change
Expand Up @@ -149,12 +149,16 @@ static int zp_map_add(emitter_t *e, zp_map_t *map, tac_operand_kind_t kind,
return 1;
}

// fwd decl for early return path in zp addition
static global_entry_t *lookup_global(emitter_t *e, char *name);

// Register a TAC operand in the ZP map (dispatches var vs temp).
static int zp_map_add_operand(emitter_t *e, zp_map_t *map, tac_operand_t *op) {
if (op->kind == OPERAND_VAR)
if (op->kind == OPERAND_VAR) {
if (lookup_global(e, op->name)) return 1;

return zp_map_add(e, map, OPERAND_VAR, op->name, 0, op->type);
if (op->kind == OPERAND_TEMP)
} if (op->kind == OPERAND_TEMP)
return zp_map_add(e, map, OPERAND_TEMP, NULL, op->temp_id, op->type);
return 1;
}
Expand Down
77 changes: 50 additions & 27 deletions c02-frontend/app/Main.hs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ import C02.Parser.AST (Program, Pos(..))
import C02.Lowering.Serialize (serializeModule)

import qualified Data.ByteString.Lazy as B
import qualified Data.ByteString as BS
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE

-- | Reads a file, validates UTF-8, and returns a String or an error message.
readUtf8Safe :: FilePath -> IO (Either String String)
readUtf8Safe path = do
-- 1. Read the file safely as raw bytes
bytes <- BS.readFile path

-- 2. Attempt to decode the bytes into Text
case TE.decodeUtf8' bytes of
-- Left indicates a UnicodeException (decoding failed)
Left err ->
return (Left ("Invalid UTF-8 encoding: " ++ show err))

-- Right indicates success; convert Data.Text to a standard String
Right text ->
return (Right (T.unpack text))


writeOutput :: Program -> String -> IO ()
writeOutput p fp =
Expand All @@ -55,33 +75,36 @@ main = do
(outPath, files) = extractOutput rest
case files of
[path] -> do
src <- readFile path
case parseProgram path src of
Left err -> hPutStr stderr (errorBundlePretty err) >> exitFailure
Right prog
-- --dump-ast is an orthogonal reporting flag: it prints the AST to
-- stdout at whatever point we stop, so both test stages can diff a
-- text golden instead of a binary .o. --parse-only stops after parsing
-- (parser stage: no analysis, since its fixtures aren't whole programs);
-- otherwise analysis runs and a clean pass emits the IR object.
| parseOnly -> if dumpAST then print prog else pure ()
| otherwise -> do
resolved <- resolveIncludes path includeDirs prog
case resolved of
Left err -> hPutStr stderr (errorBundlePretty err) >> exitFailure
Right (resolvedProg, srcs) ->
-- The root file's source isn't in @srcs@ (the resolver only
-- records files it read); add it so the renderer can show root
-- diagnostics too.
let diags = analyze resolvedProg
hasErr = any ((== Error) . severityOf . diagWhat) diags
in do
if null diags
then pure ()
else hPutStr stderr (renderDiags path (Map.insert path src srcs) diags)
if hasErr
then exitFailure
else if dumpAST then print resolvedProg else writeOutput resolvedProg outPath
s <- readUtf8Safe path
case s of
Left err -> hPutStrLn stderr err >> exitFailure
Right src -> do
case parseProgram path src of
Left err -> hPutStr stderr (errorBundlePretty err) >> exitFailure
Right prog
-- --dump-ast is an orthogonal reporting flag: it prints the AST to
-- stdout at whatever point we stop, so both test stages can diff a
-- text golden instead of a binary .o. --parse-only stops after parsing
-- (parser stage: no analysis, since its fixtures aren't whole programs);
-- otherwise analysis runs and a clean pass emits the IR object.
| parseOnly -> if dumpAST then print prog else pure ()
| otherwise -> do
resolved <- resolveIncludes path includeDirs prog
case resolved of
Left err -> hPutStr stderr (errorBundlePretty err) >> exitFailure
Right (resolvedProg, srcs) ->
-- The root file's source isn't in @srcs@ (the resolver only
-- records files it read); add it so the renderer can show root
-- diagnostics too.
let diags = analyze resolvedProg
hasErr = any ((== Error) . severityOf . diagWhat) diags
in do
if null diags
then pure ()
else hPutStr stderr (renderDiags path (Map.insert path src srcs) diags)
if hasErr
then exitFailure
else if dumpAST then print resolvedProg else writeOutput resolvedProg outPath
_ -> hPutStrLn stderr "usage: c02-frontend [--parse-only] [-I <dir>]... [-o <out.o>] <file.c02>" >> exitFailure


Expand Down
1 change: 1 addition & 0 deletions c02-frontend/c02-frontend.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ executable c02-frontend
megaparsec,
containers,
bytestring,
text,
c02-frontend

-- Driver lives in its own dir so it doesn't re-compile the library's
Expand Down
27 changes: 21 additions & 6 deletions c02-frontend/src/C02/Parser/Lexer.hs
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,25 @@ charLiteralParser :: Parser Int
charLiteralParser = ord <$> between (char '\'') (char '\'') L.charLiteral

-- Match an operator token, enforcing maximal munch: the same trick 'keyword'
-- uses, but guarding against operator chars instead of identifier chars. This is
-- what stops operator "<" from matching the '<' in "<<" or "<=". The inner 'try'
-- keeps a partial match (e.g. "<" of an expected "<=") non-consuming so
-- makeExprParser can fall through to another operator.
-- uses, but guarding only against multi-character operators that start with the
-- current operator. This prevents "<" from matching the '<' in "<<" or "<=",
-- but allows "a*-b" (multiply then negate) since *- is not a recognized token.
-- The inner 'try' keeps a partial match non-consuming so makeExprParser can
-- fall through to another operator.
operator :: String -> Parser String
operator name = lexeme (try (string name <* notFollowedBy (oneOf opChars)))
where opChars = "+-*/%<>=!&|^~" :: [Char]
operator name = lexeme (try (string name <* notFollowedBy (validContinuation name)))
where
validContinuation :: String -> Parser String
validContinuation op = case op of
"+" -> choice [string "+", string "="]
"-" -> choice [string "-", string "="]
"*" -> string "="
"/" -> string "="
"%" -> string "="
"<" -> choice [string "<", string "="]
">" -> choice [string ">", string "="]
"=" -> string "="
"!" -> string "="
"&" -> string "&"
"|" -> string "|"
_ -> empty
2 changes: 0 additions & 2 deletions c02-frontend/src/C02/Parser/Parser.hs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
{-# OPTIONS_GHC -Wno-unused-top-binds #-}

-- | The C02 grammar: turns source text into a "C02.AST" 'Program'. Built on the
-- token helpers in "C02.Lexer" and megaparsec's expression-combinator support.
-- 'parseProgram' is the stage entry point the driver (and later stages) call.
Expand Down
8 changes: 7 additions & 1 deletion docs/Deviations/FINDINGS_codegen.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Severity: **T** over-strict rejection · **P0** silent miscompile ·
| [CG-9](#cg-9) | P2 | Source | ✅ Fixed | `allocate_globals` never checks RAM_TOP — globals silently run past $3FFF | none — undocumented architecture |
| [CG-10](#cg-10) | P2 | Source | — | Interrupt handlers don't save the ABI zone, helper slots, or RET | none — undocumented architecture |
| [CG-11](#cg-11) | P2 | Source | ✅ Fixed upstream | Struct field offsets >255 wrap in `LDY #imm` on the pointer path | none — undocumented architecture |
| [CG-12](#cg-12) | P2 | Source | — | Every referenced global gets a dead ZP slot per function — wasted ZP, earlier exhaustion | none — undocumented architecture |
| [CG-12](#cg-12) | P2 | Source | ✅ Fixed | Every referenced global gets a dead ZP slot per function — wasted ZP, earlier exhaustion | none — undocumented architecture |
| [CG-13](#cg-13) | P2 | Source | — | Variable shift counts read only the low byte | none — undocumented architecture |

## Entries
Expand Down Expand Up @@ -381,6 +381,12 @@ output; a real capacity/perf trap.

**Verified:** Source.

**Resolved:** Added check in `zp_map_add_operand` for variable operands to
first lookup if the operand is a global, if it is it early returns before adding
to ZP map. Cheap fix because evervy load/store/etc goes through functions that
already check globals before ZP. The issue lied in the building of the ZP map
for each function body, not specifically in the pushing a stack frame to the stack.

### CG-13: Variable shift counts read only the low byte

`TAC_SHL/TAC_SHR` with a non-constant count do `emit_load_byte(src2, 0)` into
Expand Down
13 changes: 10 additions & 3 deletions docs/Deviations/FINDINGS_frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ Severity legend (same as `DEVIATIONS_hs_impl.md`):
| [FE-2](#fe-2) | P1 | Executed | ✅ Fixed | Root file is never in the include visited-set: self-include / root↔header cycles duplicate the root's declarations | none — undocumented architecture |
| [FE-3](#fe-3) | P2 | Executed | — | No path canonicalization: the same header via two spellings is included twice | none — undocumented architecture |
| [FE-4](#fe-4) | G | Executed | — | include-not-found diagnostic loses the include site's position | none — undocumented architecture |
| [FE-5](#fe-5) | P2 | Source | — | Lazy `readFile` lets late I/O errors escape the resolver's `try` and crash the driver | none — undocumented architecture |
| [FE-5](#fe-5) | P2 | Source | ✅ Fixed | Lazy `readFile` lets late I/O errors escape the resolver's `try` and crash the driver | none — undocumented architecture |
| [FE-6](#fe-6) | G | Source | — | Included declarations are hoisted above the includer's own, erasing include position | none — undocumented architecture |
| [FE-7](#fe-7) | T | Executed | — | No `asm { }` statement exists in the grammar | [§5.7](../SPEC.md#57-inline-assembly-asm) |
| [FE-8](#fe-8) | G | Executed | — | Char literals: an undocumented extension, not a lexeme (`'a' + 1` fails), Haskell escape rules | [§1.3](../SPEC.md#13-integer-literals) |
| [FE-9](#fe-9) | G | Executed | — | Adjacent operators fail to lex: `a*-b`, `a<-1`, `a&&&b` are parse errors | [§1.6](../SPEC.md#16-operators--punctuation) |
| [FE-9](#fe-9) | G | Executed | ✅ Fixed | Adjacent operators fail to lex: `a*-b`, `a<-1`, `a&&&b` are parse errors | [§1.6](../SPEC.md#16-operators--punctuation) |
| [FE-10](#fe-10) | G | Executed | — | Integer literals overflowing the host word silently wrap (`2^64+1` lexes as `1`) | [§1.3](../SPEC.md#13-integer-literals) |
| [FE-11](#fe-11) | T | Executed | — | Compound assignment doesn't type-check as its desugaring: `p += 1` rejected on a pointer | [§5.3](../SPEC.md#53-assignment) |
| [FE-12](#fe-12) | P2 | Executed | — | S-1/S-17 laxity reproduced: any `(void*)`-shaped actual is compatible with *everything* | [§3.2](../SPEC.md#32-type-compatibility) |
| [FE-13](#fe-13) | P2 | Executed | — | `&&`/`||` operands are entirely unchecked (struct `&&` struct accepted) | [§6.3](../SPEC.md#63-binary-operators) |
| [FE-13](#fe-13) | P2 | Executed | — | `&&`/`\|\|` operands are entirely unchecked (struct `&&` struct accepted) | [§6.3](../SPEC.md#63-binary-operators) |
| [FE-14](#fe-14) | P0 | Executed | ✅ Fixed | `interrupt` validation missing: no warning, flag not cleared → calling the function crashes at runtime | [§4.2](../SPEC.md#42-interrupt-functions) |
| [FE-15](#fe-15) | G | Executed | — | `for`-increment clause accepts a variable declaration | [§5.5](../SPEC.md#55-for-loop-clauses) |
| [FE-16](#fe-16) | P2 | Source | — | Statement-position structs get no redeclaration check at all | [§7.2](../SPEC.md#72-scope-stack--shadowing) |
Expand Down Expand Up @@ -174,6 +174,10 @@ outside the `ExceptT` channel and crashes `c02-frontend` with an uncaught
**Verified:** Source — from the `try (readFile path)` shape in
`Includes.hs:100`; not executed.

**Resolved:** Added `readUtf8Safe` that forces UTF-8 encoding and early errors
otherwise. Editted driver to not register temp output file unless frontend succeeeds.
Added `bad_encoding` test case.

### FE-6: Include position within the file is erased

`Program` separates the include list from the declaration list at parse time,
Expand Down Expand Up @@ -257,6 +261,9 @@ guard.)

**Verified:** Executed (`a*-b`).

**Resolved:** Refactored `operator` parser to build out characters char by char and not
be hardcoded to expect whitespace after the operator.

### FE-10: Host-word overflow in integer literals wraps silently

```c
Expand Down
11 changes: 7 additions & 4 deletions scripts/c02c.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ def clean_o_file_temps(o_files):
# A temp path is registered before its file is written, so a stage that
# fails early can leave a path here that never became a file — tolerate it.
try:
os.remove(f)
if os.path.isfile(f):
os.remove(f)
except FileNotFoundError:
pass

Expand Down Expand Up @@ -96,11 +97,12 @@ def main():
# --- 1. frontend: c02 source -> IR object -----------------------------
for src in src_files:
src_out = make_temp_object()
temps += [src_out] # collect temps for deletion after pipeline
o_files += [src_out]
result = subprocess.run([frontend] + frontend_args + ['-o', src_out] + [src])
if result.returncode != 0:
sys.exit(result.returncode)
# only register the temp after the frontend successfully created it
temps += [src_out] # collect temps for deletion after pipeline
o_files += [src_out]

if args.parse_only or args.dump_ast:
sys.exit(0)
Expand All @@ -115,10 +117,11 @@ def main():

if len(o_files) > 1: # more than one object: merge them
linked = make_temp_object()
temps += [linked]
result = subprocess.run([linker] + o_files + [ "-o", linked])
if result.returncode != 0:
sys.exit(result.returncode)
# only register the linked temp after successful linking
temps += [linked]
else:
linked = o_files[0] # single object nothing to merge

Expand Down
25 changes: 25 additions & 0 deletions test/codegen/globals_not_pushed_to_stack.c02
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
struct S0 { u16 a; u16 b; }
struct S1 { S0 a; S0 b; }
struct S2 { S1 a; S1 b; }
struct S3 { S2 a; S2 b; }
struct S4 { S3 a; S3 b; }
struct P16 { u8 a0; u8 a1; u8 a2; u8 a3; u8 a4; u8 a5; u8 a6; u8 a7; u8 a8; u8 a9; u8 a10; u8 a11; u8 a12; u8 a13; u8 a14; u8 a15; }
struct P15 { u8 a0; u8 a1; u8 a2; u8 a3; u8 a4; u8 a5; u8 a6; u8 a7; u8 a8; u8 a9; u8 a10; u8 a11; u8 a12; u8 a13; u8 a14; }
struct P127 { P16 a; P16 b; P16 c; P16 d; P16 e; P16 f; P15 g; }
struct S5 { S4 a; S4 b; u16 x; u16 y; u16 z; P127 padding; S0 q; S0 r; u16 s; }

S5 fatty1;
S5 fatty2;

fn main() -> void {
fatty1.s = 69;
fatty2.s = fatty1.s;

// will cause any locals to be pushed to the stack, per old bug all used globals will also be pushed.
second();
}


fn second() -> void {
fatty2.x = 420;
}
Empty file.
12 changes: 12 additions & 0 deletions test/parser/adjacent_operators.c02
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
fn main () -> void {
i8 a = (i8)7;
i8 b = (i8)10;
i8 x = 0;

i8 c = a*-b;
i8 d = a&~b;

if (x<-1) {
a+=c;
}
}
1 change: 1 addition & 0 deletions test/parser/bad_encoding.c02
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello ÿ World
1 change: 1 addition & 0 deletions test/parser/golden/adjacent_operators.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Program [] [FunctionDecl (FuncDecl {funcName = "main", funcReturnType = Void, funcReturnPtrDepth = 0, isInterrupt = False, params = [], body = Just (Nested [LocVarDecl (VarDecl {varType = I8, ptrDepth = 0, declName = "a", declInit = Just (Cast I8 0 (IntLit 7))}),LocVarDecl (VarDecl {varType = I8, ptrDepth = 0, declName = "b", declInit = Just (Cast I8 0 (IntLit 10))}),LocVarDecl (VarDecl {varType = I8, ptrDepth = 0, declName = "x", declInit = Just (IntLit 0)}),LocVarDecl (VarDecl {varType = I8, ptrDepth = 0, declName = "c", declInit = Just (Binary Mul (Var "a") (Unary Negate (Var "b")))}),LocVarDecl (VarDecl {varType = I8, ptrDepth = 0, declName = "d", declInit = Just (Binary BitAnd (Var "a") (Unary BitNot (Var "b")))}),If [(Just (Binary Lt (Var "x") (Unary Negate (IntLit 1))),[Assign (Var "a") PlusEquals (Var "c")])]])})]
1 change: 1 addition & 0 deletions test/parser/golden/bad_encoding.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Invalid UTF-8 encoding: Cannot decode byte '\xff': Data.Text.Encoding: Invalid UTF-8 stream
Loading