sued 0.24.2

shut up editor - a minimalist line-based text editor written in Rust
Documentation
//! Contains every function used by sued, including editing commands and helper
//! functions, using the Command struct implememnted in `command.rs`
//!
//! This file is part of sued.
//!
//! sued is free software licensed under the Apache License, Version 2.0.

// Ignore "function is never used" since we know they are used in main and lib
#![allow(dead_code)]

use rand::Rng;
use std::path::PathBuf;
use std::process::Command as ShellCommand;
use std::{env, fs, usize};

#[cfg(windows)]
fn is_root() -> bool {
    use is_elevated;
    is_elevated::is_elevated()
}

#[cfg(not(windows))]
// God I really hope "not Windows" means "macOS or Linux" because
// people might just actually run this on their Xbox
fn is_root() -> bool {
    use sudo;
    sudo::check() == sudo::RunningAs::Root
}

use crate::EditorState;

/// Prints a startup message with a funny joke. I hope it's funny at least.
/// Invoked at startup, obviously.
/// This should not be added to the command registry.
pub fn startup_message() {
    #[cfg(feature = "startup")]
    let mut messages: Vec<&str> = vec![
        "the shut up editor", // what "sued" expands to - *s*hut *u*p *ed*itor
        "the not standard text editor", // references "ed is the standard text editor"
        "it's pronounced \"soo ed\"", // clarifies pronunciation, because most people will pronounce it "sood", as in "being sued" (for legal frickery)
        "nothing comes for granted with this program", // references README v2023-01-20 ("Only use sued if you like pain. Nothing comes for granted with this program.")
        "no, it's not an editor that will sue you", // references README v2024-08-10 ("No, it's not 'super user editor', or an editor that will sue you.")
        "sued as in editor, not as in law", // clarifies that "sued" is not the legal term
        "want a visual mode? ~runhere vim", // originally sued v0.22.0 was going to have a visual mode but it was decided against
        "what you get is what you get", // references "Ed, man! !man ed" (https://www.gnu.org/fun/jokes/ed-msg.html)
        "what the frick is a config file", // well I mean I guess I'll add one at some point
        "free software, hell yeah", // I don't remember what this was referencing, but it was probably something
        "put that mouse AWAY", // because the editor is keyboard-only, you literally couldn't use a mouse even if you wanted to
        "it's got what plants crave!", // I've never seen Idiocracy, I got this from a Markiplier video on Job Simulator (https://youtu.be/lwvnT3AQcRM?t=608)
        "looks like you're writing a letter, would you like help?", // references Microsoft Word's Clippy
        "more Easter eggs than there are features", // that's only half true LOL
        "write a Python script then ~runhere python", // this is probably sued's killer feature and I don't even know if other editors have it
        #[cfg(feature = "lua")]
        "write a Lua script then ~script this", // Lua version of the above line
        "actually, real programmers use ~butterfly", // references https://xkcd.com/378/ ("Real Programmers")
        "want features? edit the source code yourself", // this is kind of outdated with the Lua bindings.........
        "loop { let r = read()?; let e = eval(&r); println!(\"{e}\"); }", // references the fact sued's mode of operation is a REPL
        #[cfg(feature = "lua")]
        "~eval for i, line in ipairs(sued_buffer) do print(line) end", // showcases the `~eval` command and the `sued_buffer` global
        "also try Astrion! it's got sued in it!", // this is the whole reason sued was refactored into a library
        "no undo for you", // until I eventually add it
        "want theming? edit your terminal config", // references editors like (Neo)vim, Emacs, Kakoune and Helix all having some kind of theming in the terminal
        "there's no pretty colours, just text and more text", // more or less the same as the above
        "in a world full of vscode, be an ed", // references the fact that VS Code is super heavy, the antithesis of sued
        "notepad, but with less pad", // "pad" here is referring to the graphical window, not Notepad's functionality
        "yeah let's see sublime text do this", // I've never even used Sublime Text
        "don't worry, you won't get emacs pinky here", // okay you might because of the traditional Unix-style keyboard shortcuts, but whatever
        "i bet *your* text editor doesn't even ~runhere", // if it does please let me know, so I can destroy it
    ];
    #[cfg(feature = "startup")]
    let mut sudo_messages: Vec<&str> = vec![
        "now i'm super sued!", // references Persona 1's butchered English translation: "Now I'm Super Guido!"
        "i certainly hope you know what you're doing", // references the scientists' voice lines from Half-Life 1
        "this is concerning for several reasons", // references a Flipnote video by raxdflipnote, though I can't remember which one
        "be careful while you're back there", // I think this was referencing DOOM 3? probably not
        "why the frick do you need root access", // see the joke here is that text editing while root is a very specific kind of thing
        "please do not do that", // references a Shigeru Miyamoto meme
        "no, this isn't how you're supposed to play the game", // references a Masahiro Sakurai meme (https://youtu.be/u6tvzG_88sU)
        "be grateful this is written in rust", // and not C, because Rust is infinitely safer
        "freeman you fool!", // references the scientists' voice lines from Half-Life 1
        "you're doing it wrong", // references "no items, Fox only, Final Destination" (https://youtu.be/O-7gmds2njg?t=75)
        "you might frick up your system, y'know", // well at least there's no auto-save to frick it all up
        "instead of root safeguards, we decided to mock you", // pretty much the main joke behind sued
        "aaaand now you're root, fantastic", // sarcasm! fun for the whole family
        "the su in sued doesn't mean what you think it does", // because people might look at the "su" in "sued" and think "super user"
    ];
    #[cfg(feature = "startup")]
    if is_root() {
        messages.append(&mut sudo_messages);
    }
    #[cfg(feature = "startup")]
    let message = messages[rand::thread_rng().gen_range(0..messages.len())];

    let version = if cfg!(debug_assertions) {
        match ShellCommand::new("git")
            .args(&["rev-parse", "--short", "HEAD"])
            .output()
            .ok()
            .and_then(|output| String::from_utf8(output.stdout).ok())
            .filter(|hash| !hash.trim().is_empty())
        {
            Some(hash) => format!("{}-devel (c{})", env!("CARGO_PKG_VERSION"), hash.trim()),
            None => format!("{}-devel (no commit hash?)", env!("CARGO_PKG_VERSION")),
        }
    } else {
        env!("CARGO_PKG_VERSION").to_string()
    };

    let root_warning = if is_root() { " as root" } else { "" };

    #[cfg(feature = "startup")]
    {
        if cfg!(debug_assertions) {
            println!("sued{root_warning} v{version} - {message}\nthis is a development build, expect bugs and unexpected behaviour\ntype ~cmds or ~help for commands, otherwise just start typing");
        } else {
            println!("sued{root_warning} v{version} - {message}\ntype ~cmds or ~help for commands, otherwise just start typing");
        }
    }

    #[cfg(not(feature = "startup"))]
    {
        if cfg!(debug_assertions) {
            println!("sued{root_warning} v{version}\nthis is a development build, expect bugs and unexpected behaviour\ntype ~cmds or ~help for commands, otherwise just start typing");
        } else {
            println!("sued{root_warning} v{version}\ntype ~cmds or ~help for commands, otherwise just start typing");
        }
    }
}

