daml-lint 0.3.8

Static analysis scanner for Daml smart contracts
Documentation
use daml_lint::detector::{ConfiguredDetector, Detector, Severity};
use daml_lint::detectors;
use serde::Deserialize;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

#[derive(Debug)]
pub struct LintConfig {
    base_dir: PathBuf,
    plugin_paths: Vec<PathBuf>,
    plugins: Vec<String>,
    rules: BTreeMap<String, RuleSetting>,
}

impl LintConfig {
    pub fn load(explicit_path: Option<&Path>) -> Result<Self, String> {
        let Some(path) = find_config_path(explicit_path)? else {
            return Self::default_for_cwd();
        };

        let source = std::fs::read_to_string(&path)
            .map_err(|e| format!("could not read config {}: {}", path.display(), e))?;
        let raw: RawConfig = serde_json::from_str(&source)
            .map_err(|e| format!("invalid config {}: {}", path.display(), e))?;
        let base_dir = path
            .parent()
            .unwrap_or_else(|| Path::new("."))
            .to_path_buf();
        let plugin_paths = raw
            .plugin_paths
            .into_iter()
            .map(|path| resolve_config_path(&base_dir, path))
            .collect();

        Ok(Self {
            base_dir,
            plugin_paths,
            plugins: raw.plugins,
            rules: raw.rules,
        })
    }

    pub fn load_plugin_detectors(&self) -> Result<Vec<Box<dyn Detector>>, String> {
        let mut detectors = Vec::new();
        for plugin in &self.plugins {
            let package_dir = self.resolve_plugin_package(plugin)?;
            let manifest = read_plugin_manifest(plugin, &package_dir)?;
            let namespace = plugin_namespace(plugin);

            for (rule_name, rule_id, setting) in self.enabled_rules_for_namespace(&namespace) {
                let Some(rule_path) = manifest.rules.get(rule_name) else {
                    return Err(format!(
                        "plugin '{}' does not declare rule '{}' in {}",
                        plugin,
                        rule_name,
                        package_dir.join("package.json").display()
                    ));
                };
                let script_path = package_dir.join(rule_path);
                let detector =
                    detectors::script::load_script_with_options(&script_path, &setting.options)?;
                if detector.name() != rule_name {
                    return Err(format!(
                        "plugin '{}' declares rule '{}' but {} defines NAME '{}'",
                        plugin,
                        rule_name,
                        script_path.display(),
                        detector.name()
                    ));
                }
                detectors.push(Box::new(ConfiguredDetector::new(
                    detector,
                    Some(rule_id.to_string()),
                    None,
                )) as Box<dyn Detector>);
            }
        }
        Ok(detectors)
    }

    pub fn apply_rule_settings(&self, detectors: Vec<Box<dyn Detector>>) -> Vec<Box<dyn Detector>> {
        detectors
            .into_iter()
            .filter_map(|detector| {
                let setting = self.rules.get(detector.name());
                if setting.is_some_and(|setting| !setting.enabled) {
                    return None;
                }
                let severity = setting.and_then(|setting| setting.severity);
                if severity.is_some() {
                    Some(Box::new(ConfiguredDetector::new(detector, None, severity))
                        as Box<dyn Detector>)
                } else {
                    Some(detector)
                }
            })
            .collect()
    }

    pub fn validate_rule_settings(&self, detectors: &[Box<dyn Detector>]) -> Result<(), String> {
        let detector_names: BTreeSet<&str> =
            detectors.iter().map(|detector| detector.name()).collect();
        for (rule_id, setting) in &self.rules {
            if setting.enabled && !detector_names.contains(rule_id.as_str()) {
                return Err(format!("configures unknown rule '{}'", rule_id));
            }
        }
        Ok(())
    }

    fn default_for_cwd() -> Result<Self, String> {
        Ok(Self {
            base_dir: std::env::current_dir()
                .map_err(|e| format!("could not resolve current directory: {}", e))?,
            plugin_paths: Vec::new(),
            plugins: Vec::new(),
            rules: BTreeMap::new(),
        })
    }

    fn enabled_rules_for_namespace(
        &self,
        namespace: &str,
    ) -> impl Iterator<Item = (&str, &str, &RuleSetting)> {
        let prefix = format!("{namespace}/");
        self.rules.iter().filter_map(move |(rule_id, setting)| {
            if !setting.enabled {
                return None;
            }
            rule_id
                .strip_prefix(&prefix)
                .map(|rule_name| (rule_name, rule_id.as_str(), setting))
        })
    }

    fn resolve_plugin_package(&self, plugin: &str) -> Result<PathBuf, String> {
        let candidates = package_candidates(plugin);
        let mut tried = Vec::new();

        for package_name in &candidates {
            for package_dir in self.package_dirs(package_name) {
                tried.push(package_dir.clone());
                if package_dir.join("package.json").is_file() {
                    return Ok(package_dir);
                }
            }
        }

        let tried = tried
            .iter()
            .map(|path| path.display().to_string())
            .collect::<Vec<_>>()
            .join(", ");
        Err(format!(
            "could not resolve plugin '{}'. Tried: {}",
            plugin, tried
        ))
    }

    fn package_dirs(&self, package_name: &str) -> Vec<PathBuf> {
        let mut dirs = vec![self.base_dir.join("node_modules").join(package_name)];
        for plugin_path in &self.plugin_paths {
            dirs.push(plugin_path.clone());
            dirs.push(plugin_path.join(package_name));
            dirs.push(plugin_path.join("node_modules").join(package_name));
        }
        dirs
    }
}

