typechar 1.0.0

Type any Unicode string on macOS, regardless of keyboard layout
//! Pure, testable core of typechar: argument parsing and UTF-16 chunking.
//! The CGEvent plumbing lives in main.rs.

/// What the process should do, decided from argv.
#[derive(Debug, PartialEq, Eq)]
pub enum Action {
    /// Type this text, after waiting `delay_ms` milliseconds.
    Type {
        text: String,
        delay_ms: u64,
    },
    Help,
    Version,
}

/// Parse argv (excluding argv[0]) into an Action.
pub fn parse_args(args: &[String]) -> Result<Action, String> {
    let mut delay_ms: u64 = 0;
    let mut text: Option<String> = None;
    let mut codepoints = String::new();
    let mut literal = false;

    let set_text = |s: &str, text: &mut Option<String>| -> Result<(), String> {
        if text.is_some() {
            return Err("only one text argument is allowed (quote the whole string)".into());
        }
        *text = Some(s.to_string());
        Ok(())
    };

    let mut iter = args.iter();
    while let Some(arg) = iter.next() {
        if literal {
            set_text(arg, &mut text)?;
            continue;
        }
        match arg.as_str() {
            "--help" | "-h" => return Ok(Action::Help),
            "--version" | "-V" => return Ok(Action::Version),
            "--" => literal = true,
            "--delay" | "-d" => {
                let value = iter
                    .next()
                    .ok_or("--delay requires a value in milliseconds")?;
                delay_ms = value
                    .parse()
                    .map_err(|_| format!("invalid --delay value: {value}"))?;
            }
            "--unicode" | "-u" => {
                let value = iter.next().ok_or("--unicode requires a hex codepoint")?;
                codepoints.push(decode_codepoint(value)?);
            }
            flag if flag.starts_with('-') && flag.len() > 1 => {
                return Err(format!(
                    "unknown option: {flag} (use -- to type it literally)"
                ));
            }
            other => set_text(other, &mut text)?,
        }
    }

    let text = match (text, codepoints.is_empty()) {
        (Some(t), true) => t,
        (None, false) => codepoints,
        (Some(_), false) => return Err("pass either a string or --unicode, not both".into()),
        (None, true) => return Err("nothing to type (pass a string or --unicode <hex>)".into()),
    };

    Ok(Action::Type { text, delay_ms })
}

/// Decode a hex codepoint like "20ac" or "U+20AC" into a char.
pub fn decode_codepoint(hex: &str) -> Result<char, String> {
    let digits = hex
        .strip_prefix("U+")
        .or_else(|| hex.strip_prefix("u+"))
        .unwrap_or(hex);
    let value =
        u32::from_str_radix(digits, 16).map_err(|_| format!("invalid hex codepoint: {hex}"))?;
    char::from_u32(value).ok_or(format!("not a valid Unicode scalar value: {hex}"))
}

/// Split text into UTF-16 chunks of at most `max_units` code units,
/// never splitting a surrogate pair across chunks.
pub fn utf16_chunks(text: &str, max_units: usize) -> Vec<Vec<u16>> {
    let mut chunks: Vec<Vec<u16>> = Vec::new();
    let mut current: Vec<u16> = Vec::new();
    for ch in text.chars() {
        let mut buf = [0u16; 2];
        let units = ch.encode_utf16(&mut buf);
        if current.len() + units.len() > max_units && !current.is_empty() {
            chunks.push(std::mem::take(&mut current));
        }
        current.extend_from_slice(units);
    }
    if !current.is_empty() {
        chunks.push(current);
    }
    chunks
}

#[cfg(test)]
mod tests {
    use super::*;

    fn args(v: &[&str]) -> Vec<String> {
        v.iter().map(|s| s.to_string()).collect()
    }

    // ---- parse_args ----

    #[test]
    fn types_a_positional_string() {
        let a = parse_args(&args(&[""])).unwrap();
        assert_eq!(
            a,
            Action::Type {
                text: "".into(),
                delay_ms: 0
            }
        );
    }

    #[test]
    fn types_a_multichar_snippet() {
        let a = parse_args(&args(&["¯\\_(ツ)_/¯"])).unwrap();
        assert_eq!(
            a,
            Action::Type {
                text: "¯\\_(ツ)_/¯".into(),
                delay_ms: 0
            }
        );
    }

    #[test]
    fn delay_flag_sets_delay() {
        let a = parse_args(&args(&["--delay", "150", ""])).unwrap();
        assert_eq!(
            a,
            Action::Type {
                text: "".into(),
                delay_ms: 150
            }
        );
    }