/// Returns the contents of the `file_path` as a String.
/// Used to provide functionality for the `~open` command and the text editor
/// startup.
/// This should not be added to the command registry.
pub fn open_file(file_path: &str) -> Result<String, String> {
    let path_exists = PathBuf::from(file_path).exists();
    let file_exists = fs::read_to_string(file_path);
    let is_dir = path_exists
        && match fs::metadata(file_path) {
            Ok(metadata) => metadata.is_dir(),
            Err(_) => false,
        };
    match file_exists {
        Ok(contents) => {
            return Ok(contents);
        }
        Err(e) => {
            if is_dir {
                println!("{} is a directory - will open as text", file_path);
                let listings: Vec<String> = if let Ok(listings) = fs::read_dir(file_path) {
                    listings
                        .map(|f| f.unwrap().path().display().to_string())
                        .collect()
                } else {
                    return Err(format!("failed to open directory {}", file_path));
                };
                return Ok(listings.join("\n"));
            }
            let error_specifier: &str;
            match e.kind() {
                std::io::ErrorKind::NotFound => {
                    error_specifier = "not found";
                }
                std::io::ErrorKind::PermissionDenied => {
                    error_specifier = "can't be opened";
                }
                std::io::ErrorKind::InvalidData => {
                    error_specifier = "is not text";
                }
                _ => {
                    error_specifier = "failed to open";
                }
            }
            return Err(format!(
                "file {} {}: {}",
                file_path,
                error_specifier,
                e.to_string()
            ));
        }
    }
}

/// Divides a `num` by 1000 `repetitions` times over.
///
/// Used as a helper function for the `~save` and `~open` commands. This should
/// not be added to the command registry.
fn div_thousand(num: f32, repetitions: usize) -> f32 {
    let mut n = num;
    for _ in 0..repetitions {
        n /= 1000.0;
    }
    n
}

