checkleft 0.1.0-alpha.8

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

use anyhow::{Result, bail};
use regex::Regex;

use crate::path::validate_relative_path;

static IFCHANGE_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"^LINT\.IfChange(?:\(([A-Za-z0-9_-]+)\))?$").expect("valid ifchange regex")
});
static THENCHANGE_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^LINT\.ThenChange\(([^)]+)\)$").expect("valid thenchange regex"));
static LABEL_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"^[A-Za-z0-9_-]+$").expect("valid label regex"));

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedIfChangeFile {
    pub path: PathBuf,
    pub blocks: Vec<ParsedIfChangeBlock>,
    labels: BTreeMap<String, usize>,
}

impl ParsedIfChangeFile {
    pub fn block_by_label(&self, label: &str) -> Option<&ParsedIfChangeBlock> {
        self.labels
            .get(label)
            .and_then(|index| self.blocks.get(*index))
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedIfChangeBlock {
    pub source_label: Option<String>,
    pub ifchange_line: usize,
    pub thenchange_line: usize,
    pub body_range: Option<LineRange>,
    pub target: ThenChangeTarget,
}

impl ParsedIfChangeBlock {
    pub fn full_range(&self) -> LineRange {
        LineRange {
            start: self.ifchange_line,
            end: self.thenchange_line,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineRange {
    pub start: usize,
    pub end: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ThenChangeTarget {
    File { path: PathBuf },
    Block { path: PathBuf, label: String },
}

pub fn parse_ifchange_file(path: &Path, contents: &str) -> Result<ParsedIfChangeFile> {
    validate_relative_path(path)?;

    let mut blocks = Vec::new();
    let mut labels = BTreeMap::new();
    let mut current: Option<OpenIfChangeBlock> = None;

    for (line_index, raw_line) in contents.lines().enumerate() {
        let line_number = line_index + 1;
        let Some(directive) = parse_directive(raw_line)? else {
            continue;
        };

        match directive {
            Directive::IfChange { label } => {
                if current.is_some() {
                    bail!(
                        "{}:{}: nested `LINT.IfChange` blocks are not supported",
                        path.display(),
                        line_number
                    );
                }
                if let Some(label) = label.as_ref() {
                    if labels.contains_key(label) {
                        bail!(
                            "{}:{}: duplicate `LINT.IfChange({label})` label",
                            path.display(),
                            line_number
                        );
                    }
                }
                current = Some(OpenIfChangeBlock {
                    source_label: label,
                    ifchange_line: line_number,
                });
            }
            Directive::ThenChange { target } => {
                let Some(open) = current.take() else {
                    bail!(
                        "{}:{}: `LINT.ThenChange(...)` must close a preceding `LINT.IfChange` block",
                        path.display(),
                        line_number
                    );
                };

                let body_range = if line_number > open.ifchange_line + 1 {
                    Some(LineRange {
                        start: open.ifchange_line + 1,
                        end: line_number - 1,
                    })
                } else {
                    None
                };

                let block_index = blocks.len();
                if let Some(label) = open.source_label.as_ref() {
                    labels.insert(label.clone(), block_index);
                }
                blocks.push(ParsedIfChangeBlock {
                    source_label: open.source_label,
                    ifchange_line: open.ifchange_line,
                    thenchange_line: line_number,
                    body_range,
                    target,
                });
            }
        }
    }

    if let Some(open) = current {
        bail!(
            "{}:{}: `LINT.IfChange` block is missing a closing `LINT.ThenChange(...)`",
            path.display(),
            open.ifchange_line
        );
    }

    Ok(ParsedIfChangeFile {
        path: path.to_path_buf(),
        blocks,
        labels,
    })
}

#[derive(Debug)]
struct OpenIfChangeBlock {
    source_label: Option<String>,
    ifchange_line: usize,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum Directive {
    IfChange { label: Option<String> },
    ThenChange { target: ThenChangeTarget },
}

fn parse_directive(line: &str) -> Result<Option<Directive>> {
    let text = normalize_directive_text(line);
    if let Some(captures) = IFCHANGE_RE.captures(text) {
        let label = captures.get(1).map(|value| value.as_str().to_owned());
        return Ok(Some(Directive::IfChange { label }));
    }

    let Some(captures) = THENCHANGE_RE.captures(text) else {
        return Ok(None);
    };
    let target = parse_thenchange_target(captures.get(1).expect("target").as_str())?;
    Ok(Some(Directive::ThenChange { target }))
}

fn normalize_directive_text(line: &str) -> &str {
    let mut text = line.trim();

    for prefix in ["//", "#", "--", ";", "/*", "*", "<!--"] {
        if let Some(stripped) = text.strip_prefix(prefix) {
            text = stripped.trim_start();
            break;
        }
    }

    for suffix in ["*/", "-->"] {
        if let Some(stripped) = text.strip_suffix(suffix) {
            text = stripped.trim_end();
        }
    }

    text
}

fn parse_thenchange_target(raw: &str) -> Result<ThenChangeTarget> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        bail!("`LINT.ThenChange(...)` target must not be empty");
    }

    let (path_text, label) = match trimmed.rsplit_once(':') {
        Some((path, label)) if LABEL_RE.is_match(label.trim()) => (path.trim(), Some(label.trim())),
        _ => (trimmed, None),
    };

    let path = PathBuf::from(path_text);
    validate_relative_path(&path)?;

    Ok(match label {
        Some(label) => ThenChangeTarget::Block {
            path,
            label: label.to_owned(),
        },
        None => ThenChangeTarget::File { path },
    })
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::{LineRange, ThenChangeTarget, parse_ifchange_file};

    #[test]
    fn parses_unlabeled_block_targeting_file() {
        let parsed = parse_ifchange_file(
            Path::new("backend/version.rs"),
            r#"
// LINT.IfChange
const VERSION: &str = "v1";
// LINT.ThenChange(tools/release/version.txt)
"#,
        )
        .expect("parse");

        assert_eq!(parsed.blocks.len(), 1);
        assert_eq!(parsed.blocks[0].source_label, None);
        assert_eq!(
            parsed.blocks[0].body_range,
            Some(LineRange { start: 3, end: 3 })
        );
        assert_eq!(
            parsed.blocks[0].target,
            ThenChangeTarget::File {
                path: Path::new("tools/release/version.txt").to_path_buf(),
            }
        );
    }

    #[test]
    fn parses_labeled_block_targeting_labeled_block() {
        let parsed = parse_ifchange_file(
            Path::new("backend/api/user.proto"),
            r#"
# LINT.IfChange(schema)
message User {}
# LINT.ThenChange(frontend/src/types.ts:user_schema)
"#,
        )
        .expect("parse");

        assert_eq!(parsed.blocks.len(), 1);
        assert_eq!(parsed.blocks[0].source_label.as_deref(), Some("schema"));
        assert_eq!(
            parsed.blocks[0].target,
            ThenChangeTarget::Block {
                path: Path::new("frontend/src/types.ts").to_path_buf(),
                label: "user_schema".to_owned(),
            }
        );
        assert!(parsed.block_by_label("schema").is_some());
    }

    #[test]
    fn ignores_prose_mentions_of_ifchange() {
        let parsed = parse_ifchange_file(
            Path::new("docs/guide.md"),
            "Use LINT.IfChange in comments, not in prose.\n",
        )
        .expect("parse");

        assert!(parsed.blocks.is_empty());
    }

    #[test]
    fn rejects_duplicate_labels() {
        let error = parse_ifchange_file(
            Path::new("docs/guide.md"),
            r#"
// LINT.IfChange(shared)
// LINT.ThenChange(other/file.md)
// LINT.IfChange(shared)
// LINT.ThenChange(other/file.md)
"#,
        )
        .expect_err("must fail");

        assert!(
            error
                .to_string()
                .contains("duplicate `LINT.IfChange(shared)` label")
        );
    }

    #[test]
    fn rejects_nested_ifchange_blocks() {
        let error = parse_ifchange_file(
            Path::new("docs/guide.md"),
            r#"
// LINT.IfChange
// LINT.IfChange
// LINT.ThenChange(other/file.md)
"#,
        )
        .expect_err("must fail");

        assert!(error.to_string().contains("nested `LINT.IfChange` blocks"));
    }

    #[test]
    fn rejects_missing_thenchange() {
        let error = parse_ifchange_file(
            Path::new("docs/guide.md"),
            r#"
// LINT.IfChange(orphan)
still open
"#,
        )
        .expect_err("must fail");

        assert!(
            error
                .to_string()
                .contains("missing a closing `LINT.ThenChange(...)`")
        );
    }

    #[test]
    fn rejects_thenchange_without_ifchange() {
        let error = parse_ifchange_file(
            Path::new("docs/guide.md"),
            "// LINT.ThenChange(other/file.md)\n",
        )
        .expect_err("must fail");

        assert!(
            error
                .to_string()
                .contains("must close a preceding `LINT.IfChange` block")
        );
    }

    #[test]
    fn rejects_invalid_thenchange_target() {
        let error = parse_ifchange_file(
            Path::new("docs/guide.md"),
            r#"
// LINT.IfChange
// LINT.ThenChange(../escape.md)
"#,
        )
        .expect_err("must fail");

        assert!(error.to_string().contains("path traversal is not allowed"));
    }
}