gaman-core 0.3.19

Offline core engine for Gaman migrations
Documentation
pub(crate) fn opaque_sources_equal(left: &str, right: &str) -> bool {
    left == right || canonicalize_opaque_source(left) == canonicalize_opaque_source(right)
}

pub(crate) fn opaque_option_sources_equal(left: &Option<String>, right: &Option<String>) -> bool {
    match (left, right) {
        (Some(left), Some(right)) => opaque_sources_equal(left, right),
        (None, None) => true,
        _ => false,
    }
}

pub(crate) fn canonicalize_opaque_source(source: &str) -> String {
    let normalized = normalize_line_endings(source);
    canonicalize_normalized(&normalized).unwrap_or(normalized)
}

fn normalize_line_endings(source: &str) -> String {
    let mut out = String::with_capacity(source.len());
    let mut chars = source.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch == '\r' {
            if chars.peek() == Some(&'\n') {
                chars.next();
            }
            out.push('\n');
        } else {
            out.push(ch);
        }
    }
    out
}

fn canonicalize_normalized(source: &str) -> Option<String> {
    let mut out = String::with_capacity(source.len());
    let mut i = 0;
    let mut pending_space = false;

    while i < source.len() {
        let rest = &source[i..];
        let ch = rest.chars().next()?;

        if ch.is_whitespace() {
            pending_space = true;
            i += ch.len_utf8();
            continue;
        }

        if rest.starts_with("--") {
            if let Some(end) = rest.find('\n') {
                i += end + 1;
            } else {
                i = source.len();
            }
            pending_space = true;
            continue;
        }

        if rest.starts_with("/*") {
            let end = rest.find("*/")?;
            i += end + 2;
            pending_space = true;
            continue;
        }

        if let Some(delim_len) = dollar_quote_delimiter_len(rest) {
            flush_space(&mut out, &mut pending_space);
            let delimiter = &rest[..delim_len];
            let body = &rest[delim_len..];
            let end = body.find(delimiter)?;
            let protected_len = delim_len + end + delim_len;
            out.push_str(&rest[..protected_len]);
            i += protected_len;
            continue;
        }

        if matches!(ch, '\'' | '"' | '`') {
            flush_space(&mut out, &mut pending_space);
            let protected_len = quoted_region_len(rest, ch)?;
            out.push_str(&rest[..protected_len]);
            i += protected_len;
            continue;
        }

        if ch == '[' {
            flush_space(&mut out, &mut pending_space);
            let protected_len = bracket_region_len(rest)?;
            out.push_str(&rest[..protected_len]);
            i += protected_len;
            continue;
        }

        flush_space(&mut out, &mut pending_space);
        out.push(ch);
        i += ch.len_utf8();
    }

    Some(out.trim().to_string())
}

fn flush_space(out: &mut String, pending_space: &mut bool) {
    if *pending_space && !out.is_empty() {
        out.push(' ');
    }
    *pending_space = false;
}

fn quoted_region_len(source: &str, quote: char) -> Option<usize> {
    let mut iter = source.char_indices();
    let (_, first) = iter.next()?;
    if first != quote {
        return None;
    }

    while let Some((idx, ch)) = iter.next() {
        if ch != quote {
            continue;
        }
        let next_idx = idx + ch.len_utf8();
        if source[next_idx..].starts_with(quote) {
            iter.next();
            continue;
        }
        return Some(next_idx);
    }
    None
}

fn bracket_region_len(source: &str) -> Option<usize> {
    let mut iter = source.char_indices();
    let (_, first) = iter.next()?;
    if first != '[' {
        return None;
    }

    while let Some((idx, ch)) = iter.next() {
        if ch != ']' {
            continue;
        }
        let next_idx = idx + ch.len_utf8();
        if source[next_idx..].starts_with(']') {
            iter.next();
            continue;
        }
        return Some(next_idx);
    }
    None
}

fn dollar_quote_delimiter_len(source: &str) -> Option<usize> {
    let bytes = source.as_bytes();
    if bytes.first() != Some(&b'$') {
        return None;
    }
    if bytes.get(1) == Some(&b'$') {
        return Some(2);
    }

    let mut i = 1;
    while let Some(&byte) = bytes.get(i) {
        if byte == b'$' {
            return Some(i + 1);
        }
        if !(byte == b'_' || byte.is_ascii_alphanumeric()) {
            return None;
        }
        i += 1;
    }
    None
}

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

    #[test]
    fn exact_match_is_equal() {
        assert!(opaque_sources_equal("SELECT 1", "SELECT 1"));
    }

    #[test]
    fn whitespace_outside_protected_regions_is_ignored() {
        assert!(opaque_sources_equal(
            "SELECT  a\nFROM users",
            "SELECT a FROM users"
        ));
    }

    #[test]
    fn comments_outside_protected_regions_are_ignored() {
        assert!(opaque_sources_equal(
            "SELECT a -- ignored\nFROM users /* ignored */ WHERE id = 1",
            "SELECT a FROM users WHERE id = 1"
        ));
    }

    #[test]
    fn single_quoted_contents_are_preserved() {
        assert!(!opaque_sources_equal("SELECT 'a b'", "SELECT 'ab'"));
    }

    #[test]
    fn double_quoted_contents_are_preserved() {
        assert!(!opaque_sources_equal(r#"SELECT "a b""#, r#"SELECT "ab""#));
    }

    #[test]
    fn backtick_and_bracket_contents_are_preserved() {
        assert!(!opaque_sources_equal("SELECT `a b`", "SELECT `ab`"));
        assert!(!opaque_sources_equal("SELECT [a b]", "SELECT [ab]"));
    }

    #[test]
    fn dollar_quoted_contents_are_preserved() {
        assert!(!opaque_sources_equal("SELECT $$a b$$", "SELECT $$ab$$"));
        assert!(!opaque_sources_equal(
            "SELECT $tag$a b$tag$",
            "SELECT $tag$ab$tag$"
        ));
    }

    #[test]
    fn unterminated_protected_region_falls_back_to_raw_normalized_text() {
        assert_eq!(
            canonicalize_opaque_source("SELECT 'a  b"),
            "SELECT 'a  b".to_string()
        );
    }
}