squeeze-lib 1.0.0

Finders for URLs, emails, IPs, hashes, TODOs, and more in free-form text
Documentation
use super::Finder;
use std::ops::Range;

#[derive(Default)]
pub struct Env {}

impl Env {
    fn is_name_start(b: u8) -> bool {
        b.is_ascii_alphabetic() || b == b'_'
    }

    fn is_name_char(b: u8) -> bool {
        b.is_ascii_alphanumeric() || b == b'_'
    }

    /// Bytes that can begin a POSIX/bash parameter-expansion operator right
    /// after the name inside `${...}`: `:-` `:=` `:+` `:?` (and their bare
    /// `-` `=` `+` `?` forms), `:offset:length`, `%`/`%%`, `#`/`##`,
    /// `/pat/repl`, `^`/`^^`, `,`/`,,`, and `@` transforms.
    fn is_operator_start(b: u8) -> bool {
        matches!(
            b,
            b':' | b'%' | b'#' | b'/' | b'^' | b',' | b'-' | b'=' | b'+' | b'?' | b'@'
        )
    }

    /// Scan from `from` (inside a `${...}` reference, after the name) to
    /// the balanced closing `}`, tracking nested `${...}` references.
    /// Returns the position just past the closing brace, or `None` when
    /// the reference is unclosed.
    fn balanced_end(input: &[u8], from: usize) -> Option<usize> {
        let mut depth = 0usize;
        let mut i = from;
        while i < input.len() {
            match input[i] {
                b'$' if i + 1 < input.len() && input[i + 1] == b'{' => {
                    depth += 1;
                    i += 2;
                }
                b'}' if depth == 0 => return Some(i + 1),
                b'}' => {
                    depth -= 1;
                    i += 1;
                }
                // An expression never spans lines.
                b'\n' | b'\r' => return None,
                _ => i += 1,
            }
        }
        None
    }

    /// Match a reference starting exactly at `pos` (which must hold `$`).
    /// Returns the exclusive end of the match.
    fn match_end(input: &[u8], pos: usize) -> Option<usize> {
        let after = pos + 1;
        if after >= input.len() {
            return None;
        }
        if input[after] == b'{' {
            let name_start = after + 1;
            if name_start >= input.len() || !Self::is_name_start(input[name_start]) {
                return None;
            }
            let mut name_end = name_start + 1;
            while name_end < input.len() && Self::is_name_char(input[name_end]) {
                name_end += 1;
            }
            if name_end >= input.len() {
                return None;
            }
            if input[name_end] == b'}' {
                return Some(name_end + 1);
            }
            if Self::is_operator_start(input[name_end]) {
                return Self::balanced_end(input, name_end);
            }
            return None;
        }
        if Self::is_name_start(input[after]) {
            let mut name_end = after + 1;
            while name_end < input.len() && Self::is_name_char(input[name_end]) {
                name_end += 1;
            }
            return Some(name_end);
        }
        None
    }
}

impl Finder for Env {
    fn line_agnostic(&self) -> bool {
        // Matches never contain a line terminator and `\n`/`\r` end every
        // walk exactly like the end of the input does.
        true
    }

