relay-knowledge 1.1.17

Graph-database-based knowledge graph project.
Documentation
//! Cross-style identifier equivalence and source-safe identifier masking.

pub(super) fn identifier_terms_equivalent(candidate: &str, token: &str) -> bool {
    if candidate.eq_ignore_ascii_case(token) {
        return true;
    }
    let candidate_singular = singular_identifier_term(candidate);
    if candidate_singular
        .as_deref()
        .is_some_and(|candidate| candidate.eq_ignore_ascii_case(token))
    {
        return true;
    }
    let Some(token_singular) = singular_identifier_term(token) else {
        return false;
    };

    candidate.eq_ignore_ascii_case(&token_singular)
        || candidate_singular
            .as_deref()
            .is_some_and(|candidate| candidate.eq_ignore_ascii_case(&token_singular))
}

fn singular_identifier_term(term: &str) -> Option<String> {
    if !term.is_ascii() {
        return None;
    }
    let lower = term.to_ascii_lowercase();
    if lower.len() < 4
        || !lower
            .chars()
            .all(|character| character.is_ascii_alphanumeric())
    {
        return None;
    }
    if lower == "series" || lower == "species" {
        return None;
    }
    if lower.ends_with("ies") && lower.len() > 4 {
        let mut singular = lower[..lower.len() - 3].to_owned();
        singular.push('y');
        Some(singular)
    } else if lower.ends_with('s')
        && !lower.ends_with("ss")
        && !lower.ends_with("us")
        && !lower.ends_with("is")
    {
        Some(lower[..lower.len() - 1].to_owned())
    } else {
        None
    }
}

#[derive(Clone, Copy)]
enum LexicalMode {
    Code,
    BlockComment { depth: usize },
    Quoted { quote: u8, triple: bool },
    JavaScriptRegex { in_character_class: bool },
    JavaScriptTemplate,
    RustRawString { hashes: usize },
}

const JAVASCRIPT_REGEX_PREFIX_KEYWORDS: &[&[u8]] = &[
    b"case", b"delete", b"return", b"throw", b"typeof", b"void", b"yield",
];

