Skip to content

Commit 0386f93

Browse files
smokkuCopilot
andcommitted
feat: Implement negative constants
Co-authored-by: Copilot <copilot@github.com>
1 parent 4bd5d67 commit 0386f93

12 files changed

Lines changed: 265 additions & 17 deletions

File tree

‎crates/core/src/ast/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ pub enum ExprUnaryOp {
528528
WordLittleEndian,
529529
FarLittleEndian,
530530
EvalBracketed,
531+
Negate,
531532
}
532533

533534
#[derive(Debug, Clone)]

‎crates/core/src/emit_object.rs‎

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -565,13 +565,39 @@ pub fn emit_object(
565565
match &instruction.operand {
566566
None => {}
567567
Some(OperandOp::Immediate(value)) => {
568-
if let Ok(raw) = u32::try_from(*value) {
569-
emit_literal(segment, raw, width, op.span, &mut diagnostics);
568+
// Positive values flow through emit_literal's existing
569+
// unsigned range check. Negative values that fit the
570+
// operand's signed range wrap as two's complement to
571+
// the operand width (per docs/syntax-reference.md).
572+
let v = *value;
573+
if v >= 0 {
574+
match u32::try_from(v) {
575+
Ok(raw) => emit_literal(
576+
segment, raw, width, op.span, &mut diagnostics,
577+
),
578+
Err(_) => diagnostics.push(Diagnostic::error(
579+
op.span,
580+
"immediate value out of range",
581+
)),
582+
}
570583
} else {
571-
diagnostics.push(Diagnostic::error(
572-
op.span,
573-
"immediate value cannot be negative",
574-
));
584+
let (min, mask): (i64, u32) = match width {
585+
1 => (-(1i64 << 7), 0xFF),
586+
2 => (-(1i64 << 15), 0xFFFF),
587+
3 => (-(1i64 << 23), 0xFF_FFFF),
588+
_ => (0, 0),
589+
};
590+
if v >= min {
591+
let raw = (v as u32) & mask;
592+
emit_literal(
593+
segment, raw, width, op.span, &mut diagnostics,
594+
);
595+
} else {
596+
diagnostics.push(Diagnostic::error(
597+
op.span,
598+
"immediate value cannot be negative",
599+
));
600+
}
575601
}
576602
}
577603
Some(OperandOp::ImmediateByteRelocation { kind, label }) => {

‎crates/core/src/lower.rs‎

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3594,13 +3594,13 @@ fn capitalize(text: &str) -> String {
35943594
}
35953595

35963596
fn value_fits_reg_width(value: i64, width: RegWidth) -> bool {
3597-
if value < 0 {
3598-
return false;
3599-
}
3600-
match width {
3601-
RegWidth::W8 => value <= i64::from(u8::MAX),
3602-
RegWidth::W16 => value <= i64::from(u16::MAX),
3603-
}
3597+
let bits = match width {
3598+
RegWidth::W8 => 8,
3599+
RegWidth::W16 => 16,
3600+
};
3601+
let min = -(1i64 << (bits - 1));
3602+
let max = (1i64 << bits) - 1;
3603+
(min..=max).contains(&value)
36043604
}
36053605

36063606
fn immediate_width_error(span: Span, value: i64, width: RegWidth, mnemonic: &str) -> Diagnostic {
@@ -4185,6 +4185,10 @@ fn eval_to_number_strict(
41854185
ExprUnaryOp::HighByte => Some((value >> 8) & 0xFF),
41864186
ExprUnaryOp::WordLittleEndian | ExprUnaryOp::FarLittleEndian => Some(value),
41874187
ExprUnaryOp::EvalBracketed => Some(value),
4188+
ExprUnaryOp::Negate => value.checked_neg().or_else(|| {
4189+
diagnostics.push(Diagnostic::error(span, "arithmetic overflow"));
4190+
None
4191+
}),
41884192
}
41894193
}
41904194
Expr::TypedView { expr, .. } | Expr::AddressHint { expr, .. } => {
@@ -4364,7 +4368,8 @@ fn resolve_symbolic_byte_relocation(
43644368
ExprUnaryOp::HighByte => ByteRelocationKind::HighByte,
43654369
ExprUnaryOp::WordLittleEndian
43664370
| ExprUnaryOp::FarLittleEndian
4367-
| ExprUnaryOp::EvalBracketed => return None,
4371+
| ExprUnaryOp::EvalBracketed
4372+
| ExprUnaryOp::Negate => return None,
43684373
};
43694374

43704375
let symbol = expr_ident_name(expr.as_ref())?;
@@ -5196,6 +5201,10 @@ fn eval_to_number(
51965201
ExprUnaryOp::HighByte => Some((value >> 8) & 0xFF),
51975202
ExprUnaryOp::WordLittleEndian | ExprUnaryOp::FarLittleEndian => Some(value),
51985203
ExprUnaryOp::EvalBracketed => Some(value),
5204+
ExprUnaryOp::Negate => value.checked_neg().or_else(|| {
5205+
diagnostics.push(Diagnostic::error(span, "arithmetic overflow"));
5206+
None
5207+
}),
51995208
}
52005209
}
52015210
Expr::TypedView { expr, .. } | Expr::AddressHint { expr, .. } => {
@@ -5599,6 +5608,70 @@ mod tests {
55995608
assert!(matches!(operand, OperandOp::Immediate(0x34)));
56005609
}
56015610

5611+
#[test]
5612+
fn resolves_negative_const_operand_to_signed_immediate() {
5613+
let source = "const A = -1\nfunc main @a8 {\n lda #A\n}\n";
5614+
let file = parse(SourceId(0), source).expect("parse");
5615+
let sema = analyze(&file).expect("analyze");
5616+
let fs = StdAssetFS;
5617+
let program = lower(&file, &sema, &fs, None).expect("lower");
5618+
5619+
let operand = program
5620+
.ops
5621+
.iter()
5622+
.find_map(|op| match &op.node {
5623+
Op::Instruction(instruction) if instruction.mnemonic == "lda" => {
5624+
instruction.operand.as_ref()
5625+
}
5626+
_ => None,
5627+
})
5628+
.expect("lda operand");
5629+
5630+
assert!(matches!(operand, OperandOp::Immediate(-1)));
5631+
}
5632+
5633+
#[test]
5634+
fn accepts_immediate_at_signed_lower_bound_a8() {
5635+
// -128 fits the signed range of 8-bit accumulator immediates.
5636+
let source = "func main @a8 {\n lda #-128\n}\n";
5637+
let file = parse(SourceId(0), source).expect("parse");
5638+
let sema = analyze(&file).expect("analyze");
5639+
let fs = StdAssetFS;
5640+
lower(&file, &sema, &fs, None).expect("lower");
5641+
}
5642+
5643+
#[test]
5644+
fn rejects_immediate_below_signed_lower_bound_a8() {
5645+
// -129 falls outside both i8 and u8 — error.
5646+
let source = "func main @a8 {\n lda #-129\n}\n";
5647+
let file = parse(SourceId(0), source).expect("parse");
5648+
let sema = analyze(&file).expect("analyze");
5649+
let fs = StdAssetFS;
5650+
let errors = lower(&file, &sema, &fs, None).expect_err("must fail");
5651+
assert!(
5652+
errors
5653+
.iter()
5654+
.any(|e| e.message.contains("does not fit in @a8")),
5655+
"expected @a8 width error, got {errors:?}"
5656+
);
5657+
}
5658+
5659+
#[test]
5660+
fn rejects_immediate_above_unsigned_upper_bound_a8() {
5661+
// 256 fits neither i8 nor u8 — error.
5662+
let source = "func main @a8 {\n lda #256\n}\n";
5663+
let file = parse(SourceId(0), source).expect("parse");
5664+
let sema = analyze(&file).expect("analyze");
5665+
let fs = StdAssetFS;
5666+
let errors = lower(&file, &sema, &fs, None).expect_err("must fail");
5667+
assert!(
5668+
errors
5669+
.iter()
5670+
.any(|e| e.message.contains("does not fit in @a8")),
5671+
"expected @a8 width error, got {errors:?}"
5672+
);
5673+
}
5674+
56025675
#[test]
56035676
fn resolves_bare_const_operand_to_immediate_value() {
56045677
let source = "const LIMIT = 0x34\nfunc main {\n lda LIMIT\n}\n";

‎crates/core/src/parser/expr.rs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,12 +135,31 @@ where
135135
})
136136
.boxed();
137137

138-
let mul_expr = unary
138+
// Unary minus: numeric negation prefix. Sits outside the `&` address-of
139+
// chain and inside `mul_expr`, so `5 - -1` parses as binary-sub of 5
140+
// and Negate(1): the add-level loop greedily takes the binary `-`,
141+
// and `signed_unary` then consumes the second `-`.
142+
let signed_unary = just(TokenKind::Minus)
143+
.repeated()
144+
.count()
145+
.then(unary)
146+
.map(|(minus_count, mut inner)| {
147+
for _ in 0..minus_count {
148+
inner = Expr::Unary {
149+
op: ExprUnaryOp::Negate,
150+
expr: Box::new(inner),
151+
};
152+
}
153+
inner
154+
})
155+
.boxed();
156+
157+
let mul_expr = signed_unary
139158
.clone()
140159
.then(
141160
just(TokenKind::Star)
142161
.to(ExprBinaryOp::Mul)
143-
.then(unary)
162+
.then(signed_unary)
144163
.repeated()
145164
.collect::<Vec<_>>(),
146165
)
@@ -365,6 +384,7 @@ pub(super) fn eval_static_expr(expr: &Expr) -> Option<i64> {
365384
ExprUnaryOp::HighByte => Some((value >> 8) & 0xFF),
366385
ExprUnaryOp::WordLittleEndian | ExprUnaryOp::FarLittleEndian => Some(value),
367386
ExprUnaryOp::EvalBracketed => Some(value),
387+
ExprUnaryOp::Negate => value.checked_neg(),
368388
}
369389
}
370390
Expr::TypedView { expr, .. } | Expr::AddressHint { expr, .. } => eval_static_expr(expr),

‎crates/core/src/parser/tests.rs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,20 @@ fn assert_add_one_from(expr: &Expr, previous: &str) {
1717
));
1818
}
1919

20+
fn assert_negate_of(expr: &Expr, expected_inner: i64) {
21+
let Expr::Unary {
22+
op: ExprUnaryOp::Negate,
23+
expr: inner,
24+
} = expr
25+
else {
26+
panic!("expected Unary(Negate, ...), got {expr:?}");
27+
};
28+
assert!(
29+
matches!(inner.as_ref(), Expr::Number(n, _) if *n == expected_inner),
30+
"expected Negate of Number({expected_inner}), got Negate of {inner:?}",
31+
);
32+
}
33+
2034
#[test]
2135
fn parses_far_function_and_call() {
2236
let source = "far func target {\n nop\n}\nfunc main {\n call target\n}\n";
@@ -73,6 +87,40 @@ fn parses_expression_fragment_with_address_hint_suffix() {
7387
));
7488
}
7589

