Krolyxon
Commit 86cbab6

Branches

Tags

Refs →

bench5186cbab6

move assembler and simulator into a bench51-core crate

The workspace now has a library crate shared by the front ends. The
debugger logic that lived in the TUI's App (build, run, step, breakpoints,
poke, HEX disassembly) is now bench51_core::session::Session, and syntax
highlighting is split into a tokenizer (core) and colours (TUI). The TUI
behaves as before.
Author
krolyxon
Date
2026-10-31 23:33 +0530
Parent
fa3bbf2
Commit
86cbab6a63b8363996045cead6dc97baf0ff722a

+5148 −1278

22 files changed

  1. M Cargo.lock +4268 −794
  2. M Cargo.toml +20 −3
  3. A crates/bench51-core/Cargo.toml +7 −0
  4. R src/asm/expr.rs → crates/bench51-core/src/asm/expr.rs +0 −0
  5. R src/asm/hex.rs → crates/bench51-core/src/asm/hex.rs +0 −0
  6. R src/asm/lexer.rs → crates/bench51-core/src/asm/lexer.rs +0 −0
  7. R src/asm/listing.rs → crates/bench51-core/src/asm/listing.rs +0 −0
  8. R src/asm/mod.rs → crates/bench51-core/src/asm/mod.rs +0 −0
  9. A crates/bench51-core/src/files.rs +95 −0
  10. R src/isa.rs → crates/bench51-core/src/isa.rs +1 −1
  11. A crates/bench51-core/src/lib.rs +10 −0
  12. A crates/bench51-core/src/session.rs +417 −0
  13. R src/sfr.rs → crates/bench51-core/src/sfr.rs +0 −0
  14. R src/sim/cpu.rs → crates/bench51-core/src/sim/cpu.rs +0 −0
  15. R src/sim/disasm.rs → crates/bench51-core/src/sim/disasm.rs +0 −0
  16. R src/sim/mod.rs → crates/bench51-core/src/sim/mod.rs +5 −5
  17. R src/sim/periph.rs → crates/bench51-core/src/sim/periph.rs +0 −0
  18. A crates/bench51-core/src/syntax.rs +192 −0
  19. M src/main.rs +1 −4
  20. M src/ui/app.rs +81 −309
  21. M src/ui/draw.rs +34 −50
  22. M src/ui/syntax.rs +17 −112
M Cargo.lock View

[Diff too large] View file

M Cargo.toml View
@@ -1,10 +1,27 @@
1−[package]
2−name = "bench51"
1+[workspace]
2+members = ["crates/*"]
3+
4+[workspace.package]
35 version = "0.1.0"
46 edition = "2024"
7+
8+[workspace.dependencies]
9+bench51-core = { path = "crates/bench51-core" }
10+clap = { version = "4.5.54", features = ["derive"] }
11+
12+[package]
13+name = "bench51"
14+version.workspace = true
15+edition.workspace = true
516 description = "A TUI Intel 8051 assembler, simulator and debugger"
617
718 [dependencies]
8−clap = { version = "4.5.54", features = ["derive"] }
19+bench51-core.workspace = true
20+clap.workspace = true
921 ratatui = "0.30"
1022 ratatui-textarea = "0.9"
23+
24+# egui and wgpu are unusably slow without optimisation; keep our own crates
25+# quick to rebuild.
26+[profile.dev.package."*"]
27+opt-level = 2
A crates/bench51-core/Cargo.toml View
@@ -0,0 +1,7 @@
1+[package]
2+name = "bench51-core"
3+version.workspace = true
4+edition.workspace = true
5+description = "Intel 8051 assembler, simulator and debugger session shared by the bench51 front ends"
6+
7+[dependencies]
R src/asm/expr.rs → crates/bench51-core/src/asm/expr.rs View

[No content changes]

R src/asm/hex.rs → crates/bench51-core/src/asm/hex.rs View

[No content changes]

R src/asm/lexer.rs → crates/bench51-core/src/asm/lexer.rs View

[No content changes]

R src/asm/listing.rs → crates/bench51-core/src/asm/listing.rs View

[No content changes]

R src/asm/mod.rs → crates/bench51-core/src/asm/mod.rs View

[No content changes]