pub(super) fn code_outside_comments_and_literals(language_id: &str, content: &str) -> String {
    let bytes = content.as_bytes();
    let mut code = vec![b' '; bytes.len()];
    let mut mode = if starts_inside_block_comment(language_id, content) {
        LexicalMode::BlockComment { depth: 1 }
    } else {
        LexicalMode::Code
    };
    let mut index = 0;
    while index < bytes.len() {
        if bytes[index] == b'\n' {
            code[index] = b'\n';
        }
        match mode {
            LexicalMode::Code => {
                if let Some((end, hashes)) = rust_raw_string_start(language_id, bytes, index) {
                    mode = LexicalMode::RustRawString { hashes };
                    index = end;
                } else if slash_line_comments(language_id) && bytes[index..].starts_with(b"//") {
                    index = mask_to_line_end(bytes, &mut code, index);
                } else if block_comments(language_id) && bytes[index..].starts_with(b"/*") {
                    mode = LexicalMode::BlockComment { depth: 1 };
                    index += 2;
                } else if hash_comment_starts(language_id, bytes, index)
                    || alternate_line_comment_starts(language_id, bytes, index)
                    || language_id == "sql" && bytes[index..].starts_with(b"--")
                {
                    index = mask_to_line_end(bytes, &mut code, index);
                } else if javascript_family(language_id) && bytes[index] == b'`' {
                    mode = LexicalMode::JavaScriptTemplate;
                    index += 1;
                } else if javascript_family(language_id)
                    && bytes[index] == b'/'
                    && javascript_regex_can_start(&code[..index])
                {
                    mode = LexicalMode::JavaScriptRegex {
                        in_character_class: false,
                    };
                    index += 1;
                } else if rust_lifetime_start(language_id, bytes, index) {
                    code[index] = bytes[index];
                    index += 1;
                } else if matches!(bytes[index], b'\'' | b'"') {
                    let quote = bytes[index];
                    let triple = language_id == "python"
                        && bytes[index..].starts_with(&[quote, quote, quote]);
                    mode = LexicalMode::Quoted { quote, triple };
                    index += if triple { 3 } else { 1 };
                } else {
                    code[index] = bytes[index];
                    index += 1;
                }
            }
            LexicalMode::BlockComment { depth } => {
                if language_id == "rust" && bytes[index..].starts_with(b"/*") {
                    mode = LexicalMode::BlockComment {
                        depth: depth.saturating_add(1),
                    };
                    index += 2;
                } else if bytes[index..].starts_with(b"*/") {
                    mode = if depth == 1 {
                        LexicalMode::Code
                    } else {
                        LexicalMode::BlockComment { depth: depth - 1 }
                    };
                    index += 2;
                } else {
                    index += 1;
                }
            }
            LexicalMode::Quoted { quote, triple } => {
                if bytes[index] == b'\\' {
                    index = skip_escaped_byte(bytes, &mut code, index);
                } else if triple && bytes[index..].starts_with(&[quote, quote, quote]) {
                    mode = LexicalMode::Code;
                    index += 3;
                } else if !triple && bytes[index] == quote {
                    mode = LexicalMode::Code;
                    index += 1;
                } else {
                    index += 1;
                }
            }
            LexicalMode::JavaScriptRegex { in_character_class } => {
                if bytes[index] == b'\\' {
                    index = skip_escaped_byte(bytes, &mut code, index);
                } else if bytes[index] == b'[' && !in_character_class {
                    mode = LexicalMode::JavaScriptRegex {
                        in_character_class: true,
                    };
                    index += 1;
                } else if bytes[index] == b']' && in_character_class {
                    mode = LexicalMode::JavaScriptRegex {
                        in_character_class: false,
                    };
                    index += 1;
                } else if bytes[index] == b'/' && !in_character_class {
                    mode = LexicalMode::Code;
                    index += 1;
                    while index < bytes.len() && bytes[index].is_ascii_alphabetic() {
                        index += 1;
                    }
                } else if bytes[index] == b'\n' {
                    mode = LexicalMode::Code;
                    index += 1;
                } else {
                    index += 1;
                }
            }
            LexicalMode::JavaScriptTemplate => {
                if bytes[index] == b'\\' {
                    index = skip_escaped_byte(bytes, &mut code, index);
                } else if bytes[index] == b'`' {
                    mode = LexicalMode::Code;
                    index += 1;
                } else {
                    index += 1;
                }
            }
            LexicalMode::RustRawString { hashes } => {
                if rust_raw_string_ends(bytes, index, hashes) {
                    mode = LexicalMode::Code;
                    index += hashes + 1;
                } else {
                    index += 1;
                }
            }
        }
    }

    String::from_utf8(code).expect("masking UTF-8 source with ASCII spaces preserves UTF-8")
}

fn starts_inside_block_comment(language_id: &str, content: &str) -> bool {
    if !block_comments(language_id) {
        return false;
    }
    let first_open = content.find("/*");
    if content
        .find("*/")
        .is_some_and(|first_close| first_open.is_none_or(|first_open| first_close < first_open))
    {
        return true;
    }
    if first_open.is_some() {
        return false;
    }

    content
        .lines()
        .map(str::trim_start)
        .find(|line| !line.is_empty())
        .and_then(|line| line.strip_prefix('*'))
        .is_some_and(|remainder| remainder.chars().next().is_none_or(char::is_whitespace))
}

fn slash_line_comments(language_id: &str) -> bool {
    matches!(
        language_id,
        "c" | "cpp"
            | "csharp"
            | "go"
            | "gomod"
            | "java"
            | "javascript"
            | "jsx"
            | "kotlin"
            | "php"
            | "rust"
            | "scala"
            | "swift"
            | "typescript"
            | "tsx"
    )
}

fn block_comments(language_id: &str) -> bool {
    language_id == "sql" || slash_line_comments(language_id)
}

