poexam 0.0.12

Blazingly fast PO linter.
// SPDX-FileCopyrightText: 2026 Sébastien Helleu <flashcode@flashtux.org>
//
// SPDX-License-Identifier: GPL-3.0-or-later

//! Format strings: no language.

use crate::po::format::FormatParser;

pub struct FormatNull;

impl FormatParser for FormatNull {
    #[inline]
    fn next_char(&self, s: &str, pos: usize) -> Option<(char, usize, bool)> {
        s[pos..]
            .chars()
            .next()
            .map(|c| (c, pos + c.len_utf8(), false))
    }

    #[inline]
    fn find_end_format(&self, _s: &str, _pos: usize, len: usize) -> usize {
        len
    }
}

#[cfg(test)]
mod tests {
    use crate::po::format::{
        iter::{
            FormatAcceleratorPos, FormatAcronymPos, FormatEmailPos, FormatFunctionPos,
            FormatHtmlTagPos, FormatPathPos, FormatPos, FormatUrlPos, FormatWordPos,
        },
        language::Language,
        strip_formats,
    };

    #[test]
    fn test_strip_formats() {
        assert_eq!(strip_formats("", Language::Null), "");
        assert_eq!(
            strip_formats("Hello, %s world!", Language::Null),
            "Hello, %s world!"
        );
    }

    #[test]
    fn test_format_pos() {
        assert!(FormatPos::new("", Language::Null).next().is_none());
        assert!(
            FormatPos::new("Hello, %s world!", Language::Null)
                .next()
                .is_none()
        );
    }

    #[test]
    fn test_accelerator_pos() {
        assert!(
            FormatAcceleratorPos::new("", Language::Null, '&')
                .next()
                .is_none()
        );
        // No accelerator: marker before a space, and a trailing marker.
        assert!(
            FormatAcceleratorPos::new("Drag & drop, ends with &", Language::Null, '&')
                .next()
                .is_none()
        );
        // Accelerators, an escaped "&&", and a tight literal "AT&T" (counted as
        // an accelerator since the marker sits between alphanumerics).
        assert_eq!(
            FormatAcceleratorPos::new("&File && E&xit AT&T", Language::Null, '&')
                .map(|m| (m.s, m.start, m.end))
                .collect::<Vec<_>>(),
            vec![("&", 0, 1), ("&", 10, 11), ("&", 17, 18)]
        );
        // An escaped literal immediately followed by a real accelerator.
        assert_eq!(
            FormatAcceleratorPos::new("&&&File", Language::Null, '&')
                .map(|m| (m.s, m.start, m.end))
                .collect::<Vec<_>>(),
            vec![("&", 2, 3)]
        );
        // Custom marker '_' (GTK style); "__" is the escaped literal.
        assert_eq!(
            FormatAcceleratorPos::new("_File and __literal", Language::Null, '_')
                .map(|m| (m.s, m.start, m.end))
                .collect::<Vec<_>>(),
            vec![("_", 0, 1)]
        );
    }

    #[test]
    fn test_word_pos() {
        assert!(FormatWordPos::new("", Language::Null).next().is_none());
        assert_eq!(
            FormatWordPos::new("Hello, %s world!", Language::Null)
                .map(|m| (m.s, m.start, m.end))
                .collect::<Vec<_>>(),
            vec![("Hello", 0, 5), ("s", 8, 9), ("world", 10, 15)]
        );
    }

    #[test]
    fn test_acronym_pos() {
        assert!(FormatAcronymPos::new("", Language::Null).next().is_none());
        // No acronyms when every word has at least one lowercase character.
        assert!(
            FormatAcronymPos::new("Hello, %s world!", Language::Null)
                .next()
                .is_none()
        );
        // All-uppercase words of length ≥ 2 are returned with their positions.
        assert_eq!(
            FormatAcronymPos::new("Use the HTTP API for %s and skip A", Language::Null)
                .map(|m| (m.s, m.start, m.end))
                .collect::<Vec<_>>(),
            vec![("HTTP", 8, 12), ("API", 13, 16)]
        );
        // Caseless characters (digits) inside a word are allowed, as long as
        // the word has at least one uppercase letter and no lowercase letter
        // (mirrors Python's `str.isupper()`).
        assert_eq!(
            FormatAcronymPos::new("Play MP3 and B2B files", Language::Null)
                .map(|m| (m.s, m.start, m.end))
                .collect::<Vec<_>>(),
            vec![("MP3", 5, 8), ("B2B", 13, 16)]
        );
        // Words with any lowercase letter are not acronyms.
        assert!(
            FormatAcronymPos::new("Url URLs and Json", Language::Null)
                .next()
                .is_none()
        );
        // Pure-digit words and single-character words are excluded.
        assert!(
            FormatAcronymPos::new("123 A B C", Language::Null)
                .next()
                .is_none()
        );
    }

