checkleft 0.1.0-alpha.8

Experimental repository convention checker; API and behavior may change without notice
Documentation
use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};

use anyhow::{Context, Result, bail};

use crate::input::SourceTree;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BazelrcEntryKind {
    Flag,
    Import,
    TryImport,
}

#[derive(Debug, Clone)]
pub(crate) struct BazelrcEntry {
    pub(crate) source_path: PathBuf,
    pub(crate) kind: BazelrcEntryKind,
    pub(crate) line: u32,
    pub(crate) column: u32,
    pub(crate) command: Option<String>,
    pub(crate) config_name: Option<String>,
    pub(crate) flag: Option<String>,
    pub(crate) value: Option<String>,
    pub(crate) import_path: Option<PathBuf>,
}

#[derive(Debug)]
pub(crate) struct ParsedBazelrcClosure {
    pub(crate) entries: Vec<BazelrcEntry>,
}

pub(crate) fn is_bazelrc_root_candidate(path: &Path) -> bool {
    let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
        return false;
    };
    name == ".bazelrc" || name.ends_with(".bazelrc")
}

pub(crate) fn parse_bazelrc_closure(
    path: &Path,
    tree: &dyn SourceTree,
) -> Result<ParsedBazelrcClosure> {
    let mut entries = Vec::new();
    let mut visited = HashSet::new();
    let mut pending = vec![path.to_path_buf()];

    while let Some(next_path) = pending.pop() {
        if !visited.insert(next_path.clone()) {
            continue;
        }
        let contents = tree
            .read_file(&next_path)
            .with_context(|| format!("failed to read bazelrc file {}", next_path.display()))?;
        let contents = String::from_utf8(contents)
            .with_context(|| format!("bazelrc file is not utf-8: {}", next_path.display()))?;

        let parsed_entries = parse_bazelrc_file(&next_path, &contents)?;
        for entry in &parsed_entries {
            if matches!(
                entry.kind,
                BazelrcEntryKind::Import | BazelrcEntryKind::TryImport
            ) {
                if let Some(import_path) = &entry.import_path {
                    if tree.exists(import_path) {
                        pending.push(import_path.clone());
                    }
                }
            }
        }
        entries.extend(parsed_entries);
    }

    Ok(ParsedBazelrcClosure { entries })
}

fn parse_bazelrc_file(path: &Path, contents: &str) -> Result<Vec<BazelrcEntry>> {
    let logical_lines = logical_bazelrc_lines(contents)?;
    let mut entries = Vec::new();
    for (line_number, line) in logical_lines {
        let trimmed = line.trim_start();
        if trimmed.is_empty() || trimmed.starts_with('#') {
            continue;
        }

        let tokens = shell_split(trimmed).with_context(|| {
            format!(
                "failed to tokenize bazelrc line {} in {}",
                line_number,
                path.display()
            )
        })?;
        if tokens.is_empty() {
            continue;
        }

        match tokens[0].as_str() {
            "import" | "try-import" => {
                if tokens.len() < 2 {
                    continue;
                }
                let import_path = resolve_import_path(path, &tokens[1]);
                entries.push(BazelrcEntry {
                    source_path: path.to_path_buf(),
                    kind: if tokens[0] == "import" {
                        BazelrcEntryKind::Import
                    } else {
                        BazelrcEntryKind::TryImport
                    },
                    line: line_number,
                    column: 1,
                    command: None,
                    config_name: None,
                    flag: None,
                    value: None,
                    import_path,
                });
            }
            command_token => {
                let Some((command, config_name)) = parse_command_token(command_token) else {
                    continue;
                };
                entries.extend(extract_flag_entries(
                    path,
                    line_number,
                    trimmed,
                    &tokens[1..],
                    command,
                    config_name,
                ));
            }
        }
    }

    Ok(entries)
}

