diff --git a/AGENTS.md b/AGENTS.md index 9aa7e483..562f0485 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,7 +81,8 @@ Fetch: `./scripts/fetch-profile-repos.sh` | **PHP** | Magento 2 | `example/magento2` | `-l php` | `RGCTL_MAGENTO2_REPO` | | **Python** | Home Assistant | `example/home-assistant` | `-l python` | `RGCTL_HOME_ASSISTANT_REPO` | | **Ruby** | Discourse | `example/discourse` | `-l ruby` | — | -| **Puppet** | *(deferred)* | `RGCTL_PUPPET_REPO` | `-l puppet` | `RGCTL_PUPPET_REPO` — no default ~10k corpus yet | +| **Puppet** | theforeman | `example/theforeman` | `-l puppet,erb,ruby -e spec,vendor` | `RGCTL_THEFOREMAN_REPO` — smoke corpus (~670 files, not O(10⁴)) | +| **ERB** | *(included in theforeman)* | `example/theforeman` | `-l erb` | *(same corpus — 122 .erb files, 2114 blocks)* | | **Rust** | rustc | `example/rust` | `-l rust` | `RGCTL_RUST_REPO` | | **TypeScript** | VS Code | `example/vscode` | `-l typescript` on `src/` | `RGCTL_VSCODE_REPO` | | **Kotlin** | JetBrains/kotlin | `example/kotlin` | `-l kotlin` (sparse `libraries` `plugins` `analysis`) | `RGCTL_KOTLIN_REPO` | diff --git a/Cargo.toml b/Cargo.toml index d95a5517..0505b638 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ members = [ "crates/rgctl-lang-php", "crates/rgctl-lang-ruby", "crates/rgctl-lang-puppet", + "crates/rgctl-lang-erb", "crates/rgctl-lang-kotlin", "crates/rgctl-lang-groovy", "crates/rgctl-ast-coverage", @@ -88,6 +89,7 @@ rgctl-lang-markdown = { path = "crates/rgctl-lang-markdown", version = "0.4.16" rgctl-lang-php = { path = "crates/rgctl-lang-php", version = "0.4.16" } rgctl-lang-ruby = { path = "crates/rgctl-lang-ruby", version = "0.4.16" } rgctl-lang-puppet = { path = "crates/rgctl-lang-puppet", version = "0.4.16" } +rgctl-lang-erb = { path = "crates/rgctl-lang-erb", version = "0.4.16" } rgctl-lang-kotlin = { path = "crates/rgctl-lang-kotlin", version = "0.4.16" } rgctl-lang-groovy = { path = "crates/rgctl-lang-groovy", version = "0.4.16" } rgctl-ast-coverage = { path = "crates/rgctl-ast-coverage", version = "0.4.16" } diff --git a/crates/rgctl-ast-coverage/Cargo.toml b/crates/rgctl-ast-coverage/Cargo.toml index 61be13a5..7a74ae23 100644 --- a/crates/rgctl-ast-coverage/Cargo.toml +++ b/crates/rgctl-ast-coverage/Cargo.toml @@ -22,6 +22,7 @@ tree-sitter-typescript = "0.23" tree-sitter-php = "0.24.2" tree-sitter-ruby = "0.23.1" tree-sitter-puppet = "1.3.0" +tree-sitter-embedded-template = "0.25.0" tree-sitter-kotlin-ng = "1.1.0" tree-sitter-groovy = "0.1.2" tree-sitter-md = { version = "0.5.3", default-features = false } diff --git a/crates/rgctl-ast-coverage/src/lib.rs b/crates/rgctl-ast-coverage/src/lib.rs index 473af7cc..805153f4 100644 --- a/crates/rgctl-ast-coverage/src/lib.rs +++ b/crates/rgctl-ast-coverage/src/lib.rs @@ -111,6 +111,13 @@ pub fn bundled_specs() -> &'static [CoverageSpec] { grammar_prefix: "tree-sitter-puppet@", language: || tree_sitter_puppet::LANGUAGE.into(), }, + CoverageSpec { + id: "erb", + crate_dir: "rgctl-lang-erb", + manifest_file: "erb-ast-coverage.json", + grammar_prefix: "tree-sitter-embedded-template@", + language: || tree_sitter_embedded_template::LANGUAGE.into(), + }, CoverageSpec { id: "python", crate_dir: "rgctl-lang-python", diff --git a/crates/rgctl-extraction/src/graph_builder.rs b/crates/rgctl-extraction/src/graph_builder.rs index a4e4e5f0..c8aac20c 100644 --- a/crates/rgctl-extraction/src/graph_builder.rs +++ b/crates/rgctl-extraction/src/graph_builder.rs @@ -1277,6 +1277,8 @@ fn relation_allows_external_stub(relation_type: RelationType) -> bool { | RelationType::Extends | RelationType::Implements | RelationType::Permits + | RelationType::UsesVariable + | RelationType::UsesFact ) } @@ -1290,6 +1292,10 @@ fn stub_node_type_for_target(relation: &Relation) -> NodeType { "function" | "method" => return NodeType::Function, "class" | "struct" => return NodeType::Class, "dependency" => return NodeType::Dependency, + "puppetvariable" => return NodeType::PuppetVariable, + "puppetfact" => return NodeType::PuppetFact, + "puppetresource" => return NodeType::PuppetResource, + "puppetclass" => return NodeType::PuppetClass, _ => {} } } diff --git a/crates/rgctl-lang-erb/Cargo.toml b/crates/rgctl-lang-erb/Cargo.toml new file mode 100644 index 00000000..faa62f29 --- /dev/null +++ b/crates/rgctl-lang-erb/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "rgctl-lang-erb" +version = "0.4.16" +edition.workspace = true +rust-version.workspace = true +description = "rgctl language plugin: ERB embedded templates (tree-sitter-embedded-template)" +license = "MIT OR Apache-2.0" +repository = "https://github.com/tree-sitter/tree-sitter-embedded-template" + +[dependencies] +rgctl-plugin-api = { workspace = true } +rgctl-registry = { workspace = true } +rgctl-plugin-helpers = { workspace = true } +tree-sitter = { workspace = true } +tree-sitter-embedded-template = "0.25.0" +serde_json = "1" +tracing = "0.1" +regex = "1" + +[dev-dependencies] +serde = { version = "1", features = ["derive"] } diff --git a/crates/rgctl-lang-erb/erb-ast-coverage.json b/crates/rgctl-lang-erb/erb-ast-coverage.json new file mode 100644 index 00000000..689cb51b --- /dev/null +++ b/crates/rgctl-lang-erb/erb-ast-coverage.json @@ -0,0 +1,18 @@ +{ + "grammar": "tree-sitter-embedded-template@0.25.0", + "handlers": { + "code": "Literal", + "comment": "Skip", + "comment_directive": "Symbol", + "content": "Skip", + "directive": "Symbol", + "graphql_directive": "Skip", + "output_directive": "Symbol", + "template": "Skip" + }, + "_honesty_notes": { + "code": "Text consumed by parent directive/output_directive; not emitted as its own symbol", + "content": "HTML/text between ERB tags; layout structure not extracted in Layer A", + "graphql_directive": "<%graphql %> blocks not walked; silent gap until dedicated support added" + } +} diff --git a/crates/rgctl-lang-erb/src/ast_coverage.rs b/crates/rgctl-lang-erb/src/ast_coverage.rs new file mode 100644 index 00000000..bc8ca9b5 --- /dev/null +++ b/crates/rgctl-lang-erb/src/ast_coverage.rs @@ -0,0 +1,76 @@ +//! AST coverage manifest vs pinned `tree-sitter-embedded-template` grammar. + +use std::collections::{HashMap, HashSet}; + +const MANIFEST_JSON: &str = include_str!("../erb-ast-coverage.json"); + +const ALLOWED: &[&str] = &[ + "Symbol", + "Relation", + "CfgStatement", + "AstSkeleton", + "Skip", + "Literal", +]; + +pub fn load_manifest() -> HashMap { + let v: serde_json::Value = + serde_json::from_str(MANIFEST_JSON).expect("erb-ast-coverage.json parse"); + let grammar = v["grammar"].as_str().unwrap_or(""); + assert!( + grammar.starts_with("tree-sitter-embedded-template@"), + "grammar prefix: {grammar}" + ); + let handlers = v["handlers"].as_object().expect("handlers object"); + handlers + .iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("Skip").to_string())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn erb_ast_coverage_manifest_matches_grammar() { + let manifest = load_manifest(); + assert!(!manifest.is_empty(), "manifest is empty"); + + let node_types_json = tree_sitter_embedded_template::NODE_TYPES; + let types: Vec = + serde_json::from_str(node_types_json).expect("parse NODE_TYPES"); + + let grammar_named: HashSet = types + .iter() + .filter(|t| t["named"].as_bool() == Some(true)) + .filter_map(|t| t["type"].as_str().map(String::from)) + .collect(); + + let allowed: HashSet<&str> = ALLOWED.iter().copied().collect(); + + // Every manifest key must use an allowed handler. + for (kind, handler) in &manifest { + assert!( + allowed.contains(handler.as_str()), + "handler {handler:?} for {kind:?} not in ALLOWED" + ); + } + + // Every grammar named kind must appear in the manifest. + for kind in &grammar_named { + assert!( + manifest.contains_key(kind), + "grammar named kind {kind:?} missing from erb-ast-coverage.json — add a handler" + ); + } + + // Every manifest key must still exist in the grammar (detect stale entries). + for kind in manifest.keys() { + assert!( + grammar_named.contains(kind), + "manifest key {kind:?} not in grammar named kinds — remove stale entry" + ); + } + } +} diff --git a/crates/rgctl-lang-erb/src/lib.rs b/crates/rgctl-lang-erb/src/lib.rs new file mode 100644 index 00000000..bff7810a --- /dev/null +++ b/crates/rgctl-lang-erb/src/lib.rs @@ -0,0 +1,38 @@ +//! ERB (Embedded Ruby) template language plugin for rgctl. +//! +//! Extracts variable references, fact lookups, scope accesses, and control flow +//! from `.erb` templates. Designed primarily for Puppet ERB templates but works +//! with any ERB file. +//! +//! Each ERB block (`<%= %>`, `<% %>`, `<%# %>`) is analyzed to extract: +//! - `@variable` references → `PuppetVariable` symbols +//! - `@facts[...]` references → `PuppetFact` symbols with `UsesFact` edges +//! - `scope['class::param']` → `References` edges to Puppet classes +//! - Control flow constructs (`if`, `each`, `unless`) for complexity metrics +//! +//! Block metadata includes a `translation_tier` (1–4) and, for tiers 1–3, +//! a `jinja2_pattern` hint for downstream ERB→Jinja2 translation. +//! +//! ## Honesty limits (Layer A extraction) +//! +//! - Ruby inside `<% %>` blocks is parsed with regex, not `tree-sitter-ruby`. +//! Method calls like `@facts.dig(...)` / `@facts.get('os')` are not captured. +//! Non-`@` local variables and `require` statements produce no graph edges. +//! - `<%graphql %>` directives are skipped (no symbols/relations emitted). +//! - `.epp` (Puppet EPP) templates are out of scope; a future `rgctl-lang-epp` +//! would cover those. +//! - `content` nodes (HTML/text between tags) are skipped; no layout structure. +//! - No CFG/taint/Layer F analysis — `enable_complexity = false` in `languages.toml`. + +use rgctl_registry::LanguageRegistry; +use std::sync::Arc; + +#[cfg(test)] +mod ast_coverage; +mod plugin; +pub use plugin::ErbPlugin; + +/// Register the ERB language plugin. +pub fn register(registry: &mut LanguageRegistry) { + registry.register_language_plugin(Arc::new(ErbPlugin::new().expect("init ErbPlugin"))); +} diff --git a/crates/rgctl-lang-erb/src/plugin.rs b/crates/rgctl-lang-erb/src/plugin.rs new file mode 100644 index 00000000..7e5e52c3 --- /dev/null +++ b/crates/rgctl-lang-erb/src/plugin.rs @@ -0,0 +1,669 @@ +//! ERB `LanguagePlugin` — symbols, relations, translation metadata. + +use regex::Regex; +use rgctl_plugin_api::{ + ComplexityMetrics, Error, ExtractAllResult, LanguagePlugin, Relation, RelationType, Result, + SourceLocation, Symbol, SymbolType, +}; +use std::path::Path; +use std::sync::OnceLock; +use tree_sitter::{Node, Parser, Tree}; + +/// Classification of ERB block translation complexity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Tier { + /// Simple variable output or block close — regex substitution + T1 = 1, + /// Conditionals, iteration, scope lookups — structural rewrite + T2 = 2, + /// Method chains, complex conditionals — rules + Jinja2 filters + T3 = 3, + /// Lambdas, select/map, Ruby stdlib — LLM-assisted + T4 = 4, +} + +/// What kind of ERB block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockKind { + /// `<%= ... %>` — output expression + Output, + /// `<% ... %>` — code/control flow + Code, + /// `<%# ... %>` — comment + Comment, +} + +// ── Compiled regexes (one-time init) ──────────────────────────────── + +macro_rules! re { + ($name:ident, $pat:expr) => { + fn $name() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new($pat).expect(concat!("bad regex: ", $pat))) + } + }; +} + +re!(re_simple_var, r"^@\w+$"); +re!(re_higher_order, r"\.(select|map|reject|collect|sort_by|find|detect|flat_map)\s*\{"); +re!(re_method_chain, r"\.(join|downcase|upcase|strip|chomp|first|last|length|size|split|include\?|nil\?|empty\?|to_s|to_i|to_f|capitalize|reverse|uniq|sort|flatten|compact)"); +re!(re_cond, r"^(if|elsif|unless)\s+"); +re!(re_each, r"\.each\s+do\s*\|"); +re!(re_access, r"^\w+\["); +re!(re_at_var, r"@(\w+)"); +re!(re_scope_ref, r"scope\['([^']+)'\]"); +re!(re_each_capture, r"@(\w+)\.each\s+do\s*\|([^|]+)\|"); +re!(re_hash_access, r"^(\w+)\['([^']+)'\]$"); +re!(re_join_single, r"\.join\('([^']*)'\)"); + +fn re_facts_path() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"@facts(\[(?:'[^']*'|"[^"]*")\])+"#).expect("bad regex: facts_path") + }) +} + +fn re_join_double() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"\.join\("([^"]*)"\)"#).expect("bad regex: join_double") + }) +} + +// ── Plugin ────────────────────────────────────────────────────────── + +/// Puppet ERB template language plugin. +pub struct ErbPlugin { + _parser: Parser, +} + +impl ErbPlugin { + pub fn new() -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_embedded_template::LANGUAGE.into()) + .map_err(|e| Error::PluginError(format!("Failed to set ERB grammar: {e}")))?; + Ok(Self { _parser: parser }) + } + + fn parse(&self, file_path: &Path, source: &[u8]) -> Result { + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_embedded_template::LANGUAGE.into()) + .map_err(|e| Error::PluginError(format!("Failed to set ERB grammar: {e}")))?; + parser.parse(source, None).ok_or_else(|| Error::ParseError { + file: file_path.to_path_buf(), + line: 0, + message: "Failed to parse ERB source".to_string(), + }) + } + + fn loc(node: Node, file_path: &str) -> SourceLocation { + SourceLocation { + file: file_path.to_string(), + start_line: node.start_position().row + 1, + end_line: node.end_position().row + 1, + start_column: node.start_position().column, + end_column: node.end_position().column, + } + } + + /// Extract the code content from an ERB directive node. + fn block_code(node: Node, source: &[u8]) -> Option { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "code" { + return child + .utf8_text(source) + .ok() + .map(|s| s.trim().to_string()); + } + } + None + } + + /// Classify a block's translation complexity. + fn classify_tier(code: &str, kind: BlockKind) -> Tier { + if kind == BlockKind::Comment { + return Tier::T1; + } + + let code_trimmed = code + .trim_end_matches("-%") + .trim_end_matches('%') + .trim(); + + // T1: simple variable output + if kind == BlockKind::Output && re_simple_var().is_match(code_trimmed) { + return Tier::T1; + } + + // T1: block terminators and else + if matches!(code_trimmed, "end" | "else") { + return Tier::T1; + } + + // T4: select/map/reject/collect with blocks + if re_higher_order().is_match(code_trimmed) { + return Tier::T4; + } + + // T4: require (Ruby stdlib) + if code_trimmed.starts_with("require ") || code_trimmed.starts_with("require(") { + return Tier::T4; + } + + // T3: method chains (.join, .downcase, .split, etc.) + if re_method_chain().is_match(code_trimmed) { + return Tier::T3; + } + + // T3: complex conditionals (&&, ||) + if code_trimmed.contains("&&") || code_trimmed.contains("||") { + return Tier::T3; + } + + // T2: simple conditionals + if re_cond().is_match(code_trimmed) { + return Tier::T2; + } + + // T2: iteration + if re_each().is_match(code_trimmed) { + return Tier::T2; + } + + // T2: scope lookups + if code_trimmed.contains("scope[") { + return Tier::T2; + } + + // T2: hash/array access + if kind == BlockKind::Output && re_access().is_match(code_trimmed) { + return Tier::T2; + } + + // T2: fact access + if code_trimmed.contains("@facts[") { + return Tier::T2; + } + + // Default: T2 + Tier::T2 + } + + /// Extract `@variable` references from Ruby code. + fn extract_at_variables(code: &str) -> Vec { + let mut vars: Vec = re_at_var() + .captures_iter(code) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect(); + vars.sort(); + vars.dedup(); + vars + } + + /// Extract `@facts[...]` access paths. + fn extract_fact_paths(code: &str) -> Vec { + re_facts_path() + .find_iter(code) + .map(|m| m.as_str().to_string()) + .collect() + } + + /// Extract `scope['class::param']` references. + fn extract_scope_refs(code: &str) -> Vec { + re_scope_ref() + .captures_iter(code) + .filter_map(|c| c.get(1).map(|m| m.as_str().to_string())) + .collect() + } + + /// Suggest a Jinja2 translation pattern for T1–T3 blocks. + fn jinja2_hint(code: &str, kind: BlockKind, tier: Tier) -> Option { + let trimmed = code + .trim_end_matches("-%") + .trim_end_matches('%') + .trim(); + + match (tier, kind) { + // T1 output: @var → {{ var }} + (Tier::T1, BlockKind::Output) => { + let var = trimmed.trim_start_matches('@'); + Some(format!("{{{{ {var} }}}}")) + } + // T1 comment + (_, BlockKind::Comment) => Some(format!("{{# {trimmed} #}}")), + // T1 block terminators — caller needs block stack context + (Tier::T1, BlockKind::Code) if trimmed == "end" => { + Some("{%- endif/endfor --%}".to_string()) + } + (Tier::T1, BlockKind::Code) if trimmed == "else" => Some("{% else %}".to_string()), + // T2 conditionals + (Tier::T2, BlockKind::Code) if trimmed.starts_with("if ") => { + let expr = trimmed.strip_prefix("if ").unwrap_or(trimmed); + let j2 = Self::ruby_expr_to_jinja2(expr); + Some(format!("{{% if {j2} %}}")) + } + (Tier::T2, BlockKind::Code) if trimmed.starts_with("elsif ") => { + let expr = trimmed.strip_prefix("elsif ").unwrap_or(trimmed); + let j2 = Self::ruby_expr_to_jinja2(expr); + Some(format!("{{% elif {j2} %}}")) + } + (Tier::T2, BlockKind::Code) if trimmed.starts_with("unless ") => { + let expr = trimmed.strip_prefix("unless ").unwrap_or(trimmed); + let j2 = Self::ruby_expr_to_jinja2(expr); + Some(format!("{{% if not ({j2}) %}}")) + } + // T2 iteration: @arr.each do |item| + (Tier::T2, BlockKind::Code) => { + if let Some(caps) = re_each_capture().captures(trimmed) { + let collection = caps.get(1).map(|m| m.as_str()).unwrap_or("items"); + let vars = caps.get(2).map(|m| m.as_str()).unwrap_or("item").trim(); + if vars.contains(',') { + let parts: Vec<&str> = vars.split(',').map(|s| s.trim()).collect(); + Some(format!( + "{{% for {}, {} in {}.items() %}}", + parts.first().unwrap_or(&"k"), + parts.get(1).unwrap_or(&"v"), + collection + )) + } else { + Some(format!("{{% for {vars} in {collection} %}}")) + } + } else { + None + } + } + // T2 scope lookups in output + (Tier::T2, BlockKind::Output) if trimmed.contains("scope[") => { + if let Some(caps) = re_scope_ref().captures(trimmed) { + let full_ref = caps.get(1).map(|m| m.as_str()).unwrap_or(trimmed); + let var_name = full_ref.rsplit("::").next().unwrap_or(full_ref); + Some(format!("{{{{ {var_name} }}}}")) + } else { + None + } + } + // T2 hash access in output: vhost['key'] → vhost.key + (Tier::T2, BlockKind::Output) => { + if let Some(caps) = re_hash_access().captures(trimmed) { + let obj = caps.get(1).map(|m| m.as_str()).unwrap_or("obj"); + let key = caps.get(2).map(|m| m.as_str()).unwrap_or("key"); + Some(format!("{{{{ {obj}.{key} }}}}")) + } else { + None + } + } + // T3 method chains in output + (Tier::T3, BlockKind::Output) => { + let j2 = Self::ruby_output_to_jinja2(trimmed); + Some(format!("{{{{ {j2} }}}}")) + } + _ => None, + } + } + + /// Convert a Ruby expression (from conditions) to Jinja2. + fn ruby_expr_to_jinja2(expr: &str) -> String { + re_at_var().replace_all(expr, "$1").to_string() + } + + /// Convert a Ruby output expression to Jinja2 with filters. + fn ruby_output_to_jinja2(expr: &str) -> String { + let mut j2 = re_at_var().replace_all(expr, "$1").to_string(); + j2 = re_join_single() + .replace_all(&j2, " | join('$1')") + .to_string(); + j2 = re_join_double() + .replace_all(&j2, " | join('$1')") + .to_string(); + j2 = j2.replace(".downcase", " | lower"); + j2 = j2.replace(".upcase", " | upper"); + j2 = j2.replace(".strip", " | trim"); + j2 = j2.replace(".chomp", " | trim"); + j2 = j2.replace(".capitalize", " | capitalize"); + j2 = j2.replace(".reverse", " | reverse"); + j2 = j2.replace(".length", " | length"); + j2 = j2.replace(".size", " | length"); + j2 = j2.replace(".to_s", " | string"); + j2 = j2.replace(".to_i", " | int"); + j2 = j2.replace(".to_f", " | float"); + j2 = j2.replace(".uniq", " | unique"); + j2 = j2.replace(".sort", " | sort"); + j2 = j2.replace(".flatten", " | flatten"); + j2 = j2.replace(".compact", " | reject('none')"); + j2 = j2.replace(".first", " | first"); + j2 = j2.replace(".last", " | last"); + j2 + } + + fn walk_blocks( + &self, + node: Node, + source: &[u8], + file_path: &str, + symbols: &mut Vec, + relations: &mut Vec, + ) { + let kind = match node.kind() { + "output_directive" => Some(BlockKind::Output), + "directive" => Some(BlockKind::Code), + "comment_directive" => Some(BlockKind::Comment), + // graphql_directive: <%graphql %> blocks are intentionally skipped. + // Coverage gap documented in erb-ast-coverage.json. Future: dedicated handler. + _ => None, + }; + + if let Some(kind) = kind { + if let Some(code) = Self::block_code(node, source) { + let tier = Self::classify_tier(&code, kind); + let at_vars = Self::extract_at_variables(&code); + let fact_paths = Self::extract_fact_paths(&code); + let scope_refs = Self::extract_scope_refs(&code); + let jinja2 = Self::jinja2_hint(&code, kind, tier); + + let block_type = match kind { + BlockKind::Output => "output", + BlockKind::Code => "code", + BlockKind::Comment => "comment", + }; + + let line = node.start_position().row + 1; + let sym_name = format!("erb:{block_type}@L{line}"); + + symbols.push(Symbol { + name: sym_name.clone(), + symbol_type: SymbolType::Variable, + qualified_name: Some(sym_name.clone()), + location: Self::loc(node, file_path), + signature: Some(code.clone()), + return_type: None, + parameters: vec![], + fields: vec![], + modifiers: vec![], + documentation: None, + metadata: serde_json::json!({ + "language": "erb", + "block_kind": block_type, + "translation_tier": tier as u8, + "ruby_code": code, + "jinja2_hint": jinja2, + "variables": at_vars, + "fact_paths": fact_paths, + "scope_refs": scope_refs, + }), + }); + + let rel_from = sym_name; + + for var in &at_vars { + if var == "facts" { + continue; + } + relations.push(Relation { + from: rel_from.clone(), + to: format!("${var}"), + relation_type: RelationType::UsesVariable, + location: Self::loc(node, file_path), + metadata: serde_json::json!({ + "language": "erb", + "erb_variable": format!("@{var}"), + }), + to_qualified_hint: Some(format!("${var}")), + to_type_hint: Some("puppetvariable".to_string()), + }); + } + + for fact in &fact_paths { + relations.push(Relation { + from: rel_from.clone(), + to: fact.clone(), + relation_type: RelationType::UsesFact, + location: Self::loc(node, file_path), + metadata: serde_json::json!({ + "language": "erb", + "fact_path": fact, + }), + to_qualified_hint: None, + to_type_hint: Some("puppetfact".to_string()), + }); + } + + for scope_ref in &scope_refs { + relations.push(Relation { + from: rel_from.clone(), + to: scope_ref.clone(), + relation_type: RelationType::References, + location: Self::loc(node, file_path), + metadata: serde_json::json!({ + "language": "erb", + "scope_ref": scope_ref, + }), + to_qualified_hint: Some(scope_ref.clone()), + to_type_hint: Some("puppetvariable".to_string()), + }); + } + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor).collect::>() { + self.walk_blocks(child, source, file_path, symbols, relations); + } + } +} + +impl LanguagePlugin for ErbPlugin { + fn language_id(&self) -> &str { + "erb" + } + + fn file_extensions(&self) -> Vec<&str> { + vec!["erb"] + } + + fn grammar(&self) -> Option { + Some(tree_sitter_embedded_template::LANGUAGE.into()) + } + + fn extract_symbols(&self, file_path: &Path, source: &[u8]) -> Result> { + let tree = self.parse(file_path, source)?; + let path_str = file_path.to_string_lossy(); + let mut symbols = Vec::new(); + let mut _rels = Vec::new(); + self.walk_blocks(tree.root_node(), source, &path_str, &mut symbols, &mut _rels); + Ok(symbols) + } + + fn extract_relations( + &self, + file_path: &Path, + source: &[u8], + _symbols: &[Symbol], + ) -> Result> { + let tree = self.parse(file_path, source)?; + let path_str = file_path.to_string_lossy(); + let mut _syms = Vec::new(); + let mut relations = Vec::new(); + self.walk_blocks(tree.root_node(), source, &path_str, &mut _syms, &mut relations); + Ok(relations) + } + + fn extract_all(&self, file_path: &Path, source: &[u8]) -> Result { + let tree = self.parse(file_path, source)?; + let path_str = file_path.to_string_lossy(); + let mut symbols = Vec::new(); + let mut relations = Vec::new(); + self.walk_blocks(tree.root_node(), source, &path_str, &mut symbols, &mut relations); + Ok(ExtractAllResult::from_parts(symbols, relations)) + } + + fn calculate_complexity( + &self, + symbol: &Symbol, + _source: &[u8], + ) -> Result> { + Ok(Some(ComplexityMetrics { + cyclomatic: 1, + cognitive: 0, + loc: symbol.location.end_line.saturating_sub(symbol.location.start_line) + 1, + parameters: 0, + nesting_depth: 0, + returns: 0, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plugin() -> ErbPlugin { + ErbPlugin::new().expect("plugin") + } + + #[test] + fn extracts_simple_variables() { + let src = b"

<%= @hostname %>

\n

<%= @fqdn %>

"; + let p = plugin(); + let path = Path::new("templates/index.html.erb"); + let result = p.extract_all(path, src).expect("extract_all"); + + assert_eq!(result.symbols.len(), 2); + assert!(result.symbols.iter().all(|s| { + s.metadata.get("translation_tier").and_then(|v| v.as_u64()) == Some(1) + })); + + let var_rels: Vec<_> = result + .relations + .iter() + .filter(|r| r.relation_type == RelationType::UsesVariable) + .collect(); + assert_eq!(var_rels.len(), 2); + let targets: Vec<&str> = var_rels.iter().map(|r| r.to.as_str()).collect(); + assert!(targets.contains(&"$hostname")); + assert!(targets.contains(&"$fqdn")); + } + + #[test] + fn extracts_facts_and_scope() { + let src = b"<%= @facts['os']['family'] %>\n<%= scope['profile::nginx::port'] %>"; + let p = plugin(); + let path = Path::new("templates/config.erb"); + let result = p.extract_all(path, src).expect("extract_all"); + + let fact_rels: Vec<_> = result + .relations + .iter() + .filter(|r| r.relation_type == RelationType::UsesFact) + .collect(); + assert_eq!(fact_rels.len(), 1); + assert!(fact_rels[0].to.contains("@facts['os']['family']")); + + let ref_rels: Vec<_> = result + .relations + .iter() + .filter(|r| r.relation_type == RelationType::References) + .collect(); + assert_eq!(ref_rels.len(), 1); + assert_eq!(ref_rels[0].to, "profile::nginx::port"); + } + + #[test] + fn classifies_tiers_correctly() { + assert_eq!(ErbPlugin::classify_tier("@hostname", BlockKind::Output) as u8, 1); + assert_eq!(ErbPlugin::classify_tier("end", BlockKind::Code) as u8, 1); + assert_eq!( + ErbPlugin::classify_tier("if @os == 'RedHat'", BlockKind::Code) as u8, + 2 + ); + assert_eq!( + ErbPlugin::classify_tier("@arr.each do |item|", BlockKind::Code) as u8, + 2 + ); + assert_eq!( + ErbPlugin::classify_tier("@aliases.join(' ')", BlockKind::Output) as u8, + 3 + ); + assert_eq!( + ErbPlugin::classify_tier( + "@nets.select { |n| n.include?('/') }", + BlockKind::Code + ) as u8, + 4 + ); + } + + #[test] + fn jinja2_hints_for_simple_blocks() { + let hint = ErbPlugin::jinja2_hint("@hostname", BlockKind::Output, Tier::T1); + assert_eq!(hint, Some("{{ hostname }}".to_string())); + + let hint = ErbPlugin::jinja2_hint("else", BlockKind::Code, Tier::T1); + assert_eq!(hint, Some("{% else %}".to_string())); + } + + #[test] + fn jinja2_hints_for_iteration() { + let hint = ErbPlugin::jinja2_hint( + "@vhosts.each do |vhost|", + BlockKind::Code, + Tier::T2, + ); + assert_eq!(hint, Some("{% for vhost in vhosts %}".to_string())); + + let hint = ErbPlugin::jinja2_hint( + "@headers.each do |key, value|", + BlockKind::Code, + Tier::T2, + ); + assert_eq!( + hint, + Some("{% for key, value in headers.items() %}".to_string()) + ); + } + + #[test] + fn jinja2_hints_for_method_chains() { + let hint = ErbPlugin::jinja2_hint( + "@aliases.join(' ')", + BlockKind::Output, + Tier::T3, + ); + assert_eq!(hint, Some("{{ aliases | join(' ') }}".to_string())); + + let hint = + ErbPlugin::jinja2_hint("@hostname.downcase", BlockKind::Output, Tier::T3); + assert_eq!(hint, Some("{{ hostname | lower }}".to_string())); + } + + #[test] + fn registry_extensions() { + let p = plugin(); + assert_eq!(p.language_id(), "erb"); + assert!(p.file_extensions().contains(&"erb")); + assert!(p.grammar().is_some()); + } + + #[test] + fn honesty_regex_gaps() { + // @facts.dig(...) and @facts.get('os') are NOT captured by the bracket regex. + let facts_dig = ErbPlugin::extract_fact_paths("@facts.dig('os', 'family')"); + assert!(facts_dig.is_empty(), "dig() should not match: {facts_dig:?}"); + + let facts_get = ErbPlugin::extract_fact_paths("@facts.get('os')"); + assert!(facts_get.is_empty(), "get() should not match: {facts_get:?}"); + + // Non-@ locals produce no variable references. + let locals = ErbPlugin::extract_at_variables("item['name']"); + assert!(locals.is_empty(), "non-@ locals should not match: {locals:?}"); + } + + #[test] + fn epp_files_not_handled() { + let p = plugin(); + assert!(!p.file_extensions().contains(&"epp")); + assert!(!p.can_handle(Path::new("templates/config.epp"))); + } +} diff --git a/crates/rgctl-languages/Cargo.toml b/crates/rgctl-languages/Cargo.toml index 31f83478..f9977d19 100644 --- a/crates/rgctl-languages/Cargo.toml +++ b/crates/rgctl-languages/Cargo.toml @@ -22,6 +22,7 @@ rgctl-lang-markdown = { workspace = true } rgctl-lang-php = { workspace = true } rgctl-lang-ruby = { workspace = true } rgctl-lang-puppet = { workspace = true } +rgctl-lang-erb = { workspace = true } rgctl-lang-kotlin = { workspace = true } rgctl-lang-groovy = { workspace = true } diff --git a/crates/rgctl-languages/src/lib.rs b/crates/rgctl-languages/src/lib.rs index 010d16eb..679821ea 100644 --- a/crates/rgctl-languages/src/lib.rs +++ b/crates/rgctl-languages/src/lib.rs @@ -17,6 +17,7 @@ pub fn register_languages(registry: &mut LanguageRegistry) { rgctl_lang_php::register(registry); rgctl_lang_ruby::register(registry); rgctl_lang_puppet::register(registry); + rgctl_lang_erb::register(registry); rgctl_lang_kotlin::register(registry); rgctl_lang_groovy::register(registry); } @@ -86,6 +87,16 @@ mod tests { assert_eq!(plugin.language_id(), "puppet"); } + #[test] + fn default_registry_can_process_erb_files() { + let registry = default_registry(); + assert!(registry.can_process_file(Path::new("modules/nginx/templates/nginx.conf.erb"))); + let plugin = registry + .get_plugin_for_file(Path::new("templates/index.html.erb")) + .expect("erb plugin"); + assert_eq!(plugin.language_id(), "erb"); + } + #[test] fn default_registry_can_process_kotlin_files() { let registry = default_registry(); diff --git a/languages.toml b/languages.toml index 8a7b4be7..7efb4722 100644 --- a/languages.toml +++ b/languages.toml @@ -188,3 +188,16 @@ class_kinds = ["class_declaration", "interface_declaration", "enum_declaration"] import_kinds = ["import_declaration"] enable_complexity = true enable_type_inference = false + +[languages.erb] +handler = "custom" +plugin = "ErbPlugin" +module = "crate::languages::builtin::erb" +crate = "tree-sitter-embedded-template" +extensions = ["erb"] +aliases = ["erb", "embedded-ruby"] +function_kinds = [] +class_kinds = [] +import_kinds = [] +enable_complexity = false +enable_type_inference = false diff --git a/scripts/fetch-profile-repos.sh b/scripts/fetch-profile-repos.sh index 7b8d44d7..ca4b12d7 100755 --- a/scripts/fetch-profile-repos.sh +++ b/scripts/fetch-profile-repos.sh @@ -17,6 +17,7 @@ # - llvm-project (C++ via sparse clang/) # - kotlin (JetBrains/kotlin sparse libraries+plugins+analysis — Kotlin Gate B) # - groovy (gradle/gradle — Groovy Gate B; largest single OSS .groovy tree) +# - theforeman (12 Puppet modules — Puppet+ERB Gate B; 500 .pp + 122 .erb) set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -99,9 +100,37 @@ clone_if_missing "https://github.com/microsoft/vscode.git" "$EXAMPLE_DIR/vscode" clone_if_missing "https://github.com/dotnet/roslyn.git" "$EXAMPLE_DIR/roslyn" 1 clone_sparse_llvm_clang_if_missing "$EXAMPLE_DIR/llvm-project" -# Puppet Gate B (~10⁴ .pp): deferred — no default monorepo yet. -# Override when baselining: RGCTL_PUPPET_REPO=/path/to/puppet/modules -# Suggested candidates: OpenStack puppet-* modules or a Forge module bundle under example/puppet. +# Puppet + ERB Gate B: theforeman ecosystem (12 modules, 500 .pp + 122 .erb + 64 .rb) +# Override root with RGCTL_THEFOREMAN_REPO. +clone_theforeman_if_missing() { + local dest="$1" + if [[ -d "$dest/puppet-foreman" ]]; then + echo "Already present: $dest (theforeman)" + return 0 + fi + mkdir -p "$dest" + local repos=( + theforeman/puppet-foreman + theforeman/puppet-foreman_proxy + theforeman/puppet-puppet + theforeman/puppet-dhcp + theforeman/puppet-dns + theforeman/puppet-tftp + theforeman/puppet-candlepin + theforeman/puppet-pulpcore + theforeman/puppet-puppetserver_foreman + puppetlabs/puppetlabs-apache + puppetlabs/puppetlabs-postgresql + voxpupuli/puppet-redis + ) + for repo in "${repos[@]}"; do + local name + name=$(basename "$repo") + clone_if_missing "https://github.com/${repo}.git" "$dest/$name" 1 + done +} + +clone_theforeman_if_missing "${RGCTL_THEFOREMAN_REPO:-$EXAMPLE_DIR/theforeman}" clone_sparse_node_test_if_missing() { local dest="$1"