    #[test]
    fn unicode_flag_decodes_hex_codepoint() {
        let a = parse_args(&args(&["--unicode", "20ac"])).unwrap();
        assert_eq!(
            a,
            Action::Type {
                text: "".into(),
                delay_ms: 0
            }
        );
    }

    #[test]
    fn unicode_short_flag_works() {
        let a = parse_args(&args(&["-u", "2713"])).unwrap();
        assert_eq!(
            a,
            Action::Type {
                text: "".into(),
                delay_ms: 0
            }
        );
    }

    #[test]
    fn unicode_flag_accepts_multiple_codepoints() {
        let a = parse_args(&args(&["-u", "20ac", "-u", "2713"])).unwrap();
        assert_eq!(
            a,
            Action::Type {
                text: "€✓".into(),
                delay_ms: 0
            }
        );
    }

    #[test]
    fn double_dash_makes_following_arg_literal() {
        let a = parse_args(&args(&["--", "--delay"])).unwrap();
        assert_eq!(
            a,
            Action::Type {
                text: "--delay".into(),
                delay_ms: 0
            }
        );
    }

    #[test]
    fn no_args_is_an_error() {
        assert!(parse_args(&[]).is_err());
    }

    #[test]
    fn missing_delay_value_is_an_error() {
        assert!(parse_args(&args(&["--delay"])).is_err());
    }

    #[test]
    fn non_numeric_delay_is_an_error() {
        assert!(parse_args(&args(&["--delay", "abc", ""])).is_err());
    }

    #[test]
    fn unknown_flag_is_an_error() {
        assert!(parse_args(&args(&["--frobnicate", ""])).is_err());
    }

    #[test]
    fn two_positional_args_is_an_error() {
        assert!(parse_args(&args(&["", ""])).is_err());
    }

    #[test]
    fn help_flag_wins() {
        assert_eq!(parse_args(&args(&["--help"])).unwrap(), Action::Help);
        assert_eq!(parse_args(&args(&["-h"])).unwrap(), Action::Help);
    }

    #[test]
    fn version_flag_wins() {
        assert_eq!(parse_args(&args(&["--version"])).unwrap(), Action::Version);
        assert_eq!(parse_args(&args(&["-V"])).unwrap(), Action::Version);
    }

    // ---- decode_codepoint ----

    #[test]
    fn decodes_plain_hex() {
        assert_eq!(decode_codepoint("20ac").unwrap(), '');
    }

    #[test]
    fn decodes_uppercase_and_u_plus_prefix() {
        assert_eq!(decode_codepoint("U+20AC").unwrap(), '');
        assert_eq!(decode_codepoint("u+20ac").unwrap(), '');
    }

    #[test]
    fn decodes_astral_plane_codepoint() {
        assert_eq!(decode_codepoint("1F600").unwrap(), '😀');
    }

    #[test]
    fn rejects_invalid_hex() {
        assert!(decode_codepoint("xyz").is_err());
    }

    #[test]
    fn rejects_surrogate_codepoints() {
        assert!(decode_codepoint("D800").is_err());
    }

    #[test]
    fn rejects_out_of_range_codepoints() {
        assert!(decode_codepoint("110000").is_err());
    }

    // ---- utf16_chunks ----

    #[test]
    fn short_text_is_one_chunk() {
        let chunks = utf16_chunks("abc", 20);
        assert_eq!(chunks, vec!["abc".encode_utf16().collect::<Vec<u16>>()]);
    }

    #[test]
    fn long_text_splits_at_max_units() {
        let text = "a".repeat(25);
        let chunks = utf16_chunks(&text, 20);
        assert_eq!(chunks.len(), 2);
        assert_eq!(chunks[0].len(), 20);
        assert_eq!(chunks[1].len(), 5);
    }

    #[test]
    fn never_splits_a_surrogate_pair() {
        // Each emoji is 2 UTF-16 units; max 3 forces a split decision mid-pair.
        let chunks = utf16_chunks("😀😀😀", 3);
        for chunk in &chunks {
            // A chunk must decode cleanly on its own — no lone surrogates.
            assert!(
                String::from_utf16(chunk).is_ok(),
                "chunk split a surrogate pair"
            );
        }
        let total: Vec<u16> = chunks.concat();
        assert_eq!(total, "😀😀😀".encode_utf16().collect::<Vec<u16>>());
    }

    #[test]
    fn empty_text_yields_no_chunks() {
        assert!(utf16_chunks("", 20).is_empty());
    }
}