diff --git a/codeanalyzer/neo4j/project.py b/codeanalyzer/neo4j/project.py index 33589cc..da3e562 100644 --- a/codeanalyzer/neo4j/project.py +++ b/codeanalyzer/neo4j/project.py @@ -547,6 +547,7 @@ def _callable_props(c: PyCallable, file_key: str, source: str) -> Props: "end_line": c.end_line, "docstring": _docstring_of(c.comments), "decorators": [d.qualified_name or d.name for d in (c.decorators or [])], + "modifiers": list(c.modifiers or []), "parameters_json": _stringify_if(c.parameters), "accessed_symbols_json": _stringify_if(c.accessed_symbols), "_module": file_key, diff --git a/codeanalyzer/neo4j/schema.py b/codeanalyzer/neo4j/schema.py index 272c311..49536ae 100644 --- a/codeanalyzer/neo4j/schema.py +++ b/codeanalyzer/neo4j/schema.py @@ -125,6 +125,7 @@ class RelType: **_SPAN, "docstring": "string", "decorators": "string[]", + "modifiers": "string[]", "parameters_json": "string", "accessed_symbols_json": "string", "_module": "string", diff --git a/codeanalyzer/schema/py_schema.py b/codeanalyzer/schema/py_schema.py index d1b61b4..9b5e254 100644 --- a/codeanalyzer/schema/py_schema.py +++ b/codeanalyzer/schema/py_schema.py @@ -338,6 +338,12 @@ class PyCallable(BaseModel): span: Optional[Span] = None comments: List[PyComment] = [] decorators: List[PyDecorator] = [] + # Language-level modifiers on the declaration itself (#130). `async` is the + # only one Python has today. It lives here rather than in `kind` because it + # is orthogonal to every kind -- an async method is both -- so encoding it + # in the discriminant would need async_function, async_method, + # async_generator and so on, combinatorially. + modifiers: List[str] = [] entrypoints: List[PyEntrypoint] = [] is_entrypoint: bool = False parameters: List[PyCallableParameter] = [] diff --git a/codeanalyzer/syntactic_analysis/symbol_table_builder.py b/codeanalyzer/syntactic_analysis/symbol_table_builder.py index 126b2a1..d9f1fc9 100644 --- a/codeanalyzer/syntactic_analysis/symbol_table_builder.py +++ b/codeanalyzer/syntactic_analysis/symbol_table_builder.py @@ -334,6 +334,10 @@ def _callables(self, node: AST, script: Script, source: str, prefix: str = "") - getattr(child, "end_col_offset", child.col_offset)), ) decorators = self._decorators(child, script, source) + # `async def` is a declaration modifier, not a distinct kind (#130). + modifiers = ( + ["async"] if isinstance(child, ast.AsyncFunctionDef) else [] + ) if prefix: # We're in a nested context - build signature with prefix @@ -359,6 +363,7 @@ def _callables(self, node: AST, script: Script, source: str, prefix: str = "") - .signature(signature) # Use the full signature here .span(span) .decorators(decorators) + .modifiers(modifiers) .start_line(start_line) .end_line(end_line) .code_start_line(child.body[0].lineno if child.body else start_line) diff --git a/schema.neo4j.json b/schema.neo4j.json index 17893b2..251444f 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -67,6 +67,7 @@ "end_line": "integer", "docstring": "string", "decorators": "string[]", + "modifiers": "string[]", "parameters_json": "string", "accessed_symbols_json": "string", "_module": "string", diff --git a/test/test_async_modifier.py b/test/test_async_modifier.py new file mode 100644 index 0000000..751b722 --- /dev/null +++ b/test/test_async_modifier.py @@ -0,0 +1,85 @@ +"""`async def` is recorded as a modifier, not a kind (#130). + +`kind` is the specific callable kind — function/method/constructor/lambda — and +async is orthogonal to every one of them: encoding it there would need +`async_function`, `async_method`, `async_generator`… combinatorially. The +keystone reserves `modifiers: string[]` for exactly this and says `kind` is not +a place for `is_*` flags, so `async` goes in `modifiers` and `kind` is untouched. +""" +from pathlib import Path + +import pytest + +jedi = pytest.importorskip("jedi") + +from codeanalyzer.syntactic_analysis.symbol_table_builder import SymbolTableBuilder + +SRC = '''\ +async def top_level(): + return 1 + + +def plain(): + return 2 + + +async def agen(): + yield 3 + + +def gen(): + yield 4 + + +class C: + async def method(self): + return 5 + + def sync_method(self): + return 6 + + +def outer(): + async def nested(): + return 7 + return nested +''' + + +@pytest.fixture +def module(tmp_path: Path): + f = tmp_path / "m.py" + f.write_text(SRC) + return SymbolTableBuilder(tmp_path, None).build_pymodule_from_file(f) + + +def _fn(module, name): + if name in module.functions: + return module.functions[name] + for cls in module.types.values(): + if name in cls.callables: + return cls.callables[name] + for fn in module.functions.values(): + if name in (fn.callables or {}): + return fn.callables[name] + raise KeyError(name) + + +@pytest.mark.parametrize("name", ["top_level", "agen", "method", "nested"]) +def test_async_callables_carry_the_modifier(module, name): + assert "async" in _fn(module, name).modifiers + + +@pytest.mark.parametrize("name", ["plain", "gen", "sync_method", "outer"]) +def test_sync_callables_do_not(module, name): + assert "async" not in _fn(module, name).modifiers + + +def test_kind_is_untouched(module): + """async is a modifier; the kind discriminant must not gain a variant.""" + assert _fn(module, "top_level").kind == _fn(module, "plain").kind + + +def test_an_async_generator_is_not_confused_with_a_plain_one(module): + assert "async" in _fn(module, "agen").modifiers + assert "async" not in _fn(module, "gen").modifiers