refresh_parser 0.1.0

Imlementation of `refresh` content parsing algorithm.
Documentation
use std::time;
use thiserror::Error;

#[derive(Eq, PartialEq, Error, Debug)]
pub enum ParseRefreshError {
    #[error("can`t parse refresh content")]
    BadContentError,
    #[error(transparent)]
    ParseTimeError(#[from] std::num::ParseIntError),
}

#[derive(Eq, PartialEq, Debug)]
pub struct RefreshInfo {
    pub duration: time::Duration,
    pub url: String,
}

fn skip_ascii_whitespace(code_points: &[char]) -> usize {
    let mut i: usize = 0;
    while i < code_points.len() {
        if !code_points[i].is_ascii_whitespace() {
            return i;
        }
        i += 1;
    }
    i
}

fn extract_url(code_points: &[char]) -> &[char] {
    let mut position: usize = 0;

    // 1. Let urlString be the substring of input from the code point at
    //    position to the end of the string.
    let mut url_start: usize = 0;
    let mut url_end: usize = code_points.len();

    // 2. If the code point in input pointed to by position is U+0055 (U) or
    //    U+0075 (u), then advance position to the next code point.
    //    Otherwise, jump to the step labeled skip quotes.
    if code_points[position] == 'U' || code_points[position] == 'u' {
        position += 1;

        // 3. If the code point in input pointed to by position is U+0052 (R) or
        //    U+0072 (r), then advance position to the next code point.
        //    Otherwise, jump to the step labeled parse.
        if position == code_points.len()
            || (code_points[position] != 'R' && code_points[position] != 'r')
        {
            return &code_points[url_start..url_end];
        }

        position += 1;

        // 4. If the code point in input pointed to by position is U+004C (L) or
        //    U+006C (l), then advance position to the next code point.
        //    Otherwise, jump to the step labeled parse.
        if position == code_points.len()
            || (code_points[position] != 'L' && code_points[position] != 'l')
        {
            return &code_points[url_start..url_end];
        }

        position += 1;

        // 5. Skip ASCII whitespace within input given position.
        position += skip_ascii_whitespace(&code_points[position..]);

        // 6. If the code point in input pointed to by position is U+003D (=),
        //    then advance position to the next code point. Otherwise, jump to
        //    the step labeled parse.
        if position == code_points.len() || code_points[position] != '=' {
            return &code_points[url_start..url_end];
        }

        position += 1;

        // 7. Skip ASCII whitespace within input given position.
        position += skip_ascii_whitespace(&code_points[position..]);
    }

    // 8. Skip quotes: If the code point in input pointed to by position is
    //    U+0027 (') or U+0022 ("), then let quote be that code point, and
    //    advance position to the next code point. Otherwise, let quote be
    //    the empty string.
    let mut maybe_quote: Option<char> = None;
    if position != code_points.len()
        && (code_points[position] == '\'' || code_points[position] == '"')
    {
        maybe_quote = Some(code_points[position]);
        position += 1;
    }

    // 9. Set urlString to the substring of input from the code point at
    //    position to the end of the string.
    url_start = position;

    // 10. If quote is not the empty string, and there is a code point in
    //     urlString equal to quote, then truncate urlString at that code
    //     point, so that it and all subsequent code points are removed.
    if let Some(quote) = maybe_quote {
        match code_points[url_start..].iter().position(|&c| c == quote) {
            Some(offset) => {
                // because it was calculated with url_start offset
                url_end = url_start + offset;
            }
            None => url_end = code_points.len(),
        }
    }

    &code_points[url_start..url_end]
}

/// Imlementation of `refresh` content parsing algorithm:
/// <https://html.spec.whatwg.org/#will-declaratively-refresh>
pub fn parse_refresh_content(content: String) -> Result<RefreshInfo, ParseRefreshError> {
    let code_points: Vec<char> = content.chars().collect();
    let code_points: &[char] = code_points.as_slice();
    let mut position: usize = 0;

    // 3. Skip ASCII whitespace
    position += skip_ascii_whitespace(code_points);

    // 4. Let time be 0.
    let mut duration: time::Duration = time::Duration::new(0, 0);

    // 5. Collect a sequence of code points that are ASCII digits
    let digit_start: usize = position;
    while position != code_points.len() && code_points[position].is_ascii_digit() {
        position += 1;
    }

    if position == digit_start {
        // 6. If timeString is the empty string, then:
        //    1. If the code point in input pointed to by position is not U+002E
        //       (.), then return.
        if position == code_points.len() || code_points[position] != '.' {
            return Err(ParseRefreshError::BadContentError);
        }
    } else {
        // 7. Otherwise, set time to the result of parsing timeString using the
        //    rules for parsing non-negative integers.
        match code_points[digit_start..position]
            .iter()
            .collect::<String>()
            .parse::<u64>()
        {
            Ok(t) => {
                duration = time::Duration::new(t, 0);
            }
            Err(e) => {
                return Err(ParseRefreshError::ParseTimeError(e));
            }
        }
    }

    // 8. Collect a sequence of code points that are ASCII digits and U+002E FULL
    //    STOP characters (.) from input given position. Ignore any collected
    //    characters.
    while position != code_points.len()
        && (code_points[position].is_ascii_digit() || code_points[position] == '.')
    {
        position += 1;
    }

    // 9. Let urlRecord be document's URL.
    // => user of this function must parse the url himself

    // 10. If position is not past the end of input
    if position != code_points.len() {
        // 1. If the code point in input pointed to by position is not U+003B (;),
        //    U+002C (,), or ASCII whitespace, then return.
        if code_points[position] != ';'
            && code_points[position] != ','
            && !code_points[position].is_ascii_whitespace()
        {
            return Err(ParseRefreshError::BadContentError);
        }

        // 2. Skip ASCII whitespace within input given position.
        position += skip_ascii_whitespace(&code_points[position..]);

        // 3. If the code point in input pointed to by position is U+003B (;) or
        //    U+002C (,), then advance position to the next code point.
        if position != code_points.len()
            && (code_points[position] == ';' || code_points[position] == ',')
        {
            position += 1;

            // 4. Skip ASCII whitespace within input given position.
            position += skip_ascii_whitespace(&code_points[position..]);
        }

        // 11. If position is not past the end of input, then:
        if position != code_points.len() {
            // 1-10. See extract_url.
            return Ok(RefreshInfo {
                duration,
                url: extract_url(&code_points[position..]).iter().collect(),
            });
        }
    }
    Ok(RefreshInfo {
        duration,
        url: String::from(""),
    })
}

#[cfg(test)]
mod tests {
    use crate::RefreshInfo;