#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawConfig {
    #[serde(default)]
    plugin_paths: Vec<PathBuf>,
    #[serde(default)]
    plugins: Vec<String>,
    #[serde(default)]
    rules: BTreeMap<String, RuleSetting>,
}

#[derive(Debug, Clone)]
struct RuleSetting {
    enabled: bool,
    severity: Option<Severity>,
    options: Value,
}

impl RuleSetting {
    fn from_value(value: Value) -> Result<Self, String> {
        match value {
            Value::Array(items) => Self::from_array(items),
            level => {
                Self::from_level_value(&level).map(|level| Self::from_level(level, empty_options()))
            }
        }
    }

    fn from_array(items: Vec<Value>) -> Result<Self, String> {
        let Some((level_value, option_values)) = items.split_first() else {
            return Err("rule setting array must include a severity or 'off'".to_string());
        };
        let level = Self::from_level_value(level_value)?;
        let options = match option_values {
            [] => empty_options(),
            [single] => single.clone(),
            many => Value::Array(many.to_vec()),
        };
        Ok(Self::from_level(level, options))
    }

    const fn from_level(level: RuleLevel, options: Value) -> Self {
        match level {
            RuleLevel::Off => Self {
                enabled: false,
                severity: None,
                options,
            },
            RuleLevel::Severity(severity) => Self {
                enabled: true,
                severity: Some(severity),
                options,
            },
        }
    }

    fn from_level_value(value: &Value) -> Result<RuleLevel, String> {
        match value {
            Value::String(level) => parse_level_string(level),
            Value::Number(level) => match level.as_u64() {
                Some(0) => Ok(RuleLevel::Off),
                Some(1) => Ok(RuleLevel::Severity(Severity::Medium)),
                Some(2) => Ok(RuleLevel::Severity(Severity::High)),
                _ => Err("numeric rule severity must be 0, 1, or 2".to_string()),
            },
            _ => Err("rule setting must be a severity, 'off', or an array".to_string()),
        }
    }
}

impl<'de> Deserialize<'de> for RuleSetting {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let value = Value::deserialize(deserializer)?;
        Self::from_value(value).map_err(serde::de::Error::custom)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuleLevel {
    Off,
    Severity(Severity),
}

#[derive(Debug, Deserialize)]
struct PackageJson {
    #[serde(rename = "damlLint")]
    daml_lint: Option<PluginManifest>,
}

#[derive(Debug, Deserialize)]
struct PluginManifest {
    rules: BTreeMap<String, PathBuf>,
}

fn find_config_path(explicit_path: Option<&Path>) -> Result<Option<PathBuf>, String> {
    if let Some(path) = explicit_path {
        return Ok(Some(path.to_path_buf()));
    }

    let path = std::env::current_dir()
        .map_err(|e| format!("could not resolve current directory: {}", e))?
        .join(".daml-lint.json");
    Ok(path.is_file().then_some(path))
}

fn resolve_config_path(base_dir: &Path, path: PathBuf) -> PathBuf {
    if path.is_absolute() {
        path
    } else {
        base_dir.join(path)
    }
}

fn read_plugin_manifest(plugin: &str, package_dir: &Path) -> Result<PluginManifest, String> {
    let package_json_path = package_dir.join("package.json");
    let source = std::fs::read_to_string(&package_json_path)
        .map_err(|e| format!("could not read {}: {}", package_json_path.display(), e))?;
    let package_json: PackageJson = serde_json::from_str(&source)
        .map_err(|e| format!("invalid {}: {}", package_json_path.display(), e))?;
    package_json.daml_lint.ok_or_else(|| {
        format!(
            "plugin '{}' package {} is missing damlLint.rules",
            plugin,
            package_json_path.display()
        )
    })
}

fn package_candidates(plugin: &str) -> Vec<String> {
    if let Some((scope, package)) = plugin.split_once('/') {
        if package.starts_with("daml-lint-plugin-") {
            vec![plugin.to_string()]
        } else {
            vec![
                format!("{scope}/daml-lint-plugin-{package}"),
                plugin.to_string(),
            ]
        }
    } else if plugin.starts_with("daml-lint-plugin-") {
        vec![plugin.to_string()]
    } else {
        vec![format!("daml-lint-plugin-{plugin}"), plugin.to_string()]
    }
}

fn plugin_namespace(plugin: &str) -> String {
    if let Some((scope, package)) = plugin.split_once('/') {
        format!("{scope}/{}", strip_plugin_prefix(package))
    } else {
        strip_plugin_prefix(plugin).to_string()
    }
}

fn strip_plugin_prefix(package: &str) -> &str {
    package.strip_prefix("daml-lint-plugin-").unwrap_or(package)
}

fn parse_level_string(level: &str) -> Result<RuleLevel, String> {
    match level.to_lowercase().as_str() {
        "off" => Ok(RuleLevel::Off),
        "warn" | "warning" => Ok(RuleLevel::Severity(Severity::Medium)),
        "error" => Ok(RuleLevel::Severity(Severity::High)),
        "critical" => Ok(RuleLevel::Severity(Severity::Critical)),
        "high" => Ok(RuleLevel::Severity(Severity::High)),
        "medium" => Ok(RuleLevel::Severity(Severity::Medium)),
        "low" => Ok(RuleLevel::Severity(Severity::Low)),
        "info" => Ok(RuleLevel::Severity(Severity::Info)),
        _ => Err(format!(
            "unknown rule severity '{}'. Use off, critical, high, medium, low, info, warn, or error.",
            level
        )),
    }
}

fn empty_options() -> Value {
    Value::Object(serde_json::Map::new())
}