    fn id(&self) -> &'static str {
        "env"
    }

    fn dispatchable(&self) -> bool {
        true
    }

    fn could_start_at(&self, byte: u8) -> bool {
        byte == b'$'
    }

    fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
        next == b'{' || Self::is_name_start(next)
    }

    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
        if input[pos] != b'$' {
            return None;
        }
        Self::match_end(input, pos).map(|end| pos..end)
    }

    fn find(&self, s: &str) -> Option<Range<usize>> {
        let input = s.as_bytes();
        let mut idx = 0;

        while idx < input.len() {
            let dollar = idx + input[idx..].iter().position(|&b| b == b'$')?;
            if let Some(end) = Self::match_end(input, dollar) {
                return Some(dollar..end);
            }
            idx = dollar + 1;
        }

        None
    }
}

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

    #[test]
    fn id_should_return_env() {
        let finder = Env::default();
        assert_eq!("env", finder.id());
    }

    #[test]
    fn find_should_extract_simple_var() {
        let finder = Env::default();
        let input = "use $HOME for path";
        let range = finder.find(input).unwrap();
        assert_eq!("$HOME", &input[range]);
    }

    #[test]
    fn find_should_extract_braced_var() {
        let finder = Env::default();
        let input = "use ${HOME} for path";
        let range = finder.find(input).unwrap();
        assert_eq!("${HOME}", &input[range]);
    }

    #[test]
    fn find_should_extract_var_with_underscores() {
        let finder = Env::default();
        let input = "set $MY_VAR_123 here";
        let range = finder.find(input).unwrap();
        assert_eq!("$MY_VAR_123", &input[range]);
    }

    #[test]
    fn find_should_extract_var_starting_with_underscore() {
        let finder = Env::default();
        let input = "$_private";
        let range = finder.find(input).unwrap();
        assert_eq!("$_private", &input[range]);
    }

    #[test]
    fn find_should_reject_dollar_followed_by_digit() {
        let finder = Env::default();
        assert!(finder.find("$123").is_none());
    }

    #[test]
    fn find_should_reject_bare_dollar() {
        let finder = Env::default();
        assert!(finder.find("$ foo").is_none());
    }

    #[test]
    fn find_should_reject_dollar_at_end() {
        let finder = Env::default();
        assert!(finder.find("cost is $").is_none());
    }

    #[test]
    fn find_should_reject_empty_braces() {
        let finder = Env::default();
        assert!(finder.find("${} foo").is_none());
    }

    #[test]
    fn find_should_reject_unclosed_brace() {
        let finder = Env::default();
        assert!(finder.find("${HOME foo").is_none());
    }

    #[test]
    fn find_should_reject_brace_with_digit_start() {
        let finder = Env::default();
        assert!(finder.find("${123}").is_none());
    }

    #[test]
    fn find_should_extract_var_at_start() {
        let finder = Env::default();
        let input = "$PATH is set";
        let range = finder.find(input).unwrap();
        assert_eq!("$PATH", &input[range]);
    }

    #[test]
    fn find_should_extract_var_at_end() {
        let finder = Env::default();
        let input = "see $PATH";
        let range = finder.find(input).unwrap();
        assert_eq!("$PATH", &input[range]);
    }

    #[test]
    fn find_should_handle_empty_input() {
        let finder = Env::default();
        assert!(finder.find("").is_none());
    }

    #[test]
    fn find_should_extract_multiple_vars_iteratively() {
        let finder = Env::default();
        let input = "$HOME and ${PATH} here";

        let mut results = Vec::new();
        let mut idx = 0;
        while idx < input.len() {
            if let Some(range) = finder.find(&input[idx..]) {
                results.push(&input[idx + range.start..idx + range.end]);
                idx += range.end;
            } else {
                break;
            }
        }

        assert_eq!(vec!["$HOME", "${PATH}"], results);
    }

    #[test]
    fn find_should_skip_invalid_and_find_valid() {
        let finder = Env::default();
        let input = "$123 then $VALID";
        let range = finder.find(input).unwrap();
        assert_eq!("$VALID", &input[range]);
    }

    #[test]
    fn find_should_extract_var_in_quotes() {
        let finder = Env::default();
        let input = r#"echo "$HOME""#;
        let range = finder.find(input).unwrap();
        assert_eq!("$HOME", &input[range]);
    }

    #[test]
    fn try_at_simple_var() {
        let finder = Env::default();
        assert_eq!(finder.try_at(b"$HOME rest", 0), Some(0..5));
    }

    #[test]
    fn try_at_braced_var() {
        let finder = Env::default();
        assert_eq!(finder.try_at(b"${PATH}", 0), Some(0..7));
    }

    #[test]
    fn try_at_rejects_non_dollar() {
        let finder = Env::default();
        assert!(finder.try_at(b"HOME", 0).is_none());
    }

    #[test]
    fn try_at_dollar_at_end() {
        let finder = Env::default();
        assert!(finder.try_at(b"$", 0).is_none());
    }

    #[test]
    fn try_at_dollar_digit() {
        let finder = Env::default();
        assert!(finder.try_at(b"$1", 0).is_none());
    }

    #[test]
    fn try_at_unclosed_brace() {
        let finder = Env::default();
        assert!(finder.try_at(b"${FOO", 0).is_none());
    }

    #[test]
    fn try_at_empty_brace() {
        let finder = Env::default();
        assert!(finder.try_at(b"${}", 0).is_none());
    }

    #[test]
    fn find_adjacent_vars() {
        let finder = Env::default();
        let input = "$A$B";
        let mut results = Vec::new();
        let mut idx = 0;
        while idx < input.len() {
            if let Some(range) = finder.find(&input[idx..]) {
                results.push(&input[idx + range.start..idx + range.end]);
                idx += range.end;
            } else {
                break;
            }
        }
        assert_eq!(results, vec!["$A", "$B"]);
    }

    #[test]
    fn find_dollar_space_dollar() {
        let finder = Env::default();
        assert!(finder.find("$ $").is_none());
    }
}