syntaxmate 0.2.1

Rust-native TextMate syntax highlighting with bundled grammars and themes
Documentation
use std::collections::{HashMap, VecDeque};
use std::ops::Range;

pub fn whole_match(start: usize, end: usize) -> Vec<Option<Range<usize>>> {
    vec![Some(start..end)]
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SubstitutionError {
    PatternTooLong { len: usize, max: usize },
}

#[derive(Debug, Clone)]
pub struct EndPatternCache {
    max_entries: usize,
    max_pattern_len: usize,
    order: VecDeque<String>,
    entries: HashMap<String, String>,
}

impl EndPatternCache {
    pub fn new(max_entries: usize, max_pattern_len: usize) -> Self {
        Self {
            max_entries,
            max_pattern_len,
            order: VecDeque::new(),
            entries: HashMap::new(),
        }
    }

    pub fn substitute(
        &mut self,
        pattern: &str,
        captures: &[Option<&str>],
    ) -> Result<String, SubstitutionError> {
        let key = cache_key(pattern, captures);
        if let Some(value) = self.entries.get(&key) {
            return Ok(value.clone());
        }
        let substituted = substitute_end_pattern(pattern, captures, self.max_pattern_len)?;
        if self.max_entries > 0 {
            if self.entries.len() >= self.max_entries
                && let Some(oldest) = self.order.pop_front()
            {
                self.entries.remove(&oldest);
            }
            self.order.push_back(key.clone());
            self.entries.insert(key, substituted.clone());
        }
        Ok(substituted)
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }
}

pub fn substitute_end_pattern(
    pattern: &str,
    captures: &[Option<&str>],
    max_pattern_len: usize,
) -> Result<String, SubstitutionError> {
    let mut output = String::with_capacity(pattern.len());
    let mut chars = pattern.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch != '\\' {
            output.push(ch);
        } else if let Some(next @ '1'..='9') = chars.peek().copied() {
            let mut digits = String::new();
            digits.push(next);
            chars.next();
            while let Some(digit @ '0'..='9') = chars.peek().copied() {
                digits.push(digit);
                chars.next();
            }
            let index = digits.parse::<usize>().unwrap_or(0);
            if let Some(Some(text)) = captures.get(index) {
                output.push_str(&escape_regex_literal(text));
            }
        } else {
            output.push('\\');
            if let Some(next) = chars.next() {
                output.push(next);
            }
        }
        if output.len() > max_pattern_len {
            return Err(SubstitutionError::PatternTooLong {
                len: output.len(),
                max: max_pattern_len,
            });
        }
    }
    Ok(output)
}

pub fn capture_texts<'a>(line: &'a str, captures: &[Option<Range<usize>>]) -> Vec<Option<&'a str>> {
    captures
        .iter()
        .map(|range| range.as_ref().and_then(|range| line.get(range.clone())))
        .collect()
}

fn escape_regex_literal(text: &str) -> String {
    let mut output = String::new();
    for ch in text.chars() {
        if matches!(
            ch,
            '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$'
                | '-' | ',' | '#'
                // vscode-textmate uses JavaScript's \s here. Unlike Rust's
                // is_whitespace, that includes BOM and excludes NEXT LINE.
                | '\u{0009}'..='\u{000d}' | ' ' | '\u{00a0}' | '\u{1680}'
                | '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}'
                | '\u{202f}' | '\u{205f}' | '\u{3000}' | '\u{feff}'
        ) {
            output.push('\\');
        }
        output.push(ch);
    }
    output
}

fn cache_key(pattern: &str, captures: &[Option<&str>]) -> String {
    let mut key = String::from(pattern);
    for capture in captures {
        key.push('\u{0}');
        if let Some(capture) = capture {
            key.push_str(capture);
        }
    }
    key
}

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

    #[test]
    fn substitutes_numbered_end_captures() {
        let captures = vec![Some("<<"), Some("TAG")];
        let end = substitute_end_pattern(r"^\1$", &captures, 1024).unwrap();
        assert_eq!(end, r"^TAG$");
    }

    #[test]
    fn escapes_capture_text() {
        let captures = vec![Some("all"), Some("a+b")];
        let end = substitute_end_pattern(r"\1", &captures, 1024).unwrap();
        assert_eq!(end, r"a\+b");
    }

    #[test]
    fn unmatched_begin_capture_substitutes_as_empty() {
        let captures = vec![Some("all"), Some("tag"), None];
        let end = substitute_end_pattern(r"\1\2", &captures, 1024).unwrap();
        assert_eq!(end, "tag");
    }

    #[test]
    fn cache_reuses_and_caps_entries() {
        let mut cache = EndPatternCache::new(1, 1024);
        let captures = vec![Some("all"), Some("TAG")];
        assert_eq!(cache.substitute(r"\1", &captures).unwrap(), "TAG");
        assert_eq!(cache.substitute(r"\1", &captures).unwrap(), "TAG");
        assert_eq!(cache.len(), 1);
    }
}