thread-rule-engine 0.1.0

Rule-based scanning and transformation engine for Thread
Documentation
// SPDX-FileCopyrightText: 2022 Herrington Darkholme <2883231+HerringtonDarkholme@users.noreply.github.com>
// SPDX-FileCopyrightText: 2025 Knitli Inc. <knitli@knit.li>
// SPDX-FileContributor: Adam Poulemanos <adam@knit.li>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT

use crate::DeserializeEnv;
use crate::maybe::Maybe;
use crate::rule::{Relation, Rule, RuleSerializeError, StopBy};
use crate::transform::Transformation;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use thread_ast_engine::replacer::{Content, Replacer, TemplateFix, TemplateFixError};
use thread_ast_engine::{Doc, Language, Matcher, NodeMatch};

use std::ops::Range;
use thread_utilities::{RapidMap, RapidSet};

/// A pattern string or fix object to auto fix the issue.
/// It can reference metavariables appeared in rule.
#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)]
#[serde(untagged)]
pub enum SerializableFixer {
    Str(String),
    Config(Box<SerializableFixConfig>),
    List(Vec<SerializableFixConfig>),
}

#[derive(Serialize, Deserialize, Clone, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SerializableFixConfig {
    template: String,
    #[serde(default, skip_serializing_if = "Maybe::is_absent")]
    expand_end: Maybe<Relation>,
    #[serde(default, skip_serializing_if = "Maybe::is_absent")]
    expand_start: Maybe<Relation>,
    #[serde(skip_serializing_if = "Option::is_none")]
    title: Option<String>,
}

