#![no_std] extern crate alloc; use alloc::format; use alloc::string::String; use alloc::string::ToString; use alloc::vec; use alloc::vec::Vec; use rsnix::{tty, vt}; // ── Undo ──────────────────────────────────────────────────────────────────── struct UndoEntry { lines: Vec, cx: usize, cy: usize, scroll_y: usize, } // ── Entry point ───────────────────────────────────────────────────────────── /// Run the editor on `filename` (empty = new file). Blocks until the user /// exits; the caller (shell) is expected to clear the screen afterwards. pub fn run(filename: &str) { let mut editor = Editor::new(String::from(filename)); editor.run(); } // ── Editor ────────────────────────────────────────────────────────────────── struct Editor { filename: String, lines: Vec, cx: usize, cy: usize, scroll_y: usize, modified: bool, term_rows: usize, term_cols: usize, status_msg: String, read_buf: Vec, read_pos: usize, /// Key count of the last ^X press that was blocked by unsaved changes. /// A second ^X within FORCE_EXIT_KEYS exits even with unsaved work. last_x_count: u64, /// Total keys processed (a monotonic proxy for time — rsdos has no clock /// visible to apps; FORCE_EXIT_KEYS ≈ 2 s of typing). key_count: u64, undo_stack: Vec, /// First syntax error found after the last modification, if any. syntax_error: Option, } impl Editor { /// How many processed keys after a blocked ^X a second ^X will force-exit /// despite unsaved changes. const FORCE_EXIT_KEYS: u64 = 40; /// Maximum number of undo entries kept. const MAX_UNDO: usize = 100; // ── Construction ──────────────────────────────────────────────────────── fn new(filename: String) -> Self { let lines = if !filename.is_empty() { match load_lines(&filename) { Some(v) => v, None => vec![String::new()], } } else { vec![String::new()] }; let (rows, cols) = vt::size(); Editor { filename, lines, cx: 0, cy: 0, scroll_y: 0, modified: false, term_rows: rows.saturating_sub(2), term_cols: cols, status_msg: String::new(), read_buf: Vec::new(), read_pos: 0, last_x_count: 0, key_count: 0, undo_stack: Vec::new(), syntax_error: None, } } // ── Gutter helpers ────────────────────────────────────────────────────── /// Total width of the line-number gutter including its separator. fn gutter_width(&self) -> usize { let digits = self.lines.len().max(1).to_string().len(); digits.max(2) + 3 // e.g. " 123 │ " = 6 for 3-digit numbers } /// Whether the current filename extension is a recognised source-code /// type that should get syntax highlighting and checking. fn is_source_file(&self) -> bool { let exts: &[&str] = &[ "c", "h", "cpp", "cc", "cxx", "c++", "hpp", "hh", "hxx", "rs", "go", "java", "kt", "kts", "swift", "py", "rb", "js", "ts", "jsx", "tsx", "m", "mm", "cs", "zig", "scala", "dart", "php", "r", "jl", "lua", "pl", "pm", "tcl", "s", "asm", "S", ]; let dot = self.filename.rsplit('.').next().unwrap_or(""); let ext = if dot.is_empty() || dot == self.filename { "" } else { dot }; exts.contains(&ext) } /// Draw one editor row: gutter prefix + content, or a tilde for rows /// past end-of-file. fn draw_line_at(&self, screen_row: usize, line_idx: usize, out: &mut Vec) { out.extend_from_slice(format!("\x1b[{};1H", screen_row).as_bytes()); out.extend_from_slice(b"\x1b[K"); out.extend_from_slice(b"\x1b[0m"); if line_idx < self.lines.len() { let gw = self.gutter_width(); let w = gw - 3; let gutter = format!("{:>width$} │ ", line_idx + 1, width = w); out.extend_from_slice(gutter.as_bytes()); // Truncate content to terminal width, then highlight let max_cols = self.term_cols.saturating_sub(gw); let line = &self.lines[line_idx]; let visible = if line.len() <= max_cols { &line[..] } else if let Some((end, _)) = line.char_indices().nth(max_cols) { &line[..end] } else { &line[..line.len()] }; if self.is_source_file() { out.extend_from_slice(&self.highlight_line(visible)); out.extend_from_slice(b"\x1b[0m"); } else { out.extend_from_slice(visible.as_bytes()); } } else { // Tilde row — past end of file out.extend_from_slice(b"\x1b[0m~"); } } // ── Undo ──────────────────────────────────────────────────────────────── fn push_undo(&mut self) { if self.undo_stack.len() >= Self::MAX_UNDO { self.undo_stack.remove(0); } self.undo_stack.push(UndoEntry { lines: self.lines.clone(), cx: self.cx, cy: self.cy, scroll_y: self.scroll_y, }); } fn undo(&mut self) { let entry = match self.undo_stack.pop() { Some(e) => e, None => { self.status_msg = "Nothing to undo".to_string(); return; } }; self.lines = entry.lines; self.cx = entry.cx; self.cy = entry.cy; self.scroll_y = entry.scroll_y; self.modified = true; self.status_msg = "Undone".to_string(); } // ── Help screen ───────────────────────────────────────────────────────── fn help_screen(&mut self) { let mut out: Vec = Vec::new(); out.extend_from_slice(b"\x1b[?25l\x1b[2J\x1b[H"); let help_lines = [ ("", ""), ( " \u{2550}", "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}", ), (" rsedit", " \u{2014} Keybindings"), ( " \u{2550}", "\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}\u{2550}", ), ("", ""), (" Arrow keys", " Move cursor"), (" ^\u{2190} / ^\u{2192}", " Word backward/forward"), (" ^\u{2191} / ^\u{2193}", " Page up/down"), (" Page Up / Page Down", " Scroll by page"), (" Home (^A)", " Beginning of line"), (" End (^E)", " End of line"), (" Enter", " Split line at cursor"), (" Backspace", " Delete char before cursor"), (" Delete (^D / Del)", " Delete char at cursor"), (" ^K", " Kill to end of line"), (" Tab", " Insert spaces (smart tab)"), (" ^S", " Save file"), (" ^W", " Search forward"), ( " ^X", " Exit (twice to discard changes)", ), (" ^Z", " Suspend editor (job control)"), (" ^_", " Undo"), (" ^G", " This help"), ("", ""), (" Press any key to return to the editor...", ""), ]; for (i, (left, right)) in help_lines.iter().enumerate() { let row = i + 1; out.extend_from_slice(format!("\x1b[{};1H", row).as_bytes()); out.extend_from_slice(b"\x1b[K"); out.extend_from_slice(left.as_bytes()); out.extend_from_slice(right.as_bytes()); } // Cursor on the last help line ("Press any key...") let prompt_row = help_lines.len(); out.extend_from_slice(format!("\x1b[{};1H", prompt_row).as_bytes()); out.extend_from_slice(b"\x1b[?25h"); vt::write_bytes(&out); let _ = tty::read_byte(); self.draw_full(); } // ── Search ────────────────────────────────────────────────────────────── fn search(&mut self) { let mut query = String::new(); let orig_cy = self.cy; let orig_cx = self.cx; let mut found = true; let status_row = self.term_rows + 2; loop { // Draw search prompt on the status row let mut out: Vec = Vec::new(); out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes()); out.extend_from_slice(b"\x1b[K"); if found { out.extend_from_slice(b"Search: "); } else { out.extend_from_slice(b"Search: [not found] "); } out.extend_from_slice(query.as_bytes()); vt::write_bytes(&out); let b = match self.read_byte() { Some(b) => b, None => return, }; match b { 0x0a | 0x0d => break, // Enter — accept the position 0x1b | 0x03 => { // Esc / ^C — cancel, restore original position self.cy = orig_cy; self.cx = orig_cx; break; } 0x7f => { query.pop(); found = self.find_next(&query, orig_cy, orig_cx); } _ if (0x20..=0x7e).contains(&b) && query.len() < 256 => { query.push(b as char); found = self.find_next(&query, orig_cy, orig_cx); } _ => {} } } if found && !query.is_empty() { self.status_msg = format!("Found: {}", query); } self.scroll_to_cursor(); self.draw_full(); } /// Search forward from (start_cy, start_cx) for `query`. If found, /// move the cursor there and return true. fn find_next(&mut self, query: &str, start_cy: usize, start_cx: usize) -> bool { if query.is_empty() { return false; } // Current line, after cursor if let Some(pos) = self.lines[start_cy][start_cx..].find(query) { self.cy = start_cy; self.cx = start_cx + pos; return true; } // Following lines for i in (start_cy + 1)..self.lines.len() { if let Some(pos) = self.lines[i].find(query) { self.cy = i; self.cx = pos; return true; } } false } // ── Main loop ─────────────────────────────────────────────────────────── fn run(&mut self) { self.draw_full(); while let Some(b) = self.read_byte() { self.key_count += 1; self.status_msg.clear(); let prev_cy = self.cy; let prev_scroll = self.scroll_y; let prev_modified = self.modified; let prev_line_count = self.lines.len(); // Any key other than ^X cancels a pending force-exit let prev_x_count = self.last_x_count; if b != 0x18 { self.last_x_count = 0; } match b { // ── File operations ────────────────────────────────────── 0x13 => self.save(), // ^C: no-op (raw mode: 0x03 is just a byte) 0x03 => {} // ── Undo ───────────────────────────────────────────────── // ^_ (Ctrl-underscore) — undo 0x1f => self.undo(), // ── Help ───────────────────────────────────────────────── // ^G — help screen 0x07 => self.help_screen(), // ── Search ─────────────────────────────────────────────── // ^W — search 0x17 => self.search(), // ── Tab ────────────────────────────────────────────────── // Tab → insert spaces up to the next 4-column boundary 0x09 => { self.push_undo(); const TAB_STOP: usize = 4; let n = TAB_STOP - (self.cx % TAB_STOP); for _ in 0..n { self.lines[self.cy].insert(self.cx, ' '); self.cx += 1; } self.modified = true; } // ── Exit ───────────────────────────────────────────────── 0x18 => { if !self.modified { break; } let force = prev_x_count != 0 && (self.key_count - prev_x_count) <= Self::FORCE_EXIT_KEYS; if force { break; } self.last_x_count = self.key_count; self.status_msg = "Unsaved changes! ^S to save, or ^X again to discard.".to_string(); } // ── Vertical movement ──────────────────────────────────── // ^N (next line) / ^P (prev line) — also mapped to // arrow down/up via escape sequences 0x0e | 0x10 => { let down = b == 0x0e; if down { if self.cy + 1 < self.lines.len() { self.cy += 1; } } else if self.cy > 0 { self.cy -= 1; } self.clamp_cursor(); } // ── Horizontal movement ────────────────────────────────── // ^F / ^B — right/left 0x06 | 0x02 => { let right = b == 0x06; if right { if self.cx < self.lines[self.cy].len() { self.cx += 1; } } else if self.cx > 0 { self.cx -= 1; } } // ^A / ^E — line start / line end 0x01 => self.cx = 0, 0x05 => self.cx = self.lines[self.cy].len(), // ── Kill to end of line ────────────────────────────────── // ^K 0x0b => { self.push_undo(); self.lines[self.cy].truncate(self.cx); self.modified = true; } // ── Escape sequences (arrows, pgup/pgdn, etc.) ─────────── 0x1b => self.handle_escape(), // ── Line split / join ──────────────────────────────────── 0x0a | 0x0d => { self.push_undo(); // Capture the leading whitespace before splitting so the // new line inherits the same indent level. If the text // before the cursor ends with an opening bracket, bump // the indent by one extra level. let (indent, extra) = { let line = &self.lines[self.cy]; let ws = line.len() - line.trim_start().len(); let base = line[..ws].to_string(); let before = &line[..self.cx.min(line.len())]; let bump = before .ends_with('{') .then_some(" ") .or_else(|| before.ends_with('(').then_some(" ")) .or_else(|| before.ends_with('[').then_some(" ")) .unwrap_or("") .to_string(); (base, bump) }; let full = indent.clone() + &extra; let rest = self.lines[self.cy].split_off(self.cx); self.lines.insert(self.cy + 1, rest); self.cy += 1; // Avoid doubling when split_off at 0 already moved the // full line (with its indent) into the new line. if !self.lines[self.cy].starts_with(&full) { self.lines[self.cy].insert_str(0, &full); } self.cx = full.len(); self.modified = true; } // ── Backspace ──────────────────────────────────────────── // 0x7f (DEL / Backspace in raw mode) 0x7f => { self.push_undo(); if self.cx > 0 { self.lines[self.cy].remove(self.cx - 1); self.cx -= 1; self.modified = true; } else if self.cy > 0 { let rest = self.lines.remove(self.cy); self.cy -= 1; self.cx = self.lines[self.cy].len(); self.lines[self.cy].push_str(&rest); self.modified = true; } } // ── Delete ─────────────────────────────────────────────── // ^D 0x04 => { self.push_undo(); if self.cx < self.lines[self.cy].len() { self.lines[self.cy].remove(self.cx); self.modified = true; } else if self.cy + 1 < self.lines.len() { let next = self.lines.remove(self.cy + 1); self.lines[self.cy].push_str(&next); self.modified = true; } } // ── Printable characters ───────────────────────────────── _ if (0x20..=0x7e).contains(&b) => { let ch = b as char; self.push_undo(); self.modified = true; // Auto-dedent closing brackets when the cursor is at the // end of a whitespace-only prefix (one indent level). let dedent = matches!(ch, '}' | ')' | ']') && self.cx >= 4 && self.lines[self.cy][..self.cx].chars().all(|c| c == ' '); if dedent { let indent = self.cx - 4; let after = self.lines[self.cy][self.cx..].to_string(); self.lines[self.cy] = format!("{:indent$}{}{}", "", ch, after, indent = indent); self.cx = indent + 1; } else { self.lines[self.cy].insert(self.cx, ch); self.cx += 1; } } _ => {} } // Re-check syntax after any modification if self.modified { self.syntax_error = if self.is_source_file() { self.check_syntax() } else { None }; } self.scroll_to_cursor(); self.update_screen(prev_cy, prev_scroll, prev_modified, prev_line_count); } // Restore terminal on exit: show cursor, clear screen, home. vt::write_bytes(b"\x1b[?25h\x1b[2J\x1b[H"); } // ── Cursor helpers ────────────────────────────────────────────────────── fn clamp_cursor(&mut self) { if self.cy >= self.lines.len() { self.cy = self.lines.len().saturating_sub(1); } if self.cx > self.lines[self.cy].len() { self.cx = self.lines[self.cy].len(); } } fn scroll_to_cursor(&mut self) { let screen_rows = self.term_rows; if self.cy < self.scroll_y { self.scroll_y = self.cy; } if self.cy >= self.scroll_y + screen_rows { self.scroll_y = self.cy.saturating_sub(screen_rows.saturating_sub(1)); } } // ── Screen updates ────────────────────────────────────────────────────── fn update_screen( &mut self, prev_cy: usize, prev_scroll: usize, prev_modified: bool, prev_line_count: usize, ) { // Full redraw on scroll or line-count change if self.scroll_y != prev_scroll || self.lines.len() != prev_line_count { self.draw_full(); return; } let status_changed = self.modified != prev_modified || !self.status_msg.is_empty(); let cursor_moved_only = self.cy == prev_cy && !self.modified && self.modified == prev_modified && self.status_msg.is_empty(); if cursor_moved_only { self.draw_cursor(); return; } let mut out: Vec = Vec::new(); let content_changed = self.modified || self.cy != prev_cy; if content_changed { // Redraw previous line self.draw_line_at(prev_cy.saturating_sub(self.scroll_y) + 1, prev_cy, &mut out); // If cursor moved, redraw the new line too if self.cy != prev_cy { self.draw_line_at(self.cy.saturating_sub(self.scroll_y) + 1, self.cy, &mut out); // Redraw any lines between prev_cy and self.cy let (lo, hi) = if self.cy > prev_cy { (prev_cy + 1, self.cy) } else { (self.cy + 1, prev_cy) }; for i in lo..hi { self.draw_line_at(i.saturating_sub(self.scroll_y) + 1, i, &mut out); } } // When modified flag changed but cursor didn't move, redraw // the current line anyway (text changed in place) if self.cy == prev_cy && self.modified { self.draw_line_at(self.cy.saturating_sub(self.scroll_y) + 1, self.cy, &mut out); } } // Status bar if status_changed || content_changed { out.extend_from_slice(b"\x1b7"); // save cursor let status_row = self.term_rows + 2; out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes()); out.extend_from_slice(b"\x1b[K"); out.extend_from_slice(self.status_line().as_bytes()); out.extend_from_slice(b"\x1b8"); // restore cursor } self.draw_cursor_to(&mut out); vt::write_bytes(&out); } fn draw_cursor(&self) { let mut out: Vec = Vec::new(); self.draw_cursor_to(&mut out); vt::write_bytes(&out); } fn draw_cursor_to(&self, out: &mut Vec) { let screen_row = self.cy.saturating_sub(self.scroll_y) + 1; let screen_col = self.gutter_width() + self.cx + 1; out.extend_from_slice(format!("\x1b[{};{}H", screen_row, screen_col).as_bytes()); } fn draw_full(&mut self) { let mut out: Vec = Vec::new(); out.extend_from_slice(b"\x1b[?25l"); // hide cursor out.extend_from_slice(b"\x1b[2J"); // clear entire screen let screen_rows = self.term_rows; for i in 0..screen_rows { let line_idx = self.scroll_y + i; self.draw_line_at(i + 1, line_idx, &mut out); } // Separator line let sep_row = self.term_rows + 1; out.extend_from_slice(format!("\x1b[{};1H", sep_row).as_bytes()); out.extend_from_slice(b"\x1b[K"); // Status bar let status_row = self.term_rows + 2; out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes()); out.extend_from_slice(b"\x1b[K"); out.extend_from_slice(self.status_line().as_bytes()); self.draw_cursor_to(&mut out); out.extend_from_slice(b"\x1b[?25h"); // show cursor vt::write_bytes(&out); } /// Build the status bar text, optionally clippiing to fit the terminal /// width (minus a 1-char margin). fn status_line(&self) -> String { let display_name = if self.filename.is_empty() { "[new file]" } else { &self.filename }; let pos = format!("Ln {}, Col {}", self.cy + 1, self.cx + 1); // Prepend syntax error if present let tag = self.syntax_error.as_ref().map(|e| format!("[{}]", e)); let raw = if !self.status_msg.is_empty() { self.status_msg.clone() } else if let Some(ref t) = tag { format!( "{} {} {} ^S save ^X exit ^W search ^_ undo", display_name, pos, t ) } else if self.modified { format!( "{} [modified] {} ^S save ^X exit ^W search ^_ undo", display_name, pos ) } else { format!( "{} {} ^S save ^X exit ^W search ^_ undo", display_name, pos ) }; // Truncate if wider than terminal (leave 1-char pad) let max_w = self.term_cols.saturating_sub(1); if raw.len() > max_w { raw[..max_w].to_string() } else { raw } } // ── Save ──────────────────────────────────────────────────────────────── fn save(&mut self) { if self.filename.is_empty() { self.prompt_filename(); return; } self.do_save(); } fn prompt_filename(&mut self) { let status_row = self.term_rows + 2; let mut name = String::new(); loop { let mut out: Vec = Vec::new(); out.extend_from_slice(format!("\x1b[{};1H", status_row).as_bytes()); out.extend_from_slice(b"\x1b[K"); out.extend_from_slice(b"Save as: "); out.extend_from_slice(name.as_bytes()); // Show cursor during prompt vt::write_bytes(&out); let b = match self.read_byte() { Some(b) => b, None => return, }; match b { 0x0a | 0x0d => { if !name.is_empty() { self.filename = name; self.do_save(); } break; } 0x1b => break, 0x7f => { if !name.is_empty() { name.pop(); } } 0x03 => break, _ if (0x20..=0x7e).contains(&b) && name.len() < 256 => { name.push(b as char); } _ => {} } } self.draw_full(); } fn do_save(&mut self) { // VFS (ramfs) write: truncate then write the whole buffer. match save_lines(&self.filename, &self.lines) { Ok(()) => { self.modified = false; // Clear undo stack — saving is a good checkpoint self.undo_stack.clear(); self.status_msg = format!("Saved {}", self.filename); } Err(()) => { self.status_msg = "Error: cannot write file".to_string(); } } } // ── Word navigation helpers ────────────────────────────────────────────── /// Move cursor to the start of the previous word on the current line. fn prev_word(&mut self) { let line = &self.lines[self.cy]; let bytes = line.as_bytes(); let mut pos = self.cx.min(bytes.len()); if pos == 0 { return; } // Step left past any non-word chars before cursor pos -= 1; while pos > 0 && !bytes[pos].is_ascii_alphanumeric() { pos -= 1; } // Step left through the word we landed in while pos > 0 && bytes[pos.saturating_sub(1)].is_ascii_alphanumeric() { pos -= 1; } self.cx = pos; } /// Move cursor to the start of the next word on the current line. fn next_word(&mut self) { let line = &self.lines[self.cy]; let bytes = line.as_bytes(); let mut pos = self.cx; let len = bytes.len(); // Skip through the current word while pos < len && bytes[pos].is_ascii_alphanumeric() { pos += 1; } // Skip non-word chars to the start of the next word while pos < len && !bytes[pos].is_ascii_alphanumeric() { pos += 1; } self.cx = pos; } // ── Syntax highlighting ──────────────────────────────────────────────── /// Return `line` with ANSI color codes: strings in green, comments in /// gray, brackets in yellow. Respects `\` escapes inside strings. fn highlight_line(&self, line: &str) -> Vec { let mut out = Vec::new(); let bytes = line.as_bytes(); let mut i = 0; while i < bytes.len() { // Line comment // … if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' { out.extend_from_slice(b"\x1b[90m"); out.extend_from_slice(&bytes[i..]); out.extend_from_slice(b"\x1b[0m"); return out; } // String "…" or '…' if bytes[i] == b'"' || bytes[i] == b'\'' { let quote = bytes[i]; out.extend_from_slice(b"\x1b[32m"); out.push(quote); i += 1; while i < bytes.len() { if bytes[i] == b'\\' { out.push(bytes[i]); i += 1; if i < bytes.len() { out.push(bytes[i]); i += 1; } continue; } out.push(bytes[i]); if bytes[i] == quote { i += 1; break; } i += 1; } out.extend_from_slice(b"\x1b[0m"); continue; } // Brackets () [] {} if matches!(bytes[i], b'(' | b')' | b'[' | b']' | b'{' | b'}') { out.extend_from_slice(b"\x1b[93m"); out.push(bytes[i]); out.extend_from_slice(b"\x1b[0m"); i += 1; continue; } // Regular character out.push(bytes[i]); i += 1; } out } /// Scan the entire buffer for syntax errors (unclosed strings, /// mismatched brackets). Returns the first error message, or `None` /// if the buffer is clean. fn check_syntax(&self) -> Option { // Stack of opening brackets: (char, line_0idx, col) let mut stack: Vec<(u8, usize, usize)> = Vec::new(); let mut in_string = false; let mut string_char: u8 = 0; for (li, line) in self.lines.iter().enumerate() { let bytes = line.as_bytes(); let mut i = 0; while i < bytes.len() { if in_string { if bytes[i] == b'\\' { i += 1; if i < bytes.len() { i += 1; } continue; } if bytes[i] == string_char { in_string = false; } i += 1; continue; } // Line comment if i + 1 < bytes.len() && bytes[i] == b'/' && bytes[i + 1] == b'/' { break; } match bytes[i] { b'"' | b'\'' => { in_string = true; string_char = bytes[i]; } b'(' | b'[' | b'{' => { stack.push((bytes[i], li, i)); } b')' => match stack.last() { Some(&(b'(', _, _)) => { stack.pop(); } _ => { return Some(format!("Line {}: unmatched ')'", li + 1)); } }, b']' => match stack.last() { Some(&(b'[', _, _)) => { stack.pop(); } _ => { return Some(format!("Line {}: unmatched ']'", li + 1)); } }, b'}' => match stack.last() { Some(&(b'{', _, _)) => { stack.pop(); } _ => { return Some(format!("Line {}: unmatched '}}'", li + 1)); } }, _ => {} } i += 1; } } if in_string { return Some(format!( "Unclosed string (started with '{}')", string_char as char )); } if let Some((ch, li, _)) = stack.last() { return Some(format!("Line {}: unclosed '{}'", li + 1, *ch as char)); } None } // ── Escape sequence handling ──────────────────────────────────────────── fn handle_escape(&mut self) { let b = match self.read_byte() { Some(b) => b, None => return, }; if b != 0x5b && b != b'O' { // Not a CSI / SS3 prefix. Push the byte back so the main loop // processes it normally. if self.read_pos > 0 { self.read_pos -= 1; } return; } let cmd = match self.read_byte() { Some(b) => b, None => return, }; // For numeric parameters, read the next byte to determine sequence // type (terminator ~, modifier ;, or rxvt-style arrow letter). let next = if self.read_pos < self.read_buf.len() { Some(self.read_buf[self.read_pos]) } else { None }; match cmd { // Arrow keys — only when NOT followed by modifier 0x41 if next.is_none_or(|n| n != b';') => { if self.cy > 0 { self.cy -= 1; } self.clamp_cursor(); } 0x42 if next.is_none_or(|n| n != b';') => { if self.cy + 1 < self.lines.len() { self.cy += 1; } self.clamp_cursor(); } 0x43 if next.is_none_or(|n| n != b';') => { if self.cx < self.lines[self.cy].len() { self.cx += 1; } } 0x44 if next.is_none_or(|n| n != b';') => { if self.cx > 0 { self.cx -= 1; } } // Home (ESC [ H / ESC O H) and End (ESC [ F / ESC O F) 0x48 => self.cx = 0, 0x46 => self.cx = self.lines[self.cy].len(), // ── Numeric CSI sequences ─────────────────────────────────--- // ESC [ 1~ / ESC [ 7~ → Home // ESC [ 4~ → End // ESC [ 1 ; 5 X → xterm-style Ctrl-modifier 0x31 | 0x37 | 0x34 => { let term = match self.read_byte() { Some(b) => b, None => return, }; match (cmd, term) { (0x31 | 0x37, 0x7e) => self.cx = 0, (0x34, 0x7e) => self.cx = self.lines[self.cy].len(), // ESC [ 1 ; 5 X — consume modifier + final command byte (_, 0x3b) => { let mod_b = match self.read_byte() { Some(b) => b, None => return, }; let fin = match self.read_byte() { Some(b) => b, None => return, }; if mod_b == b'5' { match fin { 0x44 => self.prev_word(), 0x43 => self.next_word(), 0x41 => { let jump = self.term_rows.min(self.cy); self.cy = self.cy.saturating_sub(jump); self.clamp_cursor(); } 0x42 => { self.cy = (self.cy + self.term_rows) .min(self.lines.len().saturating_sub(1)); self.clamp_cursor(); } _ => {} } } } _ => {} } } // ESC [ 5~ → Page Up // ESC [ 5A → rxvt Ctrl+Up // ESC [ 5B → rxvt Ctrl+Down // ESC [ 5C → rxvt Ctrl+Right // ESC [ 5D → rxvt Ctrl+Left 0x35 => { let term = match self.read_byte() { Some(b) => b, None => return, }; match term { 0x7e => { // Page Up let jump = self.term_rows.min(self.cy); self.cy = self.cy.saturating_sub(jump); self.clamp_cursor(); } // rxvt Ctrl+arrow: ESC [ 5 A-D 0x41 => { let jump = self.term_rows.min(self.cy); self.cy = self.cy.saturating_sub(jump); self.clamp_cursor(); } 0x42 => { self.cy = (self.cy + self.term_rows).min(self.lines.len().saturating_sub(1)); self.clamp_cursor(); } 0x43 => self.next_word(), 0x44 => self.prev_word(), _ => {} } } // ESC [ 6~ → Page Down // ESC [ 6A → rxvt Shift+Up (ignore) // etc. 0x36 => { let term = match self.read_byte() { Some(b) => b, None => return, }; if term == 0x7e { // Page Down self.cy = (self.cy + self.term_rows).min(self.lines.len().saturating_sub(1)); self.clamp_cursor(); } } // Delete (ESC [ P / ESC [ 3~) 0x50 => { self.push_undo(); if self.cx < self.lines[self.cy].len() { self.lines[self.cy].remove(self.cx); self.modified = true; } else if self.cy + 1 < self.lines.len() { let next = self.lines.remove(self.cy + 1); self.lines[self.cy].push_str(&next); self.modified = true; } } 0x33 => { let term = match self.read_byte() { Some(b) => b, None => return, }; if term == 0x7e { self.push_undo(); if self.cx < self.lines[self.cy].len() { self.lines[self.cy].remove(self.cx); self.modified = true; } else if self.cy + 1 < self.lines.len() { let next = self.lines.remove(self.cy + 1); self.lines[self.cy].push_str(&next); self.modified = true; } } } _ => {} } } // ── Input buffering ───────────────────────────────────────────────────── fn fill_buf(&mut self) { if self.read_pos < self.read_buf.len() { return; } self.read_buf.clear(); self.read_pos = 0; // First byte blocks until a keypress; then drain whatever else is // already queued (the tail of a multi-byte key sequence) so escape // sequences stay together in one buffer. self.read_buf.push(tty::read_byte()); while self.read_buf.len() < 64 { match tty::try_read_byte() { Some(b) => self.read_buf.push(b), None => break, } } } fn read_byte(&mut self) -> Option { self.fill_buf(); if self.read_pos < self.read_buf.len() { let b = self.read_buf[self.read_pos]; self.read_pos += 1; return Some(b); } None } } // ── File I/O (ramfs via the VFS) ─────────────────────────────────────────── /// Read a whole file from the VFS into lines. None if the file does not /// exist or is not valid UTF-8. fn load_lines(path: &str) -> Option> { let h = rsnix::fs::resolve(path)?; let mut content: Vec = Vec::new(); let mut buf = [0u8; 512]; let mut pos = 0u64; loop { let n = rsnix::fs::read(h, &mut buf, pos).ok()?; if n == 0 { break; } content.extend_from_slice(&buf[..n]); pos += n as u64; if n < buf.len() { break; } } let s = core::str::from_utf8(&content).ok()?; let mut v: Vec = s.lines().map(String::from).collect(); if s.ends_with('\n') || v.is_empty() { v.push(String::new()); } Some(v) } /// Write lines (joined with '\n') back to a VFS file, truncating first. fn save_lines(path: &str, lines: &[String]) -> Result<(), ()> { let h = rsnix::fs::resolve(path).ok_or(())?; rsnix::fs::set_len(h, 0)?; let mut buf = [0u8; 512]; let mut pos = 0u64; for line in lines { let bytes = line.as_bytes(); let mut off = 0; while off < bytes.len() { let n = core::cmp::min(512, bytes.len() - off); buf[..n].copy_from_slice(&bytes[off..off + n]); let w = rsnix::fs::write(h, &buf[..n], pos)?; if w == 0 { return Err(()); } pos += w as u64; off += w as usize; } buf[0] = b'\n'; let w = rsnix::fs::write(h, &buf[..1], pos)?; if w == 0 { return Err(()); } pos += 1; } Ok(()) }