90+
#[test]
91+
fn parses_unary_minus_on_number() {
92+
let expr = parse_expression_fragment(SourceId(0), "-1").expect("parse");
93+
assert_negate_of(&expr.node, 1);
94+
}
95+
96+
#[test]
97+
fn parses_binary_minus_with_unary_minus_rhs() {
98+
let expr = parse_expression_fragment(SourceId(0), "5 - -1").expect("parse");
99+
let Expr::Binary {
100+
op: ExprBinaryOp::Sub,
101+
lhs,
102+
rhs,
103+
} = &expr.node
104+
else {
105+
panic!("expected Binary(Sub, ...), got {:?}", expr.node);
106+
};
107+
assert!(matches!(lhs.as_ref(), Expr::Number(5, _)));
108+
assert_negate_of(rhs.as_ref(), 1);
109+
}
110+
111+
#[test]
112+
fn parses_repeated_unary_minus() {
113+
let expr = parse_expression_fragment(SourceId(0), "--1").expect("parse");
114+
let Expr::Unary {
115+
op: ExprUnaryOp::Negate,
116+
expr: inner,
117+
} = &expr.node
118+
else {
119+
panic!("expected outer Unary(Negate, ...), got {:?}", expr.node);
120+
};
121+
assert_negate_of(inner.as_ref(), 1);
122+
}
123+
76124
#[test]
77125
fn nested_symbolic_subscript_forms_produce_same_ident() {
78126
let source = "\

‎crates/core/src/parser/tests/consts.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,32 @@ fn preprocesses_const_group_with_resolvable_identifier_seed() {
9898
assert_add_one_from(&consts[2].initializer, "B");
9999
}
100100

101+
#[test]
102+
fn preprocesses_const_group_with_negative_seed() {
103+
let source = "const A = -1, B, C\n";
104+
let file = parse(SourceId(0), source).expect("parse");
105+
let Item::Const(consts) = &file.items[0].node else {
106+
panic!("expected const group");
107+
};
108+
assert_eq!(consts.len(), 3);
109+
assert_negate_of(&consts[0].initializer, 1);
110+
assert_add_one_from(&consts[1].initializer, "A");
111+
assert_add_one_from(&consts[2].initializer, "B");
112+
}
113+
114+
#[test]
115+
fn preprocesses_const_group_with_negative_seed_and_explicit_reset() {
116+
let source = "const X = -3, Y = 2, Z\n";
117+
let file = parse(SourceId(0), source).expect("parse");
118+
let Item::Const(consts) = &file.items[0].node else {
119+
panic!("expected const group");
120+
};
121+
assert_eq!(consts.len(), 3);
122+
assert_negate_of(&consts[0].initializer, 3);
123+
assert!(matches!(consts[1].initializer, Expr::Number(2, _)));
124+
assert_add_one_from(&consts[2].initializer, "Y");
125+
}
126+
101127
#[test]
102128
fn parses_top_level_evaluator_block_item() {
103129
let source = "[ A = 1 ]\n";

‎crates/core/src/sema/consts.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,10 @@ fn eval_const_expr(
341341
Ok(Number::Int(value))
342342
}
343343
ExprUnaryOp::EvalBracketed => Ok(Number::Int(value)),
344+
ExprUnaryOp::Negate => value
345+
.checked_neg()
346+
.map(Number::Int)
347+
.ok_or(ConstExprError::Overflow),
344348
}
345349
}
346350
Expr::TypedView { expr, .. } | Expr::AddressHint { expr, .. } => {

‎crates/core/src/sema/tests.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,20 @@ fn const_sequence_implicit_initializers_are_resolved() {
5959
assert_eq!(sema.vars.get("ptr").expect("ptr").address, 2);
6060
}
6161

62+
#[test]
63+
fn const_sequence_with_negative_seed_increments_into_zero_and_above() {
64+
let source = "const A = -1, B, C\nconst X = -3, Y = 2, Z\n";
65+
let file = parse(SourceId(0), source).expect("parse");
66+
let sema = analyze(&file).expect("analyze");
67+
68+
assert_eq!(sema.consts.get("A").expect("A").value, Number::Int(-1));
69+
assert_eq!(sema.consts.get("B").expect("B").value, Number::Int(0));
70+
assert_eq!(sema.consts.get("C").expect("C").value, Number::Int(1));
71+
assert_eq!(sema.consts.get("X").expect("X").value, Number::Int(-3));
72+
assert_eq!(sema.consts.get("Y").expect("Y").value, Number::Int(2));
73+
assert_eq!(sema.consts.get("Z").expect("Z").value, Number::Int(3));
74+
}
75+
6276
#[test]
6377
fn const_sequence_explicit_initializer_resets_increment_chain() {
6478
let source = "const A = 0, B, C = 10, D\nvar ptr = D\n";
24 Bytes
Binary file not shown.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[default]
2+
000000: A9 FF A9 00 A9 01 A9 FD A9 02 A9 03 C2 20 A9 FF
3+
000010: FF A9 FD FF A9 FB FF 60
4+
5+
6+
[disasm obj#0 default::main]
7+
000000: A9 FF lda #$FF
8+
000002: A9 00 lda #$00
9+
000004: A9 01 lda #$01
10+
000006: A9 FD lda #$FD
11+
000008: A9 02 lda #$02
12+
00000A: A9 03 lda #$03
13+
00000C: C2 20 rep #$20
14+
00000E: A9 FF FF lda #$FFFF
15+
000011: A9 FD FF lda #$FFFD
16+
000014: A9 FB FF lda #$FFFB
17+
000017: 60 rts

0 commit comments

Comments
 (0)