├── .gitignore ├── Cargo.lock ├── Cargo.toml └── src ├── document.rs ├── editor.rs ├── filetype.rs ├── highlighting.rs ├── main.rs ├── row.rs └── terminal.rs /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | 3 | #Added by cargo 4 | # 5 | #already existing elements are commented out 6 | 7 | /target 8 | **/*.rs.bk 9 | package.json 10 | node_modules/ 11 | create_diffs.js 12 | diffs/ 13 | create_commit.js 14 | rebase_tags.js 15 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "hecto" 5 | version = "0.1.0" 6 | dependencies = [ 7 | "termion 1.5.3 (registry+https://github.com/rust-lang/crates.io-index)", 8 | "unicode-segmentation 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", 9 | ] 10 | 11 | [[package]] 12 | name = "libc" 13 | version = "0.2.62" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | 16 | [[package]] 17 | name = "numtoa" 18 | version = "0.1.0" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | 21 | [[package]] 22 | name = "redox_syscall" 23 | version = "0.1.56" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | 26 | [[package]] 27 | name = "redox_termios" 28 | version = "0.1.1" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | dependencies = [ 31 | "redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)", 32 | ] 33 | 34 | [[package]] 35 | name = "termion" 36 | version = "1.5.3" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | dependencies = [ 39 | "libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)", 40 | "numtoa 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", 41 | "redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)", 42 | "redox_termios 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", 43 | ] 44 | 45 | [[package]] 46 | name = "unicode-segmentation" 47 | version = "1.3.0" 48 | source = "registry+https://github.com/rust-lang/crates.io-index" 49 | 50 | [metadata] 51 | "checksum libc 0.2.62 (registry+https://github.com/rust-lang/crates.io-index)" = "34fcd2c08d2f832f376f4173a231990fa5aef4e99fb569867318a227ef4c06ba" 52 | "checksum numtoa 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" 53 | "checksum redox_syscall 0.1.56 (registry+https://github.com/rust-lang/crates.io-index)" = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" 54 | "checksum redox_termios 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "7e891cfe48e9100a70a3b6eb652fef28920c117d366339687bd5576160db0f76" 55 | "checksum termion 1.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6a8fb22f7cde82c8220e5aeacb3258ed7ce996142c77cba193f203515e26c330" 56 | "checksum unicode-segmentation 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "1967f4cdfc355b37fd76d2a954fb2ed3871034eb4f26d60537d88795cfc332a9" 57 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hecto" 3 | version = "0.1.0" 4 | authors = ["Philipp Flenker "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | termion = "1" 11 | unicode-segmentation = "1" -------------------------------------------------------------------------------- /src/document.rs: -------------------------------------------------------------------------------- 1 | use crate::FileType; 2 | use crate::Position; 3 | use crate::Row; 4 | use crate::SearchDirection; 5 | use std::fs; 6 | use std::io::{Error, Write}; 7 | 8 | #[derive(Default)] 9 | pub struct Document { 10 | rows: Vec, 11 | pub file_name: Option, 12 | dirty: bool, 13 | file_type: FileType, 14 | } 15 | 16 | impl Document { 17 | pub fn open(filename: &str) -> Result { 18 | let contents = fs::read_to_string(filename)?; 19 | let file_type = FileType::from(filename); 20 | let mut rows = Vec::new(); 21 | for value in contents.lines() { 22 | rows.push(Row::from(value)); 23 | } 24 | Ok(Self { 25 | rows, 26 | file_name: Some(filename.to_string()), 27 | dirty: false, 28 | file_type, 29 | }) 30 | } 31 | pub fn file_type(&self) -> String { 32 | self.file_type.name() 33 | } 34 | pub fn row(&self, index: usize) -> Option<&Row> { 35 | self.rows.get(index) 36 | } 37 | pub fn is_empty(&self) -> bool { 38 | self.rows.is_empty() 39 | } 40 | pub fn len(&self) -> usize { 41 | self.rows.len() 42 | } 43 | fn insert_newline(&mut self, at: &Position) { 44 | if at.y > self.rows.len() { 45 | return; 46 | } 47 | if at.y == self.rows.len() { 48 | self.rows.push(Row::default()); 49 | return; 50 | } 51 | #[allow(clippy::indexing_slicing)] 52 | let current_row = &mut self.rows[at.y]; 53 | let new_row = current_row.split(at.x); 54 | #[allow(clippy::integer_arithmetic)] 55 | self.rows.insert(at.y + 1, new_row); 56 | } 57 | pub fn insert(&mut self, at: &Position, c: char) { 58 | if at.y > self.rows.len() { 59 | return; 60 | } 61 | self.dirty = true; 62 | if c == '\n' { 63 | self.insert_newline(at); 64 | } else if at.y == self.rows.len() { 65 | let mut row = Row::default(); 66 | row.insert(0, c); 67 | self.rows.push(row); 68 | } else { 69 | #[allow(clippy::indexing_slicing)] 70 | let row = &mut self.rows[at.y]; 71 | row.insert(at.x, c); 72 | } 73 | self.unhighlight_rows(at.y); 74 | } 75 | 76 | fn unhighlight_rows(&mut self, start: usize) { 77 | let start = start.saturating_sub(1); 78 | for row in self.rows.iter_mut().skip(start) { 79 | row.is_highlighted = false; 80 | } 81 | } 82 | #[allow(clippy::integer_arithmetic, clippy::indexing_slicing)] 83 | pub fn delete(&mut self, at: &Position) { 84 | let len = self.rows.len(); 85 | if at.y >= len { 86 | return; 87 | } 88 | self.dirty = true; 89 | if at.x == self.rows[at.y].len() && at.y + 1 < len { 90 | let next_row = self.rows.remove(at.y + 1); 91 | let row = &mut self.rows[at.y]; 92 | row.append(&next_row); 93 | } else { 94 | let row = &mut self.rows[at.y]; 95 | row.delete(at.x); 96 | } 97 | self.unhighlight_rows(at.y); 98 | } 99 | pub fn save(&mut self) -> Result<(), Error> { 100 | if let Some(file_name) = &self.file_name { 101 | let mut file = fs::File::create(file_name)?; 102 | self.file_type = FileType::from(file_name); 103 | for row in &mut self.rows { 104 | file.write_all(row.as_bytes())?; 105 | file.write_all(b"\n")?; 106 | } 107 | self.dirty = false; 108 | } 109 | Ok(()) 110 | } 111 | pub fn is_dirty(&self) -> bool { 112 | self.dirty 113 | } 114 | #[allow(clippy::indexing_slicing)] 115 | pub fn find(&self, query: &str, at: &Position, direction: SearchDirection) -> Option { 116 | if at.y >= self.rows.len() { 117 | return None; 118 | } 119 | let mut position = Position { x: at.x, y: at.y }; 120 | 121 | let start = if direction == SearchDirection::Forward { 122 | at.y 123 | } else { 124 | 0 125 | }; 126 | let end = if direction == SearchDirection::Forward { 127 | self.rows.len() 128 | } else { 129 | at.y.saturating_add(1) 130 | }; 131 | for _ in start..end { 132 | if let Some(row) = self.rows.get(position.y) { 133 | if let Some(x) = row.find(&query, position.x, direction) { 134 | position.x = x; 135 | return Some(position); 136 | } 137 | if direction == SearchDirection::Forward { 138 | position.y = position.y.saturating_add(1); 139 | position.x = 0; 140 | } else { 141 | position.y = position.y.saturating_sub(1); 142 | position.x = self.rows[position.y].len(); 143 | } 144 | } else { 145 | return None; 146 | } 147 | } 148 | None 149 | } 150 | pub fn highlight(&mut self, word: &Option, until: Option) { 151 | let mut start_with_comment = false; 152 | let until = if let Some(until) = until { 153 | if until.saturating_add(1) < self.rows.len() { 154 | until.saturating_add(1) 155 | } else { 156 | self.rows.len() 157 | } 158 | } else { 159 | self.rows.len() 160 | }; 161 | #[allow(clippy::indexing_slicing)] 162 | for row in &mut self.rows[..until] { 163 | start_with_comment = row.highlight( 164 | &self.file_type.highlighting_options(), 165 | word, 166 | start_with_comment, 167 | ); 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/editor.rs: -------------------------------------------------------------------------------- 1 | use crate::Document; 2 | use crate::Row; 3 | use crate::Terminal; 4 | use std::env; 5 | use std::time::Duration; 6 | use std::time::Instant; 7 | use termion::color; 8 | use termion::event::Key; 9 | 10 | const STATUS_FG_COLOR: color::Rgb = color::Rgb(63, 63, 63); 11 | const STATUS_BG_COLOR: color::Rgb = color::Rgb(239, 239, 239); 12 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 13 | const QUIT_TIMES: u8 = 3; 14 | 15 | #[derive(PartialEq, Copy, Clone)] 16 | pub enum SearchDirection { 17 | Forward, 18 | Backward, 19 | } 20 | 21 | #[derive(Default, Clone)] 22 | pub struct Position { 23 | pub x: usize, 24 | pub y: usize, 25 | } 26 | 27 | struct StatusMessage { 28 | text: String, 29 | time: Instant, 30 | } 31 | impl StatusMessage { 32 | fn from(message: String) -> Self { 33 | Self { 34 | time: Instant::now(), 35 | text: message, 36 | } 37 | } 38 | } 39 | 40 | pub struct Editor { 41 | should_quit: bool, 42 | terminal: Terminal, 43 | cursor_position: Position, 44 | offset: Position, 45 | document: Document, 46 | status_message: StatusMessage, 47 | quit_times: u8, 48 | highlighted_word: Option, 49 | } 50 | 51 | impl Editor { 52 | pub fn run(&mut self) { 53 | loop { 54 | if let Err(error) = self.refresh_screen() { 55 | die(error); 56 | } 57 | if self.should_quit { 58 | break; 59 | } 60 | if let Err(error) = self.process_keypress() { 61 | die(error); 62 | } 63 | } 64 | } 65 | pub fn default() -> Self { 66 | let args: Vec = env::args().collect(); 67 | let mut initial_status = 68 | String::from("HELP: Ctrl-F = find | Ctrl-S = save | Ctrl-Q = quit"); 69 | 70 | let document = if let Some(file_name) = args.get(1) { 71 | let doc = Document::open(file_name); 72 | if let Ok(doc) = doc { 73 | doc 74 | } else { 75 | initial_status = format!("ERR: Could not open file: {}", file_name); 76 | Document::default() 77 | } 78 | } else { 79 | Document::default() 80 | }; 81 | 82 | Self { 83 | should_quit: false, 84 | terminal: Terminal::default().expect("Failed to initialize terminal"), 85 | document, 86 | cursor_position: Position::default(), 87 | offset: Position::default(), 88 | status_message: StatusMessage::from(initial_status), 89 | quit_times: QUIT_TIMES, 90 | highlighted_word: None, 91 | } 92 | } 93 | 94 | fn refresh_screen(&mut self) -> Result<(), std::io::Error> { 95 | Terminal::cursor_hide(); 96 | Terminal::cursor_position(&Position::default()); 97 | if self.should_quit { 98 | Terminal::clear_screen(); 99 | println!("Goodbye.\r"); 100 | } else { 101 | self.document.highlight( 102 | &self.highlighted_word, 103 | Some( 104 | self.offset 105 | .y 106 | .saturating_add(self.terminal.size().height as usize), 107 | ), 108 | ); 109 | self.draw_rows(); 110 | self.draw_status_bar(); 111 | self.draw_message_bar(); 112 | Terminal::cursor_position(&Position { 113 | x: self.cursor_position.x.saturating_sub(self.offset.x), 114 | y: self.cursor_position.y.saturating_sub(self.offset.y), 115 | }); 116 | } 117 | Terminal::cursor_show(); 118 | Terminal::flush() 119 | } 120 | fn save(&mut self) { 121 | if self.document.file_name.is_none() { 122 | let new_name = self.prompt("Save as: ", |_, _, _| {}).unwrap_or(None); 123 | if new_name.is_none() { 124 | self.status_message = StatusMessage::from("Save aborted.".to_string()); 125 | return; 126 | } 127 | self.document.file_name = new_name; 128 | } 129 | 130 | if self.document.save().is_ok() { 131 | self.status_message = StatusMessage::from("File saved successfully.".to_string()); 132 | } else { 133 | self.status_message = StatusMessage::from("Error writing file!".to_string()); 134 | } 135 | } 136 | fn search(&mut self) { 137 | let old_position = self.cursor_position.clone(); 138 | let mut direction = SearchDirection::Forward; 139 | let query = self 140 | .prompt( 141 | "Search (ESC to cancel, Arrows to navigate): ", 142 | |editor, key, query| { 143 | let mut moved = false; 144 | match key { 145 | Key::Right | Key::Down => { 146 | direction = SearchDirection::Forward; 147 | editor.move_cursor(Key::Right); 148 | moved = true; 149 | } 150 | Key::Left | Key::Up => direction = SearchDirection::Backward, 151 | _ => direction = SearchDirection::Forward, 152 | } 153 | if let Some(position) = 154 | editor 155 | .document 156 | .find(&query, &editor.cursor_position, direction) 157 | { 158 | editor.cursor_position = position; 159 | editor.scroll(); 160 | } else if moved { 161 | editor.move_cursor(Key::Left); 162 | } 163 | editor.highlighted_word = Some(query.to_string()); 164 | }, 165 | ) 166 | .unwrap_or(None); 167 | 168 | if query.is_none() { 169 | self.cursor_position = old_position; 170 | self.scroll(); 171 | } 172 | self.highlighted_word = None; 173 | } 174 | fn process_keypress(&mut self) -> Result<(), std::io::Error> { 175 | let pressed_key = Terminal::read_key()?; 176 | match pressed_key { 177 | Key::Ctrl('q') => { 178 | if self.quit_times > 0 && self.document.is_dirty() { 179 | self.status_message = StatusMessage::from(format!( 180 | "WARNING! File has unsaved changes. Press Ctrl-Q {} more times to quit.", 181 | self.quit_times 182 | )); 183 | self.quit_times -= 1; 184 | return Ok(()); 185 | } 186 | self.should_quit = true 187 | } 188 | Key::Ctrl('s') => self.save(), 189 | Key::Ctrl('f') => self.search(), 190 | Key::Char(c) => { 191 | self.document.insert(&self.cursor_position, c); 192 | self.move_cursor(Key::Right); 193 | } 194 | Key::Delete => self.document.delete(&self.cursor_position), 195 | Key::Backspace => { 196 | if self.cursor_position.x > 0 || self.cursor_position.y > 0 { 197 | self.move_cursor(Key::Left); 198 | self.document.delete(&self.cursor_position); 199 | } 200 | } 201 | Key::Up 202 | | Key::Down 203 | | Key::Left 204 | | Key::Right 205 | | Key::PageUp 206 | | Key::PageDown 207 | | Key::End 208 | | Key::Home => self.move_cursor(pressed_key), 209 | _ => (), 210 | } 211 | self.scroll(); 212 | if self.quit_times < QUIT_TIMES { 213 | self.quit_times = QUIT_TIMES; 214 | self.status_message = StatusMessage::from(String::new()); 215 | } 216 | Ok(()) 217 | } 218 | fn scroll(&mut self) { 219 | let Position { x, y } = self.cursor_position; 220 | let width = self.terminal.size().width as usize; 221 | let height = self.terminal.size().height as usize; 222 | let mut offset = &mut self.offset; 223 | if y < offset.y { 224 | offset.y = y; 225 | } else if y >= offset.y.saturating_add(height) { 226 | offset.y = y.saturating_sub(height).saturating_add(1); 227 | } 228 | if x < offset.x { 229 | offset.x = x; 230 | } else if x >= offset.x.saturating_add(width) { 231 | offset.x = x.saturating_sub(width).saturating_add(1); 232 | } 233 | } 234 | fn move_cursor(&mut self, key: Key) { 235 | let terminal_height = self.terminal.size().height as usize; 236 | let Position { mut y, mut x } = self.cursor_position; 237 | let height = self.document.len(); 238 | let mut width = if let Some(row) = self.document.row(y) { 239 | row.len() 240 | } else { 241 | 0 242 | }; 243 | match key { 244 | Key::Up => y = y.saturating_sub(1), 245 | Key::Down => { 246 | if y < height { 247 | y = y.saturating_add(1); 248 | } 249 | } 250 | Key::Left => { 251 | if x > 0 { 252 | x -= 1; 253 | } else if y > 0 { 254 | y -= 1; 255 | if let Some(row) = self.document.row(y) { 256 | x = row.len(); 257 | } else { 258 | x = 0; 259 | } 260 | } 261 | } 262 | Key::Right => { 263 | if x < width { 264 | x += 1; 265 | } else if y < height { 266 | y += 1; 267 | x = 0; 268 | } 269 | } 270 | Key::PageUp => { 271 | y = if y > terminal_height { 272 | y.saturating_sub(terminal_height) 273 | } else { 274 | 0 275 | } 276 | } 277 | Key::PageDown => { 278 | y = if y.saturating_add(terminal_height) < height { 279 | y.saturating_add(terminal_height) 280 | } else { 281 | height 282 | } 283 | } 284 | Key::Home => x = 0, 285 | Key::End => x = width, 286 | _ => (), 287 | } 288 | width = if let Some(row) = self.document.row(y) { 289 | row.len() 290 | } else { 291 | 0 292 | }; 293 | if x > width { 294 | x = width; 295 | } 296 | 297 | self.cursor_position = Position { x, y } 298 | } 299 | fn draw_welcome_message(&self) { 300 | let mut welcome_message = format!("Hecto editor -- version {}", VERSION); 301 | let width = self.terminal.size().width as usize; 302 | let len = welcome_message.len(); 303 | #[allow(clippy::integer_arithmetic, clippy::integer_division)] 304 | let padding = width.saturating_sub(len) / 2; 305 | let spaces = " ".repeat(padding.saturating_sub(1)); 306 | welcome_message = format!("~{}{}", spaces, welcome_message); 307 | welcome_message.truncate(width); 308 | println!("{}\r", welcome_message); 309 | } 310 | pub fn draw_row(&self, row: &Row) { 311 | let width = self.terminal.size().width as usize; 312 | let start = self.offset.x; 313 | let end = self.offset.x.saturating_add(width); 314 | let row = row.render(start, end); 315 | println!("{}\r", row) 316 | } 317 | #[allow(clippy::integer_division, clippy::integer_arithmetic)] 318 | fn draw_rows(&self) { 319 | let height = self.terminal.size().height; 320 | for terminal_row in 0..height { 321 | Terminal::clear_current_line(); 322 | if let Some(row) = self 323 | .document 324 | .row(self.offset.y.saturating_add(terminal_row as usize)) 325 | { 326 | self.draw_row(row); 327 | } else if self.document.is_empty() && terminal_row == height / 3 { 328 | self.draw_welcome_message(); 329 | } else { 330 | println!("~\r"); 331 | } 332 | } 333 | } 334 | fn draw_status_bar(&self) { 335 | let mut status; 336 | let width = self.terminal.size().width as usize; 337 | let modified_indicator = if self.document.is_dirty() { 338 | " (modified)" 339 | } else { 340 | "" 341 | }; 342 | 343 | let mut file_name = "[No Name]".to_string(); 344 | if let Some(name) = &self.document.file_name { 345 | file_name = name.clone(); 346 | file_name.truncate(20); 347 | } 348 | status = format!( 349 | "{} - {} lines{}", 350 | file_name, 351 | self.document.len(), 352 | modified_indicator 353 | ); 354 | 355 | let line_indicator = format!( 356 | "{} | {}/{}", 357 | self.document.file_type(), 358 | self.cursor_position.y.saturating_add(1), 359 | self.document.len() 360 | ); 361 | #[allow(clippy::integer_arithmetic)] 362 | let len = status.len() + line_indicator.len(); 363 | status.push_str(&" ".repeat(width.saturating_sub(len))); 364 | status = format!("{}{}", status, line_indicator); 365 | status.truncate(width); 366 | Terminal::set_bg_color(STATUS_BG_COLOR); 367 | Terminal::set_fg_color(STATUS_FG_COLOR); 368 | println!("{}\r", status); 369 | Terminal::reset_fg_color(); 370 | Terminal::reset_bg_color(); 371 | } 372 | fn draw_message_bar(&self) { 373 | Terminal::clear_current_line(); 374 | let message = &self.status_message; 375 | if Instant::now() - message.time < Duration::new(5, 0) { 376 | let mut text = message.text.clone(); 377 | text.truncate(self.terminal.size().width as usize); 378 | print!("{}", text); 379 | } 380 | } 381 | fn prompt(&mut self, prompt: &str, mut callback: C) -> Result, std::io::Error> 382 | where 383 | C: FnMut(&mut Self, Key, &String), 384 | { 385 | let mut result = String::new(); 386 | loop { 387 | self.status_message = StatusMessage::from(format!("{}{}", prompt, result)); 388 | self.refresh_screen()?; 389 | let key = Terminal::read_key()?; 390 | match key { 391 | Key::Backspace => result.truncate(result.len().saturating_sub(1)), 392 | Key::Char('\n') => break, 393 | Key::Char(c) => { 394 | if !c.is_control() { 395 | result.push(c); 396 | } 397 | } 398 | Key::Esc => { 399 | result.truncate(0); 400 | break; 401 | } 402 | _ => (), 403 | } 404 | callback(self, key, &result); 405 | } 406 | self.status_message = StatusMessage::from(String::new()); 407 | if result.is_empty() { 408 | return Ok(None); 409 | } 410 | Ok(Some(result)) 411 | } 412 | } 413 | 414 | fn die(e: std::io::Error) { 415 | Terminal::clear_screen(); 416 | panic!(e); 417 | } 418 | -------------------------------------------------------------------------------- /src/filetype.rs: -------------------------------------------------------------------------------- 1 | pub struct FileType { 2 | name: String, 3 | hl_opts: HighlightingOptions, 4 | } 5 | 6 | #[derive(Default)] 7 | pub struct HighlightingOptions { 8 | numbers: bool, 9 | strings: bool, 10 | characters: bool, 11 | comments: bool, 12 | multiline_comments: bool, 13 | primary_keywords: Vec, 14 | secondary_keywords: Vec, 15 | } 16 | 17 | impl Default for FileType { 18 | fn default() -> Self { 19 | Self { 20 | name: String::from("No filetype"), 21 | hl_opts: HighlightingOptions::default(), 22 | } 23 | } 24 | } 25 | 26 | impl FileType { 27 | pub fn name(&self) -> String { 28 | self.name.clone() 29 | } 30 | pub fn highlighting_options(&self) -> &HighlightingOptions { 31 | &self.hl_opts 32 | } 33 | pub fn from(file_name: &str) -> Self { 34 | if file_name.ends_with(".rs") { 35 | return Self { 36 | name: String::from("Rust"), 37 | hl_opts: HighlightingOptions { 38 | numbers: true, 39 | strings: true, 40 | characters: true, 41 | comments: true, 42 | multiline_comments: true, 43 | primary_keywords: vec![ 44 | "as".to_string(), 45 | "break".to_string(), 46 | "const".to_string(), 47 | "continue".to_string(), 48 | "crate".to_string(), 49 | "else".to_string(), 50 | "enum".to_string(), 51 | "extern".to_string(), 52 | "false".to_string(), 53 | "fn".to_string(), 54 | "for".to_string(), 55 | "if".to_string(), 56 | "impl".to_string(), 57 | "in".to_string(), 58 | "let".to_string(), 59 | "loop".to_string(), 60 | "match".to_string(), 61 | "mod".to_string(), 62 | "move".to_string(), 63 | "mut".to_string(), 64 | "pub".to_string(), 65 | "ref".to_string(), 66 | "return".to_string(), 67 | "self".to_string(), 68 | "Self".to_string(), 69 | "static".to_string(), 70 | "struct".to_string(), 71 | "super".to_string(), 72 | "trait".to_string(), 73 | "true".to_string(), 74 | "type".to_string(), 75 | "unsafe".to_string(), 76 | "use".to_string(), 77 | "where".to_string(), 78 | "while".to_string(), 79 | "dyn".to_string(), 80 | "abstract".to_string(), 81 | "become".to_string(), 82 | "box".to_string(), 83 | "do".to_string(), 84 | "final".to_string(), 85 | "macro".to_string(), 86 | "override".to_string(), 87 | "priv".to_string(), 88 | "typeof".to_string(), 89 | "unsized".to_string(), 90 | "virtual".to_string(), 91 | "yield".to_string(), 92 | "async".to_string(), 93 | "await".to_string(), 94 | "try".to_string(), 95 | ], 96 | secondary_keywords: vec![ 97 | "bool".to_string(), 98 | "char".to_string(), 99 | "i8".to_string(), 100 | "i16".to_string(), 101 | "i32".to_string(), 102 | "i64".to_string(), 103 | "isize".to_string(), 104 | "u8".to_string(), 105 | "u16".to_string(), 106 | "u32".to_string(), 107 | "u64".to_string(), 108 | "usize".to_string(), 109 | "f32".to_string(), 110 | "f64".to_string(), 111 | ], 112 | }, 113 | }; 114 | } 115 | Self::default() 116 | } 117 | } 118 | 119 | impl HighlightingOptions { 120 | pub fn numbers(&self) -> bool { 121 | self.numbers 122 | } 123 | pub fn strings(&self) -> bool { 124 | self.strings 125 | } 126 | pub fn characters(&self) -> bool { 127 | self.characters 128 | } 129 | pub fn comments(&self) -> bool { 130 | self.comments 131 | } 132 | pub fn primary_keywords(&self) -> &Vec { 133 | &self.primary_keywords 134 | } 135 | pub fn secondary_keywords(&self) -> &Vec { 136 | &self.secondary_keywords 137 | } 138 | pub fn multiline_comments(&self) -> bool { 139 | self.multiline_comments 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/highlighting.rs: -------------------------------------------------------------------------------- 1 | use termion::color; 2 | #[derive(PartialEq, Clone, Copy, Debug)] 3 | pub enum Type { 4 | None, 5 | Number, 6 | Match, 7 | String, 8 | Character, 9 | Comment, 10 | MultilineComment, 11 | PrimaryKeywords, 12 | SecondaryKeywords, 13 | } 14 | 15 | impl Type { 16 | pub fn to_color(self) -> impl color::Color { 17 | match self { 18 | Type::Number => color::Rgb(220, 163, 163), 19 | Type::Match => color::Rgb(38, 139, 210), 20 | Type::String => color::Rgb(211, 54, 130), 21 | Type::Character => color::Rgb(108, 113, 196), 22 | Type::Comment | Type::MultilineComment => color::Rgb(133, 153, 0), 23 | Type::PrimaryKeywords => color::Rgb(181, 137, 0), 24 | Type::SecondaryKeywords => color::Rgb(42, 161, 152), 25 | _ => color::Rgb(255, 255, 255), 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, clippy::pedantic, clippy::restriction)] 2 | #![allow( 3 | clippy::missing_docs_in_private_items, 4 | clippy::implicit_return, 5 | clippy::shadow_reuse, 6 | clippy::print_stdout, 7 | clippy::wildcard_enum_match_arm, 8 | clippy::else_if_without_else 9 | )] 10 | mod document; 11 | mod editor; 12 | mod filetype; 13 | mod highlighting; 14 | mod row; 15 | mod terminal; 16 | pub use document::Document; 17 | use editor::Editor; 18 | pub use editor::Position; 19 | pub use editor::SearchDirection; 20 | pub use filetype::FileType; 21 | pub use filetype::HighlightingOptions; 22 | pub use row::Row; 23 | pub use terminal::Terminal; 24 | 25 | fn main() { 26 | Editor::default().run(); 27 | } 28 | -------------------------------------------------------------------------------- /src/row.rs: -------------------------------------------------------------------------------- 1 | use crate::highlighting; 2 | use crate::HighlightingOptions; 3 | use crate::SearchDirection; 4 | use std::cmp; 5 | use termion::color; 6 | use unicode_segmentation::UnicodeSegmentation; 7 | 8 | #[derive(Default)] 9 | pub struct Row { 10 | string: String, 11 | highlighting: Vec, 12 | pub is_highlighted: bool, 13 | len: usize, 14 | } 15 | 16 | impl From<&str> for Row { 17 | fn from(slice: &str) -> Self { 18 | Self { 19 | string: String::from(slice), 20 | highlighting: Vec::new(), 21 | is_highlighted: false, 22 | len: slice.graphemes(true).count(), 23 | } 24 | } 25 | } 26 | 27 | impl Row { 28 | pub fn render(&self, start: usize, end: usize) -> String { 29 | let end = cmp::min(end, self.string.len()); 30 | let start = cmp::min(start, end); 31 | let mut result = String::new(); 32 | let mut current_highlighting = &highlighting::Type::None; 33 | #[allow(clippy::integer_arithmetic)] 34 | for (index, grapheme) in self.string[..] 35 | .graphemes(true) 36 | .enumerate() 37 | .skip(start) 38 | .take(end - start) 39 | { 40 | if let Some(c) = grapheme.chars().next() { 41 | let highlighting_type = self 42 | .highlighting 43 | .get(index) 44 | .unwrap_or(&highlighting::Type::None); 45 | if highlighting_type != current_highlighting { 46 | current_highlighting = highlighting_type; 47 | let start_highlight = 48 | format!("{}", termion::color::Fg(highlighting_type.to_color())); 49 | result.push_str(&start_highlight[..]); 50 | } 51 | if c == '\t' { 52 | result.push_str(" "); 53 | } else { 54 | result.push(c); 55 | } 56 | } 57 | } 58 | let end_highlight = format!("{}", termion::color::Fg(color::Reset)); 59 | result.push_str(&end_highlight[..]); 60 | result 61 | } 62 | pub fn len(&self) -> usize { 63 | self.len 64 | } 65 | pub fn is_empty(&self) -> bool { 66 | self.len == 0 67 | } 68 | pub fn insert(&mut self, at: usize, c: char) { 69 | if at >= self.len() { 70 | self.string.push(c); 71 | self.len += 1; 72 | return; 73 | } 74 | let mut result: String = String::new(); 75 | let mut length = 0; 76 | for (index, grapheme) in self.string[..].graphemes(true).enumerate() { 77 | length += 1; 78 | if index == at { 79 | length += 1; 80 | result.push(c); 81 | } 82 | result.push_str(grapheme); 83 | } 84 | self.len = length; 85 | self.string = result; 86 | } 87 | pub fn delete(&mut self, at: usize) { 88 | if at >= self.len() { 89 | return; 90 | } 91 | let mut result: String = String::new(); 92 | let mut length = 0; 93 | for (index, grapheme) in self.string[..].graphemes(true).enumerate() { 94 | if index != at { 95 | length += 1; 96 | result.push_str(grapheme); 97 | } 98 | } 99 | self.len = length; 100 | self.string = result; 101 | } 102 | pub fn append(&mut self, new: &Self) { 103 | self.string = format!("{}{}", self.string, new.string); 104 | self.len += new.len; 105 | } 106 | pub fn split(&mut self, at: usize) -> Self { 107 | let mut row: String = String::new(); 108 | let mut length = 0; 109 | let mut splitted_row: String = String::new(); 110 | let mut splitted_length = 0; 111 | for (index, grapheme) in self.string[..].graphemes(true).enumerate() { 112 | if index < at { 113 | length += 1; 114 | row.push_str(grapheme); 115 | } else { 116 | splitted_length += 1; 117 | splitted_row.push_str(grapheme); 118 | } 119 | } 120 | 121 | self.string = row; 122 | self.len = length; 123 | self.is_highlighted = false; 124 | Self { 125 | string: splitted_row, 126 | len: splitted_length, 127 | is_highlighted: false, 128 | highlighting: Vec::new(), 129 | } 130 | } 131 | pub fn as_bytes(&self) -> &[u8] { 132 | self.string.as_bytes() 133 | } 134 | pub fn find(&self, query: &str, at: usize, direction: SearchDirection) -> Option { 135 | if at > self.len || query.is_empty() { 136 | return None; 137 | } 138 | let start = if direction == SearchDirection::Forward { 139 | at 140 | } else { 141 | 0 142 | }; 143 | let end = if direction == SearchDirection::Forward { 144 | self.len 145 | } else { 146 | at 147 | }; 148 | #[allow(clippy::integer_arithmetic)] 149 | let substring: String = self.string[..] 150 | .graphemes(true) 151 | .skip(start) 152 | .take(end - start) 153 | .collect(); 154 | let matching_byte_index = if direction == SearchDirection::Forward { 155 | substring.find(query) 156 | } else { 157 | substring.rfind(query) 158 | }; 159 | if let Some(matching_byte_index) = matching_byte_index { 160 | for (grapheme_index, (byte_index, _)) in 161 | substring[..].grapheme_indices(true).enumerate() 162 | { 163 | if matching_byte_index == byte_index { 164 | #[allow(clippy::integer_arithmetic)] 165 | return Some(start + grapheme_index); 166 | } 167 | } 168 | } 169 | None 170 | } 171 | 172 | fn highlight_match(&mut self, word: &Option) { 173 | if let Some(word) = word { 174 | if word.is_empty() { 175 | return; 176 | } 177 | let mut index = 0; 178 | while let Some(search_match) = self.find(word, index, SearchDirection::Forward) { 179 | if let Some(next_index) = search_match.checked_add(word[..].graphemes(true).count()) 180 | { 181 | #[allow(clippy::indexing_slicing)] 182 | for i in search_match..next_index { 183 | self.highlighting[i] = highlighting::Type::Match; 184 | } 185 | index = next_index; 186 | } else { 187 | break; 188 | } 189 | } 190 | } 191 | } 192 | 193 | fn highlight_str( 194 | &mut self, 195 | index: &mut usize, 196 | substring: &str, 197 | chars: &[char], 198 | hl_type: highlighting::Type, 199 | ) -> bool { 200 | if substring.is_empty() { 201 | return false; 202 | } 203 | for (substring_index, c) in substring.chars().enumerate() { 204 | if let Some(next_char) = chars.get(index.saturating_add(substring_index)) { 205 | if *next_char != c { 206 | return false; 207 | } 208 | } else { 209 | return false; 210 | } 211 | } 212 | for _ in 0..substring.len() { 213 | self.highlighting.push(hl_type); 214 | *index += 1; 215 | } 216 | true 217 | } 218 | fn highlight_keywords( 219 | &mut self, 220 | index: &mut usize, 221 | chars: &[char], 222 | keywords: &[String], 223 | hl_type: highlighting::Type, 224 | ) -> bool { 225 | if *index > 0 { 226 | #[allow(clippy::indexing_slicing, clippy::integer_arithmetic)] 227 | let prev_char = chars[*index - 1]; 228 | if !is_separator(prev_char) { 229 | return false; 230 | } 231 | } 232 | for word in keywords { 233 | if *index < chars.len().saturating_sub(word.len()) { 234 | #[allow(clippy::indexing_slicing, clippy::integer_arithmetic)] 235 | let next_char = chars[*index + word.len()]; 236 | if !is_separator(next_char) { 237 | continue; 238 | } 239 | } 240 | 241 | if self.highlight_str(index, &word, chars, hl_type) { 242 | return true; 243 | } 244 | } 245 | false 246 | } 247 | 248 | fn highlight_primary_keywords( 249 | &mut self, 250 | index: &mut usize, 251 | opts: &HighlightingOptions, 252 | chars: &[char], 253 | ) -> bool { 254 | self.highlight_keywords( 255 | index, 256 | chars, 257 | opts.primary_keywords(), 258 | highlighting::Type::PrimaryKeywords, 259 | ) 260 | } 261 | fn highlight_secondary_keywords( 262 | &mut self, 263 | index: &mut usize, 264 | opts: &HighlightingOptions, 265 | chars: &[char], 266 | ) -> bool { 267 | self.highlight_keywords( 268 | index, 269 | chars, 270 | opts.secondary_keywords(), 271 | highlighting::Type::SecondaryKeywords, 272 | ) 273 | } 274 | 275 | fn highlight_char( 276 | &mut self, 277 | index: &mut usize, 278 | opts: &HighlightingOptions, 279 | c: char, 280 | chars: &[char], 281 | ) -> bool { 282 | if opts.characters() && c == '\'' { 283 | if let Some(next_char) = chars.get(index.saturating_add(1)) { 284 | let closing_index = if *next_char == '\\' { 285 | index.saturating_add(3) 286 | } else { 287 | index.saturating_add(2) 288 | }; 289 | if let Some(closing_char) = chars.get(closing_index) { 290 | if *closing_char == '\'' { 291 | for _ in 0..=closing_index.saturating_sub(*index) { 292 | self.highlighting.push(highlighting::Type::Character); 293 | *index += 1; 294 | } 295 | return true; 296 | } 297 | } 298 | } 299 | } 300 | false 301 | } 302 | 303 | fn highlight_comment( 304 | &mut self, 305 | index: &mut usize, 306 | opts: &HighlightingOptions, 307 | c: char, 308 | chars: &[char], 309 | ) -> bool { 310 | if opts.comments() && c == '/' && *index < chars.len() { 311 | if let Some(next_char) = chars.get(index.saturating_add(1)) { 312 | if *next_char == '/' { 313 | for _ in *index..chars.len() { 314 | self.highlighting.push(highlighting::Type::Comment); 315 | *index += 1; 316 | } 317 | return true; 318 | } 319 | }; 320 | } 321 | false 322 | } 323 | #[allow(clippy::indexing_slicing, clippy::integer_arithmetic)] 324 | fn highlight_multiline_comment( 325 | &mut self, 326 | index: &mut usize, 327 | opts: &HighlightingOptions, 328 | c: char, 329 | chars: &[char], 330 | ) -> bool { 331 | if opts.comments() && c == '/' && *index < chars.len() { 332 | if let Some(next_char) = chars.get(index.saturating_add(1)) { 333 | if *next_char == '*' { 334 | let closing_index = 335 | if let Some(closing_index) = self.string[*index + 2..].find("*/") { 336 | *index + closing_index + 4 337 | } else { 338 | chars.len() 339 | }; 340 | for _ in *index..closing_index { 341 | self.highlighting.push(highlighting::Type::MultilineComment); 342 | *index += 1; 343 | } 344 | return true; 345 | } 346 | }; 347 | } 348 | false 349 | } 350 | 351 | fn highlight_string( 352 | &mut self, 353 | index: &mut usize, 354 | opts: &HighlightingOptions, 355 | c: char, 356 | chars: &[char], 357 | ) -> bool { 358 | if opts.strings() && c == '"' { 359 | loop { 360 | self.highlighting.push(highlighting::Type::String); 361 | *index += 1; 362 | if let Some(next_char) = chars.get(*index) { 363 | if *next_char == '"' { 364 | break; 365 | } 366 | } else { 367 | break; 368 | } 369 | } 370 | self.highlighting.push(highlighting::Type::String); 371 | *index += 1; 372 | return true; 373 | } 374 | false 375 | } 376 | fn highlight_number( 377 | &mut self, 378 | index: &mut usize, 379 | opts: &HighlightingOptions, 380 | c: char, 381 | chars: &[char], 382 | ) -> bool { 383 | if opts.numbers() && c.is_ascii_digit() { 384 | if *index > 0 { 385 | #[allow(clippy::indexing_slicing, clippy::integer_arithmetic)] 386 | let prev_char = chars[*index - 1]; 387 | if !is_separator(prev_char) { 388 | return false; 389 | } 390 | } 391 | loop { 392 | self.highlighting.push(highlighting::Type::Number); 393 | *index += 1; 394 | if let Some(next_char) = chars.get(*index) { 395 | if *next_char != '.' && !next_char.is_ascii_digit() { 396 | break; 397 | } 398 | } else { 399 | break; 400 | } 401 | } 402 | return true; 403 | } 404 | false 405 | } 406 | #[allow(clippy::indexing_slicing, clippy::integer_arithmetic)] 407 | pub fn highlight( 408 | &mut self, 409 | opts: &HighlightingOptions, 410 | word: &Option, 411 | start_with_comment: bool, 412 | ) -> bool { 413 | let chars: Vec = self.string.chars().collect(); 414 | if self.is_highlighted && word.is_none() { 415 | if let Some(hl_type) = self.highlighting.last() { 416 | if *hl_type == highlighting::Type::MultilineComment 417 | && self.string.len() > 1 418 | && self.string[self.string.len() - 2..] == *"*/" 419 | { 420 | return true; 421 | } 422 | } 423 | return false; 424 | } 425 | self.highlighting = Vec::new(); 426 | let mut index = 0; 427 | let mut in_ml_comment = start_with_comment; 428 | if in_ml_comment { 429 | let closing_index = if let Some(closing_index) = self.string.find("*/") { 430 | closing_index + 2 431 | } else { 432 | chars.len() 433 | }; 434 | for _ in 0..closing_index { 435 | self.highlighting.push(highlighting::Type::MultilineComment); 436 | } 437 | index = closing_index; 438 | } 439 | while let Some(c) = chars.get(index) { 440 | if self.highlight_multiline_comment(&mut index, &opts, *c, &chars) { 441 | in_ml_comment = true; 442 | continue; 443 | } 444 | in_ml_comment = false; 445 | if self.highlight_char(&mut index, opts, *c, &chars) 446 | || self.highlight_comment(&mut index, opts, *c, &chars) 447 | || self.highlight_primary_keywords(&mut index, &opts, &chars) 448 | || self.highlight_secondary_keywords(&mut index, &opts, &chars) 449 | || self.highlight_string(&mut index, opts, *c, &chars) 450 | || self.highlight_number(&mut index, opts, *c, &chars) 451 | { 452 | continue; 453 | } 454 | self.highlighting.push(highlighting::Type::None); 455 | index += 1; 456 | } 457 | self.highlight_match(word); 458 | if in_ml_comment && &self.string[self.string.len().saturating_sub(2)..] != "*/" { 459 | return true; 460 | } 461 | self.is_highlighted = true; 462 | false 463 | } 464 | } 465 | 466 | fn is_separator(c: char) -> bool { 467 | c.is_ascii_punctuation() || c.is_ascii_whitespace() 468 | } 469 | 470 | #[cfg(test)] 471 | mod test_super { 472 | use super::*; 473 | 474 | #[test] 475 | fn test_highlight_find() { 476 | let mut row = Row::from("1testtest"); 477 | row.highlighting = vec![ 478 | highlighting::Type::Number, 479 | highlighting::Type::None, 480 | highlighting::Type::None, 481 | highlighting::Type::None, 482 | highlighting::Type::None, 483 | highlighting::Type::None, 484 | highlighting::Type::None, 485 | highlighting::Type::None, 486 | highlighting::Type::None, 487 | ]; 488 | row.highlight_match(&Some("t".to_string())); 489 | assert_eq!( 490 | vec![ 491 | highlighting::Type::Number, 492 | highlighting::Type::Match, 493 | highlighting::Type::None, 494 | highlighting::Type::None, 495 | highlighting::Type::Match, 496 | highlighting::Type::Match, 497 | highlighting::Type::None, 498 | highlighting::Type::None, 499 | highlighting::Type::Match 500 | ], 501 | row.highlighting 502 | ) 503 | } 504 | 505 | #[test] 506 | fn test_find() { 507 | let row = Row::from("1testtest"); 508 | assert_eq!(row.find("t", 0, SearchDirection::Forward), Some(1)); 509 | assert_eq!(row.find("t", 2, SearchDirection::Forward), Some(4)); 510 | assert_eq!(row.find("t", 5, SearchDirection::Forward), Some(5)); 511 | } 512 | } 513 | -------------------------------------------------------------------------------- /src/terminal.rs: -------------------------------------------------------------------------------- 1 | use crate::Position; 2 | use std::io::{self, stdout, Write}; 3 | use termion::color; 4 | use termion::event::Key; 5 | use termion::input::TermRead; 6 | use termion::raw::{IntoRawMode, RawTerminal}; 7 | 8 | pub struct Size { 9 | pub width: u16, 10 | pub height: u16, 11 | } 12 | pub struct Terminal { 13 | size: Size, 14 | _stdout: RawTerminal, 15 | } 16 | 17 | impl Terminal { 18 | pub fn default() -> Result { 19 | let size = termion::terminal_size()?; 20 | Ok(Self { 21 | size: Size { 22 | width: size.0, 23 | height: size.1.saturating_sub(2), 24 | }, 25 | _stdout: stdout().into_raw_mode()?, 26 | }) 27 | } 28 | pub fn size(&self) -> &Size { 29 | &self.size 30 | } 31 | pub fn clear_screen() { 32 | print!("{}", termion::clear::All); 33 | } 34 | 35 | #[allow(clippy::cast_possible_truncation)] 36 | pub fn cursor_position(position: &Position) { 37 | let Position { mut x, mut y } = position; 38 | x = x.saturating_add(1); 39 | y = y.saturating_add(1); 40 | let x = x as u16; 41 | let y = y as u16; 42 | print!("{}", termion::cursor::Goto(x, y)); 43 | } 44 | pub fn flush() -> Result<(), std::io::Error> { 45 | io::stdout().flush() 46 | } 47 | pub fn read_key() -> Result { 48 | loop { 49 | if let Some(key) = io::stdin().lock().keys().next() { 50 | return key; 51 | } 52 | } 53 | } 54 | pub fn cursor_hide() { 55 | print!("{}", termion::cursor::Hide); 56 | } 57 | pub fn cursor_show() { 58 | print!("{}", termion::cursor::Show); 59 | } 60 | pub fn clear_current_line() { 61 | print!("{}", termion::clear::CurrentLine); 62 | } 63 | pub fn set_bg_color(color: color::Rgb) { 64 | print!("{}", color::Bg(color)); 65 | } 66 | pub fn reset_bg_color() { 67 | print!("{}", color::Bg(color::Reset)); 68 | } 69 | pub fn set_fg_color(color: color::Rgb) { 70 | print!("{}", color::Fg(color)); 71 | } 72 | pub fn reset_fg_color() { 73 | print!("{}", color::Fg(color::Reset)); 74 | } 75 | } 76 | --------------------------------------------------------------------------------