Repository navigation
feat(lang): ERB template plugin (tree-sitter-embedded-template) #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d293f0c
b390b78
1bd1656
7582537
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"] } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggestion (when useful): treat |
||
| "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" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, String> { | ||
| 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::Value> = | ||
| serde_json::from_str(node_types_json).expect("parse NODE_TYPES"); | ||
|
|
||
| let grammar_named: HashSet<String> = 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" | ||
| ); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"))); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
contentisSkip(HTML/text between ERB tags).Fine for a first slice focused on Ruby/Puppet refs. If later you care about template layout or “what markup surrounds this block,” this is the place to emit lightweight structure (e.g. file-level skeleton or CONTAINS-style anchors).
No action required for merge — calling it out as a known coverage gap.