use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use crate::error::{Error, Result};
use crate::utils::io;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Grammar {
pub language: LanguageMeta,
pub comments: CommentSyntax,
pub strings: StringSyntax,
#[serde(default)]
pub blocks: BlockSyntax,
pub patterns: HashMap<String, ConceptPattern>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LanguageMeta {
pub id: String,
pub extensions: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentSyntax {
#[serde(default)]
pub line: Vec<String>,
#[serde(default)]
pub block: Vec<(String, String)>,
#[serde(default)]
pub doc: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StringSyntax {
#[serde(default = "default_quotes")]
pub quotes: Vec<String>,
#[serde(default = "default_escape_string")]
pub escape: String,
#[serde(default)]
pub multiline: Vec<(String, String)>,
}
fn default_quotes() -> Vec<String> {
vec!["\"".to_string(), "'".to_string()]
}
fn default_escape_string() -> String {
"\\".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlockSyntax {
#[serde(default = "default_open")]
pub open: String,
#[serde(default = "default_close")]
pub close: String,
}
impl Default for BlockSyntax {
fn default() -> Self {
Self {
open: "{".to_string(),
close: "}".to_string(),
}
}
}
fn default_open() -> String {
"{".to_string()
}
fn default_close() -> String {
"}".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConceptPattern {
pub regex: String,
#[serde(default)]
pub captures: HashMap<String, usize>,
#[serde(default = "default_context")]
pub context: String,
#[serde(default = "default_true")]
pub skip_comments: bool,
#[serde(default = "default_true")]
pub skip_strings: bool,
#[serde(default)]
pub require_capture: Option<String>,
}
fn default_context() -> String {
"any".to_string()
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Region {
Code,
LineComment,
BlockComment,
StringLiteral,
}
#[derive(Debug, Clone)]
pub struct StructuralContext {
pub depth: i32,
pub region: Region,
pub block_stack: Vec<(String, i32)>,
}
impl StructuralContext {
pub fn new() -> Self {
Self {
depth: 0,
region: Region::Code,
block_stack: Vec::new(),
}
}
#[cfg(test)]
pub(crate) fn is_inside(&self, label: &str) -> bool {
self.block_stack.iter().any(|(l, _)| l == label)
}
#[cfg(test)]
pub(crate) fn current_block_label(&self) -> Option<&str> {
self.block_stack.last().map(|(l, _)| l.as_str())
}
#[cfg(test)]
pub(crate) fn push_block(&mut self, label: String) {
self.block_stack.push((label, self.depth));
}
pub(crate) fn pop_exited_blocks(&mut self) {
while let Some((_, entry_depth)) = self.block_stack.last() {
if self.depth <= *entry_depth {
self.block_stack.pop();
} else {
break;
}
}
}
}
impl Default for StructuralContext {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct ContextualLine<'a> {
pub text: &'a str,
pub line_num: usize,
pub depth: i32,
pub region: Region,
}
pub(crate) fn walk_lines<'a>(content: &'a str, grammar: &Grammar) -> Vec<ContextualLine<'a>> {
let mut ctx = StructuralContext::new();
let mut result = Vec::new();
let mut in_block_comment = false;
let mut block_comment_end = String::new();
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
let depth_at_start = ctx.depth;
let region = if in_block_comment {
if let Some(pos) = trimmed.find(block_comment_end.as_str()) {
in_block_comment = false;
let after = &trimmed[pos + block_comment_end.len()..].trim();
if after.is_empty() {
Region::BlockComment
} else {
Region::Code
}
} else {
Region::BlockComment
}
} else if is_line_comment(trimmed, &grammar.comments) {
Region::LineComment
} else {
for (open, close) in &grammar.comments.block {
if trimmed.starts_with(open.as_str())
&& (!trimmed.contains(close.as_str()) || trimmed.ends_with(open.as_str()))
{
in_block_comment = true;
block_comment_end = close.clone();
}
}
if in_block_comment {
Region::BlockComment
} else {
Region::Code
}
};
if region == Region::Code {
update_depth(line, &grammar.blocks, &grammar.strings, &mut ctx);
}
result.push(ContextualLine {
text: line,
line_num: i + 1,
depth: depth_at_start,
region,
});
ctx.pop_exited_blocks();
}
result
}
fn is_line_comment(trimmed: &str, comments: &CommentSyntax) -> bool {
for prefix in &comments.line {
if trimmed.starts_with(prefix.as_str()) {
return true;
}
}
for prefix in &comments.doc {
if trimmed.starts_with(prefix.as_str()) {
return true;
}
}
false
}
fn update_depth(
line: &str,
blocks: &BlockSyntax,
strings: &StringSyntax,
ctx: &mut StructuralContext,
) {
let mut in_string: Option<char> = None;
let mut prev_char = '\0';
for ch in line.chars() {
if let Some(quote) = in_string {
if ch == quote && prev_char != strings.escape.chars().next().unwrap_or('\\') {
in_string = None;
}
} else if strings.quotes.iter().any(|q| q.starts_with(ch)) {
in_string = Some(ch);
} else if blocks.open.starts_with(ch) {
ctx.depth += 1;
} else if blocks.close.starts_with(ch) {
ctx.depth -= 1;
}
prev_char = ch;
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Symbol {
pub concept: String,
pub captures: HashMap<String, String>,
pub line: usize,
pub depth: i32,
pub matched_text: String,
}
impl Symbol {
pub fn get(&self, key: &str) -> Option<&str> {
self.captures.get(key).map(|s| s.as_str())
}
pub fn name(&self) -> Option<&str> {
self.get("name")
}
pub fn visibility(&self) -> Option<&str> {
self.get("visibility")
}
}
pub fn extract(content: &str, grammar: &Grammar) -> Vec<Symbol> {
let lines = walk_lines(content, grammar);
let mut symbols = Vec::new();
for (concept_name, pattern) in &grammar.patterns {
let re = match Regex::new(&pattern.regex) {
Ok(r) => r,
Err(_) => continue, };
for ctx_line in &lines {
if pattern.skip_comments
&& (ctx_line.region == Region::LineComment
|| ctx_line.region == Region::BlockComment)
{
continue;
}
match pattern.context.as_str() {
"top_level" => {
if ctx_line.depth != 0 {
continue;
}
}
"in_block" => {
if ctx_line.depth == 0 {
continue;
}
}
_ => {} }
if let Some(caps) = re.captures(ctx_line.text) {
let mut capture_map = HashMap::new();
for (name, &index) in &pattern.captures {
if let Some(m) = caps.get(index) {
capture_map.insert(name.clone(), m.as_str().to_string());
}
}
if let Some(ref required) = pattern.require_capture {
if capture_map.get(required).is_none_or(|v| v.is_empty()) {
continue;
}
}
symbols.push(Symbol {
concept: concept_name.clone(),
captures: capture_map,
line: ctx_line.line_num,
depth: ctx_line.depth,
matched_text: caps[0].to_string(),
});
}
}
}
symbols.sort_by_key(|s| s.line);
symbols
}
#[cfg(test)]
pub(crate) fn extract_concept(content: &str, grammar: &Grammar, concept: &str) -> Vec<Symbol> {
extract(content, grammar)
.into_iter()
.filter(|s| s.concept == concept)
.collect()
}
pub fn load_grammar(path: &Path) -> Result<Grammar> {
let content = io::read_file(path, "read grammar file")?;
toml::from_str(&content).map_err(|e| {
Error::internal_io(
format!("Failed to parse grammar {}: {}", path.display(), e),
Some("grammar.load".to_string()),
)
})
}
pub fn load_grammar_json(path: &Path) -> Result<Grammar> {
let content = io::read_file(path, "read grammar file")?;
serde_json::from_str(&content).map_err(|e| {
Error::internal_io(
format!("Failed to parse grammar {}: {}", path.display(), e),
Some("grammar.load".to_string()),
)
})
}
#[cfg(test)]
pub(crate) fn method_names(symbols: &[Symbol]) -> Vec<String> {
symbols
.iter()
.filter(|s| {
s.concept == "method" || s.concept == "function" || s.concept == "free_function"
})
.filter_map(|s| s.name().map(|n| n.to_string()))
.collect()
}
#[cfg(test)]
pub(crate) fn type_names(symbols: &[Symbol]) -> Vec<String> {
symbols
.iter()
.filter(|s| {
s.concept == "class"
|| s.concept == "struct"
|| s.concept == "trait"
|| s.concept == "enum"
|| s.concept == "interface"
|| s.concept == "type"
})
.filter_map(|s| s.name().map(|n| n.to_string()))
.collect()
}
#[cfg(test)]
pub(crate) fn import_paths(symbols: &[Symbol]) -> Vec<String> {
symbols
.iter()
.filter(|s| s.concept == "import" || s.concept == "use")
.filter_map(|s| s.get("path").map(|p| p.to_string()))
.collect()
}
pub fn namespace(symbols: &[Symbol]) -> Option<String> {
symbols
.iter()
.find(|s| s.concept == "namespace" || s.concept == "module")
.and_then(|s| s.name().map(|n| n.to_string()))
}
#[cfg(test)]
pub(crate) fn public_symbols(symbols: &[Symbol]) -> Vec<&Symbol> {
symbols
.iter()
.filter(|s| {
s.visibility()
.is_none_or(|v| v.contains("pub") || v == "public")
})
.collect()
}
#[cfg(test)]
pub(crate) fn extract_block_body<'a>(
lines: &[ContextualLine<'a>],
start_line_idx: usize,
grammar: &Grammar,
) -> Option<Vec<&'a str>> {
let open = grammar.blocks.open.chars().next().unwrap_or('{');
let close = grammar.blocks.close.chars().next().unwrap_or('}');
let mut idx = start_line_idx;
let mut found_open = false;
let mut depth: i32 = 0;
let mut body_lines = Vec::new();
while idx < lines.len() {
let line = lines[idx].text;
for ch in line.chars() {
if ch == open {
depth += 1;
found_open = true;
} else if ch == close {
depth -= 1;
if found_open && depth == 0 {
body_lines.push(line);
return Some(body_lines);
}
}
}
if found_open {
body_lines.push(line);
}
idx += 1;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn rust_grammar() -> Grammar {
Grammar {
language: LanguageMeta {
id: "rust".to_string(),
extensions: vec!["rs".to_string()],
},
comments: CommentSyntax {
line: vec!["//".to_string()],
block: vec![("/*".to_string(), "*/".to_string())],
doc: vec!["///".to_string(), "//!".to_string()],
},
strings: StringSyntax {
quotes: vec!["\"".to_string()],
escape: "\\".to_string(),
multiline: vec![],
},
blocks: BlockSyntax::default(),
patterns: {
let mut p = HashMap::new();
p.insert(
"function".to_string(),
ConceptPattern {
regex: r"(?:pub(?:\(crate\))?\s+)?(?:async\s+)?fn\s+(\w+)\s*\(([^)]*)\)"
.to_string(),
captures: {
let mut c = HashMap::new();
c.insert("name".to_string(), 1);
c.insert("params".to_string(), 2);
c
},
context: "any".to_string(),
skip_comments: true,
skip_strings: true,
require_capture: None,
},
);
p.insert(
"struct".to_string(),
ConceptPattern {
regex: r"(?:pub(?:\(crate\))?\s+)?(?:struct|enum|trait)\s+(\w+)"
.to_string(),
captures: {
let mut c = HashMap::new();
c.insert("name".to_string(), 1);
c
},
context: "top_level".to_string(),
skip_comments: true,
skip_strings: true,
require_capture: None,
},
);
p.insert(
"import".to_string(),
ConceptPattern {
regex: r"use\s+([\w:]+(?:::\{[^}]+\})?);".to_string(),
captures: {
let mut c = HashMap::new();
c.insert("path".to_string(), 1);
c
},
context: "top_level".to_string(),
skip_comments: true,
skip_strings: true,
require_capture: None,
},
);
p
},
}
}
fn php_grammar() -> Grammar {
Grammar {
language: LanguageMeta {
id: "php".to_string(),
extensions: vec!["php".to_string()],
},
comments: CommentSyntax {
line: vec!["//".to_string(), "#".to_string()],
block: vec![("/*".to_string(), "*/".to_string())],
doc: vec![],
},
strings: StringSyntax {
quotes: vec!["\"".to_string(), "'".to_string()],
escape: "\\".to_string(),
multiline: vec![],
},
blocks: BlockSyntax::default(),
patterns: {
let mut p = HashMap::new();
p.insert(
"method".to_string(),
ConceptPattern {
regex: r"(?:(?:public|protected|private|static|abstract|final)\s+)*function\s+(\w+)\s*\(([^)]*)\)".to_string(),
captures: {
let mut c = HashMap::new();
c.insert("name".to_string(), 1);
c.insert("params".to_string(), 2);
c
},
context: "any".to_string(),
skip_comments: true,
skip_strings: true,
require_capture: None,
},
);
p.insert(
"class".to_string(),
ConceptPattern {
regex: r"(?:abstract\s+)?(?:final\s+)?(class|trait|interface)\s+(\w+)"
.to_string(),
captures: {
let mut c = HashMap::new();
c.insert("kind".to_string(), 1);
c.insert("name".to_string(), 2);
c
},
context: "top_level".to_string(),
skip_comments: true,
skip_strings: true,
require_capture: None,
},
);
p.insert(
"namespace".to_string(),
ConceptPattern {
regex: r"namespace\s+([\w\\]+);".to_string(),
captures: {
let mut c = HashMap::new();
c.insert("name".to_string(), 1);
c
},
context: "top_level".to_string(),
skip_comments: true,
skip_strings: true,
require_capture: None,
},
);
p
},
}
}
#[test]
fn walk_lines_tracks_depth() {
let content = "fn main() {\n let x = 1;\n if true {\n foo();\n }\n}\n";
let grammar = rust_grammar();
let lines = walk_lines(content, &grammar);
assert_eq!(lines[0].depth, 0); assert_eq!(lines[1].depth, 1); assert_eq!(lines[2].depth, 1); assert_eq!(lines[3].depth, 2); assert_eq!(lines[4].depth, 2); assert_eq!(lines[5].depth, 1); }
#[test]
fn walk_lines_detects_line_comments() {
let content = "let x = 1;\n// this is a comment\nlet y = 2;\n";
let grammar = rust_grammar();
let lines = walk_lines(content, &grammar);
assert_eq!(lines[0].region, Region::Code);
assert_eq!(lines[1].region, Region::LineComment);
assert_eq!(lines[2].region, Region::Code);
}
#[test]
fn walk_lines_detects_block_comments() {
let content = "let x = 1;\n/* multi\nline\ncomment */\nlet y = 2;\n";
let grammar = rust_grammar();
let lines = walk_lines(content, &grammar);
assert_eq!(lines[0].region, Region::Code);
assert_eq!(lines[1].region, Region::BlockComment);
assert_eq!(lines[2].region, Region::BlockComment);
assert_eq!(lines[3].region, Region::BlockComment);
assert_eq!(lines[4].region, Region::Code);
}
#[test]
fn depth_skips_braces_in_strings() {
let content = "let x = \"{ not a block }\";\nlet y = 1;\n";
let grammar = rust_grammar();
let lines = walk_lines(content, &grammar);
assert_eq!(lines[0].depth, 0);
assert_eq!(lines[1].depth, 0);
}
#[test]
fn php_hash_comments() {
let content = "<?php\n# this is a comment\n$x = 1;\n";
let grammar = php_grammar();
let lines = walk_lines(content, &grammar);
assert_eq!(lines[1].region, Region::LineComment);
assert_eq!(lines[2].region, Region::Code);
}
#[test]
fn extract_rust_functions() {
let content = "pub fn parse_config(path: &Path) -> Config {\n todo!()\n}\n\nfn internal() {}\n\npub(crate) fn helper() {}\n";
let grammar = rust_grammar();
let symbols = extract(content, &grammar);
let fns: Vec<_> = symbols.iter().filter(|s| s.concept == "function").collect();
assert_eq!(fns.len(), 3);
assert_eq!(fns[0].name(), Some("parse_config"));
assert_eq!(fns[1].name(), Some("internal"));
assert_eq!(fns[2].name(), Some("helper"));
}
#[test]
fn extract_rust_structs() {
let content = "pub struct Config {\n data: String,\n}\n\nenum State {\n Running,\n Stopped,\n}\n";
let grammar = rust_grammar();
let symbols = extract_concept(content, &grammar, "struct");
assert_eq!(symbols.len(), 2);
assert_eq!(symbols[0].name(), Some("Config"));
assert_eq!(symbols[1].name(), Some("State"));
}
#[test]
fn extract_rust_imports() {
let content = "use std::path::Path;\nuse crate::error::Result;\n\nfn foo() {}\n";
let grammar = rust_grammar();
let paths = import_paths(&extract(content, &grammar));
assert_eq!(paths.len(), 2);
assert_eq!(paths[0], "std::path::Path");
assert_eq!(paths[1], "crate::error::Result");
}
#[test]
fn extract_php_methods() {
let content = "<?php\nclass Foo {\n public function bar() {}\n protected function baz($x) {}\n private function internal() {}\n}\n";
let grammar = php_grammar();
let methods = extract_concept(content, &grammar, "method");
assert_eq!(methods.len(), 3);
assert_eq!(methods[0].name(), Some("bar"));
assert_eq!(methods[1].name(), Some("baz"));
assert_eq!(methods[2].name(), Some("internal"));
}
#[test]
fn extract_php_class() {
let content =
"<?php\nnamespace App\\Models;\n\nclass User {\n public function save() {}\n}\n";
let grammar = php_grammar();
let symbols = extract(content, &grammar);
let ns = namespace(&symbols);
assert_eq!(ns, Some("App\\Models".to_string()));
let types = type_names(&symbols);
assert_eq!(types, vec!["User"]);
}
#[test]
fn skip_comments_in_extraction() {
let content = "// pub fn commented_out() {}\npub fn real_fn() {}\n";
let grammar = rust_grammar();
let symbols = extract_concept(content, &grammar, "function");
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name(), Some("real_fn"));
}
#[test]
fn top_level_context_filter() {
let content = "pub struct Outer {\n inner: Inner,\n}\n\nimpl Outer {\n pub struct NotTopLevel {}\n}\n";
let grammar = rust_grammar();
let symbols = extract_concept(content, &grammar, "struct");
assert_eq!(symbols.len(), 1);
assert_eq!(symbols[0].name(), Some("Outer"));
}
#[test]
fn method_names_helper() {
let content = "pub fn alpha() {}\nfn beta() {}\n";
let grammar = rust_grammar();
let symbols = extract(content, &grammar);
let names = method_names(&symbols);
assert_eq!(names, vec!["alpha", "beta"]);
}
#[test]
fn extract_block_body_basic() {
let content = "fn foo() {\n let x = 1;\n let y = 2;\n}\n";
let grammar = rust_grammar();
let lines = walk_lines(content, &grammar);
let body = extract_block_body(&lines, 0, &grammar);
assert!(body.is_some());
let body = body.unwrap();
assert_eq!(body.len(), 4); }
#[test]
fn grammar_roundtrip_toml() {
let grammar = rust_grammar();
let toml_str = toml::to_string_pretty(&grammar).unwrap();
let parsed: Grammar = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.language.id, "rust");
assert_eq!(parsed.patterns.len(), 3);
}
#[test]
fn grammar_roundtrip_json() {
let grammar = rust_grammar();
let json_str = serde_json::to_string_pretty(&grammar).unwrap();
let parsed: Grammar = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed.language.id, "rust");
assert_eq!(parsed.patterns.len(), 3);
}
#[test]
fn structural_context_block_tracking() {
let mut ctx = StructuralContext::new();
ctx.depth = 1;
ctx.push_block("impl".to_string());
assert!(ctx.is_inside("impl"));
assert_eq!(ctx.current_block_label(), Some("impl"));
ctx.depth = 0;
ctx.pop_exited_blocks();
assert!(!ctx.is_inside("impl"));
assert_eq!(ctx.current_block_label(), None);
}
#[test]
fn public_symbols_filter() {
let content = "pub fn visible() {}\nfn hidden() {}\npub(crate) fn semi() {}\n";
let grammar = rust_grammar();
let symbols = extract(content, &grammar);
let pub_syms = public_symbols(&symbols);
assert_eq!(pub_syms.len(), 3); }
}
#[cfg(test)]
mod integration_tests {
use super::*;
#[test]
fn load_and_use_rust_grammar() {
let grammar_path = std::path::Path::new(
"/var/lib/datamachine/workspace/homeboy-modules/rust/grammar.toml",
);
if !grammar_path.exists() {
eprintln!("Skipping: Rust grammar not found at {:?}", grammar_path);
return;
}
let grammar = load_grammar(grammar_path).expect("Failed to load Rust grammar");
assert_eq!(grammar.language.id, "rust");
assert!(grammar.patterns.contains_key("function"));
assert!(grammar.patterns.contains_key("struct"));
assert!(grammar.patterns.contains_key("import"));
let sample = r#"
use std::path::Path;
use crate::error::Result;
pub struct Config {
data: String,
}
impl Config {
pub fn new() -> Self {
Self { data: String::new() }
}
pub fn load(path: &Path) -> Result<Self> {
todo!()
}
fn private_helper(&self) {}
}
pub fn standalone(x: i32) -> bool {
x > 0
}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert!(true);
}
}
"#;
let symbols = extract(sample, &grammar);
let fns: Vec<_> = symbols.iter().filter(|s| s.concept == "function").collect();
assert!(
fns.len() >= 3,
"Expected at least 3 functions, got {}: {:?}",
fns.len(),
fns.iter().map(|f| f.name()).collect::<Vec<_>>()
);
let structs: Vec<_> = symbols.iter().filter(|s| s.concept == "struct").collect();
assert_eq!(structs.len(), 1);
assert_eq!(structs[0].name(), Some("Config"));
let imports: Vec<_> = symbols.iter().filter(|s| s.concept == "import").collect();
assert_eq!(imports.len(), 2);
let impls: Vec<_> = symbols
.iter()
.filter(|s| s.concept == "impl_block")
.collect();
assert_eq!(impls.len(), 1);
let cfg_tests: Vec<_> = symbols.iter().filter(|s| s.concept == "cfg_test").collect();
assert_eq!(cfg_tests.len(), 1);
let test_attrs: Vec<_> = symbols
.iter()
.filter(|s| s.concept == "test_attribute")
.collect();
assert_eq!(test_attrs.len(), 1);
}
#[test]
fn load_and_use_php_grammar() {
let grammar_path = std::path::Path::new(
"/var/lib/datamachine/workspace/homeboy-modules/wordpress/grammar.toml",
);
if !grammar_path.exists() {
eprintln!("Skipping: PHP grammar not found at {:?}", grammar_path);
return;
}
let grammar = load_grammar(grammar_path).expect("Failed to load PHP grammar");
assert_eq!(grammar.language.id, "php");
assert!(grammar.patterns.contains_key("method"));
assert!(grammar.patterns.contains_key("class"));
assert!(grammar.patterns.contains_key("namespace"));
let sample = r#"<?php
namespace DataMachine\Abilities;
use WP_UnitTestCase;
use DataMachine\Core\Pipeline;
class PipelineAbilities extends BaseAbilities {
public function register() {
add_action('init', [$this, 'setup']);
}
public function executeCreate($config) {
return new Pipeline($config);
}
protected function validate($input) {
return true;
}
private function internal() {}
public static function getInstance() {
return new static();
}
}
"#;
let symbols = extract(sample, &grammar);
let ns = namespace(&symbols);
assert_eq!(ns, Some("DataMachine\\Abilities".to_string()));
let classes: Vec<_> = symbols.iter().filter(|s| s.concept == "class").collect();
assert_eq!(classes.len(), 1);
assert_eq!(classes[0].name(), Some("PipelineAbilities"));
assert_eq!(classes[0].get("extends"), Some("BaseAbilities"));
let methods: Vec<_> = symbols.iter().filter(|s| s.concept == "method").collect();
assert!(
methods.len() >= 4,
"Expected at least 4 methods, got {}",
methods.len()
);
let imports: Vec<_> = symbols.iter().filter(|s| s.concept == "import").collect();
assert_eq!(imports.len(), 2);
let actions: Vec<_> = symbols
.iter()
.filter(|s| s.concept == "add_action")
.collect();
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].name(), Some("init"));
}
}