From d59900a8959003e7e5e7b275a94e33c50f3a06a5 Mon Sep 17 00:00:00 2001 From: "Timothy J. Warren" Date: Wed, 24 Jul 2024 14:58:42 -0400 Subject: [PATCH] Add Rust filetype, full tests for Option --- coverage.sh | 3 - demo/editor.rs | 1686 +++++++++++++++++++++++++++++++ justfile | 8 + src/common/all_test.ts | 67 ++ src/common/ansi.ts | 41 +- src/common/config.ts | 16 +- src/common/document.ts | 20 +- src/common/editor.ts | 11 +- src/common/filetype/filetype.ts | 8 +- src/common/filetype/rust.ts | 169 ++++ src/common/highlight.ts | 52 +- src/common/types.ts | 1 + 12 files changed, 2025 insertions(+), 57 deletions(-) create mode 100644 demo/editor.rs create mode 100644 src/common/filetype/rust.ts diff --git a/coverage.sh b/coverage.sh index 3aa6979..b341c2d 100755 --- a/coverage.sh +++ b/coverage.sh @@ -4,6 +4,3 @@ deno test --allow-all --coverage=coverage deno coverage coverage --lcov > coverage/coverage.lcov genhtml -o coverage coverage/coverage.lcov rm coverage/*.json -open coverage/index.html - - diff --git a/demo/editor.rs b/demo/editor.rs new file mode 100644 index 0000000..248270c --- /dev/null +++ b/demo/editor.rs @@ -0,0 +1,1686 @@ +#![forbid(unsafe_code)] +//! Editor functionality +use crate::terminal_helpers::*; + +use std::cmp::PartialEq; +use std::fs::File; +use std::io; +use std::io::prelude::*; +use std::io::BufReader; +use std::ops::Range; +use std::time::{Duration, Instant}; + +use self::KeyCode::*; + +// ------------------------------------------------------------------------ +// Defines +// ------------------------------------------------------------------------ + +const KILO_TAB_STOP: usize = 4; +const KILO_QUIT_TIMES: u8 = 3; + +// ------------------------------------------------------------------------ +// Data +// ------------------------------------------------------------------------ + +// Use an external package's macro to create a memory-safe +// bit flag alternative +bitflags! { + #[derive(Default)] + pub struct SyntaxFlags: u32 { + const HIGHLIGHT_NUMBERS = 0b00000001; + const HIGHLIGHT_STRINGS = 0b00000010; + } +} + +/// Configuration for language syntax highlighting +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Syntax { + /// Language name, to be shown in status bar + file_type: String, + + /// File extensions + file_match: Vec<&'static str>, + + /// Keywords + keywords1: Vec<&'static str>, + + /// Type and/or secondary keywords + keywords2: Vec<&'static str>, + + /// How does a single line comment start? + singleline_comment_start: String, + + /// How does a multline comment start? + multiline_comment_start: String, + + /// How does a multiline comment end? + multiline_comment_end: String, + + /// Options for what to highlight + flags: SyntaxFlags, +} + +/// Syntax highlighting token types +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Highlight { + /// No highlighting + Normal, + + /// Single line comments + LineComment, + + /// Multiple line comments + MultiLineComment, + + /// Language keywords + Keyword1, + + /// Language types/ secondary keywords + Keyword2, + + /// Single-line strings + String, + + /// Numbers + Number, + + /// Search results + SearchMatch, +} + +/// A representation of a line in the editor +#[derive(Clone, Debug, Default)] +pub struct Row { + /// The 'raw' representation of the original characters + chars: String, + + /// The display characters for the editor + render: String, + + /// The highlighting type for each character + highlight: Vec, + + /// Are we currently highlighting a multi-line comment? + highlight_comment_start: bool, +} + +/// Main structure for the editor +/// `EditorConfig` struct in C version +#[derive(Debug)] +pub struct Editor { + cursor_x: usize, + cursor_y: usize, + render_x: usize, + col_offset: usize, + row_offset: usize, + screen_cols: usize, + screen_rows: usize, + rows: Vec, + dirty: u64, + filename: String, + status_message: String, + status_message_time: Instant, + syntax: Option, + + // Properties not present in C version + output_buffer: String, + quit_times: u8, + search_last_match: i32, + search_direction: i8, + search_last_line: usize, + search_last_hightlight: Vec, +} + +/// Keycode mapping enum +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum KeyCode { + Enter, + Escape, + Backspace, + ArrowLeft, + ArrowRight, + ArrowUp, + ArrowDown, + DeleteKey, + HomeKey, + EndKey, + PageUp, + PageDown, + Tab, + /// Control key chords + Ctrl(T), + /// Function keys (F1, etc.) T holds the index + Function(T), + /// Any other type of character + OtherKey(T), +} + +impl Syntax { + pub fn new( + file_type: &str, + file_match: Vec<&'static str>, + keywords1: Vec<&'static str>, + keywords2: Vec<&'static str>, + single_line_comment_start: &str, + multi_line_comment_start: &str, + multi_line_comment_end: &str, + flags: SyntaxFlags, + ) -> Self { + Syntax { + file_type: file_type.to_owned(), + file_match, + keywords1, + keywords2, + singleline_comment_start: single_line_comment_start.to_owned(), + multiline_comment_start: multi_line_comment_start.to_owned(), + multiline_comment_end: multi_line_comment_end.to_owned(), + flags, + } + } +} + +impl Row { + pub fn new(chars: &str) -> Self { + let mut instance = Row::default(); + instance.chars = chars.to_owned(); + + instance + } +} + +impl KeyCode { + pub fn unwrap(self) -> char { + match self { + self::Ctrl(val) => val, + self::Function(val) => val, + self::OtherKey(val) => val, + _ => panic!("called `KeyCode::unwrap()` on a `None` value"), + } + } +} + +impl Default for Editor { + fn default() -> Self { + Editor { + cursor_x: 0, + cursor_y: 0, + render_x: 0, + col_offset: 0, + row_offset: 0, + screen_cols: 0, + screen_rows: 0, + rows: vec![], + dirty: 0, + filename: String::new(), + status_message: String::new(), + + // This is the only reason I had to implement this method + // manually, instead of it being derived. Apparently an + // `Instant` struct has no default + status_message_time: Instant::now(), + + syntax: None, + + output_buffer: String::new(), + quit_times: KILO_QUIT_TIMES, + search_last_match: -1, + search_direction: 1, + search_last_line: 0, + search_last_hightlight: vec![], + } + } +} + +impl Editor { + // ------------------------------------------------------------------------ + // Init + // ------------------------------------------------------------------------ + + pub fn new() -> Self { + let size = Self::get_window_size(); + + Editor { + screen_cols: size.cols as usize, + screen_rows: (size.rows - 2) as usize, + ..Editor::default() + } + } + + // ------------------------------------------------------------------------ + // Terminal + // ------------------------------------------------------------------------ + + /// Convert stdin to specific keypresses + fn read_key(&mut self) -> Option { + // -------------------------------------------------------------------- + // Match single character + // -------------------------------------------------------------------- + let stdin = io::stdin(); + let stdin = stdin.lock(); + let mut br = BufReader::with_capacity(5, stdin); + + let mut first_read = [0; 1]; + match br.read_exact(&mut first_read) { + Ok(_) => (), + Err(e) => { + if e.kind() != io::ErrorKind::UnexpectedEof { + let error = format!("{:?}", e); + self.set_status_message(&error); + } + } + } + let first_str = String::from_utf8(first_read.to_vec()); + if first_str.is_err() { + return None; + } + let first_str = first_str.unwrap(); + + // Read the first character, if it isn't escape, just return it + let mut chs = first_str.chars(); + let ch = chs.next(); + match ch { + Some(ch) => match ch { + '\0' => return None, + '\x1b' => (), + '\x08' => return Some(Backspace), + '\x7f' => return Some(Backspace), + '\r' => return Some(Enter), + '\t' => return Some(Tab), + ch => { + if ch.is_ascii_control() { + return Some(Ctrl(ctrl_to_letter(ch))); + } + + return Some(OtherKey(ch)); + } + }, + None => return None, + } + + // -------------------------------------------------------------------- + // Match escape sequence + // -------------------------------------------------------------------- + let mut seq = [0; 4]; + let mut seq_handle = br.take(4); + match seq_handle.read(&mut seq) { + Ok(_) => (), + Err(e) => { + if e.kind() != io::ErrorKind::UnexpectedEof { + let error = format!("{:?}", e); + self.set_status_message(&error); + } + } + } + let seq_str = String::from_utf8(seq.to_vec()); + + // On error, just continue the input loop + if seq_str.is_err() { + return None; + } + let seq_str = seq_str.unwrap(); + + let mut input: Vec> = vec![]; + + for ch in seq_str.chars() { + // Since the fixed array is always filled, there + // will be null characters. Ignore these. + if ch == '\0' { + continue; + } + + input.push(match ch { + '\x1b' => Escape, + _ => OtherKey(ch), + }); + } + + // Since we matched Escape earlier, if the input is empty, + // this must be the escape key + if input.is_empty() { + return Some(Escape); + } + + match input.len() { + 4 => { + // Escape code of the form `^[[NM~` + if input[3].eq(&OtherKey('~')) { + let action = match (input[1].unwrap(), input[2].unwrap()) { + ('1', '5') => Function('5'), + ('1', '7') => Function('6'), + ('1', '8') => Function('7'), + ('1', '9') => Function('8'), + ('2', '0') => Function('9'), + ('2', '1') => Function('X'), // F10 + ('2', '4') => Function('T'), // F12 + _ => Escape, + }; + + return Some(action); + } + } + 3 => { + // Escape code of the form `^[[N~` + if input[2].eq(&OtherKey('~')) { + let action = match input[1].unwrap() { + '1' => HomeKey, + '3' => DeleteKey, + '4' => EndKey, + '5' => PageUp, + '6' => PageDown, + '7' => HomeKey, + '8' => EndKey, + _ => Escape, + }; + + return Some(action); + } + } + 2 => { + match input[0] { + // Escape code of the form `^[[X` + OtherKey('[') => { + let action = match input[1].unwrap() { + 'A' => ArrowUp, + 'B' => ArrowDown, + 'C' => ArrowRight, + 'D' => ArrowLeft, + 'H' => HomeKey, + 'F' => EndKey, + + // Eh, just return escape otherwise + _ => Escape, + }; + + return Some(action); + } + // Escape code of the form `^[OX` + OtherKey('O') => { + let action = match input[1].unwrap() { + 'H' => HomeKey, + 'F' => EndKey, + 'P' => Function('1'), + 'Q' => Function('2'), + 'R' => Function('3'), + 'S' => Function('4'), + _ => Escape, + }; + + return Some(action); + } + _ => return Some(Escape), + } + } + _ => return Some(input[0]), + } + + // If the character doesn't match any escape sequences, just + // pass that character on + return Some(input[0]); + } + + /// Get terminal size in rows and columns + fn get_window_size() -> TermSize { + match get_term_size() { + Some(size) => size, + None => get_cursor_position(), + } + } + + // ------------------------------------------------------------------------ + // Syntax Highlighting + // ------------------------------------------------------------------------ + + fn update_syntax(&mut self, index: usize) { + let rows = &mut self.rows; + let prev_row = if index > 0 { + // I shouldn't have to clone this, but the lifetime is + // different than the `row` variable above, so it + // can't be a immutable borrow. It also can't be a + // mutable borrow, because it would be considered a + // second mutable borrow...so a clone it is + Some((&mut rows[index - 1]).clone()) + } else { + None + }; + let row = &mut rows[index]; + let render_len = row.render.len(); + + // Reset the highlighting of the row + row.highlight = vec![Highlight::Normal; render_len]; + + if self.syntax.is_none() { + return; + } + + // This is dumb. This lets you get a reference to the item in + // the option, by turning Option into Option<&T>, + // which can then be unwrapped. + let current_syntax = self.syntax.as_ref().unwrap(); + + let keywords1 = ¤t_syntax.keywords1; + let keywords2 = ¤t_syntax.keywords2; + + let scs = ¤t_syntax.singleline_comment_start; + let mcs = ¤t_syntax.multiline_comment_start; + let mce = ¤t_syntax.multiline_comment_end; + + let mut prev_separator = true; + let mut in_string = false; + let mut str_start = '\0'; + let mut in_comment = prev_row.map_or(false, |row| row.highlight_comment_start); + + let mut i = 0; + let bytes = row.render.clone().into_bytes(); + while i < render_len { + let c = bytes[i] as char; + let prev_highlight = if i > 0 { + row.highlight[i - 1] + } else { + Highlight::Normal + }; + + // Single line comments + if scs.len() > 0 && !in_string && !in_comment { + let range = get_slice_range(i, scs.len(), render_len); + if &row.render[range] == scs { + // Pretty simple, highlight from the match to the end of the line + highlight_range(&mut row.highlight, i..render_len, Highlight::LineComment); + break; + } + } + + // Multi-line comments + if mcs.len() > 0 && mce.len() > 0 && !in_string { + let mce_range = get_slice_range(i, mce.len(), render_len); + let mcs_range = get_slice_range(i, mcs.len(), render_len); + if in_comment { + row.highlight[i] = Highlight::MultiLineComment; + + // End of a comment + if &row.render[mce_range.clone()] == mce { + highlight_range(&mut row.highlight, mce_range, Highlight::MultiLineComment); + + i += mce.len(); + in_comment = false; + prev_separator = true; + continue; + } else { + i += 1; + continue; + } + } else if &row.render[mcs_range.clone()] == mcs { + // Start of a multi-line comment + highlight_range(&mut row.highlight, mcs_range, Highlight::MultiLineComment); + + i += mcs.len(); + in_comment = true; + continue; + } + } + + // Strings + if current_syntax + .flags + .contains(SyntaxFlags::HIGHLIGHT_STRINGS) + { + if in_string { + row.highlight[i] = Highlight::String; + // Don't end highlighting for a string on an escaped quote + if c == '\\' && i + 1 < render_len { + row.highlight[i + 1] = Highlight::String; + i += 2; + continue; + } + + // End delimiter for the string + if c == str_start { + in_string = false; + str_start = '\0'; + } + i += 1; + prev_separator = true; + continue; + } else { + if (c == '"' || c == '\'') && prev_separator { + in_string = true; + str_start = c; + row.highlight[i] = Highlight::String; + i += 1; + continue; + } + } + } + + // Numbers + if current_syntax + .flags + .contains(SyntaxFlags::HIGHLIGHT_NUMBERS) + { + if (c.is_ascii_digit() && (prev_separator || prev_highlight == Highlight::Number)) + || (c == '.' && prev_highlight == Highlight::Number) + { + row.highlight[i] = Highlight::Number; + i += 1; + prev_separator = false; + continue; + } + } + + // Keywords + if prev_separator { + for &keyword in keywords1 { + if i + keyword.len() >= render_len { + continue; + } + + let search_range = get_slice_range(i, keyword.len(), render_len); + + let next_char_offset = i + keyword.len(); + let is_end_of_line = next_char_offset >= render_len; + let next_char = if is_end_of_line { + '\0' + } else { + bytes[next_char_offset] as char + }; + + if &row.render[search_range.clone()] == keyword && is_separator(next_char) { + highlight_range(&mut row.highlight, search_range, Highlight::Keyword1); + i += keyword.len() - 1; + } + } + + for &keyword in keywords2 { + if i + keyword.len() >= render_len { + continue; + } + + let search_range = get_slice_range(i, keyword.len(), render_len); + + let next_char_offset = i + keyword.len(); + let is_end_of_line = next_char_offset >= render_len; + let next_char = if is_end_of_line { + '\0' + } else { + bytes[next_char_offset] as char + }; + + if &row.render[search_range.clone()] == keyword && is_separator(next_char) { + highlight_range(&mut row.highlight, search_range, Highlight::Keyword2); + i += keyword.len() - 1; + } + } + } + + prev_separator = is_separator(c); + i += 1; + } + + let changed = row.highlight_comment_start != in_comment; + row.highlight_comment_start = in_comment; + + // If a multi-line comment is opened or closed, + // update the next row as well. + if changed && index + 1 < self.rows.len() { + self.update_syntax(index + 1); + } + } + + fn select_syntax_highlight(&mut self) { + if self.filename.is_empty() { + return; + } + + let mut parts: Vec<&str> = self.filename.split('.').collect(); + + let file_ext = String::from(".") + + match parts.pop() { + Some(ext) => ext, + None => return, + }; + + let languages = &get_syntax_db(); + + for language in languages { + let file_match = language.file_match.clone(); + for ext in file_match { + if ext == file_ext { + self.syntax = Some(language.clone()); + + // Re-highlight file when the type is determined + for x in 0..self.rows.len() { + self.update_syntax(x); + } + + return; + } + } + } + } + + // ------------------------------------------------------------------------ + // Input + // ------------------------------------------------------------------------ + + fn prompt( + &mut self, + prompt: &str, + cb: Option<&mut dyn Fn(&mut Self, &str, KeyCode)>, + ) -> String { + let mut buffer = String::new(); + let default_cb = &mut Self::_noop_prompt_cb; + + let cb = match cb { + Some(cb) => cb, + None => default_cb, + }; + + loop { + self.set_status_message(&format!("{} {}", prompt, buffer)); + self.refresh_screen(); + + let ch = self.read_key(); + if ch.is_some() { + let ch = ch.unwrap(); + match ch { + Backspace => { + buffer.pop(); + } + DeleteKey => { + buffer.pop(); + } + Escape => { + self.set_status_message(""); + cb(self, &String::from(""), ch); + return String::from(""); + } + Enter => { + if buffer.len() != 0 { + self.set_status_message(""); + cb(self, &buffer, ch); + return buffer; + } + } + OtherKey(ch) => { + if (!ch.is_ascii_control()) && (ch as u8) < 128 { + buffer.push(ch); + // continue; + } + } + _ => (), + }; + + cb(self, &buffer, ch); + } + } + } + + fn _noop_prompt_cb(&mut self, _: &str, _: KeyCode) {} + + fn move_cursor(&mut self, key: &KeyCode) { + let row = self.rows.get(self.cursor_y); + match key { + ArrowLeft => { + if self.cursor_x != 0 { + // Move cursor left + self.cursor_x -= 1; + } else if self.cursor_y > 0 { + // Move to the end of the previous line + self.cursor_y -= 1; + self.cursor_x = self.rows[self.cursor_y].chars.len(); + } + } + ArrowRight => { + if row.is_some() && self.cursor_x < row.unwrap().chars.len() { + // Move cursor right + self.cursor_x += 1; + } else if row.is_some() && self.cursor_x == row.unwrap().chars.len() { + // Move to start of next line + self.cursor_y += 1; + self.cursor_x = 0; + } + } + ArrowUp => { + if self.cursor_y > 0 { + self.cursor_y -= 1; + } + } + ArrowDown => { + if self.cursor_y < self.rows.len() { + self.cursor_y += 1; + } + } + _ => (), + }; + + let row = self.rows.get(self.cursor_y); + let row_len = if row.is_some() { + row.unwrap().chars.len() + } else { + 0 + }; + + // Snap to end of line when scrolling down + if self.cursor_x > row_len { + self.cursor_x = row_len; + } + } + + /// Route user input to the appropriate handler method + pub fn process_keypress(&mut self) -> Option> { + let key = self.read_key(); + if key.is_some() { + let ch = key.unwrap(); + + match ch { + Backspace => self._del_or_backspace(Backspace), + DeleteKey => self._del_or_backspace(DeleteKey), + Enter => self.insert_new_line(), + Escape => (), + ArrowUp => self.move_cursor(&ArrowUp), + ArrowDown => self.move_cursor(&ArrowDown), + ArrowLeft => self.move_cursor(&ArrowLeft), + ArrowRight => self.move_cursor(&ArrowRight), + PageUp => self._page_up_or_down(PageUp), + PageDown => self._page_up_or_down(PageDown), + HomeKey => { + self.cursor_x = 0; + } + EndKey => { + if self.cursor_y < self.rows.len() { + self.cursor_x = self.rows[self.cursor_y].chars.len(); + } + } + Tab => self.insert_char('\t'), + Ctrl(c) => match c { + 'f' => self.find(), + 's' => { + // Save success/error message handled by save method + match self.save() { + Ok(_) => (), + Err(_) => (), + } + } + 'q' => { + if self.dirty > 0 && self.quit_times > 0 { + self.set_status_message(&format!("WARNING!!! File has unsaved changes. Press Ctrl-Q {} more times to quit.", self.quit_times)); + self.quit_times -= 1; + return Some(OtherKey('\0')); + } + print!("\x1b[2J"); + print!("\x1b[H"); + // Break out of the input loop + return None; + } + _ => (), + }, + Function(_) => (), + OtherKey(c) => { + self.insert_char(c); + } + }; + + self.quit_times = KILO_QUIT_TIMES; + + return key; + } + + // Continue the main input loop + Some(OtherKey('\0')) + } + + fn _del_or_backspace(&mut self, key: KeyCode) { + if key == DeleteKey { + self.move_cursor(&ArrowRight); + } + self.delete_char(); + } + + fn _page_up_or_down(&mut self, key: KeyCode) { + let mut times = self.screen_rows; + + // Update the cursor position + match key { + PageUp => { + self.cursor_y = self.row_offset; + } + PageDown => { + self.cursor_y = self.row_offset + self.screen_rows - 1; + if self.cursor_y > self.rows.len() { + self.cursor_y = self.rows.len(); + } + } + _ => (), + } + + // Scroll the file up or down + while times > 1 { + times -= 1; + self.move_cursor(match key { + PageUp => &ArrowUp, + PageDown => &ArrowDown, + _ => &OtherKey('\0'), + }) + } + } + + // ------------------------------------------------------------------------ + // Output + // ------------------------------------------------------------------------ + + /// Equivalent of the abAppend function + /// in the original tutorial, just appends + /// to the `output_buffer` String in the + /// editor struct. + fn append_out(&mut self, str: &str) { + self.output_buffer.push_str(str); + } + + fn append_out_char(&mut self, ch: char) { + self.output_buffer.push(ch); + } + + fn scroll(&mut self) { + self.render_x = 0; + if self.cursor_y < self.rows.len() { + self.render_x = self.row_cx_to_rx(self.cursor_y, self.cursor_x); + } + + // Vertical scrolling + if self.cursor_y < self.row_offset { + self.row_offset = self.cursor_y; + } + if self.cursor_y >= self.row_offset + self.screen_rows { + self.row_offset = self.cursor_y - self.screen_rows + 1; + } + + // Horizontal scrolling + if self.render_x < self.col_offset { + self.col_offset = self.render_x; + } + if self.render_x >= self.col_offset + self.screen_cols { + self.col_offset = self.render_x - self.screen_cols + 1; + } + } + + fn draw_rows(&mut self) { + for y in 0..self.screen_rows { + let file_row = y + self.row_offset; + if file_row >= self.rows.len() { + if self.rows.is_empty() && y == (self.screen_rows / 3) { + let mut welcome = format!( + "Oxidized Kilo editor -- version {}", + env!("CARGO_PKG_VERSION") + ); + if welcome.len() > self.screen_cols { + welcome.truncate(self.screen_cols) + } + + // Center welcome message + let mut padding = (self.screen_cols - welcome.len()) / 2; + if padding > 0 { + self.append_out_char('~'); + padding -= 1; + } + for _ in 0..padding { + self.append_out_char(' '); + } + + self.append_out(&welcome); + } else { + self.append_out_char('~'); + } + } else { + let output = self.rows[file_row].render.clone(); + let mut current_color: i32 = -1; + + for (x, ch) in output.char_indices() { + if ch.is_ascii_control() { + // Display unprintable characters in inverted colors + let sym = if ch as u8 <= 26 { + ('@' as u8 + ch as u8) as char + } else { + '?' + }; + + self.append_out("\x1b[7m"); + self.append_out_char(sym); + self.append_out("\x1b[m"); + if current_color != -1 { + let code = format!("\x1b[{}m", current_color); + self.append_out(&code); + } + } else if self.rows[file_row].highlight[x] == Highlight::Normal { + if current_color != -1 { + self.append_out("\x1b[0m"); + self.append_out("\x1b[39m"); + current_color = -1; + } + self.append_out_char(ch); + } else { + let color = syntax_to_color(self.rows[file_row].highlight[x]); + if color != current_color { + current_color = color; + let code = format!("\x1b[{}m", color); + self.append_out("\x1b[0m"); + self.append_out(&code); + } + self.append_out_char(ch); + } + } + self.append_out("\x1b[0m"); + self.append_out("\x1b[39m"); + } + + self.append_out("\x1b[K"); + self.append_out("\r\n"); + } + } + + fn draw_status_bar(&mut self) { + self.append_out("\x1b[7m"); + + let filename = if self.filename.is_empty() { + "[No Name]" + } else { + &self.filename + }; + + let modified = if self.dirty > 0 { "(modified)" } else { "" }; + + let mut left_message = format!("{:.80} - {} lines {}", filename, self.rows.len(), modified); + let file_type = match &self.syntax { + Some(s) => &s.file_type, + None => "no ft", + }; + let right_message = format!("{} | {}/{}", file_type, self.cursor_y + 1, self.rows.len()); + let mut len = left_message.len(); + if len > self.screen_cols { + len = self.screen_cols; + left_message.truncate(len); + } + self.append_out(&left_message); + + for x in len..self.screen_cols { + if self.screen_cols - x == right_message.len() { + self.append_out(&right_message); + break; + } + self.append_out(" "); + } + self.append_out("\x1b[m"); + self.append_out("\r\n"); + } + + fn draw_message_bar(&mut self) { + self.append_out("\x1b[K"); + + let mut message = self.status_message.clone(); + let message_len = message.len(); + if message_len > self.screen_cols { + message.truncate(self.screen_cols); + } + + let five_seconds = Duration::from_secs(5); + if message_len > 0 && self.status_message_time.elapsed() < five_seconds { + self.append_out(&message); + } + } + + pub fn refresh_screen(&mut self) { + self.scroll(); + self.output_buffer.clear(); + + // Hide cursor, reposition cursor + self.append_out("\x1b[?25l"); + self.append_out("\x1b[H"); + + self.draw_rows(); + self.draw_status_bar(); + self.draw_message_bar(); + + // Move cursor to state position + let y = (self.cursor_y - self.row_offset) + 1; + let x = (self.render_x - self.col_offset) + 1; + let cursor_code = format!("\x1b[{};{}H", y, x); + self.append_out(&cursor_code.as_str()); + + // Show cursor + self.append_out("\x1b[?25h"); + + let stdout = io::stdout(); + let mut handle = stdout.lock(); + + // If you can't write to stdout, you might as well just panic + handle.write_all(&self.output_buffer.as_bytes()).unwrap(); + + // Explicitly flush output so that the cursor actually appears correctly + io::stdout().flush().unwrap(); + } + + /// Set the status bar message + /// + /// To avoid creating a macro that would just forward to + /// the `format!` macro, this method only accepts a pre-formatted + /// string. + /// + /// # Example + /// + /// ```no-run + /// # use rs-kilo::editor::Editor; + /// # let editor = Editor::new(); + /// let message = format!("{} is {}", key, status); + /// editor.set_status_message(&message); + /// ``` + pub fn set_status_message(&mut self, message: &str) { + self.status_message = message.to_owned(); + self.status_message_time = Instant::now(); + } + + // ------------------------------------------------------------------------ + // Row Operations + // ------------------------------------------------------------------------ + + /// Convert cursor x position to the rendered x position + fn row_cx_to_rx(&mut self, index: usize, cx: usize) -> usize { + let mut rx: usize = 0; + + for (i, ch) in self.rows[index].chars.char_indices() { + if i == cx { + return rx; + } + + if ch == '\t' { + rx += (KILO_TAB_STOP - 1) - (rx % KILO_TAB_STOP); + } + + rx += 1; + } + + rx + } + + /// Convert rendered x position to cursor x position + fn row_rx_to_cx(&mut self, index: usize, rx: usize) -> usize { + let mut current_rx: usize = 0; + let mut cx: usize = 0; + + for ch in self.rows[index].chars.chars() { + if ch == '\t' { + current_rx += (KILO_TAB_STOP - 1) - (current_rx % KILO_TAB_STOP); + } + + current_rx += 1; + + if current_rx > rx { + return cx; + } + + cx += 1; + } + + cx + } + + /// Convert file characters to their display equivalents + fn update_row(&mut self, index: usize) { + let row = &mut self.rows[index]; + let str = row.chars.clone(); + + // Cheat at rendering tabs as spaces + let str = str.replace('\t', " "); + row.render = str; + + // Syntax highlighting + self.update_syntax(index); + } + + fn insert_row(&mut self, at: usize, content: &str) { + if at > self.rows.len() { + return; + } + + let row = Row::new(content); + + self.rows.insert(at, row); + self.update_row(at); + + self.dirty += 1; + } + + fn delete_row(&mut self, row_index: usize) { + if row_index > self.rows.len() { + return; + } + + self.rows.remove(row_index); + + self.dirty += 1; + } + + fn row_insert_char(&mut self, row_index: usize, char_index: usize, ch: char) { + let mut at = char_index; + let row = &mut self.rows[row_index]; + + if at > row.chars.len() { + at = row.chars.len(); + } + + row.chars.insert(at, ch); + + self.update_row(row_index); + + self.dirty += 1; + } + + fn row_append_string(&mut self, row_index: usize, strng: &str) { + let row = &mut self.rows[row_index]; + row.chars += strng; + + self.update_row(row_index); + + self.dirty += 1; + } + + fn row_delete_char(&mut self, row_index: usize, char_index: usize) { + let row = &mut self.rows[row_index]; + if char_index >= row.chars.len() { + return; + } + + row.chars.remove(char_index); + self.update_row(row_index); + + self.dirty += 1; + } + + // ------------------------------------------------------------------------ + // Editor Operations + // ------------------------------------------------------------------------ + + fn insert_char(&mut self, ch: char) { + if self.cursor_y == self.rows.len() { + self.insert_row(self.rows.len(), ""); + } + + self.row_insert_char(self.cursor_y, self.cursor_x, ch); + self.cursor_x += 1; + } + + fn insert_new_line(&mut self) { + if self.cursor_x == 0 { + self.insert_row(self.cursor_y, ""); + } else { + // Clone the contents of the current row + let row = &mut self.rows[self.cursor_y]; + let row_chars = row.chars.clone(); + + // Truncate the original row up to the cursor + row.chars.truncate(self.cursor_x); + + // Create the new row as a slice of the contents of the old + // row, from the cursor to the end of the line + let slice = &row_chars[self.cursor_x..]; + self.insert_row(self.cursor_y + 1, slice); + + self.update_row(self.cursor_y); + } + + self.cursor_y += 1; + self.cursor_x = 0; + } + + fn delete_char(&mut self) { + if self.cursor_y == self.rows.len() { + return; + } + + if self.cursor_x == 0 && self.cursor_y == 0 { + return; + } + + if self.cursor_x > 0 { + self.row_delete_char(self.cursor_y, self.cursor_x - 1); + self.cursor_x -= 1; + } else { + // When deleting the first character in the row, collapse that row into the previous one + self.cursor_x = self.rows[self.cursor_y - 1].chars.len(); + self.row_append_string(self.cursor_y - 1, &self.rows[self.cursor_y].chars.clone()); + self.delete_row(self.cursor_y); + + self.cursor_y -= 1; + } + } + + // ------------------------------------------------------------------------ + // File I/O + // ------------------------------------------------------------------------ + + fn rows_to_string(&mut self) -> String { + let mut output = String::new(); + + for row in &self.rows { + // When the file is opened, newlines are stripped + // make sure to add them back when saving! + output += &row.chars; + output.push('\n'); + } + + output + } + + /// Open a file for display + pub fn open(&mut self, filename: &str) -> io::Result<()> { + let file = File::open(filename); + + match file { + Ok(_) => { + let buf_reader = BufReader::new(file.unwrap()); + + let lines = buf_reader.lines().map(|l| clean_unwrap(l)); + + for line in lines { + self.insert_row(self.rows.len(), &line); + } + + // Make sure the whole file is loaded before + // showing the filename and selecting the syntax + // for highlighting + self.filename = filename.to_owned(); + self.select_syntax_highlight(); + + self.dirty = 0; + }, + // Gracefully handle errors opening a file + Err(e) => { + self.set_status_message(&e.to_string()); + } + } + + Ok(()) + } + + fn save(&mut self) -> io::Result<()> { + if self.filename.len() == 0 { + self.filename = self.prompt("Save as (ESC to cancel):", None); + if self.filename.len() == 0 { + self.set_status_message("Save aborted"); + return Ok(()); + } + + self.select_syntax_highlight(); + } + + let mut file = File::create(&self.filename)?; + let data = &mut self.rows_to_string(); + + let res = file.write_all(data.as_bytes()); + + match res { + Ok(()) => { + self.dirty = 0; + + self.set_status_message(&format!("{} bytes written to disk", data.len())); + } + Err(e) => self.set_status_message(&format!("Failed to save: {}", e.to_string())), + }; + + file.sync_all()?; + + Ok(()) + } + + // ------------------------------------------------------------------------ + // Find + // ------------------------------------------------------------------------ + + fn find_callback(&mut self, query: &str, key: KeyCode) { + if !self.search_last_hightlight.is_empty() { + self.rows[self.search_last_line].highlight = self.search_last_hightlight.clone(); + self.search_last_hightlight.clear(); + } + + if key == Enter || key == Escape { + self.search_last_match = -1; + self.search_direction = 1; + return; + } else if key == ArrowRight || key == ArrowDown { + self.search_direction = 1; + } else if key == ArrowLeft || key == ArrowUp { + self.search_direction = -1; + } else { + self.search_last_match = -1; + self.search_direction = 1; + } + + if self.search_last_match == -1 { + self.search_direction = 1; + } + + if query.is_empty() { + return; + } + + let mut current = self.search_last_match; + for x in 0..self.rows.len() { + current += self.search_direction as i32; + + if current == -1 { + current = self.rows.len() as i32 - 1; + } else if current == self.rows.len() as i32 { + current = 0; + } + + match self.rows[current as usize].render.find(query) { + None => (), + Some(start) => { + self.search_last_match = current; + self.cursor_y = current as usize; + self.cursor_x = self.row_rx_to_cx(x, start); + self.row_offset = self.rows.len(); + + self.search_last_line = current as usize; + self.search_last_hightlight = self.rows[current as usize].highlight.clone(); + + // Highlight matching search result + let len = start + query.len(); + highlight_range( + &mut self.rows[current as usize].highlight, + start..len, + Highlight::SearchMatch, + ); + + break; + } + } + } + } + + fn find(&mut self) { + let saved_cx = self.cursor_x; + let saved_cy = self.cursor_y; + let saved_coloff = self.col_offset; + let saved_rowoff = self.row_offset; + + let query = self.prompt( + "Search (Use ESC/Arrows/Enter):", + Some(&mut Self::find_callback), + ); + + if query.is_empty() { + self.cursor_x = saved_cx; + self.cursor_y = saved_cy; + self.col_offset = saved_coloff; + self.row_offset = saved_rowoff; + } + } +} + +// ------------------------------------------------------------------------ +// Functions +// ------------------------------------------------------------------------ + +/// Get the language highlighting config +fn get_syntax_db() -> Vec { + vec![ + Syntax::new( + "C/C++", + vec![".c", ".h", ".cpp"], + vec![ + "continue", "typedef", "switch", "return", "static", "while", "break", "struct", + "union", "class", "else", "enum", "for", "case", "if", + ], + vec![ + "#include", "unsigned", "#define", "#ifndef", "double", "signed", "#endif", + "#ifdef", "float", "#error", "#undef", "long", "char", "int", "void", "#if", + ], + "//", + "/*", + "*/", + SyntaxFlags::HIGHLIGHT_NUMBERS | SyntaxFlags::HIGHLIGHT_STRINGS, + ), + Syntax::new( + "Rust", + vec![".rs"], + vec![ + "continue", "return", "static", "struct", "unsafe", "break", "const", "crate", + "extern", "match", "super", "trait", "where", "else", "enum", "false", "impl", + "loop", "move", "self", "type", "while", "for", "let", "mod", "pub", "ref", "true", + "use", "mut", "as", "fn", "if", "in", + ], + vec![ + "DoubleEndedIterator", + "ExactSizeIterator", + "IntoIterator", + "PartialOrd", + "PartialEq", + "Iterator", + "ToString", + "Default", + "ToOwned", + "Extend", + "FnOnce", + "Option", + "String", + "AsMut", + "AsRef", + "Clone", + "Debug", + "FnMut", + "Sized", + "Unpin", + "array", + "isize", + "usize", + "&str", + "Copy", + "Drop", + "From", + "Into", + "None", + "Self", + "Send", + "Some", + "Sync", + "Sync", + "bool", + "char", + "i128", + "u128", + "Box", + "Err", + "Ord", + "Vec", + "dyn", + "f32", + "f64", + "i16", + "i32", + "i64", + "str", + "u16", + "u32", + "u64", + "Eq", + "Fn", + "Ok", + "i8", + "u8", + ], + "//", + "/*", + "*/", + SyntaxFlags::HIGHLIGHT_NUMBERS | SyntaxFlags::HIGHLIGHT_STRINGS, + ), + Syntax::new( + "JavaScript/TypeScript", + vec![".js", ".mjs", ".jsx", ".ts", ".tsx"], + vec![ + "instanceof", + "continue", + "debugger", + "function", + "default", + "extends", + "finally", + "delete", + "export", + "import", + "return", + "switch", + "typeof", + "break", + "catch", + "class", + "const", + "super", + "throw", + "while", + "yield", + "case", + "else", + "this", + "void", + "with", + "from", + "for", + "new", + "try", + "var", + "do", + "if", + "in", + "as", + ], + vec![ + "=>", "Number", "String", "Object", "Math", "JSON", "Boolean", + ], + "//", + "/*", + "*/", + SyntaxFlags::HIGHLIGHT_NUMBERS | SyntaxFlags::HIGHLIGHT_STRINGS, + ), + ] +} + +/// Convert Ctrl+letter chord to their +/// letter equivalent +fn ctrl_to_letter(c: char) -> char { + let key = c as u8; + + if (!c.is_ascii_control()) || c == '\x7f' { + panic!("Only ascii control characters have associated letters") + } + + // Shift forward to the letter equivalent + (key + 0x60) as char +} + +/// Determine whether a character is one which separates tokens +/// in the language to highlight +fn is_separator(input_char: char) -> bool { + if input_char.is_ascii_whitespace() || input_char == '\0' { + return true; + } + + let separator_chars = ",.()+-/*=~%<>[];"; + + for ch in separator_chars.chars() { + if input_char == ch { + return true; + } + } + + false +} + +/// Get the highlight color escape sequence for +/// the type of syntax +fn syntax_to_color(syntax_type: Highlight) -> i32 { + use Highlight::*; + + match syntax_type { + Keyword1 => 33, // Yellow + Keyword2 => 32, // Green + LineComment => 36, // Cyan + MultiLineComment => 90, // Bright Black + Normal => 37, // White + Number => 31, // Red + SearchMatch => 7, // Reverse! + String => 35, // Magenta + } +} + +/// Get a range for a slice of a string or vector, checking the length of the +/// string or vector to prevent panics on invalid access. +/// +/// If `start` to `start + search_len`, is within the size of the search target (`haystack_len`) +/// that range is returned. Otherwise, the range is from `start` to `haystack_len`. +fn get_slice_range(start: usize, needle_len: usize, haystack_len: usize) -> Range { + let search_len = start + needle_len; + if search_len >= haystack_len { + start..haystack_len + } else { + start..search_len + } +} + +/// Set the highlighting type for the specified range +/// Kind of similar to the C memset calls +fn highlight_range(vec: &mut Vec, range: Range, value: Highlight) { + for x in range { + vec[x] = value; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn select_syntax_highlight_selects_language() { + let langs = get_syntax_db(); + let mut editor = Editor::new(); + editor.filename = String::from("foo.c"); + + editor.select_syntax_highlight(); + + assert_eq!(editor.syntax.as_ref(), Some(&langs[0])); + } + + #[test] + fn is_separator_works() { + // Check each explicit character + for ch in ",.()+-/*=~%<>[];".chars() { + assert_eq!(is_separator(ch), true); + } + + // Check each whitespace character + for ch in " \t\n\r\x0c".chars() { + assert_eq!( + is_separator(ch), + true, + "Character {:#} should be a separator", + ch as u8 + ); + } + + // Letters are not separators! + for ch in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_".chars() { + assert_eq!(is_separator(ch), false); + } + } + + #[test] + fn ctrl_to_letter_() { + let a = ctrl_to_letter('\x01'); + assert_eq!(a, 'a', "ctrl_to_letter gives letter from ctrl chord"); + } + + #[test] + #[should_panic] + fn ctrl_to_letter_panic() { + // Del code doesn't map to Ctrl+letter combo + ctrl_to_letter('\x7f'); + } +} diff --git a/justfile b/justfile index ed6e8d0..89848e1 100644 --- a/justfile +++ b/justfile @@ -5,6 +5,10 @@ default: # Test coverage coverage: deno-coverage bun-coverage +# Generate test coverage and open report in default browser +open-coverage: coverage + open coverage/index.html + # Typescript checking check: deno-check bun-check @@ -12,6 +16,10 @@ check: deno-check bun-check docs: deno doc --html --name="Scroll" ./src/scroll.ts ./src/common/*.ts ./src/deno/mod.ts ./src/bun/mod.ts ./src/tsx/mod.ts +# Generate source docs and open in default browser +open-docs: docs + open docs/all_symbols.html + # Reformat the code fmt: deno fmt diff --git a/src/common/all_test.ts b/src/common/all_test.ts index e64171d..f919498 100644 --- a/src/common/all_test.ts +++ b/src/common/all_test.ts @@ -500,11 +500,78 @@ const OptionTest = { assertEquivalent(Option.from(Some('foo')), Some('foo')); assertEquivalent(Some(Some('bar')), Some('bar')); }, + '.isSome': () => { + assertFalse(None.isSome()); + assertTrue(Option.from('foo').isSome()); + assertTrue(Some('foo').isSome()); + }, + '.isNone': () => { + assertTrue(None.isNone()); + assertFalse(Option.from('foo').isNone()); + assertFalse(Some('foo').isNone()); + }, '.toString': () => { assertEquals(Some({}).toString(), 'Some ({})'); assertEquals(Some([1, 2, 3]).toString(), 'Some ([1,2,3])'); assertEquals(None.toString(), 'None'); }, + '.isSomeAnd': () => { + assertFalse(Option.from().isSomeAnd((_a) => true)); + assertTrue(Option.from('foo').isSomeAnd((a) => typeof a === 'string')); + }, + '.isNoneAnd': () => { + assertTrue(None.isNoneAnd(() => true)); + assertFalse(None.isNoneAnd(() => false)); + assertFalse(Some('x').isNoneAnd(() => true)); + }, + '.map': () => { + const fn = (_a: any) => 'bar'; + + assertEquivalent(Some('bar'), Some('foo').map(fn)); + assertNone(None.map(fn)); + }, + '.mapOr': () => { + const fn = (_a: any) => 'bar'; + + assertEquals('bar', Some('foo').mapOr('baz', fn)); + assertEquals('baz', None.mapOr('baz', fn)); + }, + '.mapOrElse': () => { + const fn = (_a: any) => 'bar'; + const defFn = () => 'baz'; + + assertEquals('bar', Some('foo').mapOrElse(defFn, fn)); + assertEquals('baz', None.mapOrElse(defFn, fn)); + }, + '.unwrapOr': () => { + assertEquals('foo', Some('foo').unwrapOr('bar')); + assertEquals('bar', None.unwrapOr('bar')); + }, + '.unwrapOrElse': () => { + const fn = () => 'bar'; + assertEquals('foo', Some('foo').unwrapOrElse(fn)); + assertEquals('bar', None.unwrapOrElse(fn)); + }, + '.and': () => { + const optb = Some('bar'); + assertEquivalent(optb, Some('foo').and(optb)); + assertEquivalent(None, None.and(optb)); + }, + '.andThen': () => { + const fn = (x: any) => Some(typeof x === 'string'); + assertEquivalent(Some(true), Some('foo').andThen(fn)); + assertNone(None.andThen(fn)); + }, + '.or': () => { + const optb = Some('bar'); + assertEquivalent(Some('foo'), Some('foo').or(optb)); + assertEquivalent(optb, None.or(optb)); + }, + '.orElse': () => { + const fn = () => Some('bar'); + assertEquivalent(Some('foo'), Some('foo').orElse(fn)); + assertEquivalent(Some('bar'), None.orElse(fn)); + }, }; // ---------------------------------------------------------------------------- diff --git a/src/common/ansi.ts b/src/common/ansi.ts index ef91615..fb6f6e0 100644 --- a/src/common/ansi.ts +++ b/src/common/ansi.ts @@ -132,8 +132,47 @@ export const Ansi = { moveCursorDown, textFormat, color, + colors: { + rgb, + Black: AnsiColor.FgBlack, + Red: AnsiColor.FgRed, + Green: AnsiColor.FgGreen, + Yellow: AnsiColor.FgYellow, + Blue: AnsiColor.FgBlue, + Magenta: AnsiColor.FgMagenta, + Cyan: AnsiColor.FgCyan, + White: AnsiColor.FgWhite, + BrightBlack: AnsiColor.FgBrightBlack, + BrightRed: AnsiColor.FgBrightRed, + BrightGreen: AnsiColor.FgBrightGreen, + BrightYellow: AnsiColor.FgBrightYellow, + BrightBlue: AnsiColor.FgBrightBlue, + BrightMagenta: AnsiColor.FgBrightMagenta, + BrightCyan: AnsiColor.FgBrightCyan, + BrightWhite: AnsiColor.FgBrightWhite, + Invert: AnsiColor.Invert, + background: { + rgb: (r: number, g: number, b: number) => rgb(r, g, b, Ground.Back), + Black: AnsiColor.BgBlack, + Red: AnsiColor.BgRed, + Green: AnsiColor.BgGreen, + Yellow: AnsiColor.BgYellow, + Blue: AnsiColor.BgBlue, + Magenta: AnsiColor.BgMagenta, + Cyan: AnsiColor.BgCyan, + White: AnsiColor.BgWhite, + BrightBlack: AnsiColor.BgBrightBlack, + BrightRed: AnsiColor.BgBrightRed, + BrightGreen: AnsiColor.BgBrightGreen, + BrightYellow: AnsiColor.BgBrightYellow, + BrightBlue: AnsiColor.BgBrightBlue, + BrightMagenta: AnsiColor.BgBrightMagenta, + BrightCyan: AnsiColor.BgBrightCyan, + BrightWhite: AnsiColor.BgBrightWhite, + Invert: AnsiColor.Invert, + }, + }, color256, - rgb, }; export default Ansi; diff --git a/src/common/config.ts b/src/common/config.ts index ea6143e..5e60a1a 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -1,4 +1,5 @@ -import { ITerminalSize } from './types.ts'; +import Ansi from './ansi.ts'; +import { HighlightType, ITerminalSize } from './types.ts'; export const SCROLL_VERSION = '0.0.1'; export const SCROLL_QUIT_TIMES = 3; @@ -11,3 +12,16 @@ export const defaultTerminalSize: ITerminalSize = { rows: 24, cols: 80, }; + +export const SCROLL_COLOR_SCHEME: Map = new Map([ + [HighlightType.Match, Ansi.colors.Invert.toString()], // Inverted color + [HighlightType.Number, Ansi.color256(196)], // Bright Red + [HighlightType.Character, Ansi.color256(207)], // Magenta + [HighlightType.String, Ansi.color256(45)], // Cyan + [HighlightType.SingleLineComment, Ansi.color256(248)], // Light Gray + [HighlightType.MultiLineComment, Ansi.color256(240)], // Medium-light Gray + [HighlightType.Keyword1, Ansi.color256(226)], // Yellow + [HighlightType.Keyword2, Ansi.color256(118)], // Green + [HighlightType.Operator, Ansi.color256(215)], // Orange/Brown + [HighlightType.None, Ansi.ResetFormatting], +]); diff --git a/src/common/document.ts b/src/common/document.ts index b4836a4..7096bf4 100644 --- a/src/common/document.ts +++ b/src/common/document.ts @@ -9,22 +9,16 @@ export class Document { /** * Each line of the current document */ - #rows: Row[]; + #rows: Row[] = []; /** - * Has the document been modified? + * @param dirty - Has the document been modified? + * @param type - The meta-data for the file type of the current document */ - public dirty: boolean; - - /** - * The meta-data for the file type of the current document - */ - public type: FileType; - - private constructor() { - this.#rows = []; - this.dirty = false; - this.type = FileType.default(); + private constructor( + public dirty: boolean = false, + public type: FileType = FileType.default(), + ) { } public get fileType(): string { diff --git a/src/common/editor.ts b/src/common/editor.ts index 7f5bb8c..fc5ce29 100644 --- a/src/common/editor.ts +++ b/src/common/editor.ts @@ -325,7 +325,7 @@ export default class Editor { const screenHeight = this.screen.rows; let { x, y } = this.cursor; const height = this.numRows; - let width = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0; + let width = this.row(y).mapOr(0, (r) => r.size); switch (char) { case KeyCommand.ArrowUp: @@ -370,7 +370,7 @@ export default class Editor { break; } - width = (this.row(y).isSome()) ? this.row(y).unwrap().size : 0; + width = this.row(y).mapOr(0, (r) => r.size); if (x > width) { x = width; @@ -383,9 +383,10 @@ export default class Editor { * Calculate the window of a file to display */ protected scroll(): void { - this.renderX = (this.row(this.cursor.y).isSome()) - ? this.row(this.cursor.y).unwrap().cxToRx(this.cursor.x) - : 0; + this.renderX = this.row(this.cursor.y).mapOr( + 0, + (r) => r.cxToRx(this.cursor.x), + ); const { y } = this.cursor; const offset = this.offset; diff --git a/src/common/filetype/filetype.ts b/src/common/filetype/filetype.ts index 92f05ed..47cc290 100644 --- a/src/common/filetype/filetype.ts +++ b/src/common/filetype/filetype.ts @@ -3,6 +3,7 @@ import { AbstractFileType } from './base.ts'; import { CFile } from './c.ts'; import { CSSFile } from './css.ts'; import { JavaScriptFile, TypeScriptFile } from './javascript.ts'; +import { RustFile } from './rust.ts'; import { ShellFile } from './shell.ts'; // ---------------------------------------------------------------------------- @@ -10,14 +11,15 @@ import { ShellFile } from './shell.ts'; // ---------------------------------------------------------------------------- export const fileTypeMap = new Map([ + ['.bash', ShellFile], ['.c', CFile], - ['.h', CFile], ['.css', CSSFile], - ['.json', JavaScriptFile], + ['.h', CFile], ['.js', JavaScriptFile], + ['.json', JavaScriptFile], ['.jsx', JavaScriptFile], ['.mjs', JavaScriptFile], - ['.bash', ShellFile], + ['.rs', RustFile], ['.sh', ShellFile], ['.ts', TypeScriptFile], ['.tsx', TypeScriptFile], diff --git a/src/common/filetype/rust.ts b/src/common/filetype/rust.ts new file mode 100644 index 0000000..b588971 --- /dev/null +++ b/src/common/filetype/rust.ts @@ -0,0 +1,169 @@ +import Option, { Some } from '../option.ts'; +import { + AbstractFileType, + defaultHighlightOptions, + FileLang, + HighlightingOptions, +} from './base.ts'; + +export class RustFile extends AbstractFileType { + public readonly name: FileLang = FileLang.Rust; + public readonly singleLineComment = Some('//'); + public readonly multiLineCommentStart: Option = Some('/*'); + public readonly multiLineCommentEnd: Option = Some('*/'); + public readonly keywords1 = [ + 'continue', + 'return', + 'static', + 'struct', + 'unsafe', + 'break', + 'const', + 'crate', + 'extern', + 'match', + 'super', + 'trait', + 'where', + 'else', + 'enum', + 'false', + 'impl', + 'loop', + 'move', + 'self', + 'type', + 'while', + 'for', + 'let', + 'mod', + 'pub', + 'ref', + 'true', + 'use', + 'mut', + 'as', + 'fn', + 'if', + 'in', + ]; + public readonly keywords2 = [ + 'DoubleEndedIterator', + 'ExactSizeIterator', + 'IntoIterator', + 'PartialOrd', + 'PartialEq', + 'Iterator', + 'ToString', + 'Default', + 'ToOwned', + 'Extend', + 'FnOnce', + 'Option', + 'String', + 'AsMut', + 'AsRef', + 'Clone', + 'Debug', + 'FnMut', + 'Sized', + 'Unpin', + 'array', + 'isize', + 'usize', + '&str', + 'Copy', + 'Drop', + 'From', + 'Into', + 'None', + 'Self', + 'Send', + 'Some', + 'Sync', + 'bool', + 'char', + 'i128', + 'u128', + 'Box', + 'Err', + 'Ord', + 'Vec', + 'dyn', + 'f32', + 'f64', + 'i16', + 'i32', + 'i64', + 'str', + 'u16', + 'u32', + 'u64', + 'Eq', + 'Fn', + 'Ok', + 'i8', + 'u8', + '&mut self', + '&mut', + '&self', + 'self', + ]; + public readonly operators = [ + '||=', + '>>=', + '<=>', + '<<=', + '&&=', + '**=', + '..=', + '...', + '||', + '|=', + '>>', + '>=', + '=>', + '==', + '<=', + '<<', + '<-', + '+=', + '++', + '^=', + '%=', + '&=', + '&&', + '/=', + '*=', + '**', + '..', + '!=', + ':=', + '::', + '->', + '-=', + '--', + '~', + '|', + '>', + '=', + '<', + '+', + '^', + '%', + '&', + '*', + '.', + '!', + ':', + ';', + ',', + '-', + ]; + public readonly hlOptions: HighlightingOptions = { + ...defaultHighlightOptions, + characters: true, + binNumbers: true, + hexNumbers: true, + }; +} diff --git a/src/common/highlight.ts b/src/common/highlight.ts index 4f6e9d4..7919562 100644 --- a/src/common/highlight.ts +++ b/src/common/highlight.ts @@ -1,48 +1,38 @@ import Ansi from './ansi.ts'; +import { SCROLL_COLOR_SCHEME } from './config.ts'; +/** + * The type of Syntax being highlighted + */ export enum HighlightType { + /** No highlighting */ None, + /** Number literals */ Number, + /** Search results */ Match, + /** Character literals */ Character, + /** String literals */ String, + /** Single line comments */ SingleLineComment, + /** Multi-line comments */ MultiLineComment, + /** Primary keywords */ Keyword1, + /** Secondary keywords */ Keyword2, + /** Math/logic operators */ Operator, } +/** + * Return the configured ANSI formatting escape codes for the + * type of syntax specified + * + * @param type The type of syntax to highlight + */ export function highlightToColor(type: HighlightType): string { - switch (type) { - case HighlightType.Number: - return Ansi.color256(196); - - case HighlightType.Match: - return Ansi.color256(21); - - case HighlightType.Character: - return Ansi.color256(207); - - case HighlightType.String: - return Ansi.color256(45); - - case HighlightType.SingleLineComment: - return Ansi.color256(248); - - case HighlightType.MultiLineComment: - return Ansi.color256(240); - - case HighlightType.Keyword1: - return Ansi.color256(226); - - case HighlightType.Keyword2: - return Ansi.color256(118); - - case HighlightType.Operator: - return Ansi.color256(215); - - default: - return Ansi.ResetFormatting; - } + return SCROLL_COLOR_SCHEME.get(type) ?? Ansi.ResetFormatting; } diff --git a/src/common/types.ts b/src/common/types.ts index 93aed89..bd6cd2f 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,3 +1,4 @@ +export { HighlightType } from './highlight.ts'; export { Position } from './position.ts'; export type { ITestBase } from './runtime/test_base.ts'; export type {