    use super::parse_refresh_content;
    use super::ParseRefreshError;

    use std::time;

    #[test]
    fn test_parse_refresh_content() {
        assert_eq!(
            parse_refresh_content(String::from("10;url=https://example.com/")).unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("https://example.com/")
            },
            "test correct refresh content",
        );
        assert_eq!(
            parse_refresh_content(String::from("10;UrL=https://example.com/")).unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("https://example.com/")
            },
            "test dancing url",
        );
        assert_eq!(
            parse_refresh_content(String::from("	10 ;\n \t \r url = https://ex ample.com/ "))
                .unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("https://ex ample.com/ ")
            },
            "test ascii whitespaces",
        );
        assert_eq!(
            parse_refresh_content(String::from("10...2.3.44...34;url=https://example.com/"))
                .unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("https://example.com/")
            },
            "test dots",
        );
        assert_eq!(
            parse_refresh_content(String::from("10;url='https://example.com/'")).unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("https://example.com/")
            },
            "test quotes",
        );
        assert_eq!(
            parse_refresh_content(String::from("10;url=\"https://example.com/\"")).unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("https://example.com/")
            },
            "test quotes",
        );
        assert_eq!(
            parse_refresh_content(String::from("10;url=\"https://example.com/ hello")).unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("https://example.com/ hello")
            },
            "test unclosed quote",
        );
        assert_eq!(
            parse_refresh_content(String::from("10 url=https://example.com/")).unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("https://example.com/")
            },
            "test space delimiter",
        );
        assert_eq!(
            parse_refresh_content(String::from("10,url=https://example.com/")).unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("https://example.com/")
            },
            "test comma delimiter",
        );
        assert_eq!(
            parse_refresh_content(String::from("10")).unwrap(),
            RefreshInfo {
                duration: time::Duration::new(10, 0),
                url: String::from("")
            },
            "test empty link",
        );
        assert_eq!(
            parse_refresh_content(String::from("qweQWEqwe")).unwrap_err(),
            ParseRefreshError::BadContentError,
            "test bad content",
        );
        assert_eq!(
            parse_refresh_content(String::from("18446744073709551616")) // 2 ** 64
                .unwrap_err()
                .to_string(),
            String::from("number too large to fit in target type"),
            "test very large time",
        );
    }
}