checkleft 0.1.0-alpha.8

Experimental repository convention checker; API and behavior may change without notice
Documentation
use std::path::Path;

use tree_sitter::{Node, Parser, Tree};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StarlarkFileKind {
    Build,
    Module,
    Bzl,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SourceLocation {
    pub(crate) line: u32,
    pub(crate) column: u32,
}

#[derive(Debug)]
pub(crate) struct ParsedStarlarkFile<'a> {
    tree: Tree,
    pub(crate) source: &'a [u8],
}

impl ParsedStarlarkFile<'_> {
    pub(crate) fn root(&self) -> Node<'_> {
        self.tree.root_node()
    }
}

pub(crate) fn starlark_file_kind(path: &Path) -> Option<StarlarkFileKind> {
    match path.file_name().and_then(|name| name.to_str()) {
        Some("BUILD") | Some("BUILD.bazel") => Some(StarlarkFileKind::Build),
        Some("MODULE.bazel") => Some(StarlarkFileKind::Module),
        _ if matches!(path.extension().and_then(|ext| ext.to_str()), Some("bzl")) => {
            Some(StarlarkFileKind::Bzl)
        }
        _ => None,
    }
}

pub(crate) fn parse_starlark_file(contents: &str) -> Option<ParsedStarlarkFile<'_>> {
    let mut parser = Parser::new();
    if parser
        .set_language(&tree_sitter_starlark::LANGUAGE.into())
        .is_err()
    {
        return None;
    }
    let tree = parser.parse(contents, None)?;
    if tree.root_node().has_error() {
        return None;
    }
    Some(ParsedStarlarkFile {
        tree,
        source: contents.as_bytes(),
    })
}

pub(crate) fn normalize_callee(node: Node<'_>, source: &[u8]) -> Option<String> {
    match node.kind() {
        "identifier" => Some(node.utf8_text(source).ok()?.to_owned()),
        "attribute" => {
            let object = node.child_by_field_name("object")?;
            let attribute = node.child_by_field_name("attribute")?;
            let object = normalize_callee(object, source)?;
            let attribute = attribute.utf8_text(source).ok()?;
            Some(format!("{object}.{attribute}"))
        }
        _ => None,
    }
}

pub(crate) fn call_function_name<'a>(node: Node<'_>, source: &'a [u8]) -> Option<&'a str> {
    let function = node.child_by_field_name("function")?;
    if function.kind() != "identifier" {
        return None;
    }
    function.utf8_text(source).ok()
}

pub(crate) fn find_matching_string_literal<'a>(
    node: Node<'_>,
    source: &[u8],
    values: &'a [String],
) -> Option<(&'a str, SourceLocation)> {
    if node.kind() == "string" {
        let text = node.utf8_text(source).ok()?;
        let literal = unquote_starlark_string(text)?;
        if let Some(matched) = values.iter().find(|value| value.as_str() == literal) {
            return Some((matched.as_str(), source_location(node)));
        }
    }

    let mut cursor = node.walk();
    for child in node.named_children(&mut cursor) {
        if let Some(result) = find_matching_string_literal(child, source, values) {
            return Some(result);
        }
    }
    None
}

pub(crate) fn source_location(node: Node<'_>) -> SourceLocation {
    let position = node.start_position();
    SourceLocation {
        line: (position.row + 1) as u32,
        column: (position.column + 1) as u32,
    }
}

fn unquote_starlark_string(raw: &str) -> Option<&str> {
    for prefix in [
        "r", "R", "rb", "rB", "Rb", "RB", "br", "bR", "Br", "BR", "b", "B",
    ] {
        if let Some(rest) = raw.strip_prefix(prefix) {
            return unquote_starlark_string(rest);
        }
    }

    let bytes = raw.as_bytes();
    if bytes.len() < 2 {
        return None;
    }

    match (bytes.first(), bytes.last()) {
        (Some(b'"'), Some(b'"')) | (Some(b'\''), Some(b'\'')) => Some(&raw[1..raw.len() - 1]),
        _ => None,
    }
}