/// Returns the size of the current buffer in bytes, KB, MB, or GB, formatted
/// as a String.
///
/// Used as a helper function for the `~save` and `~open` commands. This should
/// not be added to the command registry.
pub fn get_file_size(state: &EditorState) -> String {
    let file_size = state
        .buffer
        .contents
        .iter()
        .fold(0, |acc, line| acc + line.len());

    const NL: usize = 0; // "null"
    const KB: usize = 10_u32.pow(3) as usize;
    const MB: usize = 10_u32.pow(6) as usize;
    const GB: usize = 10_u32.pow(9) as usize;
    const OL: usize = usize::MAX; // "over limit"

    match file_size {
        NL..KB => format!("{} bytes", file_size),
        // I'm still technically repeating myself here...
        KB..MB => format!("{} KB", div_thousand(file_size as f32, 1)),
        MB..GB => format!("{} MB", div_thousand(file_size as f32, 2)),
        GB..OL => format!("{} GB", div_thousand(file_size as f32, 3)),
        _ => "like, a lot of bytes".to_string(), // Normally this can't be reached
    }
}

/// Checks if a given `line_number` is in the `file_buffer`.
/// Used by any command that deals with line numbers or ranges.
/// This should not be a part of the command registry.
pub fn check_if_line_in_buffer(
    file_buffer: &Vec<String>,
    line_number: usize,
) -> Result<(), String> {
    if line_number < 1 {
        return Err(format!("invalid line {}", line_number));
    }

    if file_buffer.is_empty() {
        return Err("no buffer contents".to_string());
    }

    if line_number <= file_buffer.len() {
        return Ok(());
    }

    Err(format!("no line {}", line_number))
}

pub fn set_cursor_position(args: Vec<&str>, state: &mut EditorState, overtake: bool) -> String {
    match state.buffer.contents.len() {
        0 => return "no buffer contents".to_string(),
        1 => return "buffer contains only one line".to_string(),
        _ => (),
    }

    if args.len() < 2 {
        if overtake {
            return format!(
                "overtake what? try {}overtake [line], between 1 and {}",
                state.prefix,
                state.buffer.contents.len()
            );
        }
        return format!(
            "point where? try {}point [position], between 1 and {}",
            state.prefix,
            state.buffer.contents.len() + 1
        );
    }

    let is_relative = args[1].starts_with("+") || args[1].starts_with("-");

    let position = match args[1].parse::<isize>() {
        Ok(pos) => {
            if is_relative {
                pos + state.cursor as isize + 1
            } else {
                pos
            }
        }
        Err(e) => match args[1] {
            "start" => 1,
            "end" => state.buffer.contents.len() as isize + 1,
            _ => return format!("invalid position {}: {}", args[1], e),
        },
    };

    if let Err(msg) = check_if_line_in_buffer(&state.buffer.contents, position as usize) {
        // Since we're pointing to the new line, we don't consider `contents + 1` as an error state
        if position != state.buffer.contents.len() as isize + 1 {
            return msg;
        }
    }

    // We subtract by 1 because the cursor is 0-indexed
    // while the `~show` command and the prompt are 1-indexed
    state.cursor = position as usize - 1;

    let message: String;

    if position == state.buffer.contents.len() as isize + 1 || position <= 1 {
        // Don't display the line number if we're pointing to the new line
        // or if we're at (or somehow below) line 1
        message = format!("set cursor position to {position}");
    } else {
        if overtake {
            let line_contents = state.buffer.contents[state.cursor].clone();
            state.buffer.contents.remove(state.cursor);
            state.existing_content = line_contents;
            message = format!("overtook line {position} and set cursor position");
        } else {
            // Refer to the previous line, because text will be entered BEFORE
            // the cursor position - it will not replace it in point mode,
            // therefore we should print the line before the cursor position to
            // help users understand where their next input will go
            let max_length: usize = state.buffer.contents.len().to_string().len();
            let line_number_padded = format!("{:width$}", state.cursor, width = max_length);
            let output_line = format!(
                "{}{}",
                line_number_padded,
                state.buffer.contents[state.cursor - 1]
            );
            message = format!("set cursor position to {position}\n{output_line}");
        }
    }

    message
}

/// A helper function used for the ~substitute command.
pub fn split_pattern_replacement(combined_args: &str) -> Vec<&str> {
    let mut pattern_replacement = Vec::new();
    let mut start = 0;
    let mut escaped = false;

    for (i, c) in combined_args.char_indices() {
        if escaped {
            escaped = false;
        } else if c == '\\' {
            escaped = true;
        } else if c == '/' {
            pattern_replacement.push(&combined_args[start..i]);
            start = i + 1;
        }
    }

    if start <= combined_args.len() {
        pattern_replacement.push(&combined_args[start..]);
    }

    pattern_replacement
}