fn hash_comment_starts(language_id: &str, bytes: &[u8], index: usize) -> bool {
    if bytes[index] != b'#' {
        return false;
    }
    match language_id {
        "cmake" | "dockerfile" | "ini" | "make" | "ninja" | "php" | "properties" | "python"
        | "ruby" | "starlark" | "toml" | "yaml" => true,
        "bash" => {
            index == 0
                || bytes[index - 1].is_ascii_whitespace()
                || b";&|()".contains(&bytes[index - 1])
        }
        _ => false,
    }
}

fn alternate_line_comment_starts(language_id: &str, bytes: &[u8], index: usize) -> bool {
    let marker_matches = match language_id {
        "ini" => bytes[index] == b';',
        "properties" => bytes[index] == b'!',
        _ => false,
    };
    marker_matches
        && bytes[..index]
            .iter()
            .rev()
            .take_while(|byte| **byte != b'\n')
            .all(|byte| byte.is_ascii_whitespace())
}

fn mask_to_line_end(bytes: &[u8], code: &mut [u8], mut index: usize) -> usize {
    while index < bytes.len() && bytes[index] != b'\n' {
        index += 1;
    }
    if index < bytes.len() {
        code[index] = b'\n';
        index += 1;
    }
    index
}

fn skip_escaped_byte(bytes: &[u8], code: &mut [u8], index: usize) -> usize {
    let escaped = index + 1;
    if bytes.get(escaped) == Some(&b'\n') {
        code[escaped] = b'\n';
    }
    (index + 2).min(bytes.len())
}

fn javascript_family(language_id: &str) -> bool {
    matches!(language_id, "javascript" | "jsx" | "typescript" | "tsx")
}

fn javascript_regex_can_start(code: &[u8]) -> bool {
    let line_start = code
        .iter()
        .rposition(|byte| *byte == b'\n')
        .map_or(0, |index| index + 1);
    let Some(previous) = code[line_start..]
        .iter()
        .rposition(|byte| !byte.is_ascii_whitespace())
        .map(|index| line_start + index)
    else {
        return true;
    };
    if b"=([{,:;!?&|+-*%^~<>".contains(&code[previous]) {
        return true;
    }
    let prefix = &code[..=previous];
    let word_start = prefix
        .iter()
        .rposition(|byte| !(byte.is_ascii_alphanumeric() || *byte == b'_'))
        .map_or(0, |index| index + 1);
    JAVASCRIPT_REGEX_PREFIX_KEYWORDS.contains(&&prefix[word_start..])
}

fn rust_lifetime_start(language_id: &str, bytes: &[u8], index: usize) -> bool {
    if language_id != "rust"
        || bytes[index] != b'\''
        || index
            .checked_sub(1)
            .is_some_and(|previous| is_identifier_byte(bytes[previous]))
        || !bytes
            .get(index + 1)
            .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_')
    {
        return false;
    }
    let mut end = index + 2;
    while bytes.get(end).is_some_and(|byte| is_identifier_byte(*byte)) {
        end += 1;
    }

    bytes.get(end) != Some(&b'\'')
}

fn rust_raw_string_start(language_id: &str, bytes: &[u8], index: usize) -> Option<(usize, usize)> {
    if language_id != "rust"
        || index
            .checked_sub(1)
            .is_some_and(|previous| is_identifier_byte(bytes[previous]))
    {
        return None;
    }
    let mut cursor = index;
    if bytes.get(cursor) == Some(&b'b') {
        cursor += 1;
    }
    if bytes.get(cursor) != Some(&b'r') {
        return None;
    }
    cursor += 1;
    let hashes_start = cursor;
    while bytes.get(cursor) == Some(&b'#') {
        cursor += 1;
    }
    (bytes.get(cursor) == Some(&b'"')).then_some((cursor + 1, cursor - hashes_start))
}

fn rust_raw_string_ends(bytes: &[u8], index: usize, hashes: usize) -> bool {
    bytes.get(index) == Some(&b'"')
        && bytes
            .get(index + 1..index + 1 + hashes)
            .is_some_and(|suffix| suffix.iter().all(|byte| *byte == b'#'))
}

fn is_identifier_byte(byte: u8) -> bool {
    byte.is_ascii_alphanumeric() || byte == b'_'
}

#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;