helm-schema-ir 0.0.4

Generate an accurate JSON schema for any helm chart
Documentation
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};

use helm_schema_ast::{DefineIndex, TemplateExpr};

use crate::abstract_value::AbstractValue;
use crate::analysis_db::IrAnalysisDb;
use crate::fragment_assignment::{
    AssignmentKind, ParsedHelperAssignment, apply_local_set_mutations_from_exprs,
    parse_helper_assignment_from_exprs,
};
use crate::fragment_expr_eval::FragmentEvalContext;
use helm_schema_ast::parse_expr_text;
use test_util::prelude::sim_assert_eq;

#[derive(Clone, Debug, PartialEq)]
struct ParsedHelperAssignmentWithRhs {
    variable: String,
    kind: AssignmentKind,
    rhs: String,
    rhs_expr: TemplateExpr,
}

fn strip_template_action_wrapping(line: &str) -> Option<String> {
    let after_open = line.trim_start().strip_prefix("{{")?;
    let close_at = after_open.find("}}")?;
    let body = &after_open[..close_at];
    let body = body.strip_prefix('-').unwrap_or(body);
    let body = body.strip_suffix('-').unwrap_or(body);
    Some(body.trim().to_string())
}

fn assignment_rhs_text(text: &str, kind: AssignmentKind) -> Option<String> {
    let owned;
    let trimmed = if text.trim_start().starts_with("{{") {
        owned = strip_template_action_wrapping(text)?;
        owned.trim()
    } else {
        text.trim()
    };
    let (operator, operator_len) = match kind {
        AssignmentKind::Declaration => (":=", 2usize),
        AssignmentKind::Assignment => ("=", 1usize),
    };
    let index = trimmed.find(operator)?;
    Some(trimmed[index + operator_len..].trim().to_string())
}

fn parse_helper_assignment(text: &str) -> Option<ParsedHelperAssignmentWithRhs> {
    let ParsedHelperAssignment {
        variable,
        kind,
        rhs_expr,
    } = parse_helper_assignment_from_exprs(&parse_expr_text(text))?;
    Some(ParsedHelperAssignmentWithRhs {
        variable,
        kind,
        rhs: assignment_rhs_text(text, kind)?,
        rhs_expr,
    })
}

fn apply_local_set_mutations(
    text: &str,
    local_bindings: &mut HashMap<String, AbstractValue>,
    current_dot: Option<&AbstractValue>,
    context: FragmentEvalContext<'_>,
    seen: &mut HashSet<String>,
) -> bool {
    apply_local_set_mutations_from_exprs(
        &parse_expr_text(text),
        local_bindings,
        current_dot,
        context,
        seen,
    )
}

fn empty_context(analysis_db: &IrAnalysisDb) -> FragmentEvalContext<'_> {
    FragmentEvalContext::new(analysis_db)
}

#[test]
fn parse_helper_assignment_detects_declaration_from_ast() {
    let Some(assignment) = parse_helper_assignment(r"{{- $image := .Values.image.repository -}}")
    else {
        panic!("parse helper assignment");
    };

    sim_assert_eq!(have: assignment.variable, want: "image");
    sim_assert_eq!(have: assignment.kind, want: AssignmentKind::Declaration);
    sim_assert_eq!(have: assignment.rhs, want: ".Values.image.repository");
    sim_assert_eq!(
        have: assignment.rhs_expr,
        want: TemplateExpr::Field(vec![
            "Values".to_string(),
            "image".to_string(),
            "repository".to_string()
        ])
    );
}

#[test]
fn parse_helper_assignment_detects_assignment_from_ast() {
    let Some(assignment) = parse_helper_assignment(r"{{- $image = .Values.global.image -}}") else {
        panic!("parse helper assignment");
    };

    sim_assert_eq!(have: assignment.variable, want: "image");
    sim_assert_eq!(have: assignment.kind, want: AssignmentKind::Assignment);
    sim_assert_eq!(have: assignment.rhs, want: ".Values.global.image");
    sim_assert_eq!(
        have: assignment.rhs_expr,
        want: TemplateExpr::Field(vec![
            "Values".to_string(),
            "global".to_string(),
            "image".to_string()
        ])
    );
}

#[test]
fn local_set_mutation_uses_shared_expression_eval_for_computed_key() {
    let mut locals = HashMap::from([(
        "config".to_string(),
        AbstractValue::Dict(BTreeMap::from([
            (
                "name".to_string(),
                AbstractValue::ValuesPath("serviceAccount.name".to_string()),
            ),
            (
                "annotations".to_string(),
                AbstractValue::ValuesPath("serviceAccount.annotations".to_string()),
            ),
        ])),
    )]);
    let defines = DefineIndex::new();
    let analysis_db = IrAnalysisDb::new(&defines);
    let context = empty_context(&analysis_db);
    let mut seen = HashSet::new();

    assert!(apply_local_set_mutations(
        r#"{{- $_ := set $config (printf "%s" "name") "generated" -}}"#,
        &mut locals,
        None,
        context,
        &mut seen,
    ));

    sim_assert_eq!(
        have: locals.get("config"),
        want: Some(&AbstractValue::Overlay {
            entries: BTreeMap::from([(
                "name".to_string(),
                AbstractValue::StringSet(BTreeSet::from(["generated".to_string()])),
            )]),
            fallback: Box::new(AbstractValue::Dict(BTreeMap::from([
                (
                    "name".to_string(),
                    AbstractValue::ValuesPath("serviceAccount.name".to_string()),
                ),
                (
                    "annotations".to_string(),
                    AbstractValue::ValuesPath("serviceAccount.annotations".to_string()),
                ),
            ]))),
        })
    );
}

#[test]
fn local_set_mutation_resolves_computed_key_from_literal_local() {
    let mut locals = HashMap::from([
        (
            "patch".to_string(),
            AbstractValue::JsonDecodedPath("patch.*".to_string()),
        ),
        (
            "opPathKey".to_string(),
            AbstractValue::StringSet(BTreeSet::from(["path".to_string()])),
        ),
    ]);
    let defines = DefineIndex::new();
    let analysis_db = IrAnalysisDb::new(&defines);
    let context = empty_context(&analysis_db);
    let mut seen = HashSet::new();

    assert!(apply_local_set_mutations(
        r#"{{- $_ := set $patch (printf "%sKey" $opPathKey) "derived" -}}"#,
        &mut locals,
        None,
        context,
        &mut seen,
    ));

    sim_assert_eq!(
        have: locals
            .get("patch")
            .and_then(|patch| patch.apply_to_path(&["pathKey".to_string()])),
        want: Some(AbstractValue::StringSet(BTreeSet::from([
            "derived".to_string()
        ])))
    );
}