diff --git a/codebase_rag/constants/ast_nodes.py b/codebase_rag/constants/ast_nodes.py index 12101483e..ddd62427f 100644 --- a/codebase_rag/constants/ast_nodes.py +++ b/codebase_rag/constants/ast_nodes.py @@ -112,6 +112,7 @@ QUERY_LOCALS = "locals" QUERY_CONFIG = "config" QUERY_LANGUAGE = "language" +QUERY_HIGHLIGHTS = "highlights" # (H) Query capture names CAPTURE_FUNCTION = "function" @@ -119,6 +120,38 @@ CAPTURE_CALL = "call" CAPTURE_IMPORT = "import" CAPTURE_IMPORT_FROM = "import_from" +CAPTURE_KEYWORD_MODIFIER = "keyword.modifier" +CAPTURE_KEYWORD = "keyword" +CAPTURE_ATTRIBUTE = "attribute" +CAPTURE_FUNCTION_DECORATOR = "function.decorator" + +# (H) Modifier extraction +EXCLUDED_KEYWORDS = frozenset( + { + "def", + "class", + "fn", + "struct", + "impl", + "interface", + "enum", + "function", + "trait", + "type", + "void", + "None", + "True", + "False", + "null", + "true", + "false", + "return", + "import", + "from", + "as", + "where", + } +) # (H) Tree-sitter Python import node types TS_IMPORT_STATEMENT = "import_statement" diff --git a/codebase_rag/constants/graph.py b/codebase_rag/constants/graph.py index 65439529c..561b22c9e 100644 --- a/codebase_rag/constants/graph.py +++ b/codebase_rag/constants/graph.py @@ -187,6 +187,7 @@ class AuditCheck(StrEnum): # (H) Property keys KEY_PARAMETERS = "parameters" KEY_DECORATORS = "decorators" +KEY_MODIFIERS = "modifiers" KEY_DOCSTRING = "docstring" KEY_IS_EXPORTED = "is_exported" # (H) Marks a method that overrides a method of an EXTERNAL stdlib base class diff --git a/codebase_rag/parser_loader.py b/codebase_rag/parser_loader.py index f9d6ebae6..232921107 100644 --- a/codebase_rag/parser_loader.py +++ b/codebase_rag/parser_loader.py @@ -244,6 +244,48 @@ def _create_locals_query( return None +def _create_highlights_query( + language: Language, lang_name: cs.SupportedLanguage +) -> Query | None: + query_str = "" + + # (H) TSX shares the TypeScript grammar for highlights + query_lang_name = ( + cs.SupportedLanguage.TS if lang_name == cs.SupportedLanguage.TSX else lang_name + ) + + try: + module_name = ( + f"{cs.TREE_SITTER_MODULE_PREFIX}{query_lang_name.replace('-', '_')}" + ) + module = importlib.import_module(module_name) + if hasattr(module, "HIGHLIGHTS_QUERY"): + query_str = module.HIGHLIGHTS_QUERY + except Exception as e: + logger.debug( + f"Failed to load standard highlights query for {query_lang_name}: {e}" + ) + + try: + fallback_path = ( + Path(__file__).parent / "queries" / "highlights" / f"{query_lang_name}.scm" + ) + if fallback_path.exists(): + custom_queries = fallback_path.read_text(encoding="utf-8") + query_str = ( + query_str + "\n" + custom_queries if query_str else custom_queries + ) + + if query_str: + return Query(language, query_str) + except Exception as e: + logger.debug( + f"Failed to load fallback highlights query for {query_lang_name}: {e}" + ) + + return None + + COMBINED_FUNC_CLASS_QUERIES: dict[cs.SupportedLanguage, Query | None] = {} COMBINED_FUNC_CLASS_IMPORT_QUERIES: dict[cs.SupportedLanguage, Query | None] = {} @@ -287,6 +329,7 @@ def _create_language_queries( calls=_create_optional_query(language, call_patterns), imports=_create_optional_query(language, combined_import_patterns), locals=_create_locals_query(language, lang_name), + highlights=_create_highlights_query(language, lang_name), config=lang_config, language=language, parser=parser, diff --git a/codebase_rag/parsers/class_ingest/mixin.py b/codebase_rag/parsers/class_ingest/mixin.py index 8e6c0b7fc..e087b5de0 100644 --- a/codebase_rag/parsers/class_ingest/mixin.py +++ b/codebase_rag/parsers/class_ingest/mixin.py @@ -31,6 +31,7 @@ from ..rs import RustTypeInferenceEngine from ..rs import utils as rs_utils from ..utils import ( + extract_modifiers_and_decorators, function_span_key, ingest_method, record_cpp_definition_span, @@ -686,10 +687,15 @@ def _process_class_node( ) node_type = nt.determine_node_type(class_node, class_name, class_qn, language) + modifiers, decorators = extract_modifiers_and_decorators( + class_node, lang_queries + ) + class_props: PropertyDict = { cs.KEY_QUALIFIED_NAME: class_qn, cs.KEY_NAME: class_name, - cs.KEY_DECORATORS: self._extract_decorators(class_node), + cs.KEY_MODIFIERS: modifiers, + cs.KEY_DECORATORS: decorators, cs.KEY_START_LINE: class_node.start_point[0] + 1, cs.KEY_END_LINE: class_node.end_point[0] + 1, cs.KEY_DOCSTRING: self._get_docstring(class_node), @@ -956,8 +962,8 @@ def _ingest_class_methods( self.simple_name_lookup, self._get_docstring, language, - self._extract_decorators, - method_qualified_name, + lang_queries=lang_queries, + method_qualified_name=method_qualified_name, file_path=file_path, repo_path=self.repo_path, external_override_names=external_override_names, diff --git a/codebase_rag/parsers/cpp_frontend/frontend.py b/codebase_rag/parsers/cpp_frontend/frontend.py index f100c697a..818cc4055 100644 --- a/codebase_rag/parsers/cpp_frontend/frontend.py +++ b/codebase_rag/parsers/cpp_frontend/frontend.py @@ -115,6 +115,7 @@ def _node_props(self, cursor: Cursor, qn: str, name: str, rel: str) -> PropertyD return { cs.KEY_QUALIFIED_NAME: qn, cs.KEY_NAME: name, + cs.KEY_MODIFIERS: [], cs.KEY_DECORATORS: [], cs.KEY_START_LINE: cursor.location.line, cs.KEY_END_LINE: cursor.extent.end.line, diff --git a/codebase_rag/parsers/function_ingest.py b/codebase_rag/parsers/function_ingest.py index 26cdeb654..df36f2e1d 100644 --- a/codebase_rag/parsers/function_ingest.py +++ b/codebase_rag/parsers/function_ingest.py @@ -30,6 +30,7 @@ from .rs import utils as rs_utils from .utils import ( callable_parameter_indices, + extract_modifiers_and_decorators, function_span_key, get_function_captures, ingest_method, @@ -95,6 +96,7 @@ class _DeferredJsAnonymous(NamedTuple): module_qn: str language: cs.SupportedLanguage lang_config: LanguageSpec + lang_queries: LanguageQueries class _DeferredMethod(NamedTuple): @@ -115,6 +117,7 @@ class resolves scope-first: two same-leaf classes (ast::Type and start_line: int start_col: int end_line: int + lang_queries: LanguageQueries | None = None class _DeferredGoMethod(NamedTuple): @@ -124,6 +127,7 @@ class _DeferredGoMethod(NamedTuple): module_qn: str receiver_type: str file_path: Path | None + lang_queries: LanguageQueries | None = None class _DeferredCppContainment(NamedTuple): @@ -177,9 +181,6 @@ class FunctionIngestMixin: @abstractmethod def _get_docstring(self, node: ASTNode) -> str | None: ... - @abstractmethod - def _extract_decorators(self, node: ASTNode) -> list[str]: ... - def _ingest_all_functions( self, root_node: Node, @@ -197,6 +198,7 @@ def _ingest_all_functions( if not result: return lang_config, captures = result + lang_queries = queries[language] file_path = self.module_qn_to_file_path.get(module_qn) has_classes = bool(captures.get(cs.CAPTURE_CLASS)) @@ -205,7 +207,9 @@ def _ingest_all_functions( continue if language == cs.SupportedLanguage.CPP: - if self._handle_cpp_out_of_class_method(func_node, module_qn): + if self._handle_cpp_out_of_class_method( + func_node, module_qn, lang_queries + ): continue # (H) The query captures a templated function twice: the # (H) template_declaration wrapper AND its inner definition. @@ -220,7 +224,7 @@ def _ingest_all_functions( continue if language == cs.SupportedLanguage.GO and self._defer_go_receiver_method( - func_node, module_qn + func_node, module_qn, lang_queries ): continue @@ -238,13 +242,18 @@ def _ingest_all_functions( if language in cs.JS_TS_LANGUAGES and resolution.is_anonymous: self._deferred_js_anonymous.append( _DeferredJsAnonymous( - func_node, resolution, module_qn, language, lang_config + func_node, + resolution, + module_qn, + language, + lang_config, + lang_queries, ) ) continue self._register_function( - func_node, resolution, module_qn, language, lang_config + func_node, resolution, module_qn, language, lang_config, lang_queries ) # (H) Record a free C++ function's return type so a chained call off a @@ -310,6 +319,7 @@ def _flush_deferred_js_anonymous(self) -> None: entry.module_qn, entry.language, entry.lang_config, + entry.lang_queries, ) def _resolve_function_identity( @@ -431,7 +441,12 @@ def _is_cpp_defined(self, qn: str) -> bool: return suffix in cs.CPP_EXTENSIONS or suffix in cs.C_EXTENSIONS return False - def _handle_cpp_out_of_class_method(self, func_node: Node, module_qn: str) -> bool: + def _handle_cpp_out_of_class_method( + self, + func_node: Node, + module_qn: str, + lang_queries: LanguageQueries | None = None, + ) -> bool: if not cpp_utils.is_out_of_class_method_definition(func_node): return False @@ -471,7 +486,7 @@ def _handle_cpp_out_of_class_method(self, func_node: Node, module_qn: str) -> bo simple_name_lookup=self.simple_name_lookup, get_docstring_func=self._get_docstring, language=cs.SupportedLanguage.CPP, - extract_decorators_func=self._extract_decorators, + lang_queries=lang_queries, file_path=file_path, repo_path=self.repo_path, ) @@ -508,9 +523,15 @@ def _handle_cpp_out_of_class_method(self, func_node: Node, module_qn: str) -> bo method_name = cpp_utils.extract_function_name(func_node) if not method_name: return True - decorators = self._extract_decorators(func_node) + decorators = [] + modifiers = [] + if lang_queries: + modifiers, decorators = extract_modifiers_and_decorators( + func_node, lang_queries + ) props: PropertyDict = { cs.KEY_NAME: method_name, + cs.KEY_MODIFIERS: modifiers, cs.KEY_DECORATORS: decorators, cs.KEY_START_LINE: func_node.start_point[0] + 1, cs.KEY_END_LINE: func_node.end_point[0] + 1, @@ -537,6 +558,7 @@ def _handle_cpp_out_of_class_method(self, func_node: Node, module_qn: str) -> bo start_line=func_node.start_point[0] + 1, start_col=func_node.start_point[1], end_line=func_node.end_point[0] + 1, + lang_queries=lang_queries, ) ) @@ -625,7 +647,12 @@ def resolve_deferred_cpp_methods(self) -> int: self._deferred_cpp_methods = [] return ingested - def _defer_go_receiver_method(self, func_node: Node, module_qn: str) -> bool: + def _defer_go_receiver_method( + self, + func_node: Node, + module_qn: str, + lang_queries: LanguageQueries | None = None, + ) -> bool: if not go_utils.is_receiver_method(func_node): return False receiver_type = go_utils.extract_receiver_type_name(func_node) @@ -774,6 +801,7 @@ def _register_function( module_qn: str, language: cs.SupportedLanguage, lang_config: LanguageSpec, + lang_queries: LanguageQueries, ) -> None: unique_qn = self.function_registry.register_unique_qn( resolution.qualified_name, func_node.start_point[0] + 1 @@ -781,7 +809,9 @@ def _register_function( if unique_qn != resolution.qualified_name: resolution = resolution._replace(qualified_name=unique_qn) - func_props = self._build_function_props(func_node, resolution, module_qn) + func_props = self._build_function_props( + func_node, resolution, module_qn, lang_queries + ) is_macro = func_node.type == cs.TS_RS_MACRO_DEFINITION if is_macro: # (H) Rust macros live in a separate namespace from functions; @@ -863,13 +893,21 @@ def _record_cpp_template_child_location( break def _build_function_props( - self, func_node: Node, resolution: FunctionResolution, module_qn: str + self, + func_node: Node, + resolution: FunctionResolution, + module_qn: str, + lang_queries: LanguageQueries, ) -> PropertyDict: file_path = self.module_qn_to_file_path.get(module_qn) + modifiers, decorators = extract_modifiers_and_decorators( + func_node, lang_queries + ) props: PropertyDict = { cs.KEY_QUALIFIED_NAME: resolution.qualified_name, cs.KEY_NAME: resolution.name, - cs.KEY_DECORATORS: self._extract_decorators(func_node), + cs.KEY_MODIFIERS: modifiers, + cs.KEY_DECORATORS: decorators, cs.KEY_START_LINE: func_node.start_point[0] + 1, cs.KEY_END_LINE: func_node.end_point[0] + 1, cs.KEY_DOCSTRING: self._get_docstring(func_node), diff --git a/codebase_rag/parsers/utils.py b/codebase_rag/parsers/utils.py index f3065068f..3032d9808 100644 --- a/codebase_rag/parsers/utils.py +++ b/codebase_rag/parsers/utils.py @@ -126,6 +126,71 @@ def get_function_captures( return FunctionCapturesResult(lang_config, captures) +def extract_modifiers_and_decorators( + node: ASTNode, lang_queries: LanguageQueries +) -> tuple[list[str], list[str]]: + query = lang_queries.get(cs.QUERY_HIGHLIGHTS) + if not query: + return [], [] + + cursor = get_query_cursor(query) + + body_node = node.child_by_field_name(cs.FIELD_BODY) + header_end_byte = body_node.start_byte if body_node else node.end_byte + + target_node = node + if node.parent and node.parent.type in ( + cs.TS_PY_DECORATED_DEFINITION, + cs.TS_EXPORT_STATEMENT, + ): + target_node = node.parent + + query_nodes = [target_node] + curr_sibling = target_node.prev_named_sibling + while curr_sibling and ( + curr_sibling.type == cs.TS_RS_ATTRIBUTE_ITEM + or ( + target_node.type == cs.TS_METHOD_DEFINITION + and curr_sibling.type == cs.TS_DECORATOR + ) + ): + query_nodes.insert(0, curr_sibling) + curr_sibling = curr_sibling.prev_named_sibling + + modifiers: list[str] = [] + decorators: list[str] = [] + + for q_node in query_nodes: + if q_node == target_node: + cursor.set_byte_range(q_node.start_byte, header_end_byte) + else: + cursor.set_byte_range(q_node.start_byte, q_node.end_byte) + + captures = sorted_captures(cursor, q_node) + for name, nodes in captures.items(): + if ( + name.startswith(cs.CAPTURE_KEYWORD_MODIFIER) + or name == cs.CAPTURE_KEYWORD + ): + for n in nodes: + text = safe_decode_text(n) + if ( + text + and text not in modifiers + and text not in cs.EXCLUDED_KEYWORDS + ): + modifiers.append(text) + elif name.startswith(cs.CAPTURE_ATTRIBUTE) or name.startswith( + cs.CAPTURE_FUNCTION_DECORATOR + ): + for n in nodes: + text = safe_decode_text(n) + if text and text not in decorators: + decorators.append(text) + + return modifiers, decorators + + @lru_cache(maxsize=50000) def _cached_decode_bytes(text_bytes: bytes) -> str: return text_bytes.decode(cs.ENCODING_UTF8) @@ -155,7 +220,10 @@ def contains_node(parent: ASTNode, target: ASTNode) -> bool: def _decorator_tail_names(decorators: list[str]) -> set[str]: return { - decorator.lstrip(cs.DECORATOR_AT).split(cs.SEPARATOR_DOT)[-1] + decorator.lstrip("@#[]() ") + .split("(")[0] + .split(cs.SEPARATOR_DOT)[-1] + .rstrip(")] ") for decorator in decorators } @@ -588,7 +656,7 @@ def ingest_method( simple_name_lookup: SimpleNameLookup, get_docstring_func: Callable[[ASTNode], str | None], language: cs.SupportedLanguage | None = None, - extract_decorators_func: Callable[[ASTNode], list[str]] | None = None, + lang_queries: LanguageQueries | None = None, method_qualified_name: str | None = None, file_path: Path | None = None, repo_path: Path | None = None, @@ -623,7 +691,12 @@ def ingest_method( method_qn, method_node.start_point[0] + 1 ) - decorators = extract_decorators_func(method_node) if extract_decorators_func else [] + decorators = [] + modifiers = [] + if lang_queries: + modifiers, decorators = extract_modifiers_and_decorators( + method_node, lang_queries + ) # (H) Local import breaks the export_detection -> cpp.utils -> utils cycle. from . import export_detection @@ -631,6 +704,7 @@ def ingest_method( method_props: PropertyDict = { cs.KEY_QUALIFIED_NAME: method_qn, cs.KEY_NAME: method_name, + cs.KEY_MODIFIERS: modifiers, cs.KEY_DECORATORS: decorators, cs.KEY_START_LINE: method_node.start_point[0] + 1, cs.KEY_END_LINE: method_node.end_point[0] + 1, @@ -723,6 +797,7 @@ def module_function_props( props: PropertyDict = { cs.KEY_QUALIFIED_NAME: function_qn, cs.KEY_NAME: function_name, + cs.KEY_MODIFIERS: [], cs.KEY_DECORATORS: [], cs.KEY_START_LINE: function_node.start_point[0] + 1, cs.KEY_END_LINE: function_node.end_point[0] + 1, diff --git a/codebase_rag/queries/highlights/java.scm b/codebase_rag/queries/highlights/java.scm new file mode 100644 index 000000000..210b3b0df --- /dev/null +++ b/codebase_rag/queries/highlights/java.scm @@ -0,0 +1,17 @@ +(marker_annotation) @function.decorator +(annotation) @function.decorator + +[ + "public" + "protected" + "private" + "abstract" + "static" + "final" + "strictfp" + "default" + "synchronized" + "native" + "transient" + "volatile" +] @keyword.modifier diff --git a/codebase_rag/queries/highlights/javascript.scm b/codebase_rag/queries/highlights/javascript.scm new file mode 100644 index 000000000..54feece79 --- /dev/null +++ b/codebase_rag/queries/highlights/javascript.scm @@ -0,0 +1,17 @@ +(decorator) @function.decorator + +[ + "async" + "export" + "default" + "static" + "get" + "set" + "public" + "private" + "protected" + "readonly" + "abstract" + "declare" + "override" +] @keyword.modifier diff --git a/codebase_rag/queries/highlights/php.scm b/codebase_rag/queries/highlights/php.scm new file mode 100644 index 000000000..b16e26845 --- /dev/null +++ b/codebase_rag/queries/highlights/php.scm @@ -0,0 +1,11 @@ +(attribute_group) @function.decorator + +[ + "public" + "protected" + "private" + "abstract" + "final" + "static" + "readonly" +] @keyword.modifier diff --git a/codebase_rag/queries/highlights/python.scm b/codebase_rag/queries/highlights/python.scm new file mode 100644 index 000000000..3bc865d37 --- /dev/null +++ b/codebase_rag/queries/highlights/python.scm @@ -0,0 +1,5 @@ +(decorator) @function.decorator + +[ + "async" +] @keyword.modifier diff --git a/codebase_rag/queries/highlights/rust.scm b/codebase_rag/queries/highlights/rust.scm new file mode 100644 index 000000000..6635df4d8 --- /dev/null +++ b/codebase_rag/queries/highlights/rust.scm @@ -0,0 +1,11 @@ +(attribute_item) @function.decorator +(inner_attribute_item) @function.decorator + +[ + "pub" + "async" + "unsafe" + "extern" + "const" + "default" +] @keyword.modifier diff --git a/codebase_rag/queries/highlights/typescript.scm b/codebase_rag/queries/highlights/typescript.scm new file mode 100644 index 000000000..54feece79 --- /dev/null +++ b/codebase_rag/queries/highlights/typescript.scm @@ -0,0 +1,17 @@ +(decorator) @function.decorator + +[ + "async" + "export" + "default" + "static" + "get" + "set" + "public" + "private" + "protected" + "readonly" + "abstract" + "declare" + "override" +] @keyword.modifier diff --git a/codebase_rag/tests/test_function_ingest.py b/codebase_rag/tests/test_function_ingest.py index 1d7b6e8a6..2ae1cf0ba 100644 --- a/codebase_rag/tests/test_function_ingest.py +++ b/codebase_rag/tests/test_function_ingest.py @@ -466,8 +466,9 @@ def test_basic_function_props( is_exported=False, ) + lang_queries = parsers_and_queries[1][cs.SupportedLanguage.PYTHON] result = definition_processor._build_function_props( - func_node, resolution, "proj.module" + func_node, resolution, "proj.module", lang_queries ) assert result["qualified_name"] == "proj.module.my_function" @@ -499,8 +500,9 @@ def test_exported_function_props( is_exported=True, ) + lang_queries = parsers_and_queries[1][cs.SupportedLanguage.PYTHON] result = definition_processor._build_function_props( - func_node, resolution, "proj.module" + func_node, resolution, "proj.module", lang_queries ) assert result["is_exported"] is True diff --git a/codebase_rag/tests/test_graph_audit.py b/codebase_rag/tests/test_graph_audit.py index 796f04698..27e30d66e 100644 --- a/codebase_rag/tests/test_graph_audit.py +++ b/codebase_rag/tests/test_graph_audit.py @@ -32,6 +32,7 @@ def _function(qn: str) -> GraphNodeRecord: props: PropertyDict = { "qualified_name": qn, "name": qn.rsplit(".", 1)[-1], + "modifiers": [], "decorators": [], "path": "src/mod.py", "absolute_path": "/repo/src/mod.py", diff --git a/codebase_rag/tests/test_modifiers_and_decorators.py b/codebase_rag/tests/test_modifiers_and_decorators.py new file mode 100644 index 000000000..8670812a5 --- /dev/null +++ b/codebase_rag/tests/test_modifiers_and_decorators.py @@ -0,0 +1,197 @@ +import pytest + +from codebase_rag import constants as cs +from codebase_rag.parser_loader import _create_highlights_query, load_parsers +from codebase_rag.parsers.utils import ( + _decorator_tail_names, + extract_modifiers_and_decorators, +) +from codebase_rag.tests.test_function_ingest import find_first_node_of_type, parse_code + + +def test_decorator_tail_names_with_arguments() -> None: + decorators = ["@cached_property(ttl=3600)", "#[test]", "@abc.abstractmethod()"] + result = _decorator_tail_names(decorators) + assert result == {"cached_property", "test", "abstractmethod"} + + +def test_python_def_class_not_in_modifiers() -> None: + parsers, queries = load_parsers() + + code = "def my_func(): pass" + root = parse_code(code, cs.SupportedLanguage.PYTHON, parsers) + func_node = find_first_node_of_type(root, "function_definition") + assert func_node is not None + lang_queries = queries[cs.SupportedLanguage.PYTHON] + + modifiers, _ = extract_modifiers_and_decorators(func_node, lang_queries) + assert "def" not in modifiers + + code_class = "class MyClass: pass" + root_class = parse_code(code_class, cs.SupportedLanguage.PYTHON, parsers) + class_node = find_first_node_of_type(root_class, "class_definition") + assert class_node is not None + + modifiers_class, _ = extract_modifiers_and_decorators(class_node, lang_queries) + assert "class" not in modifiers_class + + +def test_python_decorated_definition() -> None: + parsers, queries = load_parsers() + + code_dec = "@cached_property\ndef decorated_func(): pass" + root_dec = parse_code(code_dec, cs.SupportedLanguage.PYTHON, parsers) + func_node_dec = find_first_node_of_type(root_dec, "function_definition") + assert func_node_dec is not None + lang_queries = queries[cs.SupportedLanguage.PYTHON] + + _, decorators = extract_modifiers_and_decorators(func_node_dec, lang_queries) + assert "@cached_property" in decorators + + +def test_rust_fn_not_in_modifiers() -> None: + parsers, queries = load_parsers() + + code = "fn my_func() {}" + root = parse_code(code, cs.SupportedLanguage.RUST, parsers) + func_node = find_first_node_of_type(root, "function_item") + assert func_node is not None + lang_queries = queries[cs.SupportedLanguage.RUST] + + modifiers, _ = extract_modifiers_and_decorators(func_node, lang_queries) + assert "fn" not in modifiers + + +def test_rust_outer_attributes_captured() -> None: + parsers, queries = load_parsers() + + code = "#[test]\n#[derive(Debug)]\nfn my_func() {}" + root = parse_code(code, cs.SupportedLanguage.RUST, parsers) + func_node = find_first_node_of_type(root, "function_item") + assert func_node is not None + lang_queries = queries[cs.SupportedLanguage.RUST] + + _, decorators = extract_modifiers_and_decorators(func_node, lang_queries) + assert "#[test]" in decorators + assert "#[derive(Debug)]" in decorators + + +def test_fallback_scm_loads_on_import_failure(monkeypatch: pytest.MonkeyPatch) -> None: + parsers, queries = load_parsers() + + real_lang = parsers[cs.SupportedLanguage.PYTHON].language + + def mock_import_module(name: str) -> None: + raise ImportError("Mocked import failure") + + monkeypatch.setattr("importlib.import_module", mock_import_module) + + query = _create_highlights_query(real_lang, cs.SupportedLanguage.PYTHON) + assert query is not None + + +def test_fallback_scm_missing_and_import_fails(monkeypatch: pytest.MonkeyPatch) -> None: + parsers, queries = load_parsers() + + real_lang = parsers[cs.SupportedLanguage.PYTHON].language + + def mock_import_module(name: str) -> None: + raise ImportError("Mocked import failure") + + monkeypatch.setattr("importlib.import_module", mock_import_module) + + from pathlib import Path + + original_exists = Path.exists + + def mock_exists(self: Path) -> bool: + if "highlights" in str(self) and "python.scm" in str(self): + return False + return original_exists(self) + + monkeypatch.setattr(Path, "exists", mock_exists) + + query = _create_highlights_query(real_lang, cs.SupportedLanguage.PYTHON) + assert query is None + + +def test_fallback_scm_read_fails(monkeypatch: pytest.MonkeyPatch) -> None: + parsers, queries = load_parsers() + + real_lang = parsers[cs.SupportedLanguage.PYTHON].language + + def mock_import_module(name: str) -> None: + raise ImportError("Mocked import failure") + + monkeypatch.setattr("importlib.import_module", mock_import_module) + + from pathlib import Path + + original_read_text = Path.read_text + + def mock_read_text(self: Path, *args: any, **kwargs: any) -> str: + if "highlights" in str(self) and "python.scm" in str(self): + raise OSError("Mocked read failure") + return original_read_text(self, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", mock_read_text) + + query = _create_highlights_query(real_lang, cs.SupportedLanguage.PYTHON) + assert query is None + + +def test_typescript_decorated_method() -> None: + parsers, queries = load_parsers() + code = "class Foo { @methodDec decoratedMethod(): void {} }" + root = parse_code(code, cs.SupportedLanguage.TS, parsers) + method_node = find_first_node_of_type(root, cs.TS_METHOD_DEFINITION) + assert method_node is not None + lang_queries = queries[cs.SupportedLanguage.TS] + _, decorators = extract_modifiers_and_decorators(method_node, lang_queries) + assert "@methodDec" in decorators + + +def test_tsx_decorated_method() -> None: + parsers, queries = load_parsers() + code = "class Foo { @methodDec decoratedMethod(): void {} }" + root = parse_code(code, cs.SupportedLanguage.TSX, parsers) + method_node = find_first_node_of_type(root, cs.TS_METHOD_DEFINITION) + assert method_node is not None + lang_queries = queries[cs.SupportedLanguage.TSX] + assert lang_queries["highlights"] is not None, "TSX highlights query should load" + _, decorators = extract_modifiers_and_decorators(method_node, lang_queries) + assert "@methodDec" in decorators + + +def test_java_modifiers_present() -> None: + parsers, queries = load_parsers() + code = "public static void foo() {}" + root = parse_code(code, cs.SupportedLanguage.JAVA, parsers) + func_node = find_first_node_of_type(root, "method_declaration") + assert func_node is not None + lang_queries = queries[cs.SupportedLanguage.JAVA] + modifiers, _ = extract_modifiers_and_decorators(func_node, lang_queries) + assert "public" in modifiers + assert "static" in modifiers + + +def test_php_single_attribute_captured_once() -> None: + parsers, queries = load_parsers() + code = " None: + parsers, queries = load_parsers() + code = "