fallow-cli 3.4.2

CLI for fallow, codebase intelligence for TypeScript and JavaScript
Documentation
use super::MigrationWarning;

const STYLELINT_SELECTOR_RULES: &[&str] = &[
    "declaration-no-important",
    "max-nesting-depth",
    "no-descending-specificity",
    "selector-max-attribute",
    "selector-max-class",
    "selector-max-combinators",
    "selector-max-compound-selectors",
    "selector-max-id",
    "selector-max-specificity",
    "selector-max-type",
    "selector-max-universal",
];

const UNSUPPORTED_STYLELINT_RULES: &[(&str, &str)] = &[
    ("alpha-value-notation", "value notation stays in Stylelint"),
    (
        "at-rule-no-vendor-prefix",
        "vendor prefix policy stays in Stylelint",
    ),
    (
        "color-function-notation",
        "color formatting stays in Stylelint",
    ),
    ("color-hex-case", "color formatting stays in Stylelint"),
    ("color-hex-length", "color formatting stays in Stylelint"),
    (
        "custom-property-pattern",
        "naming conventions stay in Stylelint",
    ),
    (
        "declaration-block-no-duplicate-properties",
        "syntax linting stays in Stylelint",
    ),
    (
        "declaration-block-single-line-max-declarations",
        "formatting stays in Stylelint",
    ),
    (
        "declaration-empty-line-before",
        "formatting stays in Stylelint",
    ),
    ("font-family-name-quotes", "formatting stays in Stylelint"),
    ("function-name-case", "formatting stays in Stylelint"),
    ("length-zero-no-unit", "formatting stays in Stylelint"),
    ("no-empty-source", "syntax linting stays in Stylelint"),
    ("order/order", "declaration ordering stays in Stylelint"),
    (
        "order/properties-order",
        "declaration ordering stays in Stylelint",
    ),
    (
        "property-no-vendor-prefix",
        "vendor prefix policy stays in Stylelint",
    ),
    ("rule-empty-line-before", "formatting stays in Stylelint"),
    (
        "selector-class-pattern",
        "naming conventions stay in Stylelint",
    ),
    (
        "selector-id-pattern",
        "naming conventions stay in Stylelint",
    ),
    (
        "selector-no-vendor-prefix",
        "vendor prefix policy stays in Stylelint",
    ),
    ("string-quotes", "formatting stays in Stylelint"),
    (
        "value-no-vendor-prefix",
        "vendor prefix policy stays in Stylelint",
    ),
];

pub(super) fn migrate_stylelint(
    stylelint: &serde_json::Value,
    config: &mut serde_json::Map<String, serde_json::Value>,
    warnings: &mut Vec<MigrationWarning>,
) {
    let Some(obj) = stylelint.as_object() else {
        warnings.push(MigrationWarning {
            source: "stylelint",
            field: "(root)".to_string(),
            message: "expected an object, got something else".to_string(),
            suggestion: None,
        });
        return;
    };

    let Some(rules) = obj.get("rules").and_then(serde_json::Value::as_object) else {
        warnings.push(MigrationWarning {
            source: "stylelint",
            field: "rules".to_string(),
            message: "no Stylelint rules object found to map".to_string(),
            suggestion: Some("keep Stylelint for formatting and syntax linting".to_string()),
        });
        return;
    };

    if rules
        .iter()
        .any(|(rule, value)| STYLELINT_SELECTOR_RULES.contains(&rule.as_str()) && is_enabled(value))
    {
        let rule_map = ensure_object(config, "rules");
        rule_map
            .entry("css-selector-complexity".to_string())
            .or_insert_with(|| serde_json::Value::String("warn".to_string()));
        let audit_map = ensure_object(config, "audit");
        audit_map
            .entry("css".to_string())
            .or_insert(serde_json::Value::Bool(true));
        audit_map
            .entry("cssDeep".to_string())
            .or_insert(serde_json::Value::Bool(true));
    }

    push_unsupported_stylelint_warnings(rules, warnings);
}

fn ensure_object<'a>(
    config: &'a mut serde_json::Map<String, serde_json::Value>,
    key: &str,
) -> &'a mut serde_json::Map<String, serde_json::Value> {
    let entry = config
        .entry(key.to_string())
        .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
    if !entry.is_object() {
        *entry = serde_json::Value::Object(serde_json::Map::new());
    }
    match entry {
        serde_json::Value::Object(obj) => obj,
        _ => unreachable!("entry was just made object"),
    }
}

fn is_enabled(value: &serde_json::Value) -> bool {
    match value {
        serde_json::Value::Null => false,
        serde_json::Value::Bool(enabled) => *enabled,
        serde_json::Value::Array(items) => items.first().is_some_and(is_enabled),
        _ => true,
    }
}

fn push_unsupported_stylelint_warnings(
    rules: &serde_json::Map<String, serde_json::Value>,
    warnings: &mut Vec<MigrationWarning>,
) {
    for (rule, message) in UNSUPPORTED_STYLELINT_RULES {
        if rules.get(*rule).is_some_and(is_enabled) {
            warnings.push(MigrationWarning {
                source: "stylelint",
                field: (*rule).to_string(),
                message: (*message).to_string(),
                suggestion: Some("leave this rule in Stylelint".to_string()),
            });
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn empty_config() -> serde_json::Map<String, serde_json::Value> {
        serde_json::Map::new()
    }

    #[test]
    fn migrate_stylelint_selector_rules_enable_styling_audit() {
        let stylelint: serde_json::Value = serde_json::from_str(
            r#"{"rules":{"selector-max-id":0,"max-nesting-depth":[3],"declaration-no-important":true}}"#,
        )
        .unwrap();
        let mut config = empty_config();
        let mut warnings = Vec::new();

        migrate_stylelint(&stylelint, &mut config, &mut warnings);

        assert_eq!(
            config["rules"]["css-selector-complexity"],
            serde_json::json!("warn")
        );
        assert_eq!(config["audit"]["css"], serde_json::json!(true));
        assert_eq!(config["audit"]["cssDeep"], serde_json::json!(true));
        assert!(warnings.is_empty());
    }

    #[test]
    fn migrate_stylelint_unsupported_rules_warn_without_mapping() {
        let stylelint: serde_json::Value = serde_json::from_str(
            r#"{"rules":{"color-hex-case":"lower","selector-class-pattern":"^[a-z]+$"}}"#,
        )
        .unwrap();
        let mut config = empty_config();
        let mut warnings = Vec::new();

        migrate_stylelint(&stylelint, &mut config, &mut warnings);

        assert!(config.is_empty());
        assert_eq!(warnings.len(), 2);
        assert!(
            warnings
                .iter()
                .any(|warning| warning.field == "color-hex-case")
        );
        assert!(
            warnings
                .iter()
                .any(|warning| warning.field == "selector-class-pattern")
        );
    }

    #[test]
    fn migrate_stylelint_disabled_rule_is_ignored() {
        let stylelint: serde_json::Value =
            serde_json::from_str(r#"{"rules":{"selector-max-id":null}}"#).unwrap();
        let mut config = empty_config();
        let mut warnings = Vec::new();

        migrate_stylelint(&stylelint, &mut config, &mut warnings);

        assert!(config.is_empty());
        assert!(warnings.is_empty());
    }
}