CoolFace
Apppublic

salim0986/graph-bug-ai

sourceHugging Facemitupdated 4mo agoView on Hugging Face
0likes
parser.py575 linesDownload Raw Back to src
1"""2Universal Parser — M1 upgrade3Extracts declarations AND structural relationships (calls, imports, inheritance, type-use)4from source files using tree-sitter AST queries.5 6Backward-compatible: parse_file() / parse_code() still return (captures, code_bytes)7New: extract_relationships() returns a ParseReport with typed records.8"""9 10import os11from dataclasses import dataclass, field12from typing import List, Optional, Tuple, Any13from tree_sitter_languages import get_language, get_parser14import tree_sitter15from .logger import setup_logger16 17logger = setup_logger(__name__)18 19# ---------------------------------------------------------------------------20# Language → file-extension map (unchanged from original)21# ---------------------------------------------------------------------------22 23EXTENSION_MAP = {24    # Web / JS25    ".js": "javascript",26    ".jsx": "javascript",27    ".ts": "typescript",28    ".tsx": "tsx",29    ".vue": "vue",30    ".html": "html",31    ".css": "css",32    ".json": "json",33    # Backend / Core34    ".py": "python",35    ".go": "go",36    ".rs": "rust",37    ".java": "java",38    ".rb": "ruby",39    ".php": "php",40    ".cs": "c_sharp",41    ".cpp": "cpp",42    ".c": "c",43    ".h": "c",44    ".hpp": "cpp",45    # Systems / Scripting46    ".sh": "bash",47    ".lua": "lua",48    ".yaml": "yaml",49    ".yml": "yaml",50    ".toml": "toml",51    # Config / Infra52    ".tf": "hcl",53    ".dockerfile": "dockerfile",54    ".make": "make",55    # Docs56    ".md": "markdown",57}58 59 60def detect_language_from_filename(filename: str) -> Optional[str]:61    """Return language name from file extension, or None if unsupported."""62    _, ext = os.path.splitext(filename)63    return EXTENSION_MAP.get(ext.lower())64 65 66# ---------------------------------------------------------------------------67# Typed relationship records68# ---------------------------------------------------------------------------69 70@dataclass71class Declaration:72    """A named declaration (function, class, method, interface, struct …)."""73    name: str74    type: str          # "function" | "class" | "interface" | "method" | "struct" | "other"75    file: str76    start_line: int77    end_line: int78    code: str = ""     # first 2000 chars of the declaration body79 80 81@dataclass82class Call:83    """A function/method call site."""84    caller: str        # enclosing function name, or "module_level"85    callee: str        # name of the function/method being called86    file: str87    line: int88 89 90@dataclass91class Import:92    """An import / include / use statement."""93    file: str94    module: str        # module or path being imported95    alias: Optional[str]  # specific name imported (e.g. `from os import path`)96    line: int97 98 99@dataclass100class Inheritance:101    """A class-extends or class-implements relationship."""102    child: str         # the subclass / implementing class103    parent: str        # the superclass or interface104    file: str105    line: int106 107 108@dataclass109class TypeUse:110    """A type annotation reference inside a function or class."""111    context: str       # enclosing function / class name112    type_name: str113    file: str114    line: int115 116 117@dataclass118class ParseReport:119    """Full extraction result for one file."""120    file: str121    language: Optional[str]122    status: str = "failed"   # "success" | "partial" | "failed"123    declarations: List[Declaration] = field(default_factory=list)124    calls: List[Call] = field(default_factory=list)125    imports: List[Import] = field(default_factory=list)126    inheritances: List[Inheritance] = field(default_factory=list)127    type_uses: List[TypeUse] = field(default_factory=list)128    errors: List[str] = field(default_factory=list)129 130    @property131    def nodes_extracted(self) -> int:132        return len(self.declarations)133 134    @property135    def relationships_extracted(self) -> int:136        return len(self.calls) + len(self.imports) + len(self.inheritances)137 138 139# ---------------------------------------------------------------------------140# Node-type → declaration-type mapping141# ---------------------------------------------------------------------------142 143_DECL_TYPE_MAP = {144    "function_definition":   "function",145    "function_declaration":  "function",146    "function_item":         "function",147    "method_definition":     "method",148    "method_declaration":    "method",149    "class_definition":      "class",150    "class_declaration":     "class",151    "class_specifier":       "class",152    "struct_item":           "struct",153    "struct_specifier":      "struct",154    "interface_declaration": "interface",155    "trait_item":            "interface",156    "impl_item":             "class",157    "enum_declaration":      "class",158    "enum_item":             "class",159    "abstract_class_declaration": "class",160    "namespace_declaration": "other",161    "type_alias_declaration": "other",162}163 164# Node types that represent a function/method scope (for enclosing-name lookup)165_ENCLOSING_FUNC_TYPES = frozenset({166    "function_definition",167    "function_declaration",168    "function_item",169    "method_definition",170    "method_declaration",171    "arrow_function",172    "function_expression",173    "closure_expression",174    "lambda",175})176 177# Class-scope node types across supported languages.178# Used to detect when a function_definition is actually a method.179_CLASS_SCOPE_TYPES = frozenset({180    "class_definition",    # Python181    "class_declaration",   # JS / TS / Java / C#182    "class_specifier",     # C++183    "impl_item",           # Rust184    "class_body",          # Java (body of a class)185    "declaration_list",    # C# class body186})187 188 189def _is_inside_class(func_node: Any) -> bool:190    """191    Return True when *func_node* is a method — i.e. its nearest enclosing192    structural scope is a class, not another function.193 194    Stops walking upward the moment it hits either:195    - a class-scope node  → True (it's a method)196    - another function node → False (it's a nested function, not a method)197    """198    current = func_node.parent199    while current is not None:200        t = current.type201        if t in _CLASS_SCOPE_TYPES:202            return True203        if t in _ENCLOSING_FUNC_TYPES:204            return False  # Crossed a function boundary before any class205        current = current.parent206    return False207 208 209# ---------------------------------------------------------------------------210# Main parser class211# ---------------------------------------------------------------------------212 213class UniversalParser:214    def __init__(self):215        current_dir = os.path.dirname(os.path.abspath(__file__))216        self.base_query_path = os.path.join(current_dir, "queries")217 218    # ------------------------------------------------------------------219    # BACKWARD-COMPATIBLE API220    # ------------------------------------------------------------------221 222    def parse_file(self, file_path: str) -> Tuple[Optional[Any], Optional[bytes]]:223        """224        Original interface: (captures, code_bytes) or (None, None).225        Still used by graph_builder.process_file_nodes().226        """227        _, ext = os.path.splitext(file_path)228        lang_name = EXTENSION_MAP.get(ext)229        if not lang_name:230            return None, None231        try:232            language = get_language(lang_name)233            parser = get_parser(lang_name)234            with open(file_path, "rb") as f:235                code_bytes = f.read()236            tree = parser.parse(code_bytes)237            query_file = os.path.join(self.base_query_path, lang_name, "tags.scm")238            if not os.path.exists(query_file):239                return None, None240            with open(query_file) as f:241                query_scm = f.read()242            query = language.query(query_scm)243            captures = query.captures(tree.root_node)244            return captures, code_bytes245        except FileNotFoundError:246            logger.warning(f"File not found: {file_path}")247            return None, None248        except Exception as e:249            logger.warning(f"Parser error in {file_path}: {e}")250            return None, None251 252    def parse_file_ast(self, file_path: str) -> Tuple[Optional[Any], Optional[bytes], Optional[str]]:253        """254        Returns (tree, code_bytes, language_name).255        Used by the new SemanticChunker in M2.256        """257        _, ext = os.path.splitext(file_path)258        lang_name = EXTENSION_MAP.get(ext)259        if not lang_name:260            return None, None, None261        try:262            parser = get_parser(lang_name)263            with open(file_path, "rb") as f:264                code_bytes = f.read()265            tree = parser.parse(code_bytes)266            return tree, code_bytes, lang_name267        except FileNotFoundError:268            logger.warning(f"File not found: {file_path}")269            return None, None, None270        except Exception as e:271            logger.warning(f"Parser error in {file_path}: {e}")272            return None, None, None273 274    def parse_code(self, code_bytes: bytes, lang_name: str) -> Tuple[Optional[Any], Optional[bytes]]:275        """276        Original interface: (captures, code_bytes) or (None, None).277        """278        if not lang_name or lang_name == "text":279            return None, None280        try:281            language = get_language(lang_name)282            parser = get_parser(lang_name)283            tree = parser.parse(code_bytes)284            query_file = os.path.join(self.base_query_path, lang_name, "tags.scm")285            if not os.path.exists(query_file):286                return None, None287            with open(query_file) as f:288                query_scm = f.read()289            query = language.query(query_scm)290            captures = query.captures(tree.root_node)291            return captures, code_bytes292        except Exception as e:293            logger.warning(f"Parser error for {lang_name}: {e}")294            return None, None295 296    # ------------------------------------------------------------------297    # NEW M1 API — relationship extraction298    # ------------------------------------------------------------------299 300    def extract_relationships(301        self,302        code_bytes: bytes,303        lang_name: str,304        file_path: str = "",305    ) -> ParseReport:306        """307        Extract declarations + structural relationships from code bytes.308 309        Returns a ParseReport with:310          - declarations  (functions, classes, interfaces …)311          - calls         (caller → callee)312          - imports       (module dependencies)313          - inheritances  (extends / implements)314          - type_uses     (type annotations)315          - status        success | partial | failed316          - errors        human-readable error list317        """318        report = ParseReport(file=file_path, language=lang_name)319 320        if not lang_name or lang_name == "text":321            report.errors.append(f"Unsupported language: {lang_name!r}")322            return report323 324        try:325            language = get_language(lang_name)326            ts_parser = get_parser(lang_name)327            tree = ts_parser.parse(code_bytes)328        except Exception as e:329            report.errors.append(f"Tree-sitter init error: {e}")330            return report331 332        # --- Step 1: declarations from tags.scm ---333        tags_ok = self._extract_declarations(tree, language, code_bytes, file_path, report)334 335        # --- Step 2: relationships from relationships.scm ---336        rels_ok = self._extract_rels(tree, language, code_bytes, file_path, report)337 338        # --- Step 3: detect AST syntax errors ---339        # tree-sitter is error-tolerant; it inserts ERROR/MISSING nodes for invalid340        # syntax rather than refusing to parse.  These nodes produce garbage captures.341        has_syntax_errors = tree.root_node.has_error342        if has_syntax_errors:343            report.errors.append("Source contains syntax errors — AST ERROR nodes present; results may be incomplete")344 345        if tags_ok and rels_ok and not has_syntax_errors:346            report.status = "success"347        elif tags_ok or rels_ok:348            report.status = "partial"349        else:350            report.status = "failed"351 352        logger.debug(353            f"[Parser] {file_path} ({lang_name}): "354            f"status={report.status} decls={report.nodes_extracted} "355            f"calls={len(report.calls)} imports={len(report.imports)} "356            f"inherit={len(report.inheritances)}"357        )358        return report359 360    def extract_relationships_from_file(self, file_path: str) -> ParseReport:361        """Convenience wrapper: read file then call extract_relationships()."""362        _, ext = os.path.splitext(file_path)363        lang_name = EXTENSION_MAP.get(ext.lower())364        report = ParseReport(file=file_path, language=lang_name)365 366        if not lang_name:367            report.errors.append(f"Unsupported extension: {ext!r}")368            return report369        try:370            with open(file_path, "rb") as f:371                code_bytes = f.read()372            return self.extract_relationships(code_bytes, lang_name, file_path)373        except FileNotFoundError:374            report.errors.append(f"File not found: {file_path}")375            return report376        except Exception as e:377            report.errors.append(f"Read error: {e}")378            return report379 380    # ------------------------------------------------------------------381    # Internal helpers382    # ------------------------------------------------------------------383 384    def _extract_declarations(385        self,386        tree: Any,387        language: Any,388        code_bytes: bytes,389        file_path: str,390        report: ParseReport,391    ) -> bool:392        """Populate report.declarations from tags.scm."""393        tags_file = os.path.join(self.base_query_path, language.__class__.__name__.lower(), "tags.scm")394        # language object doesn't expose name directly; use a workaround395        lang_name = report.language or ""396        tags_file = os.path.join(self.base_query_path, lang_name, "tags.scm")397 398        if not os.path.exists(tags_file):399            report.errors.append(f"No tags.scm for {lang_name}")400            return False401        try:402            with open(tags_file) as f:403                tags_scm = f.read()404            query = language.query(tags_scm)405            captures = query.captures(tree.root_node)406        except Exception as e:407            report.errors.append(f"tags.scm compile/run error: {e}")408            return False409 410        seen: set = set()411        for node, cap_name in captures:412            if cap_name != "name":413                continue414            parent = node.parent415            if parent is None:416                continue417            parent_type = parent.type418            # Avoid duplicate decls (same start byte)419            key = (parent.start_byte, parent_type)420            if key in seen:421                continue422            seen.add(key)423 424            decl_type = _DECL_TYPE_MAP.get(parent_type, "other")425            # Promote function_definition → "method" when the nearest enclosing426            # structural scope is a class.  Python methods are syntactically427            # function_definitions; only context distinguishes them.428            if decl_type == "function" and _is_inside_class(parent):429                decl_type = "method"430            name_text = code_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="ignore")431            body = code_bytes[parent.start_byte:parent.end_byte].decode("utf-8", errors="ignore")[:2000]432 433            report.declarations.append(Declaration(434                name=name_text,435                type=decl_type,436                file=file_path,437                start_line=parent.start_point[0],438                end_line=parent.end_point[0],439                code=body,440            ))441        return True442 443    def _extract_rels(444        self,445        tree: Any,446        language: Any,447        code_bytes: bytes,448        file_path: str,449        report: ParseReport,450    ) -> bool:451        """Populate calls/imports/inheritances/type_uses from relationships.scm."""452        lang_name = report.language or ""453        rels_file = os.path.join(self.base_query_path, lang_name, "relationships.scm")454 455        if not os.path.exists(rels_file):456            report.errors.append(f"No relationships.scm for {lang_name}")457            return False458        try:459            with open(rels_file) as f:460                rels_scm = f.read()461            query = language.query(rels_scm)462            captures = query.captures(tree.root_node)463        except Exception as e:464            report.errors.append(f"relationships.scm error: {e}")465            return False466 467        # Process captures in document order (tree-sitter guarantees this)468        # We track "current inherit child" while walking the list so that469        # subsequent inherit.parent captures can reference the right child.470        current_inherit_child: Optional[Tuple[str, int]] = None  # (name, line)471        current_implement_child: Optional[Tuple[str, int]] = None472        current_implement_interface: Optional[Tuple[str, int]] = None473        pending_import_module: Optional[Tuple[str, int]] = None  # (module_text, line)474        # Dedup set: prevent the same call expression from being recorded twice475        # when multiple SCM patterns match the same AST node.476        seen_calls: set = set()477 478        for node, cap_name in captures:479            text = code_bytes[node.start_byte:node.end_byte].decode("utf-8", errors="ignore").strip()480            line = node.start_point[0]481 482            if cap_name == "call.callee":483                caller = self._find_enclosing_name(node, code_bytes)484                if text:485                    call_key = (caller, text, line)486                    if call_key not in seen_calls:487                        seen_calls.add(call_key)488                        report.calls.append(Call(caller=caller, callee=text, file=file_path, line=line))489 490            elif cap_name == "import.module":491                # Strip surrounding quotes (string literals in some languages)492                module = text.strip("\"'`")493                pending_import_module = (module, line)494                report.imports.append(Import(file=file_path, module=module, alias=None, line=line))495 496            elif cap_name == "import.name":497                name_clean = text.strip("\"'`")498                if pending_import_module and report.imports:499                    # Attach the specific name to the most recent import500                    last = report.imports[-1]501                    if last.alias is None:502                        report.imports[-1] = Import(503                            file=last.file,504                            module=last.module,505                            alias=name_clean,506                            line=last.line,507                        )508                else:509                    report.imports.append(Import(file=file_path, module=name_clean, alias=None, line=line))510 511            elif cap_name == "inherit.child":512                current_inherit_child = (text, line)513 514            elif cap_name == "inherit.parent":515                if current_inherit_child and text:516                    child_name, child_line = current_inherit_child517                    report.inheritances.append(Inheritance(518                        child=child_name,519                        parent=text,520                        file=file_path,521                        line=child_line,522                    ))523 524            elif cap_name == "implement.child":525                current_implement_child = (text, line)526                if current_implement_interface:527                    interface_name, _ = current_implement_interface528                    report.inheritances.append(Inheritance(529                        child=text,530                        parent=interface_name,531                        file=file_path,532                        line=line,533                    ))534                    current_implement_child = None535                    current_implement_interface = None536 537            elif cap_name == "implement.interface":538                current_implement_interface = (text, line)539                if current_implement_child and text:540                    child_name, child_line = current_implement_child541                    report.inheritances.append(Inheritance(542                        child=child_name,543                        parent=text,544                        file=file_path,545                        line=child_line,546                    ))547                    current_implement_child = None548                    current_implement_interface = None549 550            elif cap_name == "type.use":551                context = self._find_enclosing_name(node, code_bytes)552                if text and text not in ("None", "void", "bool", "int", "str", "float"):553                    report.type_uses.append(TypeUse(554                        context=context,555                        type_name=text,556                        file=file_path,557                        line=line,558                    ))559 560        return True561 562    def _find_enclosing_name(self, node: Any, code_bytes: bytes) -> str:563        """564        Walk up the AST to find the nearest enclosing function/method name.565        Returns "module_level" if no enclosing function is found.566        """567        current = node.parent568        while current is not None:569            if current.type in _ENCLOSING_FUNC_TYPES:570                name_node = current.child_by_field_name("name")571                if name_node:572                    return code_bytes[name_node.start_byte:name_node.end_byte].decode("utf-8", errors="ignore")573            current = current.parent574        return "module_level"575