A crates/bench51-core/src/files.rs View
@@ -0,0 +1,95 @@
1+//! Loading programs: assembly sources as they are, Intel HEX images as
2+//! disassembled source.
3+
4+use std::path::Path;
5+
6+use crate::asm::hex;
7+use crate::sfr::{hex8, hex16};
8+use crate::sim::disasm;
9+
10+pub enum Loaded {
11+ Source(String),
12+ /// A HEX image turned into source that reassembles to the same bytes.
13+ Hex {
14+ source: String,
15+ bytes: usize,
16+ },
17+}
18+
19+pub fn is_hex_path(path: &Path) -> bool {
20+ path.extension()
21+ .is_some_and(|e| e.eq_ignore_ascii_case("hex") || e.eq_ignore_ascii_case("ihx"))
22+}
23+
24+/// Reads `path`. Errors are ready to show to the user.
25+pub fn load(path: &Path) -> Result<Loaded, String> {
26+ let text = std::fs::read_to_string(path)
27+ .map_err(|e| format!("cannot open {}: {e}", path.display()))?;
28+ if !is_hex_path(path) {
29+ return Ok(Loaded::Source(text));
30+ }
31+ let bytes = hex::read(&text).map_err(|e| format!("invalid HEX file: {e}"))?;
32+ Ok(Loaded::Hex {
33+ source: disassemble_image(&bytes, path),
34+ bytes: bytes.len(),
35+ })
36+}
37+
38+/// Builds an assembler source from a HEX image by disassembling each
39+/// contiguous run of bytes. Reassembling it reproduces the image exactly.
40+pub fn disassemble_image(bytes: &[(u16, u8)], path: &Path) -> String {
41+ let mut code = Box::new([0u8; 0x10000]);
42+ let mut used = vec![false; 0x10000];
43+ for &(a, b) in bytes {
44+ code[a as usize] = b;
45+ used[a as usize] = true;
46+ }
47+ let mut src = format!("; disassembled from {}\n", path.display());
48+ let mut addr = 0usize;
49+ while addr < 0x10000 {
50+ if !used[addr] {
51+ addr += 1;
52+ continue;
53+ }
54+ src.push_str(&format!("\n ORG {}\n", hex16(addr as u16)));
55+ while addr < 0x10000 && used[addr] {
56+ let d = disasm::disassemble(&code, addr as u16);
57+ let size = d.size as usize;
58+ if (addr..addr + size).all(|a| a < 0x10000 && used[a]) {
59+ src.push_str(&format!(" {:<24}; {:04X}\n", d.text, addr));
60+ addr += size;
61+ } else {
62+ src.push_str(&format!(
63+ " DB {:<21}; {:04X}\n",
64+ hex8(code[addr]),
65+ addr
66+ ));
67+ addr += 1;
68+ }
69+ }
70+ }
71+ src.push_str(" END\n");
72+ src
73+}
74+
75+#[cfg(test)]
76+mod tests {
77+ use std::path::Path;
78+
79+ use crate::asm::{assemble, hex};
80+
81+ #[test]
82+ fn hex_disassembly_reassembles_to_the_same_image() {
83+ let out = assemble(include_str!("../../../examples/serial_hello.asm"));
84+ assert!(out.ok());
85+ let image = hex::read(&out.intel_hex()).unwrap();
86+ let src = super::disassemble_image(&image, Path::new("x.hex"));
87+ let again = assemble(&src);
88+ assert!(again.ok(), "{:?}", again.diagnostics);
89+ let mut a: Vec<_> = out.bytes().collect();
90+ let mut b: Vec<_> = again.bytes().collect();
91+ a.sort();
92+ b.sort();
93+ assert_eq!(a, b);
94+ }
95+}
R src/isa.rs → crates/bench51-core/src/isa.rs View
@@ -28,7 +28,7 @@ pub enum Operand {
2828
2929 impl Operand {
3030 /// Bytes this operand occupies after the opcode byte.
31− pub fn len(self) -> u8 {
31+ pub(crate) fn len(self) -> u8 {
3232 match self {
3333 Operand::Imm8 | Operand::Direct | Operand::Bit | Operand::NotBit => 1,
3434 Operand::Addr11 | Operand::Rel => 1,
A crates/bench51-core/src/lib.rs View
@@ -0,0 +1,10 @@
1+//! Intel 8051 assembler, simulator and debugger session shared by the
2+//! bench51 front ends (the `bench51` TUI and the `bench51-gui` desktop app).
3+
4+pub mod asm;
5+pub mod files;
6+pub mod isa;
7+pub mod session;
8+pub mod sfr;
9+pub mod sim;
10+pub mod syntax;
A crates/bench51-core/src/session.rs View
@@ -0,0 +1,417 @@
1+//! A debugging session: the simulator, the program loaded into it,
2+//! breakpoints and run state. Front ends own the source text and decide
3+//! when to build; everything else about running a program lives here.
4+
5+use std::collections::{HashMap, HashSet};
6+use std::path::Path;
7+
8+use crate::asm::{self, AsmOutput, Diagnostic};
9+use crate::sim::{Sim, Stop};
10+
11+/// Result of the last successful assembly, which the simulator runs.
12+pub struct Build {
13+ pub source: Vec<String>,
14+ pub out: AsmOutput,
15+ pub addr_to_line: HashMap<u16, usize>,
16+}
17+
18+impl Build {
19+ /// First address emitted by `line`, if any.
20+ pub fn line_addr(&self, line: usize) -> Option<u16> {
21+ self.out
22+ .lines
23+ .get(line)
24+ .filter(|l| !l.bytes.is_empty())
25+ .and_then(|l| l.addr)
26+ }
27+}
28+
29+/// Instructions executed per tick (one tick ≈ 33 ms) for each speed setting.
30+pub const SPEEDS: [u32; 8] = [1, 5, 25, 100, 500, 2_000, 10_000, 40_000];
31+
32+/// Instruction budget for step over / step out / run to cursor.
33+const STEP_BUDGET: u32 = 5_000_000;
34+
35+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36+pub enum MemSpace {
37+ Iram,
38+ Sfr,
39+ Xram,
40+ Code,
41+}
42+
43+impl MemSpace {
44+ pub const ALL: [MemSpace; 4] = [
45+ MemSpace::Iram,
46+ MemSpace::Sfr,
47+ MemSpace::Xram,
48+ MemSpace::Code,
49+ ];
50+
51+ pub fn name(self) -> &'static str {
52+ match self {
53+ MemSpace::Iram => "IRAM",
54+ MemSpace::Sfr => "SFR",
55+ MemSpace::Xram => "XRAM",
56+ MemSpace::Code => "CODE",
57+ }
58+ }
59+}
60+
61+pub struct Session {
62+ pub sim: Sim,
63+ /// Last assembler result, even if it had errors (for diagnostics).
64+ pub last_asm: Option<(String, AsmOutput)>,
65+ /// Last error-free build, loaded into the simulator.
66+ pub build: Option<Build>,
67+ /// Breakpoints by source line.
68+ pub breakpoints: HashSet<usize>,
69+ pub running: bool,
70+ /// The next run chunk starts a fresh run and may leave a breakpoint.
71+ resume: bool,
72+ /// Index into `SPEEDS`.
73+ pub speed: usize,
74+ pub clock_mhz: f64,
75+ /// Memory before the last step or run chunk, to highlight changes.
76+ pub prev_iram: [u8; 256],
77+ pub prev_sfr: [u8; 128],
78+ /// Outcome of the last command, for the status line.
79+ pub status: String,
80+}
81+
82+impl Session {
83+ pub fn new(clock_mhz: f64) -> Self {
84+ Self {
85+ sim: Sim::new(),
86+ last_asm: None,
87+ build: None,
88+ breakpoints: HashSet::new(),
89+ running: false,
90+ resume: false,
91+ speed: 5,
92+ clock_mhz,
93+ prev_iram: [0; 256],
94+ prev_sfr: [0; 128],
95+ status: String::new(),
96+ }
97+ }
98+
99+ /// Forgets the program and breakpoints, for a newly loaded source.
100+ pub fn clear(&mut self) {
101+ self.breakpoints.clear();
102+ self.build = None;
103+ self.last_asm = None;
104+ self.running = false;
105+ }
106+
107+ /// Whether `lines` differ from the source the simulator is running.
108+ pub fn is_stale<'a>(&self, lines: impl IntoIterator<Item = &'a str>) -> bool {
109+ self.build
110+ .as_ref()
111+ .is_none_or(|b| !b.source.iter().map(String::as_str).eq(lines))
112+ }
113+
114+ /// Diagnostics of the last assembly, if it was of exactly `source`.
115+ pub fn diagnostics_for(&self, source: &str) -> &[Diagnostic] {
116+ match &self.last_asm {
117+ Some((src, out)) if src == source => &out.diagnostics,
118+ _ => &[],
119+ }
120+ }
121+
122+ // ---- Build ----
123+
124+ /// Assembles `source`. On success the program is loaded into a freshly
125+ /// reset simulator and, if `path` (the saved source file) is given,
126+ /// `.lst` and `.hex` files are written next to it.
127+ pub fn assemble(&mut self, source: &str, path: Option<&Path>) -> bool {
128+ let out = asm::assemble(source);
129+ let ok = out.ok();
130+ if ok {
131+ self.sim.load_code(out.bytes());
132+ self.sim.reset();
133+ self.running = false;
134+ self.status = format!("assembled: {} bytes, 0 errors", out.code_size());
135+ if let Some(path) = path {
136+ self.write_outputs(&out, source, path);
137+ }
138+ self.build = Some(Build {
139+ source: source.split('\n').map(String::from).collect(),
140+ addr_to_line: out.addr_to_line(),
141+ out: out.clone(),
142+ });
143+ self.snapshot();
144+ } else {
145+ let first = &out.diagnostics[0];
146+ self.status = format!(
147+ "{} error(s); line {}: {}",
148+ out.diagnostics.len(),
149+ first.line + 1,
150+ first.msg
151+ );
152+ }
153+ self.last_asm = Some((source.to_string(), out));
154+ ok
155+ }
156+
157+ /// Makes sure a build of `source` is loaded; returns false on errors.
158+ pub fn ensure_built(&mut self, source: &str, path: Option<&Path>) -> bool {
159+ !self.is_stale(source.split('\n')) || self.assemble(source, path)
160+ }
161+
162+ fn write_outputs(&mut self, out: &AsmOutput, source: &str, path: &Path) {
163+ let res = std::fs::write(path.with_extension("lst"), out.listing(source))
164+ .and_then(|_| std::fs::write(path.with_extension("hex"), out.intel_hex()));
165+ if let Err(e) = res {
166+ self.status = format!("assembled, but writing .lst/.hex failed: {e}");
167+ }
168+ }
169+
170+ // ---- Breakpoints ----
171+
172+ pub fn toggle_breakpoint(&mut self, line: usize) {
173+ if !self.breakpoints.remove(&line) {
174+ self.breakpoints.insert(line);
175+ }
176+ }
177+
178+ pub fn bp_addrs(&self) -> HashSet<u16> {
179+ let Some(b) = &self.build else {
180+ return HashSet::new();
181+ };
182+ self.breakpoints
183+ .iter()
184+ .filter_map(|&l| b.line_addr(l))
185+ .collect()
186+ }
187+
188+ // ---- Execution ----
189+
190+ pub fn snapshot(&mut self) {
191+ self.prev_iram = self.sim.iram;
192+ self.prev_sfr = self.sim.sfr;
193+ }
194+
195+ /// Source line of the instruction at the PC.
196+ pub fn pc_line(&self) -> Option<usize> {
197+ self.build.as_ref()?.addr_to_line.get(&self.sim.pc).copied()
198+ }
199+
200+ /// Stops running and describes why in `status`.
201+ pub fn report(&mut self, stop: Stop) {
202+ self.running = false;
203+ self.status = match stop {
204+ Stop::Breakpoint(pc) => format!("breakpoint at {pc:04X}h"),
205+ Stop::ReservedOpcode { pc, opcode } => {
206+ format!("reserved opcode {opcode:02X}h at {pc:04X}h — stopped")
207+ }
208+ Stop::PowerDown => "CPU in power-down mode (PCON.PD) — reset to continue".into(),
209+ Stop::Target | Stop::Budget => format!("stopped at {:04X}h", self.sim.pc),
210+ };
211+ }
212+
213+ fn step_with(&mut self, f: impl FnOnce(&mut Sim, &HashSet<u16>) -> Stop) {
214+ self.running = false;
215+ self.snapshot();
216+ let bps = self.bp_addrs();
217+ match f(&mut self.sim, &bps) {
218+ Stop::Target => self.status = format!("PC = {:04X}h", self.sim.pc),
219+ s => self.report(s),
220+ }
221+ }
222+
223+ pub fn step_into(&mut self) {
224+ self.step_with(|s, _| match s.step() {
225+ Ok(()) => Stop::Target,
226+ Err(e) => e,
227+ });
228+ }
229+
230+ pub fn step_over(&mut self) {
231+ self.step_with(|s, b| s.step_over(STEP_BUDGET, b));
232+ }
233+
234+ pub fn step_out(&mut self) {
235+ self.step_with(|s, b| s.step_out(STEP_BUDGET, b));
236+ }
237+
238+ /// Runs until the first instruction of `line`, or a breakpoint.
239+ /// Returns false if the line holds no code.
240+ pub fn run_to_line(&mut self, line: usize) -> bool {
241+ let Some(target) = self.build.as_ref().and_then(|b| b.line_addr(line)) else {
242+ self.status = "no code on that line".into();
243+ return false;
244+ };
245+ self.step_with(|s, bps| {
246+ let mut bps = bps.clone();
247+ bps.insert(target);
248+ match s.run(STEP_BUDGET, &bps, true) {
249+ Stop::Breakpoint(pc) if pc == target => Stop::Target,
250+ other => other,
251+ }
252+ });
253+ true
254+ }
255+
256+ /// Starts free running; `tick` then advances the program. Needs a build.
257+ pub fn start(&mut self) {
258+ if self.build.is_none() {
259+ return;
260+ }
261+ self.running = true;
262+ self.resume = true;
263+ self.status = "running…".into();
264+ }
265+
266+ pub fn pause(&mut self) {
267+ self.running = false;
268+ self.status = format!("paused at {:04X}h", self.sim.pc);
269+ }
270+
271+ /// Runs one chunk of `SPEEDS[speed]` instructions if running. Front ends
272+ /// call this every ~33 ms.
273+ pub fn tick(&mut self) {
274+ if !self.running {
275+ return;
276+ }
277+ self.snapshot();
278+ let bps = self.bp_addrs();
279+ let resume = std::mem::take(&mut self.resume);
280+ match self.sim.run(SPEEDS[self.speed], &bps, resume) {
281+ Stop::Budget => {}
282+ s => self.report(s),
283+ }
284+ }
285+
286+ pub fn reset(&mut self) {
287+ self.sim.reset();
288+ self.running = false;
289+ self.snapshot();
290+ self.status = "reset".into();
291+ }
292+
293+ /// Writes `value` at `addr` in `space`. Returns false if out of range.
294+ pub fn poke(&mut self, space: MemSpace, addr: u32, value: u8) -> bool {
295+ let ok = match space {
296+ MemSpace::Iram if addr <= 0xFF => {
297+ self.sim.iram[addr as usize] = value;
298+ true
299+ }
300+ MemSpace::Sfr if (0x80..=0xFF).contains(&addr) => {
301+ self.sim.set_sfr(addr as u8, value);
302+ true
303+ }
304+ MemSpace::Xram if addr <= 0xFFFF => {
305+ self.sim.xram[addr as usize] = value;
306+ true
307+ }
308+ MemSpace::Code if addr <= 0xFFFF => {
309+ self.sim.code[addr as usize] = value;
310+ true
311+ }
312+ _ => false,
313+ };
314+ self.status = if ok {
315+ format!("wrote {value:02X}h at {addr:X}h")
316+ } else {
317+ "address out of range".into()
318+ };
319+ ok
320+ }
321+
322+ /// Simulated time since reset at `clock_mhz` (12 clocks per machine cycle).
323+ pub fn elapsed(&self) -> String {
324+ let micros = self.sim.cycles as f64 * 12.0 / self.clock_mhz;
325+ if micros >= 1_000_000.0 {
326+ format!("{:.3} s", micros / 1e6)
327+ } else if micros >= 1000.0 {
328+ format!("{:.3} ms", micros / 1e3)
329+ } else {
330+ format!("{micros:.2} µs")
331+ }
332+ }
333+}
334+
335+/// Name of the interrupt source served at vector `v`.
336+pub fn vector_name(v: u16) -> &'static str {
337+ match v {
338+ 0x03 => "INT0",
339+ 0x0B => "T0",
340+ 0x13 => "INT1",
341+ 0x1B => "T1",
342+ 0x23 => "UART",
343+ _ => "?",
344+ }
345+}
346+
347+#[cfg(test)]
348+mod tests {
349+ use super::{MemSpace, Session};
350+
351+ const PROG: &str = "\
352+ ORG 0
353+ LJMP main
354+ ORG 30h
355+main: MOV R0,#0
356+loop: ACALL bump
357+ SJMP loop
358+bump: INC R0
359+ NOP
360+ RET
361+ END";
362+
363+ fn session() -> Session {
364+ let mut s = Session::new(12.0);
365+ assert!(s.assemble(PROG, None), "{}", s.status);
366+ s
367+ }
368+
369+ #[test]
370+ fn breakpoints_steps_and_run_to_line() {
371+ let mut s = session();
372+ s.toggle_breakpoint(6); // bump: INC R0
373+ s.start();
374+ s.tick();
375+ assert!(!s.running);
376+ assert_eq!(s.pc_line(), Some(6));
377+ assert_eq!(s.status, format!("breakpoint at {:04X}h", s.sim.pc));
378+
379+ s.step_into(); // INC R0
380+ assert_eq!(s.sim.reg(0), 1);
381+ assert_eq!(s.pc_line(), Some(7));
382+ s.step_out(); // back after ACALL
383+ assert_eq!(s.pc_line(), Some(5));
384+
385+ s.toggle_breakpoint(6);
386+ s.step_into(); // SJMP loop
387+ assert_eq!(s.pc_line(), Some(4));
388+ s.step_over(); // whole call
389+ assert_eq!((s.pc_line(), s.sim.reg(0)), (Some(5), 2));
390+
391+ assert!(s.run_to_line(7));
392+ assert_eq!((s.pc_line(), s.sim.reg(0)), (Some(7), 3));
393+ assert!(!s.run_to_line(0)); // ORG: no code
394+ }
395+
396+ #[test]
397+ fn staleness_errors_and_poke() {
398+ let mut s = session();
399+ assert!(!s.is_stale(PROG.split('\n')));
400+ let edited = PROG.replace("NOP", "NOP\n NOP");
401+ assert!(s.is_stale(edited.split('\n')));
402+ assert!(s.ensure_built(&edited, None));
403+ assert!(!s.is_stale(edited.split('\n')));
404+
405+ let bad = " MOV A,\n FOO\n";
406+ assert!(!s.assemble(bad, None));
407+ assert_eq!(s.diagnostics_for(bad).len(), 2);
408+ assert!(s.diagnostics_for(PROG).is_empty());
409+ assert!(s.build.is_some(), "a failed build keeps the last good one");
410+
411+ assert!(s.poke(MemSpace::Iram, 0x30, 0xAB));
412+ assert_eq!(s.sim.iram[0x30], 0xAB);
413+ assert!(s.poke(MemSpace::Sfr, 0x90, 0x0F));
414+ assert!(!s.poke(MemSpace::Sfr, 0x20, 1));
415+ assert_eq!(s.status, "address out of range");
416+ }
417+}
R src/sfr.rs → crates/bench51-core/src/sfr.rs View

[No content changes]

R src/sim/cpu.rs → crates/bench51-core/src/sim/cpu.rs View

[No content changes]

R src/sim/disasm.rs → crates/bench51-core/src/sim/disasm.rs View

[No content changes]

R src/sim/mod.rs → crates/bench51-core/src/sim/mod.rs View
@@ -385,7 +385,7 @@ mod example_tests {
385385
386386 #[test]
387387 fn serial_hello_greets_and_echoes() {
388− let mut s = load(include_str!("../../examples/serial_hello.asm"));
388+ let mut s = load(include_str!("../../../../examples/serial_hello.asm"));
389389 steps(&mut s, 60_000);
390390 assert_eq!(
391391 String::from_utf8_lossy(&s.uart.tx_log),
@@ -398,7 +398,7 @@ mod example_tests {
398398
399399 #[test]
400400 fn timer_irq_ticks() {
401− let mut s = load(include_str!("../../examples/timer_irq.asm"));
401+ let mut s = load(include_str!("../../../../examples/timer_irq.asm"));
402402 steps(&mut s, 5_000);
403403 // ~10000 cycles at one interrupt per 1000 cycles.
404404 assert!((3..=10).contains(&s.reg(7)), "R7 = {}", s.reg(7));
@@ -406,7 +406,7 @@ mod example_tests {
406406
407407 #[test]
408408 fn button_counts_edges_while_idle() {
409− let mut s = load(include_str!("../../examples/button_int0.asm"));
409+ let mut s = load(include_str!("../../../../examples/button_int0.asm"));
410410 steps(&mut s, 20);
411411 assert!(s.idle);
412412 for _ in 0..3 {
@@ -420,10 +420,10 @@ mod example_tests {
420420
421421 #[test]
422422 fn bcd_and_lookup() {
423− let mut s = load(include_str!("../../examples/bcd_add.asm"));
423+ let mut s = load(include_str!("../../../../examples/bcd_add.asm"));
424424 steps(&mut s, 20);
425425 assert_eq!((s.iram[0x31], s.iram[0x30]), (0x69, 0x12));
426− let mut s = load(include_str!("../../examples/lookup_movc.asm"));
426+ let mut s = load(include_str!("../../../../examples/lookup_movc.asm"));
427427 steps(&mut s, 3 + 4 * 5 + 3); // setup, digits 0-3, then up to MOV P2,A
428428 assert_eq!(s.sfr(crate::sfr::P2), 0x66); // digit 4
429429 }
R src/sim/periph.rs → crates/bench51-core/src/sim/periph.rs View

[No content changes]

A crates/bench51-core/src/syntax.rs View
@@ -0,0 +1,192 @@
1+//! Minimal 8051 assembly tokenizer for syntax highlighting.
2+
3+use std::ops::Range;
4+
5+use crate::{isa, sfr};
6+
7+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8+pub enum Tok {
9+ /// Whitespace, punctuation and identifiers without a special meaning.
10+ Plain,
11+ Comment,
12+ Str,
13+ /// A label definition (`name:` at the start of a line).
14+ Label,
15+ Mnemonic,
16+ /// Directives and expression operators (`ORG`, `DB`, `HIGH`, `MOD`, …).
17+ Directive,
18+ Register,
19+ /// Predefined SFR and bit names.
20+ Sfr,
21+ Number,
22+}
23+
24+fn is_directive(w: &str) -> bool {
25+ matches!(
26+ w,
27+ "ORG"
28+ | "DB"
29+ | "DW"
30+ | "DS"
31+ | "END"
32+ | "EQU"
33+ | "SET"
34+ | "BIT"
35+ | "DATA"
36+ | "IDATA"
37+ | "XDATA"
38+ | "CODE"
39+ | "CSEG"
40+ | "XSEG"
41+ | "DSEG"
42+ | "AT"
43+ | "USING"
44+ | "NAME"
45+ | "BYTE"
46+ | "WORD"
47+ )
48+}
49+
50+fn word_kind(w: &str, first_word: bool) -> Tok {
51+ let u = w.to_ascii_uppercase();
52+ let reg = matches!(u.as_str(), "A" | "AB" | "C" | "DPTR" | "PC")
53+ || (u.len() == 2
54+ && u.starts_with('R')
55+ && u.as_bytes()[1].is_ascii_digit()
56+ && u.as_bytes()[1] < b'8');
57+ if reg {
58+ Tok::Register
59+ } else if is_directive(&u) {
60+ Tok::Directive
61+ } else if first_word && isa::is_mnemonic(&u) {
62+ Tok::Mnemonic
63+ } else if sfr::SFRS.iter().chain(sfr::BITS).any(|(n, _)| *n == u) {
64+ Tok::Sfr
65+ } else if matches!(
66+ u.as_str(),
67+ "HIGH" | "LOW" | "MOD" | "AND" | "OR" | "XOR" | "NOT" | "SHL" | "SHR"
68+ ) {
69+ Tok::Directive
70+ } else {
71+ Tok::Plain
72+ }
73+}
74+
75+/// Splits a source line into classified byte ranges that together cover
76+/// the whole line.
77+pub fn tokens(line: &str) -> Vec<(Range<usize>, Tok)> {
78+ let b = line.as_bytes();
79+ let n = b.len();
80+ let mut out = Vec::new();
81+ let mut i = 0;
82+ let mut seen_word = false;
83+ while i < n {
84+ let c = b[i];
85+ let start = i;
86+ let tok = if c == b';' {
87+ i = n;
88+ Tok::Comment
89+ } else if c == b'\'' || c == b'"' {
90+ i += 1;
91+ while i < n && b[i] != c {
92+ i += 1;
93+ }
94+ i = (i + 1).min(n);
95+ Tok::Str
96+ } else if c.is_ascii_alphabetic() || c == b'_' || c == b'?' {
97+ while i < n && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'?') {
98+ i += 1;
99+ }
100+ if !seen_word && b.get(i) == Some(&b':') {
101+ Tok::Label
102+ } else {
103+ let t = word_kind(&line[start..i], !seen_word);
104+ seen_word = true;
105+ t
106+ }
107+ } else if c.is_ascii_digit() || c == b'#' || c == b'$' {
108+ i += 1;
109+ while i < n && b[i].is_ascii_alphanumeric() {
110+ i += 1;
111+ }
112+ Tok::Number
113+ } else {
114+ // Non-ASCII bytes land here too, so ranges stay on char boundaries.
115+ while i < n && !(b[i].is_ascii_alphanumeric() || b"_?;'\"#$".contains(&b[i])) {
116+ i += 1;
117+ }
118+ Tok::Plain
119+ };
120+ out.push((start..i, tok));
121+ }
122+ out
123+}
124+
125+#[cfg(test)]
126+mod tests {
127+ use super::{Tok, tokens};
128+
129+ fn kinds(line: &str) -> Vec<(&str, Tok)> {
130+ tokens(line)
131+ .into_iter()
132+ .filter(|(_, t)| *t != Tok::Plain)
133+ .map(|(r, t)| (&line[r], t))
134+ .collect()
135+ }
136+
137+ #[test]
138+ fn classifies_an_instruction_line() {
139+ assert_eq!(
140+ kinds("main: MOV TMOD,#20h ; timer 1"),
141+ vec![
142+ ("main", Tok::Label),
143+ ("MOV", Tok::Mnemonic),
144+ ("TMOD", Tok::Sfr),
145+ ("#20h", Tok::Number),
146+ ("; timer 1", Tok::Comment),
147+ ]
148+ );
149+ assert_eq!(
150+ kinds(" mov a,r7"),
151+ vec![
152+ ("mov", Tok::Mnemonic),
153+ ("a", Tok::Register),
154+ ("r7", Tok::Register)
155+ ]
156+ );
157+ }
158+
159+ #[test]
160+ fn directives_strings_and_operators() {
161+ assert_eq!(
162+ kinds("msg: DB \"Hi; there\", HIGH(x) ; c"),
163+ vec![
164+ ("msg", Tok::Label),
165+ ("DB", Tok::Directive),
166+ ("\"Hi; there\"", Tok::Str),
167+ ("HIGH", Tok::Directive),
168+ ("; c", Tok::Comment),
169+ ]
170+ );
171+ }
172+
173+ #[test]
174+ fn ranges_cover_the_line_even_with_unicode() {
175+ for line in [
176+ "",
177+ " ",
178+ "x DB 'é', 1 ; µs → ok",
179+ "\"unterminated",
180+ "€€ MOV",
181+ ] {
182+ let t = tokens(line);
183+ let mut pos = 0;
184+ for (r, _) in &t {
185+ assert_eq!(r.start, pos, "{line:?}");
186+ assert!(line.is_char_boundary(r.end));
187+ pos = r.end;
188+ }
189+ assert_eq!(pos, line.len());
190+ }
191+ }
192+}
M src/main.rs View
@@ -1,12 +1,9 @@
1−mod asm;
2−mod isa;
3−mod sfr;
4−mod sim;
51 mod ui;
62
73 use std::path::PathBuf;
84 use std::process::ExitCode;
95
6+use bench51_core::asm;
107 use clap::Parser;
118
129 /// Intel 8051 assembler, simulator and TUI debugger.
M src/ui/app.rs View
@@ -1,13 +1,11 @@
1−use std::collections::{HashMap, HashSet};
21 use std::path::{Path, PathBuf};
32 use std::process::Command;
43
4+use bench51_core::files::{self, Loaded};
5+use bench51_core::session::{MemSpace, SPEEDS, Session};
56 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
67 use ratatui_textarea::{CursorMove, TextArea};
78
8−use crate::asm::{self, AsmOutput};
9−use crate::sim::{Sim, Stop, disasm};
10−
119 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1210 pub enum Mode {
1311 Edit,
@@ -31,13 +29,7 @@ pub enum Focus {
3129 Serial,
3230 }
3331
34−#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35−pub enum MemTab {
36− Iram,
37− Sfr,
38− Xram,
39− Code,
40−}
32+pub type MemTab = MemSpace;
4133
4234 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
4335 pub enum PromptKind {
@@ -52,27 +44,6 @@ pub struct Prompt {
5244 pub input: String,
5345 }
5446
55−/// Result of the last successful assembly, which the debugger runs.
56−pub struct Build {
57− pub source: Vec<String>,
58− pub out: AsmOutput,
59− pub addr_to_line: HashMap<u16, usize>,
60−}
61−
62−impl Build {
63− /// First address emitted by `line`, if any.
64− pub fn line_addr(&self, line: usize) -> Option<u16> {
65− self.out
66− .lines
67− .get(line)
68− .filter(|l| !l.bytes.is_empty())
69− .and_then(|l| l.addr)
70− }
71−}
72−
73−/// Instructions executed per frame (~30 fps) for each speed setting.
74−pub const SPEEDS: [u32; 8] = [1, 5, 25, 100, 500, 2_000, 10_000, 40_000];
75−
7647 pub struct App {
7748 pub path: Option<PathBuf>,
7849 pub editor: TextArea<'static>,
@@ -82,27 +53,14 @@ pub struct App {
8253 pub focus: Focus,
8354 pub mem_tab: MemTab,
8455 pub mem_scroll: usize,
85− /// Last assembler result, even if it had errors (for diagnostics).
86− pub last_asm: Option<(String, AsmOutput)>,
87− /// Last error-free build, loaded into the simulator.
88− pub build: Option<Build>,
89− pub sim: Sim,
90− /// Breakpoints by source line.
91− pub breakpoints: HashSet<usize>,
92− pub running: bool,
93− /// The next run chunk starts a fresh run and may leave a breakpoint.
94− resume: bool,
95− pub speed: usize,
96− pub clock_mhz: f64,
97− pub status: String,
56+ /// Simulator, build, breakpoints and run state.
57+ pub session: Session,
9858 /// Selected line in the debug source view.
9959 pub src_cursor: usize,
10060 pub src_scroll: usize,
10161 pub out_scroll: usize,
10262 pub port_sel: (usize, u8),
10363 pub serial_input: String,
104− pub prev_iram: [u8; 256],
105− pub prev_sfr: [u8; 128],
10664 pub show_help: bool,
10765 pub prompt: Option<Prompt>,
10866 pub quit: bool,
@@ -123,28 +81,19 @@ impl App {
12381 focus: Focus::Source,
12482 mem_tab: MemTab::Iram,
12583 mem_scroll: 0,
126− last_asm: None,
127− build: None,
128− sim: Sim::new(),
129− breakpoints: HashSet::new(),
130− running: false,
131− resume: false,
132− speed: 5,
133− clock_mhz,
134− status: "F1 help · F7 assemble · F5 run · F11 step".into(),
84+ session: Session::new(clock_mhz),
13585 src_cursor: 0,
13686 src_scroll: 0,
13787 out_scroll: 0,
13888 port_sel: (1, 0),
13989 serial_input: String::new(),
140− prev_iram: [0; 256],
141− prev_sfr: [0; 128],
14290 show_help: false,
14391 prompt: None,
14492 quit: false,
14593 quit_armed: false,
14694 external_edit: false,
14795 };
96+ app.session.status = "F1 help · F7 assemble · F5 run · F11 step".into();
14897 match path {
14998 Some(p) => app.open(&p),
15099 None => app.set_text(TEMPLATE),
@@ -161,9 +110,7 @@ impl App {
161110 });
162111 crate::ui::draw::style_editor(&mut self.editor);
163112 self.saved_text = self.text();
164− self.breakpoints.clear();
165− self.build = None;
166− self.last_asm = None;
113+ self.session.clear();
167114 self.focus = Focus::Source;
168115 self.serial_input.clear();
169116 }
@@ -178,9 +125,8 @@ impl App {
178125
179126 /// Source differs from what the simulator is running.
180127 pub fn stale(&self) -> bool {
181− self.build
182− .as_ref()
183− .is_none_or(|b| b.source != self.editor.lines())
128+ self.session
129+ .is_stale(self.editor.lines().iter().map(String::as_str))
184130 }
185131
186132 pub fn title(&self) -> String {
@@ -194,37 +140,24 @@ impl App {
194140 // ---- Files ----
195141
196142 fn open(&mut self, path: &Path) {
197− let text = match std::fs::read_to_string(path) {
198− Ok(t) => t,
199− Err(e) => {
200− self.status = format!("cannot open {}: {e}", path.display());
201− return;
143+ match files::load(path) {
144+ Err(e) => self.session.status = e,
145+ Ok(Loaded::Hex { source, bytes }) => {
146+ self.set_text(&source);
147+ self.saved_text.clear();
148+ self.path = None;
149+ self.assemble();
150+ self.session.status = format!(
151+ "loaded {} ({bytes} bytes) and disassembled it",
152+ path.display()
153+ );
202154 }
203− };
204− if path
205− .extension()
206− .is_some_and(|e| e.eq_ignore_ascii_case("hex") || e.eq_ignore_ascii_case("ihx"))
207− {
208− match asm::hex::read(&text) {
209− Ok(bytes) => {
210− let src = disassemble_image(&bytes, path);
211− self.set_text(&src);
212− self.saved_text.clear();
213− self.path = None;
214− self.assemble();
215− self.status = format!(
216− "loaded {} ({} bytes) and disassembled it",
217− path.display(),
218− bytes.len()
219− );
220− }
221− Err(e) => self.status = format!("invalid HEX file: {e}"),
155+ Ok(Loaded::Source(text)) => {
156+ self.set_text(&text);
157+ self.path = Some(path.to_path_buf());
158+ self.mode = Mode::Edit;
159+ self.session.status = format!("opened {}", path.display());
222160 }
223− } else {
224− self.set_text(&text);
225− self.path = Some(path.to_path_buf());
226− self.mode = Mode::Edit;
227− self.status = format!("opened {}", path.display());
228161 }
229162 }
230163
@@ -241,10 +174,10 @@ impl App {
241174 match std::fs::write(&path, text) {
242175 Ok(()) => {
243176 self.saved_text = self.text();
244− self.status = format!("saved {}", path.display());
177+ self.session.status = format!("saved {}", path.display());
245178 self.path = Some(path);
246179 }
247− Err(e) => self.status = format!("cannot save {}: {e}", path.display()),
180+ Err(e) => self.session.status = format!("cannot save {}: {e}", path.display()),
248181 }
249182 }
250183
@@ -266,7 +199,7 @@ impl App {
266199 None => {
267200 let p = std::env::temp_dir().join(format!("bench51-{}.asm", std::process::id()));
268201 if let Err(e) = std::fs::write(&p, self.text() + "\n") {
269− self.status = format!("cannot write {}: {e}", p.display());
202+ self.session.status = format!("cannot write {}: {e}", p.display());
270203 return;
271204 }
272205 (p, true)
@@ -280,11 +213,11 @@ impl App {
280213 let _ = std::fs::remove_file(&path);
281214 }
282215 match (result, text) {
283− (Err(e), _) => self.status = format!("cannot start editor: {e}"),
216+ (Err(e), _) => self.session.status = format!("cannot start editor: {e}"),
284217 (Ok(s), _) if !s.success() => {
285− self.status = format!("editor exited with {s}; changes not loaded")
218+ self.session.status = format!("editor exited with {s}; changes not loaded")
286219 }
287− (Ok(_), Err(e)) => self.status = format!("cannot read {}: {e}", path.display()),
220+ (Ok(_), Err(e)) => self.session.status = format!("cannot read {}: {e}", path.display()),
288221 (Ok(_), Ok(text)) => self.reload(&text, !temp),
289222 }
290223 }
@@ -299,7 +232,7 @@ impl App {
299232 lines
300233 };
301234 if lines == self.editor.lines() {
302− self.status = "no changes from external editor".into();
235+ self.session.status = "no changes from external editor".into();
303236 return;
304237 }
305238 let cursor = self.editor.cursor();
@@ -312,61 +245,33 @@ impl App {
312245 self.saved_text = self.text();
313246 }
314247 let n = self.editor.lines().len();
315− self.breakpoints.retain(|&l| l < n);
248+ self.session.breakpoints.retain(|&l| l < n);
316249 if self.assemble() {
317− self.status = format!("reloaded from external editor; {}", self.status);
250+ self.session.status = format!("reloaded from external editor; {}", self.session.status);
318251 }
319252 }
320253
321254 // ---- Build & run ----
322255
323256 /// Assembles the editor contents. On success the program is loaded
324− /// into a freshly reset simulator.
257+ /// into a freshly reset simulator; on errors the cursor jumps to the
258+ /// first one.
325259 pub fn assemble(&mut self) -> bool {
326260 let source = self.text();
327− let out = asm::assemble(&source);
328− let ok = out.ok();
261+ let ok = self.session.assemble(&source, self.path.as_deref());
329262 if ok {
330− self.sim.load_code(out.bytes());
331− self.sim.reset();
332− self.running = false;
333− self.status = format!("assembled: {} bytes, 0 errors", out.code_size());
334− self.write_outputs(&out, &source);
335− self.build = Some(Build {
336− source: self.editor.lines().to_vec(),
337− addr_to_line: out.addr_to_line(),
338− out: out.clone(),
339− });
340− self.snapshot();
341263 self.follow_pc();
342− } else {
264+ } else if let Some((_, out)) = &self.session.last_asm {
343265 let first = &out.diagnostics[0];
344− self.status = format!(
345− "{} error(s); line {}: {}",
346− out.diagnostics.len(),
347− first.line + 1,
348− first.msg
349− );
350266 self.editor
351267 .move_cursor(CursorMove::Jump(first.line as u16, first.col as u16));
352268 self.mode = Mode::Edit;
353269 self.out_tab = OutTab::Errors;
354270 }
355271 self.out_scroll = 0;
356− self.last_asm = Some((source, out));
357272 ok
358273 }
359274
360− /// Writes `.lst` and `.hex` next to a saved source file.
361− fn write_outputs(&mut self, out: &AsmOutput, source: &str) {
362− let Some(path) = &self.path else { return };
363− let res = std::fs::write(path.with_extension("lst"), out.listing(source))
364− .and_then(|_| std::fs::write(path.with_extension("hex"), out.intel_hex()));
365− if let Err(e) = res {
366− self.status = format!("assembled, but writing .lst/.hex failed: {e}");
367− }
368− }
369−
370275 /// Makes sure an up-to-date build is loaded; returns false on errors.
371276 fn ensure_built(&mut self) -> bool {
372277 if self.stale() && !self.assemble() {
@@ -379,132 +284,65 @@ impl App {
379284 true
380285 }
381286
382− pub fn bp_addrs(&self) -> HashSet<u16> {
383− let Some(b) = &self.build else {
384− return HashSet::new();
385− };
386− self.breakpoints
387− .iter()
388− .filter_map(|&l| b.line_addr(l))
389− .collect()
390− }
391−
392− pub fn snapshot(&mut self) {
393− self.prev_iram = self.sim.iram;
394− self.prev_sfr = self.sim.sfr;
395− }
396−
397− pub fn pc_line(&self) -> Option<usize> {
398− self.build.as_ref()?.addr_to_line.get(&self.sim.pc).copied()
399− }
400−
401− /// Scrolls the source view so the PC line is visible.
287+ /// Moves the source cursor to the PC line.
402288 pub fn follow_pc(&mut self) {
403− if let Some(l) = self.pc_line() {
289+ if let Some(l) = self.session.pc_line() {
404290 self.src_cursor = l;
405291 }
406292 }
407293
408− fn report(&mut self, stop: Stop) {
409− self.running = false;
410− self.status = match stop {
411− Stop::Breakpoint(pc) => format!("breakpoint at {pc:04X}h"),
412− Stop::ReservedOpcode { pc, opcode } => {
413− format!("reserved opcode {opcode:02X}h at {pc:04X}h — stopped")
414− }
415− Stop::PowerDown => "CPU in power-down mode (PCON.PD) — reset to continue".into(),
416− Stop::Target | Stop::Budget => format!("stopped at {:04X}h", self.sim.pc),
417− };
418− self.follow_pc();
419− }
420−
421− fn step_cmd(&mut self, f: impl FnOnce(&mut Sim, &HashSet<u16>) -> Stop) {
294+ /// Runs a session command once a build is loaded, then follows the PC.
295+ fn debug_cmd(&mut self, f: impl FnOnce(&mut Session)) {
422296 if !self.ensure_built() {
423297 return;
424298 }
425− self.running = false;
426− self.snapshot();
427− let bps = self.bp_addrs();
428− let stop = f(&mut self.sim, &bps);
429− match stop {
430− Stop::Target => {
431− self.status = format!("PC = {:04X}h", self.sim.pc);
432− self.follow_pc();
433− }
434− Stop::Budget => self.report(Stop::Budget),
435− s => self.report(s),
436− }
299+ f(&mut self.session);
300+ self.follow_pc();
437301 }
438302
439303 fn toggle_run(&mut self) {
440− if self.running {
441− self.running = false;
442− self.status = format!("paused at {:04X}h", self.sim.pc);
304+ if self.session.running {
305+ self.session.pause();
443306 self.follow_pc();
444307 } else if self.ensure_built() {
445− self.running = true;
446− self.resume = true;
447− self.status = "running…".into();
308+ self.session.start();
448309 }
449310 }
450311
451− fn run_to_cursor(&mut self) {
452− if !self.ensure_built() {
453− return;
454− }
455− let line = if self.mode == Mode::Debug {
312+ /// Source line the cursor is on in the current mode.
313+ fn cursor_line(&self) -> usize {
314+ if self.mode == Mode::Debug {
456315 self.src_cursor
457316 } else {
458317 self.editor.cursor().0
459− };
460− let Some(target) = self.build.as_ref().and_then(|b| b.line_addr(line)) else {
461− self.status = "no code on that line".into();
318+ }
319+ }
320+
321+ fn run_to_cursor(&mut self) {
322+ if !self.ensure_built() {
462323 return;
463− };
464− self.step_cmd(|s, bps| {
465− let mut bps = bps.clone();
466− bps.insert(target);
467− match s.run(5_000_000, &bps, true) {
468− Stop::Breakpoint(pc) if pc == target => Stop::Target,
469− other => other,
470− }
324+ }
325+ let line = self.cursor_line();
326+ self.debug_cmd(|s| {
327+ s.run_to_line(line);
471328 });
472329 }
473330
474331 fn reset(&mut self) {
475− self.sim.reset();
476− self.running = false;
477− self.snapshot();
332+ self.session.reset();
478333 self.follow_pc();
479− self.status = "reset".into();
480334 }
481335
482336 fn toggle_breakpoint(&mut self) {
483− let line = if self.mode == Mode::Debug {
484− self.src_cursor
485− } else {
486− self.editor.cursor().0
487− };
488− if !self.breakpoints.remove(&line) {
489− self.breakpoints.insert(line);
490− }
337+ let line = self.cursor_line();
338+ self.session.toggle_breakpoint(line);
491339 }
492340
493341 /// Called once per frame.
494342 pub fn tick(&mut self) {
495− if !self.running {
496− return;
497− }
498− self.snapshot();
499− let bps = self.bp_addrs();
500− let resume = std::mem::take(&mut self.resume);
501− match self.sim.run(SPEEDS[self.speed], &bps, resume) {
502− Stop::Budget => {
503− if let Some(l) = self.pc_line() {
504− self.src_cursor = l;
505− }
506− }
507− s => self.report(s),
343+ if self.session.running {
344+ self.session.tick();
345+ self.follow_pc();
508346 }
509347 }
510348
@@ -541,7 +379,7 @@ impl App {
541379 (KeyCode::Char('q'), true) => {
542380 if self.dirty() && !self.quit_armed {
543381 self.quit_armed = true;
544− self.status = "unsaved changes — press Ctrl-Q again to quit".into();
382+ self.session.status = "unsaved changes — press Ctrl-Q again to quit".into();
545383 } else {
546384 self.quit = true;
547385 }
@@ -571,12 +409,9 @@ impl App {
571409 }
572410 (KeyCode::F(8), _) => self.run_to_cursor(),
573411 (KeyCode::F(9), _) => self.toggle_breakpoint(),
574− (KeyCode::F(10), _) => self.step_cmd(|s, b| s.step_over(5_000_000, b)),
575− (KeyCode::F(11), _) => self.step_cmd(|s, _| match s.step() {
576− Ok(()) => Stop::Target,
577− Err(e) => e,
578− }),
579− (KeyCode::F(12), _) => self.step_cmd(|s, b| s.step_out(5_000_000, b)),
412+ (KeyCode::F(10), _) => self.debug_cmd(Session::step_over),
413+ (KeyCode::F(11), _) => self.debug_cmd(Session::step_into),
414+ (KeyCode::F(12), _) => self.debug_cmd(Session::step_out),
580415 _ => match self.mode {
581416 Mode::Edit => self.on_edit_key(key),
582417 Mode::Debug => self.on_debug_key(key),
@@ -595,7 +430,7 @@ impl App {
595430 KeyCode::Down if alt => self.out_scroll += 1,
596431 KeyCode::PageUp if alt => self.out_scroll = self.out_scroll.saturating_sub(20),
597432 KeyCode::PageDown if alt => self.out_scroll += 20,
598− KeyCode::Esc if self.running => self.toggle_run(),
433+ KeyCode::Esc if self.session.running => self.toggle_run(),
599434 _ => {
600435 self.editor.input(key);
601436 }
@@ -608,7 +443,7 @@ impl App {
608443 KeyCode::Enter => {
609444 let mut bytes = std::mem::take(&mut self.serial_input).into_bytes();
610445 bytes.push(b'\r');
611− self.sim.uart_send(&bytes);
446+ self.session.sim.uart_send(&bytes);
612447 return;
613448 }
614449 KeyCode::Backspace => {
@@ -628,7 +463,7 @@ impl App {
628463 KeyCode::Tab => self.cycle_focus(true),
629464 KeyCode::BackTab => self.cycle_focus(false),
630465 KeyCode::Esc => {
631− if self.running {
466+ if self.session.running {
632467 self.toggle_run();
633468 } else if self.focus == Focus::Serial {
634469 self.focus = Focus::Source;
@@ -637,9 +472,9 @@ impl App {
637472 }
638473 }
639474 KeyCode::Char('+') | KeyCode::Char('=') => {
640− self.speed = (self.speed + 1).min(SPEEDS.len() - 1)
475+ self.session.speed = (self.session.speed + 1).min(SPEEDS.len() - 1)
641476 }
642− KeyCode::Char('-') => self.speed = self.speed.saturating_sub(1),
477+ KeyCode::Char('-') => self.session.speed = self.session.speed.saturating_sub(1),
643478 KeyCode::Char('?') => self.show_help = true,
644479 // Letter aliases for terminals that swallow function keys.
645480 KeyCode::Char('r') => self.toggle_run(),
@@ -684,6 +519,7 @@ impl App {
684519
685520 fn move_src(&mut self, code: KeyCode, page: usize) {
686521 let max = self
522+ .session
687523 .build
688524 .as_ref()
689525 .map_or(0, |b| b.source.len().saturating_sub(1));
@@ -694,7 +530,7 @@ impl App {
694530 KeyCode::PageDown => self.src_cursor + page,
695531 KeyCode::Home => 0,
696532 KeyCode::End => max,
697− KeyCode::Char('.') => self.pc_line().unwrap_or(self.src_cursor),
533+ KeyCode::Char('.') => self.session.pc_line().unwrap_or(self.src_cursor),
698534 _ => self.src_cursor,
699535 }
700536 .min(max);
@@ -745,7 +581,7 @@ impl App {
745581 KeyCode::Left => *bit = (*bit + 1).min(7),
746582 KeyCode::Right => *bit = bit.saturating_sub(1),
747583 KeyCode::Char(' ') | KeyCode::Enter => {
748− self.sim.pins[*port] ^= 1 << *bit;
584+ self.session.sim.pins[*port] ^= 1 << *bit;
749585 }
750586 _ => {}
751587 }
@@ -786,38 +622,14 @@ impl App {
786622 parts.first().and_then(|s| parse(s)),
787623 parts.get(1).and_then(|s| parse(s)),
788624 ) else {
789− self.status = "usage: <addr> <value> (hex), e.g. 30 FF".into();
625+ self.session.status = "usage: <addr> <value> (hex), e.g. 30 FF".into();
790626 return;
791627 };
792628 if val > 0xFF {
793− self.status = "value must be a byte".into();
629+ self.session.status = "value must be a byte".into();
794630 return;
795631 }
796− let v = val as u8;
797− let ok = match self.mem_tab {
798− MemTab::Iram if addr <= 0xFF => {
799− self.sim.iram[addr as usize] = v;
800− true
801− }
802− MemTab::Sfr if (0x80..=0xFF).contains(&addr) => {
803− self.sim.set_sfr(addr as u8, v);
804− true
805− }
806− MemTab::Xram if addr <= 0xFFFF => {
807− self.sim.xram[addr as usize] = v;
808− true
809− }
810− MemTab::Code if addr <= 0xFFFF => {
811− self.sim.code[addr as usize] = v;
812− true
813− }
814− _ => false,
815− };
816− self.status = if ok {
817− format!("wrote {v:02X}h at {addr:X}h")
818− } else {
819− "address out of range".into()
820− };
632+ self.session.poke(self.mem_tab, addr, val as u8);
821633 }
822634 }
823635
@@ -848,46 +660,6 @@ fn editor_command(path: &Path, line: usize) -> Command {
848660 cmd
849661 }
850662
851−/// Builds an assembler source from a HEX image by disassembling each
852−/// contiguous run of bytes. Reassembling it reproduces the image exactly.
853−fn disassemble_image(bytes: &[(u16, u8)], path: &Path) -> String {
854− let mut code = Box::new([0u8; 0x10000]);
855− let mut used = vec![false; 0x10000];
856− for &(a, b) in bytes {
857− code[a as usize] = b;
858− used[a as usize] = true;
859− }
860− let mut src = format!("; disassembled from {}\n", path.display());
861− let mut addr = 0usize;
862− while addr < 0x10000 {
863− if !used[addr] {
864− addr += 1;
865− continue;
866− }
867− src.push_str(&format!(
868− "\n ORG {}\n",
869− crate::sfr::hex16(addr as u16)
870− ));
871− while addr < 0x10000 && used[addr] {
872− let d = disasm::disassemble(&code, addr as u16);
873− let size = d.size as usize;
874− if (addr..addr + size).all(|a| a < 0x10000 && used[a]) {
875− src.push_str(&format!(" {:<24}; {:04X}\n", d.text, addr));
876− addr += size;
877− } else {
878− src.push_str(&format!(
879− " DB {:<21}; {:04X}\n",
880− crate::sfr::hex8(code[addr]),
881− addr
882− ));
883− addr += 1;
884− }
885− }
886− }
887− src.push_str(" END\n");
888− src
889−}
890−
891663 const TEMPLATE: &str = "\
892664 ; New 8051 program. F7 assembles, F5 runs, F11 steps, F1 shows all keys.
893665 ORG 0000h
M src/ui/draw.rs View
@@ -5,9 +5,10 @@ use ratatui::text::{Line, Span};
55 use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph, Tabs, Wrap};
66 use ratatui_textarea::TextArea;
77
8−use super::app::{App, Focus, MemTab, Mode, OutTab, PromptKind, SPEEDS};
8+use super::app::{App, Focus, MemTab, Mode, OutTab, PromptKind};
99 use super::syntax::highlight;
10−use crate::sfr::{self, IE, IP, PCON, PSW, SCON, TCON, TH0, TH1, TL0, TL1, TMOD};
10+use bench51_core::session::{SPEEDS, vector_name};
11+use bench51_core::sfr::{self, IE, IP, PCON, PSW, SCON, TCON, TH0, TH1, TL0, TL1, TMOD};
1112
1213 const ACCENT: Color = Color::Cyan;
1314 const CHANGED: Style = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
@@ -73,12 +74,12 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) {
7374 Style::new().bg(Color::Magenta).fg(Color::White).bold(),
7475 ),
7576 };
76− let (state, state_style) = if app.running {
77+ let (state, state_style) = if app.session.running {
7778 (
7879 " RUNNING ",
7980 Style::new().bg(Color::Green).fg(Color::Black).bold(),
8081 )
81− } else if app.build.is_none() {
82+ } else if app.session.build.is_none() {
8283 (
8384 " NOT BUILT ",
8485 Style::new().bg(Color::DarkGray).fg(Color::White),
@@ -96,14 +97,14 @@ fn draw_header(f: &mut Frame, app: &App, area: Rect) {
9697 Span::styled(state, state_style),
9798 Span::raw(format!(" {}", app.title())),
9899 ];
99− if app.build.is_some() && app.stale() {
100+ if app.session.build.is_some() && app.stale() {
100101 spans.push(Span::styled(
101102 " (source changed — F7 to rebuild)",
102103 Style::new().fg(Color::Yellow),
103104 ));
104105 }
105106 spans.push(Span::styled(
106− format!(" speed {}/frame (+/-)", SPEEDS[app.speed]),
107+ format!(" speed {}/frame (+/-)", SPEEDS[app.session.speed]),
107108 DIM,
108109 ));
109110 f.render_widget(Line::from(spans), area);
@@ -137,7 +138,7 @@ fn draw_footer(f: &mut Frame, app: &App, area: Rect) {
137138 }
138139 };
139140 let width = area.width as usize;
140− let status = format!(" {}", app.status);
141+ let status = format!(" {}", app.session.status);
141142 let pad = width.saturating_sub(status.chars().count() + keys.len() + 1);
142143 let line = Line::from(vec![
143144 Span::styled(status, Style::new().fg(Color::White)),
@@ -163,16 +164,16 @@ fn draw_edit(f: &mut Frame, app: &mut App, area: Rect) {
163164 // Breakpoint / error markers aligned with the editor's scroll position.
164165 let top = app.editor.scroll_offset().0 as usize;
165166 let errors: Vec<usize> = app
166− .last_asm
167− .as_ref()
168− .filter(|(src, _)| *src == app.text())
169− .map(|(_, o)| o.diagnostics.iter().map(|d| d.line).collect())
170− .unwrap_or_default();
167+ .session
168+ .diagnostics_for(&app.text())
169+ .iter()
170+ .map(|d| d.line)
171+ .collect();
171172 let marks: Vec<Line> = (top..top + gutter.height as usize)
172173 .map(|l| {
173174 if errors.contains(&l) {
174175 Line::styled("✗", Style::new().fg(Color::Red).bold())
175− } else if app.breakpoints.contains(&l) {
176+ } else if app.session.breakpoints.contains(&l) {
176177 Line::styled("●", Style::new().fg(Color::Red))
177178 } else {
178179 Line::raw("")
@@ -186,6 +187,7 @@ fn draw_edit(f: &mut Frame, app: &mut App, area: Rect) {
186187
187188 fn draw_output(f: &mut Frame, app: &mut App, area: Rect) {
188189 let n_err = app
190+ .session
189191 .last_asm
190192 .as_ref()
191193 .map_or(0, |(_, o)| o.diagnostics.len());
@@ -214,7 +216,7 @@ fn draw_output(f: &mut Frame, app: &mut App, area: Rect) {
214216 .highlight_style(Style::new().fg(ACCENT).bold().underlined());
215217 f.render_widget(tabs, tabs_area);
216218
217− let lines: Vec<Line> = match (app.out_tab, &app.last_asm) {
219+ let lines: Vec<Line> = match (app.out_tab, &app.session.last_asm) {
218220 (_, None) => vec![Line::styled("Press F7 to assemble.", DIM)],
219221 (OutTab::Listing, Some((src, out))) => out
220222 .listing(src)
@@ -312,7 +314,7 @@ fn draw_source(f: &mut Frame, app: &mut App, area: Rect) {
312314 let block = panel("Source", focused);
313315 let inner = block.inner(area);
314316 f.render_widget(block, area);
315− let Some(build) = &app.build else {
317+ let Some(build) = &app.session.build else {
316318 f.render_widget(
317319 Paragraph::new("Nothing assembled yet — press F7.").style(DIM),
318320 inner,
@@ -326,7 +328,7 @@ fn draw_source(f: &mut Frame, app: &mut App, area: Rect) {
326328 } else if app.src_cursor >= app.src_scroll + h {
327329 app.src_scroll = app.src_cursor + 1 - h;
328330 }
329− let pc_line = build.addr_to_line.get(&app.sim.pc).copied();
331+ let pc_line = build.addr_to_line.get(&app.session.sim.pc).copied();
330332 let lines: Vec<Line> = build
331333 .source
332334 .iter()
@@ -343,7 +345,7 @@ fn draw_source(f: &mut Frame, app: &mut App, area: Rect) {
343345 } else {
344346 Style::new()
345347 };
346− let bp = if app.breakpoints.contains(&i) {
348+ let bp = if app.session.breakpoints.contains(&i) {
347349 Span::styled("●", bg.fg(Color::Red))
348350 } else {
349351 Span::styled(" ", bg)
@@ -394,27 +396,20 @@ fn flags(byte: u8, names: [&'static str; 8]) -> Vec<Span<'static>> {
394396 }
395397
396398 fn draw_registers(f: &mut Frame, app: &App, area: Rect) {
397− let s = &app.sim;
399+ let s = &app.session.sim;
398400 let block = panel("Registers", false);
399401 let inner = block.inner(area);
400402 f.render_widget(block, area);
401− let ch = |addr: u8| s.sfr(addr) != app.prev_sfr[(addr & 0x7F) as usize];
403+ let ch = |addr: u8| s.sfr(addr) != app.session.prev_sfr[(addr & 0x7F) as usize];
402404 let reg = |n: u8| {
403405 let a = s.reg_addr(n) as usize;
404406 vec![
405407 Span::styled(format!("R{n} "), LABEL),
406− hex2(s.iram[a], s.iram[a] != app.prev_iram[a]),
408+ hex2(s.iram[a], s.iram[a] != app.session.prev_iram[a]),
407409 Span::raw(" "),
408410 ]
409411 };
410− let micros = s.cycles as f64 * 12.0 / app.clock_mhz;
411− let time = if micros >= 1_000_000.0 {
412− format!("{:.3} s", micros / 1e6)
413− } else if micros >= 1000.0 {
414− format!("{:.3} ms", micros / 1e3)
415− } else {
416− format!("{micros:.2} µs")
417− };
412+ let time = app.session.elapsed();
418413 let lines = vec![
419414 Line::from(vec![
420415 Span::styled("PC ", LABEL),
@@ -461,18 +456,18 @@ fn draw_registers(f: &mut Frame, app: &App, area: Rect) {
461456 Line::from(vec![
462457 Span::styled("Time ", LABEL),
463458 Span::raw(time),
464− Span::styled(format!(" @{}MHz", app.clock_mhz), DIM),
459+ Span::styled(format!(" @{}MHz", app.session.clock_mhz), DIM),
465460 ]),
466461 ];
467462 f.render_widget(Paragraph::new(lines), inner);
468463 }
469464
470465 fn draw_peripherals(f: &mut Frame, app: &App, area: Rect) {
471− let s = &app.sim;
466+ let s = &app.session.sim;
472467 let block = panel("Peripherals", false);
473468 let inner = block.inner(area);
474469 f.render_widget(block, area);
475− let ch = |addr: u8| s.sfr(addr) != app.prev_sfr[(addr & 0x7F) as usize];
470+ let ch = |addr: u8| s.sfr(addr) != app.session.prev_sfr[(addr & 0x7F) as usize];
476471 let sfr_val = |name: &'static str, addr: u8| {
477472 vec![
478473 Span::styled(name, LABEL),
@@ -609,17 +604,6 @@ fn draw_peripherals(f: &mut Frame, app: &App, area: Rect) {
609604 f.render_widget(Paragraph::new(lines), inner);
610605 }
611606
612−fn vector_name(v: u16) -> &'static str {
613− match v {
614− 0x03 => "INT0",
615− 0x0B => "T0",
616− 0x13 => "INT1",
617− 0x1B => "T1",
618− 0x23 => "UART",
619− _ => "?",
620− }
621−}
622−
623607 fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
624608 let focused = app.focus == Focus::Memory;
625609 let block = panel("Memory", focused);
@@ -646,7 +630,7 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
646630 return;
647631 }
648632
649− let s = &app.sim;
633+ let s = &app.session.sim;
650634 let per_row: usize = if content.width >= 74 { 16 } else { 8 };
651635 let total = match app.mem_tab {
652636 MemTab::Iram => 256,
@@ -655,9 +639,9 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
655639 let rows = total / per_row;
656640 let h = content.height as usize;
657641 app.mem_scroll = app.mem_scroll.min(rows.saturating_sub(h));
658− let bps = app.bp_addrs();
642+ let bps = app.session.bp_addrs();
659643 let pc = s.pc as usize;
660− let pc_len = crate::isa::info(s.code[pc]).map_or(1, |i| i.size as usize);
644+ let pc_len = bench51_core::isa::info(s.code[pc]).map_or(1, |i| i.size as usize);
661645 let bank = s.reg_addr(0) as usize;
662646
663647 let lines: Vec<Line> = (app.mem_scroll..rows.min(app.mem_scroll + h))
@@ -679,7 +663,7 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
679663 let v = s.iram[a];
680664 let st = if a == s.sp() as usize {
681665 Style::new().fg(Color::Black).bg(Color::LightMagenta)
682− } else if v != app.prev_iram[a] {
666+ } else if v != app.session.prev_iram[a] {
683667 CHANGED
684668 } else if (bank..bank + 8).contains(&a) {
685669 Style::new().fg(Color::LightBlue)
@@ -724,7 +708,7 @@ fn draw_memory(f: &mut Frame, app: &mut App, area: Rect) {
724708 }
725709
726710 fn draw_sfr_table(f: &mut Frame, app: &App, area: Rect) {
727− let s = &app.sim;
711+ let s = &app.session.sim;
728712 let cols = if area.width >= 64 { 3 } else { 2 };
729713 let per_col = sfr::SFRS.len().div_ceil(cols);
730714 let lines: Vec<Line> = (0..per_col)
@@ -733,7 +717,7 @@ fn draw_sfr_table(f: &mut Frame, app: &App, area: Rect) {
733717 for c in 0..cols {
734718 if let Some(&(name, addr)) = sfr::SFRS.get(c * per_col + r) {
735719 let v = s.read_direct(addr, true);
736− let changed = s.sfr(addr) != app.prev_sfr[(addr & 0x7F) as usize];
720+ let changed = s.sfr(addr) != app.session.prev_sfr[(addr & 0x7F) as usize];
737721 spans.push(Span::styled(format!("{name:<5}"), LABEL));
738722 spans.push(Span::styled(format!("{addr:02X} "), DIM));
739723 spans.push(hex2(v, changed));
@@ -754,7 +738,7 @@ fn draw_ports(f: &mut Frame, app: &App, area: Rect) {
754738 let block = panel("Ports", focused);
755739 let inner = block.inner(area);
756740 f.render_widget(block, area);
757− let s = &app.sim;
741+ let s = &app.session.sim;
758742 let mut lines: Vec<Line> = (0..4)
759743 .map(|p| {
760744 let latch = s.sfr(sfr::P0 + 0x10 * p as u8);
@@ -797,7 +781,7 @@ fn draw_serial(f: &mut Frame, app: &App, area: Rect) {
797781 Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
798782
799783 let mut text = String::new();
800− for &b in &app.sim.uart.tx_log {
784+ for &b in &app.session.sim.uart.tx_log {
801785 match b {
802786 b'\n' => text.push('\n'),
803787 b'\r' => {}
M src/ui/syntax.rs View
@@ -1,122 +1,27 @@
1−//! Minimal 8051 assembly syntax highlighting.
1+//! 8051 assembly syntax highlighting for the TUI.
22
3+use bench51_core::syntax::{Tok, tokens};
34 use ratatui::style::{Color, Modifier, Style};
45 use ratatui::text::Span;
56
6−use crate::{isa, sfr};
7−
8−const COMMENT: Style = Style::new().fg(Color::DarkGray);
9−const LABEL: Style = Style::new().fg(Color::Yellow);
10−const MNEMONIC: Style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
11−const DIRECTIVE: Style = Style::new().fg(Color::Magenta);
12−const REGISTER: Style = Style::new().fg(Color::LightBlue);
13−const NUMBER: Style = Style::new().fg(Color::LightGreen);
14−const STRING: Style = Style::new().fg(Color::Green);
15−const SFR: Style = Style::new().fg(Color::LightMagenta);
16−
17−fn is_directive(w: &str) -> bool {
18− matches!(
19− w,
20− "ORG"
21− | "DB"
22− | "DW"
23− | "DS"
24− | "END"
25− | "EQU"
26− | "SET"
27− | "BIT"
28− | "DATA"
29− | "IDATA"
30− | "XDATA"
31− | "CODE"
32− | "CSEG"
33− | "XSEG"
34− | "DSEG"
35− | "AT"
36− | "USING"
37− | "NAME"
38− | "BYTE"
39− | "WORD"
40− )
41−}
42−
43−fn word_style(w: &str, first_word: bool) -> Style {
44− let u = w.to_ascii_uppercase();
45− let reg = matches!(u.as_str(), "A" | "AB" | "C" | "DPTR" | "PC")
46− || (u.len() == 2
47− && u.starts_with('R')
48− && u.as_bytes()[1].is_ascii_digit()
49− && u.as_bytes()[1] < b'8');
50− if reg {
51− REGISTER
52− } else if is_directive(&u) {
53− DIRECTIVE
54− } else if first_word && isa::is_mnemonic(&u) {
55− MNEMONIC
56− } else if sfr::SFRS.iter().chain(sfr::BITS).any(|(n, _)| *n == u) {
57− SFR
58− } else if matches!(
59− u.as_str(),
60− "HIGH" | "LOW" | "MOD" | "AND" | "OR" | "XOR" | "NOT" | "SHL" | "SHR"
61− ) {
62− DIRECTIVE
63− } else {
64− Style::new()
7+fn style(t: Tok) -> Style {
8+ match t {
9+ Tok::Plain => Style::new(),
10+ Tok::Comment => Style::new().fg(Color::DarkGray),
11+ Tok::Label => Style::new().fg(Color::Yellow),
12+ Tok::Mnemonic => Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
13+ Tok::Directive => Style::new().fg(Color::Magenta),
14+ Tok::Register => Style::new().fg(Color::LightBlue),
15+ Tok::Number => Style::new().fg(Color::LightGreen),
16+ Tok::Str => Style::new().fg(Color::Green),
17+ Tok::Sfr => Style::new().fg(Color::LightMagenta),
6518 }
6619 }
6720
6821 /// Splits a source line into styled spans, applying `base` underneath.
6922 pub fn highlight(line: &str, base: Style) -> Vec<Span<'static>> {
70− let chars: Vec<char> = line.chars().collect();
71− let mut spans = Vec::new();
72− let mut i = 0;
73− let mut seen_word = false;
74− let push = |spans: &mut Vec<Span<'static>>, s: String, st: Style| {
75− spans.push(Span::styled(s, base.patch(st)))
76− };
77− while i < chars.len() {
78− let c = chars[i];
79− let start = i;
80− if c == ';' {
81− push(&mut spans, chars[i..].iter().collect(), COMMENT);
82− break;
83− } else if c == '\'' || c == '"' {
84− i += 1;
85− while i < chars.len() && chars[i] != c {
86− i += 1;
87− }
88− i = (i + 1).min(chars.len());
89− push(&mut spans, chars[start..i].iter().collect(), STRING);
90− } else if c.is_ascii_alphabetic() || c == '_' || c == '?' {
91− while i < chars.len()
92− && (chars[i].is_ascii_alphanumeric() || chars[i] == '_' || chars[i] == '?')
93− {
94− i += 1;
95− }
96− let w: String = chars[start..i].iter().collect();
97− let is_label = !seen_word && chars.get(i) == Some(&':');
98− let style = if is_label {
99− LABEL
100− } else {
101− let st = word_style(&w, !seen_word);
102− seen_word = true;
103− st
104− };
105− push(&mut spans, w, style);
106− } else if c.is_ascii_digit() || c == '#' || c == '$' {
107− i += 1;
108− while i < chars.len() && chars[i].is_ascii_alphanumeric() {
109− i += 1;
110− }
111− push(&mut spans, chars[start..i].iter().collect(), NUMBER);
112− } else {
113− while i < chars.len()
114− && !(chars[i].is_ascii_alphanumeric() || "_?;'\"#$".contains(chars[i]))
115− {
116− i += 1;
117− }
118− push(&mut spans, chars[start..i].iter().collect(), Style::new());
119− }
120− }
121− spans
23+ tokens(line)
24+ .into_iter()
25+ .map(|(r, t)| Span::styled(line[r].to_string(), base.patch(style(t))))
26+ .collect()
12227 }