srb-lens 0.5.1

Static analysis tool for Sorbet-typed Ruby projects — extracts method signatures, call graphs, and type information from Sorbet's CFG, symbol table, and parse tree
Documentation
use serde::Deserialize;
use std::collections::HashMap;

#[derive(Debug)]
pub struct SymbolTree {
    pub root: RawSymbol,
}

#[derive(Debug, Deserialize)]
pub struct RawSymbol {
    pub id: u64,
    pub name: SymbolName,
    pub kind: String,
    #[serde(rename = "superClass")]
    pub super_class: Option<u64>,
    pub mixins: Option<Vec<u64>>,
    #[serde(rename = "isModule")]
    pub is_module: Option<bool>,
    pub arguments: Option<Vec<RawArgument>>,
    pub children: Option<Vec<RawSymbol>>,
}

#[derive(Debug, Deserialize)]
pub struct SymbolName {
    pub kind: String,
    pub name: String,
    pub unique: Option<String>,
}

#[derive(Debug, Deserialize)]
pub struct RawArgument {
    pub name: SymbolName,
    #[serde(rename = "isBlock")]
    pub is_block: Option<bool>,
    #[serde(rename = "isDefault")]
    pub is_default: Option<bool>,
    #[serde(rename = "isKeyword")]
    pub is_keyword: Option<bool>,
    #[serde(rename = "isRepeated")]
    pub is_repeated: Option<bool>,
}

impl SymbolTree {
    pub fn build_id_map(&self) -> HashMap<u64, String> {
        let mut map = HashMap::new();
        build_id_map_recursive(&self.root, "", &mut map);
        map
    }
}

fn build_id_map_recursive(symbol: &RawSymbol, parent_fqn: &str, map: &mut HashMap<u64, String>) {
    let fqn = if parent_fqn.is_empty() || symbol.name.name == "<root>" {
        symbol.name.name.clone()
    } else {
        format!("{}::{}", parent_fqn, symbol.name.name)
    };

    if symbol.name.name != "<root>" {
        map.insert(symbol.id, fqn.clone());
    }

    if let Some(children) = &symbol.children {
        let current_fqn = if symbol.name.name == "<root>" {
            ""
        } else {
            &fqn
        };
        for child in children {
            build_id_map_recursive(child, current_fqn, map);
        }
    }
}

pub fn parse(json: &str) -> Result<SymbolTree, SymbolTableParseError> {
    let root: RawSymbol = serde_json::from_str(json)?;
    Ok(SymbolTree { root })
}

#[derive(Debug, thiserror::Error)]
pub enum SymbolTableParseError {
    #[error("JSON parse error: {0}")]
    Json(#[from] serde_json::Error),
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_and_id_map() {
        let json = r#"{
            "id": 24,
            "name": { "kind": "CONSTANT", "name": "<root>" },
            "kind": "CLASS_OR_MODULE",
            "superClass": 48,
            "children": [
                {
                    "id": 32336,
                    "name": { "kind": "CONSTANT", "name": "Booth" },
                    "kind": "CLASS_OR_MODULE",
                    "superClass": 32288,
                    "mixins": [42464, 42432],
                    "children": [
                        {
                            "id": 308897,
                            "name": { "kind": "UTF8", "name": "archive!" },
                            "kind": "METHOD",
                            "arguments": [
                                { "name": { "kind": "UTF8", "name": "<blk>" }, "isBlock": true }
                            ]
                        }
                    ]
                },
                {
                    "id": 32288,
                    "name": { "kind": "CONSTANT", "name": "ApplicationRecord" },
                    "kind": "CLASS_OR_MODULE",
                    "superClass": 48
                }
            ]
        }"#;

        let tree = parse(json).unwrap();
        assert_eq!(tree.root.name.name, "<root>");

        let id_map = tree.build_id_map();
        assert_eq!(id_map.get(&32336), Some(&"Booth".to_string()));
        assert_eq!(id_map.get(&32288), Some(&"ApplicationRecord".to_string()));
        assert_eq!(id_map.get(&308897), Some(&"Booth::archive!".to_string()));
    }

    #[test]
    fn test_parse_method_with_args() {
        let json = r#"{
            "id": 24,
            "name": { "kind": "CONSTANT", "name": "<root>" },
            "kind": "CLASS_OR_MODULE",
            "children": [
                {
                    "id": 100,
                    "name": { "kind": "CONSTANT", "name": "Campaign" },
                    "kind": "CLASS_OR_MODULE",
                    "isModule": false,
                    "superClass": 200,
                    "children": [
                        {
                            "id": 1001,
                            "name": { "kind": "UTF8", "name": "active?" },
                            "kind": "METHOD",
                            "arguments": [
                                { "name": { "kind": "UTF8", "name": "at" }, "isBlock": false },
                                { "name": { "kind": "UTF8", "name": "<blk>" }, "isBlock": true }
                            ]
                        }
                    ]
                }
            ]
        }"#;

        let tree = parse(json).unwrap();
        let campaign = &tree.root.children.as_ref().unwrap()[0];
        assert_eq!(campaign.is_module, Some(false));

        let method = &campaign.children.as_ref().unwrap()[0];
        assert_eq!(method.name.name, "active?");
        let args = method.arguments.as_ref().unwrap();
        assert_eq!(args.len(), 2);
        assert_eq!(args[0].name.name, "at");
        assert_eq!(args[0].is_block, Some(false));
        assert_eq!(args[1].name.name, "<blk>");
        assert_eq!(args[1].is_block, Some(true));
    }

    #[test]
    fn test_nested_modules() {
        let json = r#"{
            "id": 24,
            "name": { "kind": "CONSTANT", "name": "<root>" },
            "kind": "CLASS_OR_MODULE",
            "children": [
                {
                    "id": 100,
                    "name": { "kind": "CONSTANT", "name": "AdminArea" },
                    "kind": "CLASS_OR_MODULE",
                    "isModule": true,
                    "children": [
                        {
                            "id": 200,
                            "name": { "kind": "CONSTANT", "name": "CampaignsController" },
                            "kind": "CLASS_OR_MODULE",
                            "superClass": 300
                        }
                    ]
                }
            ]
        }"#;

        let tree = parse(json).unwrap();
        let id_map = tree.build_id_map();
        assert_eq!(id_map.get(&100), Some(&"AdminArea".to_string()));
        assert_eq!(
            id_map.get(&200),
            Some(&"AdminArea::CampaignsController".to_string())
        );
    }
}