Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/codegraphcontext/tools/indexing/scip_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,9 @@ def should_skip_file(file_path: Path) -> bool:
ts_data = ts_parser.parse(file_path, is_dependency, index_source=True)
if "error" not in ts_data:
ts_funcs = {f["name"]: f for f in ts_data.get("functions", [])}
ts_funcs_at = {(f["name"], f["line_number"]): f for f in ts_data.get("functions", [])}
for f in file_data.get("functions", []):
ts_f = ts_funcs.get(f["name"])
ts_f = ts_funcs_at.get((f["name"], f["line_number"])) or ts_funcs.get(f["name"])
if ts_f:
f.update(
{
Expand All @@ -137,6 +138,8 @@ def should_skip_file(file_path: Path) -> bool:
"decorators": ts_f.get("decorators", []),
}
)
if not f.get("args") and ts_f.get("args"):
f["args"] = ts_f["args"]

ts_item_map = {}
ts_key_map = {}
Expand Down
8 changes: 7 additions & 1 deletion src/codegraphcontext/tools/languages/csharp.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,11 +525,17 @@ def _parse_calls(self, captures: list, source_code: str) -> list[dict]:
)
class_context = (cls_name, cls_line) if cls_name else None

call_node = node.parent
if call_node is not None and call_node.type in ("member_access_expression", "qualified_name"):
call_node = call_node.parent
arg_list = call_node.child_by_field_name("arguments") if call_node is not None else None
args = [self._get_node_text(a) for a in arg_list.named_children if a.type == "argument"] if arg_list else []