    #[test]
    fn test_url_pos() {
        assert!(FormatUrlPos::new("", Language::Null).next().is_none());
        assert!(
            FormatUrlPos::new("Hello, %s world!", Language::Null)
                .next()
                .is_none()
        );
        assert_eq!(
            FormatUrlPos::new(
                "Visit https://example.com or <https://example2.com> for more info.",
                Language::Null
            )
            .map(|m| (m.s, m.start, m.end))
            .collect::<Vec<_>>(),
            vec![
                ("https://example.com", 6, 25),
                ("https://example2.com", 30, 50)
            ]
        );
    }

    #[test]
    fn test_email_pos() {
        assert!(FormatEmailPos::new("", Language::Null).next().is_none());
        assert!(
            FormatEmailPos::new("Hello, %s world!", Language::Null)
                .next()
                .is_none()
        );
        assert_eq!(
            FormatEmailPos::new(
                "Contact us at user+test@domain.com or <user1@domain2.com> for more info. Invalid: user@domain",
                Language::Null
            )
            .map(|m| (m.s, m.start, m.end))
            .collect::<Vec<_>>(),
            vec![("user+test@domain.com", 14, 34), ("user1@domain2.com", 39, 56)]
        );
    }

    #[test]
    fn test_path_pos() {
        assert!(FormatPathPos::new("", Language::Null).next().is_none());
        assert!(
            FormatPathPos::new("Hello, %s world!", Language::Null)
                .next()
                .is_none()
        );
        assert_eq!(
            FormatPathPos::new("Path: /home/%s/file.txt", Language::Null)
                .map(|m| (m.s, m.start, m.end))
                .collect::<Vec<_>>(),
            vec![("/home/%s/file.txt", 6, 23)]
        );
    }

    #[test]
    fn test_function_pos() {
        assert!(FormatFunctionPos::new("", Language::Null).next().is_none());
        assert!(
            FormatFunctionPos::new("Hello, world!", Language::Null)
                .next()
                .is_none()
        );
        assert_eq!(
            FormatFunctionPos::new(
                "Use foo() and bar.baz() and Class::method() and ptr->m() here",
                Language::Null
            )
            .map(|m| (m.s, m.start, m.end))
            .collect::<Vec<_>>(),
            vec![
                ("foo()", 4, 9),
                ("bar.baz()", 14, 23),
                ("Class::method()", 28, 43),
                ("ptr->m()", 48, 56),
            ]
        );
        // Not a function: invalid char inside parens, space before `()`. Leading
        // `.` is not part of the name since names must start with `\w`.
        assert_eq!(
            FormatFunctionPos::new("foo(bar) and foo () and .bar() here", Language::Null)
                .map(|m| (m.s, m.start, m.end))
                .collect::<Vec<_>>(),
            vec![("bar()", 25, 30)]
        );
    }

    #[test]
    fn test_html_tags_pos() {
        assert!(FormatHtmlTagPos::new("", Language::Null).next().is_none());
        assert!(
            FormatHtmlTagPos::new("Hello, %s world!", Language::Null)
                .next()
                .is_none()
        );
        assert_eq!(
            FormatHtmlTagPos::new(
                r#"Hello <b>%s</b>! 3 < 5 <br/>Click <a href="https://example.com">here</a><span title="a > b"></span><br"#,
                Language::Null
            )
            .map(|m| (m.s, m.start, m.end))
            .collect::<Vec<_>>(),
            vec![
                ("<b>", 6, 9),
                ("</b>", 11, 15),
                ("<br/>", 23, 28),
                (r#"<a href="https://example.com">"#, 34, 64),
                ("</a>", 68, 72),
                (r#"<span title="a > b">"#, 72, 92),
                ("</span>", 92, 99),
            ]
        );
    }
}