#[derive(Error, Debug)]
pub enum FixerError {
    #[error("Fixer template is invalid.")]
    InvalidTemplate(#[from] TemplateFixError),
    #[error("Fixer expansion contains invalid rule.")]
    WrongExpansion(#[from] RuleSerializeError),
    #[error("Rewriter must have exactly one fixer.")]
    InvalidRewriter,
    #[error("Fixer in list must have title.")]
    MissingTitle,
}

#[derive(Clone, Debug)]
struct Expansion {
    matches: Rule,
    stop_by: StopBy,
}

impl Expansion {
    fn parse<L: Language>(
        relation: &Maybe<Relation>,
        env: &DeserializeEnv<L>,
    ) -> Result<Option<Self>, FixerError> {
        let inner = match relation {
            Maybe::Absent => return Ok(None),
            Maybe::Present(r) => r.clone(),
        };
        let stop_by = StopBy::try_from(inner.stop_by, env)?;
        let matches = env.deserialize_rule(inner.rule)?;
        Ok(Some(Self { matches, stop_by }))
    }
}

#[derive(Clone, Debug)]
pub struct Fixer {
    template: TemplateFix,
    expand_start: Option<Expansion>,
    expand_end: Option<Expansion>,
    title: Option<String>,
}

impl Fixer {
    fn do_parse<L: Language>(
        serialized: &SerializableFixConfig,
        env: &DeserializeEnv<L>,
        transform: &Option<RapidMap<String, Transformation>>,
    ) -> Result<Self, FixerError> {
        let SerializableFixConfig {
            template: fix,
            expand_end,
            expand_start,
            title,
        } = serialized;
        let expand_start = Expansion::parse(expand_start, env)?;
        let expand_end = Expansion::parse(expand_end, env)?;
        let template = if let Some(trans) = transform {
            let keys: Vec<std::sync::Arc<str>> = trans
                .keys()
                .map(|k| std::sync::Arc::from(k.as_str()))
                .collect();
            TemplateFix::with_transform(fix, &env.lang, &keys)
        } else {
            TemplateFix::try_new(fix, &env.lang)?
        };
        Ok(Self {
            template,
            expand_start,
            expand_end,
            title: title.clone(),
        })
    }

    pub fn parse<L: Language>(
        fixer: &SerializableFixer,
        env: &DeserializeEnv<L>,
        transform: &Option<RapidMap<String, Transformation>>,
    ) -> Result<Vec<Self>, FixerError> {
        let ret = match fixer {
            SerializableFixer::Str(fix) => Self::with_transform(fix, env, transform),
            SerializableFixer::Config(cfg) => Self::do_parse(cfg, env, transform),
            SerializableFixer::List(list) => {
                return Self::parse_list(list, env, transform);
            }
        };
        Ok(vec![ret?])
    }

    fn parse_list<L: Language>(
        list: &[SerializableFixConfig],
        env: &DeserializeEnv<L>,
        transform: &Option<RapidMap<String, Transformation>>,
    ) -> Result<Vec<Self>, FixerError> {
        list.iter()
            .map(|cfg| {
                if cfg.title.is_none() {
                    return Err(FixerError::MissingTitle);
                }
                Self::do_parse(cfg, env, transform)
            })
            .collect()
    }

    pub(crate) fn with_transform<L: Language>(
        fix: &str,
        env: &DeserializeEnv<L>,
        transform: &Option<RapidMap<String, Transformation>>,
    ) -> Result<Self, FixerError> {
        let template = if let Some(trans) = transform {
            let keys: Vec<std::sync::Arc<str>> = trans
                .keys()
                .map(|k| std::sync::Arc::from(k.as_str()))
                .collect();
            TemplateFix::with_transform(fix, &env.lang, &keys)
        } else {
            TemplateFix::try_new(fix, &env.lang)?
        };
        Ok(Self {
            template,
            expand_end: None,
            expand_start: None,
            title: None,
        })
    }

    pub fn from_str<L: Language>(src: &str, lang: &L) -> Result<Self, FixerError> {
        let template = TemplateFix::try_new(src, lang)?;
        Ok(Self {
            template,
            expand_start: None,
            expand_end: None,
            title: None,
        })
    }

    pub fn title(&self) -> Option<&str> {
        self.title.as_deref()
    }

    pub(crate) fn used_vars(&self) -> RapidSet<&str> {
        self.template.used_vars()
    }
}

impl<D, C> Replacer<D> for Fixer
where
    D: Doc<Source = C>,
    C: Content,
{
    fn generate_replacement(&self, nm: &NodeMatch<'_, D>) -> Vec<C::Underlying> {
        // simple forwarding to template
        self.template.generate_replacement(nm)
    }
    fn get_replaced_range(&self, nm: &NodeMatch<'_, D>, matcher: impl Matcher) -> Range<usize> {
        let range = nm.range();
        if self.expand_start.is_none() && self.expand_end.is_none() {
            return if let Some(len) = matcher.get_match_len(nm.get_node().clone()) {
                range.start..range.start + len
            } else {
                range
            };
        }
        let start = expand_start(self.expand_start.as_ref(), nm);
        let end = expand_end(self.expand_end.as_ref(), nm);
        start..end
    }
}

fn expand_start<D: Doc>(expansion: Option<&Expansion>, nm: &NodeMatch<'_, D>) -> usize {
    let node = nm.get_node();
    let mut env = std::borrow::Cow::Borrowed(nm.get_env());
    let Some(start) = expansion else {
        return node.range().start;
    };
    let node = start.stop_by.find(
        || node.prev(),
        || node.prev_all(),
        |n| start.matches.match_node_with_env(n, &mut env),
    );
    node.map(|n| n.range().start)
        .unwrap_or_else(|| nm.range().start)
}

fn expand_end<D: Doc>(expansion: Option<&Expansion>, nm: &NodeMatch<'_, D>) -> usize {
    let node = nm.get_node();
    let mut env = std::borrow::Cow::Borrowed(nm.get_env());
    let Some(end) = expansion else {
        return node.range().end;
    };
    let node = end.stop_by.find(
        || node.next(),
        || node.next_all(),
        |n| end.matches.match_node_with_env(n, &mut env),
    );
    node.map(|n| n.range().end)
        .unwrap_or_else(|| nm.range().end)
}

#[cfg(test)]
mod test {
    use super::*;
    use crate::from_str;
    use crate::maybe::Maybe;
    use crate::test::TypeScript;
    use thread_ast_engine::tree_sitter::LanguageExt;

    #[test]
    fn test_parse() {
        let fixer: SerializableFixer = from_str("test").expect("should parse");
        assert!(matches!(fixer, SerializableFixer::Str(_)));
    }

    fn parse(config: SerializableFixConfig) -> Result<Fixer, FixerError> {
        let config = SerializableFixer::Config(Box::new(config));
        let env = DeserializeEnv::new(TypeScript::Tsx);
        let fixer = Fixer::parse(&config, &env, &Some(Default::default()))?.remove(0);
        Ok(fixer)
    }

    #[test]
    fn test_deserialize_object() -> Result<(), serde_yaml::Error> {
        let src = "{template: 'abc', expandEnd: {regex: ',', stopBy: neighbor}}";
        let SerializableFixer::Config(cfg) = from_str(src)? else {
            panic!("wrong parsing")
        };
        assert_eq!(cfg.template, "abc");
        let Maybe::Present(relation) = cfg.expand_end else {
            panic!("wrong parsing")
        };
        let rule = relation.rule;
        assert_eq!(rule.regex, Maybe::Present(",".to_string()));
        assert!(rule.pattern.is_absent());
        Ok(())
    }

    #[test]
    fn test_parse_config() -> Result<(), FixerError> {
        let relation = from_str("{regex: ',', stopBy: neighbor}").expect("should deser");
        let config = SerializableFixConfig {
            expand_end: Maybe::Present(relation),
            expand_start: Maybe::Absent,
            template: "abcd".to_string(),
            title: None,
        };
        let ret = parse(config)?;
        assert!(ret.expand_start.is_none());
        assert!(ret.expand_end.is_some());
        assert!(matches!(ret.template, TemplateFix::Textual(_)));
        Ok(())
    }

    #[test]
    fn test_parse_str() -> Result<(), FixerError> {
        let config = SerializableFixer::Str("abcd".to_string());
        let env = DeserializeEnv::new(TypeScript::Tsx);
        let ret = Fixer::parse(&config, &env, &None)?.remove(0);
        assert!(ret.expand_end.is_none());
        assert!(ret.expand_start.is_none());
        assert!(matches!(ret.template, TemplateFix::Textual(_)));
        Ok(())
    }

    #[test]
    fn test_replace_fixer() -> Result<(), FixerError> {
        let expand_end = from_str("{regex: ',', stopBy: neighbor}").expect("should word");
        let config = SerializableFixConfig {
            expand_end: Maybe::Present(expand_end),
            expand_start: Maybe::Absent,
            template: "var $A = 456".to_string(),
            title: None,
        };
        let fixer = parse(config)?;
        let grep = TypeScript::Tsx.ast_grep("let a = 123");
        let node = grep.root().find("let $A = 123").expect("should found");
        let edit = fixer.generate_replacement(&node);
        assert_eq!(String::from_utf8_lossy(&edit), "var a = 456");
        Ok(())
    }

    #[test]
    fn test_replace_range() -> Result<(), FixerError> {
        use thread_ast_engine::matcher::KindMatcher;
        let expand_end = from_str("{regex: ',', stopBy: neighbor}").expect("should word");
        let config = SerializableFixConfig {
            expand_end: Maybe::Present(expand_end),
            expand_start: Maybe::Absent,
            template: "c: 456".to_string(),
            title: None,
        };
        let fixer = parse(config)?;
        let grep = TypeScript::Tsx.ast_grep("var a = { b: 123, }");
        let matcher = KindMatcher::new("pair", &TypeScript::Tsx);
        let node = grep.root().find(&matcher).expect("should found");
        let edit = node.make_edit(&matcher, &fixer);
        let text = String::from_utf8_lossy(&edit.inserted_text);
        assert_eq!(text, "c: 456");
        assert_eq!(edit.position, 10);
        assert_eq!(edit.deleted_length, 7);
        Ok(())
    }

    #[test]
    fn test_fixer_list() -> Result<(), FixerError> {
        let config: SerializableFixer = from_str(
            r"
- { template: 'abc', title: 'fixer 1'}
- { template: 'def', title: 'fixer 2'}",
        )
        .expect("should parse");
        let env = DeserializeEnv::new(TypeScript::Tsx);
        let fixers = Fixer::parse(&config, &env, &Some(Default::default()))?;
        assert_eq!(fixers.len(), 2);
        let config: SerializableFixer = from_str(
            r"
- { template: 'abc', title: 'fixer 1'}
- { template: 'def'}",
        )
        .expect("should parse");
        let env = DeserializeEnv::new(TypeScript::Tsx);
        let ret = Fixer::parse(&config, &env, &Some(Default::default()));
        assert!(ret.is_err());
        Ok(())
    }
}