call_data = {
"name": call_name,
"full_name": full_call_name,
"line_number": line_number,
"args": [],
"args": args,
"inferred_obj_type": None,
"context": (context_name, context_type, context_line),
"class_context": class_context,
Expand Down
15 changes: 10 additions & 5 deletions src/codegraphcontext/tools/scip_indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,7 @@ def parse(self, index_scip_path: Path, project_path: Path) -> Dict[str, Any]:
elif src_line.startswith("trait "):
kind = 53 # Trait
info["kind"] = kind
elif sym.endswith("()."):
elif self._is_callable_symbol(sym):
info["kind"] = 26 if "#" in sym else 17

# Apply Rust implementations if available
Expand Down Expand Up @@ -623,9 +623,9 @@ def parse(self, index_scip_path: Path, project_path: Path) -> Dict[str, Any]:
kind = ck
if kind == 0:
# Check method before function: methods have # in symbol
if sym.endswith("().") and "#" in sym:
if self._is_callable_symbol(sym) and "#" in sym:
kind = 26 # Method
elif sym.endswith("()."):
elif self._is_callable_symbol(sym):
kind = 17 # Function
elif sym.endswith("#"):
# Check source to distinguish class/interface/trait
Expand Down Expand Up @@ -716,15 +716,20 @@ def parse(self, index_scip_path: Path, project_path: Path) -> Dict[str, Any]:

return {"files": files_data, "symbol_table": symbol_def_table}

@staticmethod
def _is_callable_symbol(sym: str) -> bool:
import re
return re.search(r"\((?:\+\d+)?\)\.$", sym) is not None

def _name_from_symbol(self, symbol: str) -> str:
import re
# scip-clang appends a hash in parentheses to disambiguate overloads / template args
s = re.sub(r"\([0-9a-fA-F]{4,}\)\.?$", "", symbol)
# Strip parameter descriptors: .($param), .($p1).($p2), etc.
s = re.sub(r"\.\(\$?[^)]*\)", "", s)
s = s.rstrip(".#")
# Remove function/method call markers: ()
s = re.sub(r"\(\)\.?$", "", s)
# Remove function/method call markers: () and the scip-dotnet overload marker (+N)
s = re.sub(r"\((?:\+\d+)?\)\.?$", "", s)
parts = re.split(r"[/#]", s)
name = parts[-1] if parts else symbol
name = re.sub(r"^`([^`]+)`$", r"\1", name)
Expand Down
84 changes: 84 additions & 0 deletions tests/unit/tools/test_scip_dotnet_overload_calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""With scip-dotnet `(+N)` overloads recognised, one C# file holds two `M` function
nodes. scip-dotnet leaves `display_name` empty, so both SCIP nodes arrive with
`args == []`; the Tree-sitter call resolver then cannot tell the overloads apart
and resolves neither call, leaving only the SCIP file-level edges.

The SCIP side is faked with exactly what ScipIndexParser yields for this source
(scip-dotnet main); the Tree-sitter parser and the call resolver are the real ones.
"""

from __future__ import annotations

from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

from codegraphcontext.tools.graph_builder import TreeSitterParser
from codegraphcontext.tools.indexing.scip_pipeline import run_scip_index_async

SOURCE = """namespace Kinds
{
public class OverloadUser
{
public void M() { }
public void M(int x) { }

public void Caller()
{
M();
M(1);
}
}
}
"""


class _FakeScipIndexer:
def run(self, _project_path: Path, _lang: str, output_dir: Path) -> Path:
scip_file = output_dir / "index.scip"
scip_file.write_bytes(b"fake")
return scip_file


class _FakeScipIndexParser:
files_data: dict = {}

def parse(self, _index_scip_path: Path, _project_path: Path) -> dict:
return {"files": self.files_data}


def _scip_function(name: str, line: int) -> dict:
return {"name": name, "line_number": line, "end_line": line, "docstring": None, "lang": "csharp",
"is_dependency": False, "return_type": None, "args": [], "cyclomatic_complexity": 1,
"decorators": [], "context": None, "class_context": None}


@pytest.mark.asyncio
async def test_calls_to_dotnet_overloads_resolve_to_the_matching_overload(tmp_path: Path) -> None:
repo = tmp_path / "repo"
repo.mkdir()
source = repo / "Calls.cs"
source.write_text(SOURCE, encoding="utf-8")
path = str(source.resolve())
_FakeScipIndexParser.files_data = {
path: {"functions": [_scip_function("M", 5), _scip_function("M", 6), _scip_function("Caller", 8)],
"classes": [{"name": "OverloadUser", "line_number": 3, "end_line": 3, "bases": [], "context": None}],
"variables": [], "imports": [], "function_calls_scip": [], "module_level_calls_scip": [],
"path": path, "lang": "csharp", "is_dependency": False},
}
writer = MagicMock()
ts_parser = TreeSitterParser("c_sharp")

await run_scip_index_async(
repo, False, None, "csharp", writer, MagicMock(), [".cs"],
lambda ext: ts_parser if ext == ".cs" else None,
SimpleNamespace(ScipIndexer=_FakeScipIndexer, ScipIndexParser=_FakeScipIndexParser),
)

fn_to_fn = writer.write_function_call_groups.call_args.args[0]
assert sorted((c["caller_name"], c["line_number"], c["called_name"], c["called_line_number"]) for c in fn_to_fn) == [
("Caller", 10, "M", 5),
("Caller", 11, "M", 6),
]
75 changes: 75 additions & 0 deletions tests/unit/tools/test_scip_dotnet_overloads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Tests for scip-dotnet overload symbols."""

from pathlib import Path

from codegraphcontext.tools import scip_pb2
from codegraphcontext.tools.scip_indexer import ScipIndexParser

PREFIX = "scip-dotnet nuget . . Overloads/"
SOURCE = """namespace Overloads
{
public class Calc
{
public void M() { }
public void M(int x) { }
public void M(string x) { }

public void Caller()
{
M();
M(1);
M("a");
}
}
}
"""
OCCURRENCES = [
("Calc#", [2, 17, 21], 1),
("Calc#M().", [4, 20, 21], 1),
("Calc#M(+1).", [5, 20, 21], 1),
("Calc#M(+1).(x)", [5, 26, 27], 1),
("Calc#M(+2).", [6, 20, 21], 1),
("Calc#M(+2).(x)", [6, 29, 30], 1),
("Calc#Caller().", [8, 20, 26], 1),
("Calc#M().", [10, 12, 13], 0),
("Calc#M(+1).", [11, 12, 13], 0),
("Calc#M(+2).", [12, 12, 13], 0),
]


def _parse(tmp_path: Path) -> dict:
index = scip_pb2.Index()
doc = index.documents.add()
doc.relative_path = "Calc.cs"
doc.language = "C#"
for suffix, rng, roles in OCCURRENCES:
occ = doc.occurrences.add()
occ.symbol = PREFIX + suffix
occ.range.extend(rng)
occ.symbol_roles = roles
for suffix in dict.fromkeys(s for s, _, roles in OCCURRENCES if roles & 1):
info = doc.symbols.add()
info.symbol = PREFIX + suffix
info.kind = 0
scip_path = tmp_path / "index.scip"
scip_path.write_bytes(index.SerializeToString())
(tmp_path / "Calc.cs").write_text(SOURCE)
result = ScipIndexParser().parse(scip_path, tmp_path)
return result["files"][str((tmp_path / "Calc.cs").resolve())]


def test_every_dotnet_overload_definition_becomes_a_function_node(tmp_path: Path) -> None:
file_data = _parse(tmp_path)
assert sorted(f["line_number"] for f in file_data["functions"]) == [5, 6, 7, 9]


def test_dotnet_overload_names_drop_the_disambiguator(tmp_path: Path) -> None:
parser = ScipIndexParser()
assert parser._name_from_symbol(PREFIX + "Calc#M().") == "M"
assert parser._name_from_symbol(PREFIX + "Calc#M(+1).") == "M"
assert parser._name_from_symbol(PREFIX + "Calc#M(+12).") == "M"

file_data = _parse(tmp_path)
calls = file_data["function_calls_scip"] + file_data["module_level_calls_scip"]
assert sorted((c["ref_line"], c["callee_name"]) for c in calls) == [(11, "M"), (12, "M"), (13, "M")]
assert len({c["callee_symbol"] for c in calls}) == 3