sued 0.24.2

shut up editor - a minimalist line-based text editor written in Rust
Documentation
//! sued - shut up editor, a vector-oriented line editor by Arsalan "Aeri" Kazmi
//!
//! to understand sued, read `README.md` or `https://aeriavelocity.codeberg.page/sued`.
//!
//! sued is free software licensed under the Apache License, Version 2.0.

use std::env;

#[cfg(feature = "repl")]
use {
    rustyline::error::*, rustyline::DefaultEditor, sued::exit_status::ExitStatus,
    sued::run_repl_command, rustyline::config::Configurer,
};

#[cfg(feature = "history")]
use {
    std::fs::{self, File},
    std::path::PathBuf,
};

use sued::command::{Command, CommandAction, CommandScope};
use sued::commands;
use sued::{helper, EditorState};

/// The main function defines the sued REPL loop for sued as a text editor.
fn main() {
    helper::startup_message();

    #[cfg(feature = "repl")]
    let mut rl = DefaultEditor::new().expect("Failed to create the editor");
    let mut state = EditorState::new();

    commands::register_all(&mut state.registry);

    let prefix_cmd: Command = Command {
        name: "prefix",
        description: "set the command prefix",
        documentation: "",
        scope: CommandScope::REPLOnly,
        arguments: vec!["new_prefix"],
        action: CommandAction::new(|args: Vec<&str>, state: &mut EditorState| {
            state.prefix.clear();
            let new_prefix = args.get(1).map(|&s| s);
            if let Some(prefix) = new_prefix {
                if prefix.len() > 1 {
                    return format!("requested prefix too long");
                }
                if new_prefix.unwrap().chars().all(|c| c.is_alphanumeric()) {
                    println!("warning: chosen prefix is not a symbol");
                }
                state.prefix = prefix.to_string();
                format!("prefix set to {}", prefix)
            } else {
                state.prefix = "~".to_string();
                "prefix reset to ~, try passing a prefix if you wanted that instead".to_string()
            }
        }),
    };

    let clear_cmd: Command = Command {
        name: "clear",
        description: "empty the file buffer",
        documentation: "this command will effectively reset the buffer to its \
                        default state, clearing the buffer contents and setting \
                        the file path to the None variant",
        scope: CommandScope::Global,
        action: CommandAction::new(|_args: Vec<&str>, state: &mut EditorState| {
            state.buffer.contents.clear();
            state.buffer.file_path = None;
            state.cursor = 0;
            "buffer cleared".to_string()
        }),
        ..Command::default()
    };

    state.registry.add_command(prefix_cmd);
    state.registry.add_command(clear_cmd);

    let args: Vec<String> = env::args().collect();
    if args.len() >= 2 {
        match helper::open_file(&args[1]) {
            Ok(opened) => {
                if !opened.is_empty() {
                    let file_contents: Vec<String> =
                        opened.split('\n').map(|line| line.to_string()).collect();
                    state.buffer.contents = file_contents;
                }
                state.buffer.file_path = Some(args[1].clone());
                let file_size_display = helper::get_file_size(&state);
                println!("opened {} as text with {}", args[1], file_size_display);
            }
            Err(e) => {
                println!("{e}");
                // We set it anyway because the user *could* be pointing to a
                // file that doesn't exist but will when `~save` is called
                state.buffer.file_path = Some(args[1].clone());
            }
        }
    }

    if !cfg!(feature = "repl") {
        eprintln!("sued requires the `repl` feature enabled to run the text editor");
        eprintln!("try appending `--features=repl` to your cargo command");
        std::process::exit(1);
    }

    // By default, set the cursor to the end of the file
    state.cursor = state.buffer.contents.len();

    #[cfg(feature = "history")]
    let config_path = if cfg!(windows) {
        env::var("APPDATA")
            .expect("APPDATA not set") // APPDATA is always set on Windows so this unwrap is safe
            .parse::<PathBuf>()
            .expect("APPDATA is not a valid path")
            .join("sued")
            .canonicalize()
            .unwrap_or_else(|_| {
                let mut path = PathBuf::new();
                path.push(env::var("APPDATA").unwrap());
                path.push("sued");
                fs::create_dir_all(&path).unwrap();
                path
            })
    } else {
        env::var("XDG_CONFIG_HOME") // I really hope you have that set, because *I* sure don't! :D
            .unwrap_or_else(|_| "/home/".to_string())
            .parse::<PathBuf>()
            .expect("XDG_CONFIG_HOME is not a valid path")
            .join("sued")
            .canonicalize()
            .unwrap_or_else(|_| {
                let mut path = PathBuf::new();
                path.push(
                    env::var("XDG_CONFIG_HOME")
                        .unwrap_or_else(|_| format!("{}/.config", env::var("HOME").unwrap())),
                );
                path.push("sued");
                fs::create_dir_all(&path).unwrap();
                path
            })
    };

    #[cfg(feature = "history")]
    let history_file: PathBuf = config_path.join("command-history.txt");

    // Initialise the prompt - use +1 to convert to 1 indexing
    let mut prompt = format!("{}", state.cursor + 1);

    #[cfg(all(feature = "repl", feature = "history"))]
    {
        match rl.load_history(&history_file) {
            Ok(()) => println!("loaded command history from {}", history_file.display()),
            Err(_) => (),
        }
    }

    #[cfg(feature = "repl")]
    loop {
        let line = match rl.readline_with_initial(&prompt, (&state.existing_content, "")) {
            Ok(line) => line,
            Err(ReadlineError::Interrupted) => {
                eprintln!("use ~exit to leave sued");
                continue;
            }
            _ => {
                eprintln!("error reading input");
                continue;
            }
        };

        let command = line.trim_end().to_string(); 

        let command_args = command.trim_start().split(' ').collect::<Vec<&str>>();
        if command_args[0].starts_with(&state.prefix) {
            match interpret_command(command_args, &mut state) {
                ExitStatus::Success(msg) => {
                    let _ = rl.add_history_entry(command.clone());
                    println!("{}", msg);
                }
                ExitStatus::Failure(msg) => {
                    if msg.as_str() == "exit" {
                        // We save history ONLY on exit to avoid hogging system resources
                        #[cfg(feature = "history")]
                        {
                            if !history_file.exists() {
                                if let Err(e) = File::create(&history_file) {
                                    eprintln!("error creating command history file: {}", e);
                                }
                            }
                            if let Err(e) = rl.set_max_history_size(100) {
                                eprintln!("error setting max history size: {}", e);
                            }
                            if let Err(e) = rl.save_history(&history_file) {
                                eprintln!("error saving command history: {}", e);
                            }
                        }
                        break;
                    } else {
                        eprintln!("error: {}", msg);
                    }
                }
            }
        } else {
            let to_write = command;
            let position = state.cursor;
            state.existing_content.clear();
            if to_write.contains("\n") {
                let to_write_split = to_write.split("\n");
                for line in to_write_split {
                    state.buffer.contents.push(line.to_string());
                    state.cursor += 1;
                }
            } else {
                state.buffer.contents.insert(position, to_write.clone());
            }
            let indentation = to_write.chars().take_while(|c| *c == ' ').count();
            if indentation > 0 {
                state.existing_content = format!("{}", " ".repeat(indentation))
            }
            state.cursor += 1;
        }

        // If, after processing the command, the cursor is out of bounds, set it to the end
        if let Err(_) =
            helper::check_if_line_in_buffer(&mut state.buffer.contents, state.cursor + 1)
        {
            state.cursor = state.buffer.contents.len();
        }

        let max_length = state.buffer.contents.len().to_string().len();
        let cursor_padded: String = format!("{:width$}", state.cursor + 1, width = max_length);
        prompt = format!("{}", cursor_padded);
    }
}

/// Used for processing commands through the REPL.
#[cfg(feature = "repl")]
fn interpret_command(command_args: Vec<&str>, state: &mut EditorState) -> ExitStatus {
    let output = match command_args[0]
        .to_lowercase()
        .replace(state.prefix.as_str(), "")
        .as_str()
    {
        // We manually handle "exit" because the `CommandAction` struct must
        // return a string, not an ExitStatus
        "exit" | "quit" => return ExitStatus::Failure("exit".to_string()),
        _ => {
            let mut args = command_args.clone();
            let prefix_cloned = state.prefix.clone();
            if let Some(stripped) = args[0].to_lowercase().strip_prefix(prefix_cloned.as_str()) {
                args[0] = stripped;
                let cmd = run_repl_command(args, state);
                if let ExitStatus::Success(msg) = cmd {
                    msg
                } else {
                    format!("{}", cmd)
                }
            } else {
                panic!("unable to process non-repl command");
            }
        }
    };

    ExitStatus::Success(output)
}