znote 1.0.0

A lightweight desktop editor for Typst with live preview, built with Tauri
use std::net::{Ipv4Addr, Ipv6Addr};

use super::rules::{Check, Rules};

fn iban(text: &str) -> bool {
    let compact: String = text.chars().filter(|c| *c != ' ').collect();
    if !(15..=34).contains(&compact.len()) {
        return false;
    }
    let (head, tail) = compact.split_at(4);
    let mut rest = 0u32;
    for c in tail.chars().chain(head.chars()) {
        let Some(value) = c.to_digit(36) else {
            return false;
        };
        rest = if value < 10 {
            (rest * 10 + value) % 97
        } else {
            (rest * 100 + value) % 97
        };
    }
    rest == 1
}

fn luhn(text: &str) -> bool {
    let digits: Vec<u32> = text.chars().filter_map(|c| c.to_digit(10)).collect();
    if !(13..=19).contains(&digits.len()) {
        return false;
    }
    let sum: u32 = digits
        .iter()
        .rev()
        .enumerate()
        .map(|(place, &digit)| match (place % 2, digit * 2) {
            (0, _) => digit,
            (_, doubled) if doubled > 9 => doubled - 9,
            (_, doubled) => doubled,
        })
        .sum();
    sum.is_multiple_of(10)
}

fn ipv6(text: &str) -> bool {
    text.matches(':').count() >= 2
        && text.chars().filter(char::is_ascii_hexdigit).count() >= 4
        && text.parse::<Ipv6Addr>().is_ok()
}

fn phone(text: &str) -> bool {
    let digits = text.chars().filter(char::is_ascii_digit).count();
    let enough = if text.starts_with('+') { 8 } else { 9 };
    (enough..=15).contains(&digits)
}

fn amount(text: &str, after: &str) -> bool {
    !(text.starts_with('$') && after.starts_with('$')) && !text.ends_with('$')
}

fn valid(check: Option<Check>, text: &str, after: &str) -> bool {
    match check {
        None => true,
        Some(Check::Iban) => iban(text),
        Some(Check::Luhn) => luhn(text),
        Some(Check::Ipv4) => text.parse::<Ipv4Addr>().is_ok(),
        Some(Check::Ipv6) => ipv6(text),
        Some(Check::Phone) => phone(text),
        Some(Check::Amount) => amount(text, after),
    }
}

pub fn find<'a>(rules: &'a Rules, text: &str, amounts: bool) -> Vec<(usize, usize, &'a str)> {
    let mut found = Vec::new();
    for pattern in rules
        .patterns
        .iter()
        .filter(|pattern| amounts || !pattern.amounts)
    {
        for hit in pattern.regex.find_iter(text) {
            let end = hit.start() + hit.as_str().trim_end_matches(pattern.trim.as_slice()).len();
            let piece = &text[hit.start()..end];
            if !piece.is_empty()
                && !piece.contains('\n')
                && valid(pattern.check, piece, &text[end..])
            {
                found.push((hit.start(), end, pattern.kind.as_str()));
            }
        }
    }
    found
}

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

    fn kinds(text: &str) -> Vec<(&str, &'static str)> {
        let mut found = find(rules().unwrap(), text, true);
        found.sort();
        found
            .into_iter()
            .map(|(start, end, kind)| (&text[start..end], kind))
            .collect()
    }

    #[test]
    fn contact_details_and_numbers_are_found_and_checked() {
        assert_eq!(
            kinds("Mail jan.de-vries@acme.example.nl or see https://acme.example/x?y=1."),
            [
                ("jan.de-vries@acme.example.nl", "EMAIL"),
                ("https://acme.example/x?y=1", "URL")
            ]
        );
        let ibans: Vec<(&str, &str)> =
            kinds("IBAN NL91 ABNA 0417 1643 00 and NL91 ABNA 0417 1643 01")
                .into_iter()
                .filter(|(_, kind)| *kind == "IBAN")
                .collect();
        assert_eq!(ibans, [("NL91 ABNA 0417 1643 00", "IBAN")]);
        assert_eq!(
            kinds("card 4111 1111 1111 1111, not 4111 1111 1111 1112"),
            [("4111 1111 1111 1111", "CARD")]
        );
        assert_eq!(
            kinds("hosts 10.0.0.1, 999.1.1.1 and fe80::1:2:3 at 12:30"),
            [("10.0.0.1", "IP"), ("fe80::1:2:3", "IP")]
        );
        assert_eq!(
            kinds(r"Typst escapes it as first\_last\@acme.example"),
            [(r"first\_last\@acme.example", "EMAIL")]
        );
        assert_eq!(
            kinds("call +31 6 1234 5678 or 010-123 4567, not 2024-10-14"),
            [("+31 6 1234 5678", "PHONE"), ("010-123 4567", "PHONE")]
        );
    }

    #[test]
    fn amounts_need_a_currency_and_typst_math_is_left_alone() {
        assert_eq!(
            kinds("costs € 1.250,00 or EUR 40k, 300 USD and \\$12 but not $5$ or 300 apples"),
            [
                ("€ 1.250,00", "AMOUNT"),
                ("EUR 40k", "AMOUNT"),
                ("300 USD", "AMOUNT"),
                ("\\$12", "AMOUNT")
            ]
        );
        assert!(find(rules().unwrap(), "€ 10", false).is_empty());
        assert_eq!(
            kinds("€ 5 mln and 2 miljoen EUR"),
            [("€ 5 mln", "AMOUNT"), ("2 miljoen EUR", "AMOUNT")]
        );
    }
}