fn logical_bazelrc_lines(contents: &str) -> Result<Vec<(u32, String)>> {
    let mut result = Vec::new();
    let mut current = String::new();
    let mut current_start_line = 1u32;

    for (index, line) in contents.lines().enumerate() {
        let line_number = (index + 1) as u32;
        if current.is_empty() {
            current_start_line = line_number;
        } else {
            current.push(' ');
        }

        let continued = line.ends_with('\\');
        if continued {
            current.push_str(line.trim_end_matches('\\'));
            continue;
        }

        current.push_str(line);
        result.push((current_start_line, current.clone()));
        current.clear();
    }

    if !current.is_empty() {
        bail!("unterminated bazelrc line continuation");
    }

    Ok(result)
}

fn shell_split(line: &str) -> Result<Vec<String>> {
    let mut tokens = Vec::new();
    let mut current = String::new();
    let mut chars = line.chars().peekable();
    let mut in_single = false;
    let mut in_double = false;

    while let Some(ch) = chars.next() {
        match ch {
            '\'' if !in_double => in_single = !in_single,
            '"' if !in_single => in_double = !in_double,
            '\\' if !in_single => {
                if let Some(next) = chars.next() {
                    current.push(next);
                }
            }
            ch if ch.is_whitespace() && !in_single && !in_double => {
                if !current.is_empty() {
                    tokens.push(std::mem::take(&mut current));
                }
            }
            other => current.push(other),
        }
    }

    if in_single || in_double {
        bail!("unterminated quoted string");
    }
    if !current.is_empty() {
        tokens.push(current);
    }

    Ok(tokens)
}

fn resolve_import_path(current_path: &Path, raw_import: &str) -> Option<PathBuf> {
    if let Some(rest) = raw_import.strip_prefix("%workspace%/") {
        return Some(PathBuf::from(rest));
    }

    let candidate = Path::new(raw_import);
    if candidate.is_absolute() {
        return None;
    }

    let parent = current_path.parent().unwrap_or(Path::new(""));
    Some(normalize_relative_path(&parent.join(candidate)))
}

fn normalize_relative_path(path: &Path) -> PathBuf {
    let mut normalized = PathBuf::new();
    for component in path.components() {
        match component {
            Component::CurDir => {}
            Component::ParentDir => {
                normalized.pop();
            }
            Component::Normal(part) => normalized.push(part),
            _ => {}
        }
    }
    normalized
}

fn parse_command_token(token: &str) -> Option<(String, Option<String>)> {
    let (command, config_name) = match token.split_once(':') {
        Some((command, config_name)) => (command, Some(config_name)),
        None => (token, None),
    };

    let command = command.trim().to_ascii_lowercase();
    if command.is_empty() {
        return None;
    }
    let config_name = config_name.and_then(|name| {
        let trimmed = name.trim();
        (!trimmed.is_empty()).then(|| trimmed.to_owned())
    });
    Some((command, config_name))
}

fn extract_flag_entries(
    path: &Path,
    line_number: u32,
    line: &str,
    tokens: &[String],
    command: String,
    config_name: Option<String>,
) -> Vec<BazelrcEntry> {
    let mut entries = Vec::new();
    let mut index = 0usize;

    while index < tokens.len() {
        let token = &tokens[index];
        if !token.starts_with('-') {
            index += 1;
            continue;
        }

        let (flag_name, inline_value) = match token.split_once('=') {
            Some((name, value)) => (name.trim_start_matches('-'), Some(value.to_owned())),
            None => (token.trim_start_matches('-'), None),
        };
        if flag_name.is_empty() {
            index += 1;
            continue;
        }

        let mut value = inline_value;
        if value.is_none() {
            if let Some(next) = tokens.get(index + 1) {
                if !next.starts_with('-') {
                    value = Some(next.clone());
                    index += 1;
                }
            }
        }

        let column = line.find(token).map(|col| (col + 1) as u32).unwrap_or(1);
        entries.push(BazelrcEntry {
            source_path: path.to_path_buf(),
            kind: BazelrcEntryKind::Flag,
            line: line_number,
            column,
            command: Some(command.clone()),
            config_name: config_name.clone(),
            flag: Some(flag_name.to_owned()),
            value,
            import_path: None,
        });
        index += 1;
    }

    entries
}