diff --git a/README.md b/README.md index bc14845..d771674 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ a graph representation of your source code, the graph name should be the same as the name of the folder you've requested to analyze, for the example above a graph named: "GraphRAG-SDK". -At the moment only the Python and C languages are supported, we do intend to support additional languages. +At the moment Python, Java, and C# languages are supported, we do intend to support additional languages. At this point you can explore and query your source code using various tools Here are several options: diff --git a/api/analyzers/analyzer.py b/api/analyzers/analyzer.py index 73d2661..bd849f2 100644 --- a/api/analyzers/analyzer.py +++ b/api/analyzers/analyzer.py @@ -127,7 +127,7 @@ def add_symbols(self, entity: Entity) -> None: pass @abstractmethod - def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> Entity: + def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]: """ Resolve a symbol to an entity. @@ -138,7 +138,7 @@ def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_ symbol (Node): The symbol node. Returns: - Entity: The entity. + list[Entity]: The resolved entities. """ pass diff --git a/api/analyzers/csharp/__init__.py b/api/analyzers/csharp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/analyzers/csharp/analyzer.py b/api/analyzers/csharp/analyzer.py new file mode 100644 index 0000000..9b968c5 --- /dev/null +++ b/api/analyzers/csharp/analyzer.py @@ -0,0 +1,144 @@ +import subprocess +from pathlib import Path + +from multilspy import SyncLanguageServer +from ...entities.entity import Entity +from ...entities.file import File +from typing import Optional +from ..analyzer import AbstractAnalyzer + +import tree_sitter_c_sharp as tscsharp +from tree_sitter import Language, Node, QueryCursor + +import logging +logger = logging.getLogger('code_graph') + +class CSharpAnalyzer(AbstractAnalyzer): + def __init__(self) -> None: + super().__init__(Language(tscsharp.language())) + + def _captures(self, pattern: str, node: Node) -> dict: + """Run a tree-sitter query and return captures dict.""" + query = self.language.query(pattern) + cursor = QueryCursor(query) + return cursor.captures(node) + + def add_dependencies(self, path: Path, files: list[Path]): + if Path(f"{path}/temp_deps_cs").is_dir(): + return + if any(Path(f"{path}").glob("*.csproj")) or any(Path(f"{path}").glob("*.sln")): + subprocess.run(["dotnet", "restore"], cwd=str(path)) + + def get_entity_label(self, node: Node) -> str: + if node.type == 'class_declaration': + return "Class" + elif node.type == 'interface_declaration': + return "Interface" + elif node.type == 'enum_declaration': + return "Enum" + elif node.type == 'struct_declaration': + return "Struct" + elif node.type == 'method_declaration': + return "Method" + elif node.type == 'constructor_declaration': + return "Constructor" + raise ValueError(f"Unknown entity type: {node.type}") + + def get_entity_name(self, node: Node) -> str: + if node.type in ['class_declaration', 'interface_declaration', 'enum_declaration', + 'struct_declaration', 'method_declaration', 'constructor_declaration']: + name_node = node.child_by_field_name('name') + if name_node is None: + return '' + return name_node.text.decode('utf-8') + raise ValueError(f"Unknown entity type: {node.type}") + + def get_entity_docstring(self, node: Node) -> Optional[str]: + if node.type in ['class_declaration', 'interface_declaration', 'enum_declaration', + 'struct_declaration', 'method_declaration', 'constructor_declaration']: + # Walk back through contiguous comment siblings to collect + # multi-line XML doc comments (each /// line is a separate node) + lines = [] + sibling = node.prev_sibling + while sibling and sibling.type == "comment": + lines.insert(0, sibling.text.decode('utf-8')) + sibling = sibling.prev_sibling + return '\n'.join(lines) if lines else None + raise ValueError(f"Unknown entity type: {node.type}") + + def get_entity_types(self) -> list[str]: + return ['class_declaration', 'interface_declaration', 'enum_declaration', + 'struct_declaration', 'method_declaration', 'constructor_declaration'] + + def add_symbols(self, entity: Entity) -> None: + if entity.node.type in ['class_declaration', 'struct_declaration']: + base_list_captures = self._captures("(base_list (_) @base_type)", entity.node) + if 'base_type' in base_list_captures: + first = True + for base_type in base_list_captures['base_type']: + if first and entity.node.type == 'class_declaration': + # NOTE: Without semantic analysis, we cannot distinguish a base + # class from an interface in C# base_list. By convention, the + # base class is listed first; if a class only implements + # interfaces, this will produce a spurious base_class edge that + # the LSP resolution in second_pass can correct. + entity.add_symbol("base_class", base_type) + first = False + else: + entity.add_symbol("implement_interface", base_type) + elif entity.node.type == 'interface_declaration': + base_list_captures = self._captures("(base_list (_) @base_type)", entity.node) + if 'base_type' in base_list_captures: + for base_type in base_list_captures['base_type']: + entity.add_symbol("extend_interface", base_type) + elif entity.node.type in ['method_declaration', 'constructor_declaration']: + captures = self._captures("(invocation_expression) @reference.call", entity.node) + if 'reference.call' in captures: + for caller in captures['reference.call']: + entity.add_symbol("call", caller) + captures = self._captures("(parameter_list (parameter type: (_) @parameter))", entity.node) + if 'parameter' in captures: + for parameter in captures['parameter']: + entity.add_symbol("parameters", parameter) + if entity.node.type == 'method_declaration': + return_type = entity.node.child_by_field_name('type') + if return_type: + entity.add_symbol("return_type", return_type) + + def is_dependency(self, file_path: str) -> bool: + return "temp_deps_cs" in file_path + + def resolve_path(self, file_path: str, path: Path) -> str: + return file_path + + def resolve_type(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]: + res = [] + for file, resolved_node in self.resolve(files, lsp, file_path, path, node): + type_dec = self.find_parent(resolved_node, ['class_declaration', 'interface_declaration', 'enum_declaration', 'struct_declaration']) + if type_dec in file.entities: + res.append(file.entities[type_dec]) + return res + + def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, node: Node) -> list[Entity]: + res = [] + if node.type == 'invocation_expression': + func_node = node.child_by_field_name('function') + if func_node and func_node.type == 'member_access_expression': + func_node = func_node.child_by_field_name('name') + if func_node: + node = func_node + for file, resolved_node in self.resolve(files, lsp, file_path, path, node): + method_dec = self.find_parent(resolved_node, ['method_declaration', 'constructor_declaration', 'class_declaration', 'interface_declaration', 'enum_declaration', 'struct_declaration']) + if method_dec and method_dec.type in ['class_declaration', 'interface_declaration', 'enum_declaration', 'struct_declaration']: + continue + if method_dec in file.entities: + res.append(file.entities[method_dec]) + return res + + def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]: + if key in ["implement_interface", "base_class", "extend_interface", "parameters", "return_type"]: + return self.resolve_type(files, lsp, file_path, path, symbol) + elif key in ["call"]: + return self.resolve_method(files, lsp, file_path, path, symbol) + else: + raise ValueError(f"Unknown key {key}") diff --git a/api/analyzers/java/analyzer.py b/api/analyzers/java/analyzer.py index 4ae01d5..0facd3d 100644 --- a/api/analyzers/java/analyzer.py +++ b/api/analyzers/java/analyzer.py @@ -125,7 +125,7 @@ def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_ res.append(file.entities[method_dec]) return res - def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> Entity: + def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]: if key in ["implement_interface", "base_class", "extend_interface", "parameters", "return_type"]: return self.resolve_type(files, lsp, file_path, path, symbol) elif key in ["call"]: diff --git a/api/analyzers/python/analyzer.py b/api/analyzers/python/analyzer.py index 80bba88..a4dd445 100644 --- a/api/analyzers/python/analyzer.py +++ b/api/analyzers/python/analyzer.py @@ -114,7 +114,7 @@ def resolve_method(self, files: dict[Path, File], lsp: SyncLanguageServer, file_ res.append(file.entities[method_dec]) return res - def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> Entity: + def resolve_symbol(self, files: dict[Path, File], lsp: SyncLanguageServer, file_path: Path, path: Path, key: str, symbol: Node) -> list[Entity]: if key in ["base_class", "parameters", "return_type"]: return self.resolve_type(files, lsp, file_path, path, symbol) elif key in ["call"]: diff --git a/api/analyzers/source_analyzer.py b/api/analyzers/source_analyzer.py index 4e1385b..40d410b 100644 --- a/api/analyzers/source_analyzer.py +++ b/api/analyzers/source_analyzer.py @@ -10,6 +10,7 @@ # from .c.analyzer import CAnalyzer from .java.analyzer import JavaAnalyzer from .python.analyzer import PythonAnalyzer +from .csharp.analyzer import CSharpAnalyzer from multilspy import SyncLanguageServer from multilspy.multilspy_config import MultilspyConfig @@ -24,7 +25,8 @@ # '.c': CAnalyzer(), # '.h': CAnalyzer(), '.py': PythonAnalyzer(), - '.java': JavaAnalyzer()} + '.java': JavaAnalyzer(), + '.cs': CSharpAnalyzer()} class NullLanguageServer: def start_server(self): @@ -136,7 +138,12 @@ def second_pass(self, graph: Graph, files: list[Path], path: Path) -> None: lsps[".py"] = SyncLanguageServer.create(config, logger, str(path)) else: lsps[".py"] = NullLanguageServer() - with lsps[".java"].start_server(), lsps[".py"].start_server(): + if any(path.rglob('*.cs')): + config = MultilspyConfig.from_dict({"code_language": "csharp"}) + lsps[".cs"] = SyncLanguageServer.create(config, logger, str(path)) + else: + lsps[".cs"] = NullLanguageServer() + with lsps[".java"].start_server(), lsps[".py"].start_server(), lsps[".cs"].start_server(): files_len = len(self.files) for i, file_path in enumerate(files): file = self.files[file_path] @@ -166,7 +173,8 @@ def analyze_files(self, files: list[Path], path: Path, graph: Graph) -> None: self.second_pass(graph, files, path) def analyze_sources(self, path: Path, ignore: list[str], graph: Graph) -> None: - files = list(path.rglob("*.java")) + list(path.rglob("*.py")) + path = path.resolve() + files = list(path.rglob("*.java")) + list(path.rglob("*.py")) + list(path.rglob("*.cs")) # First pass analysis of the source code self.first_pass(path, files, ignore, graph) diff --git a/pyproject.toml b/pyproject.toml index 6a0dc5c..acde18a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "tree-sitter-c>=0.24.1,<0.25.0", "tree-sitter-python>=0.25.0,<0.26.0", "tree-sitter-java>=0.23.5,<0.24.0", + "tree-sitter-c-sharp>=0.23.1,<0.24.0", "flask>=3.1.0,<4.0.0", "python-dotenv>=1.0.1,<2.0.0", "multilspy @ git+https://github.com/AviAvni/multilspy.git@python-init-params", diff --git a/tests/source_files/csharp/Program.cs b/tests/source_files/csharp/Program.cs new file mode 100644 index 0000000..0f61809 --- /dev/null +++ b/tests/source_files/csharp/Program.cs @@ -0,0 +1,48 @@ +using System; + +namespace TestProject +{ + public interface ILogger + { + void Log(string message); + } + + public class ConsoleLogger : ILogger + { + public void Log(string message) + { + Console.WriteLine(message); + } + } + + /// + /// Represents a task to be executed. + /// + public class Task + { + public string Name { get; set; } + public int Duration { get; set; } + + private ILogger _logger; + + public Task(string name, int duration, ILogger logger) + { + Name = name; + Duration = duration; + _logger = logger; + _logger.Log("Task created: " + name); + } + + public bool Execute() + { + _logger.Log("Executing: " + Name); + return true; + } + + public void Abort(float delay) + { + _logger.Log("Aborting: " + Name); + Execute(); + } + } +} diff --git a/tests/test_csharp_analyzer.py b/tests/test_csharp_analyzer.py new file mode 100644 index 0000000..da2d1ad --- /dev/null +++ b/tests/test_csharp_analyzer.py @@ -0,0 +1,69 @@ +import os +import unittest + +from api import SourceAnalyzer, Graph + + +class Test_CSharp_Analyzer(unittest.TestCase): + def setUp(self): + self.g = Graph("csharp") + + def tearDown(self): + self.g.delete() + + def test_analyzer(self): + analyzer = SourceAnalyzer() + + # Get the current file path + current_file_path = os.path.abspath(__file__) + + # Get the directory of the current file + current_dir = os.path.dirname(current_file_path) + + # Append 'source_files/csharp' to the current directory + path = os.path.join(current_dir, 'source_files') + path = os.path.join(path, 'csharp') + path = str(path) + + analyzer.analyze_local_folder(path, self.g) + + # Verify ILogger interface was detected + q = "MATCH (n:Interface {name: 'ILogger'}) RETURN n LIMIT 1" + res = self.g._query(q).result_set + self.assertEqual(len(res), 1) + + # Verify ConsoleLogger class was detected + q = "MATCH (n:Class {name: 'ConsoleLogger'}) RETURN n LIMIT 1" + res = self.g._query(q).result_set + self.assertEqual(len(res), 1) + + # Verify Task class was detected + q = "MATCH (n:Class {name: 'Task'}) RETURN n LIMIT 1" + res = self.g._query(q).result_set + self.assertEqual(len(res), 1) + + # Verify methods were detected + for method_name in ['Log', 'Execute', 'Abort']: + q = "MATCH (n {name: $name}) RETURN n LIMIT 1" + res = self.g._query(q, {'name': method_name}).result_set + self.assertGreaterEqual(len(res), 1, f"Method {method_name} not found") + + # Verify Constructor was detected + q = "MATCH (n:Constructor {name: 'Task'}) RETURN n LIMIT 1" + res = self.g._query(q).result_set + self.assertEqual(len(res), 1) + + # Verify DEFINES relationships exist (File -> Class/Interface) + q = "MATCH (f:File)-[:DEFINES]->(n) RETURN count(n)" + res = self.g._query(q).result_set + self.assertGreater(res[0][0], 0) + + # Verify class defines methods + q = "MATCH (c:Class {name: 'Task'})-[:DEFINES]->(m) RETURN count(m)" + res = self.g._query(q).result_set + self.assertGreater(res[0][0], 0) + + # Verify ConsoleLogger implements ILogger + q = "MATCH (c:Class {name: 'ConsoleLogger'})-[:IMPLEMENTS]->(i:Interface {name: 'ILogger'}) RETURN c, i LIMIT 1" + res = self.g._query(q).result_set + self.assertEqual(len(res), 1) diff --git a/uv.lock b/uv.lock index 6fefb54..a352528 100644 --- a/uv.lock +++ b/uv.lock @@ -262,6 +262,7 @@ dependencies = [ { name = "toml" }, { name = "tree-sitter" }, { name = "tree-sitter-c" }, + { name = "tree-sitter-c-sharp" }, { name = "tree-sitter-java" }, { name = "tree-sitter-python" }, { name = "validators" }, @@ -285,6 +286,7 @@ requires-dist = [ { name = "toml", specifier = ">=0.10.2,<0.11.0" }, { name = "tree-sitter", specifier = ">=0.25.2,<0.26.0" }, { name = "tree-sitter-c", specifier = ">=0.24.1,<0.25.0" }, + { name = "tree-sitter-c-sharp", specifier = ">=0.23.1,<0.24.0" }, { name = "tree-sitter-java", specifier = ">=0.23.5,<0.24.0" }, { name = "tree-sitter-python", specifier = ">=0.25.0,<0.26.0" }, { name = "validators", specifier = ">=0.35.0,<0.36.0" }, @@ -495,11 +497,15 @@ dependencies = [ { name = "pypdf" }, { name = "python-dotenv" }, { name = "ratelimit" }, + { name = "rdflib" }, { name = "requests" }, { name = "rich" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/73/34/eba77fb2d56107a9f4d07647c2bdc227e9a23ba2bac75ef4fddfc1ad15b6/graphrag_sdk-0.8.1.tar.gz", hash = "sha256:285d43a450bf27f2c737025fbf6277b05f097743b585192ec72086386d2fb904", size = 57790, upload-time = "2025-09-29T09:52:12.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/4b/5ff07312214e2d84aa245fe3980b6c156549136f36d1868c861013957895/graphrag_sdk-0.8.1-py3-none-any.whl", hash = "sha256:6bf1e1e41a4c7bb30fdd8c156af61de5f324ff9afb1908c587ca58657e0e0be2", size = 83540, upload-time = "2026-01-15T13:13:41.457Z" }, +] [[package]] name = "grpcio" @@ -1159,6 +1165,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pypdf" version = "5.9.0" @@ -1239,6 +1254,18 @@ version = "2.2.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ab/38/ff60c8fc9e002d50d48822cc5095deb8ebbc5f91a6b8fdd9731c87a147c9/ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42", size = 5251, upload-time = "2018-12-17T18:55:49.675Z" } +[[package]] +name = "rdflib" +version = "7.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" }, +] + [[package]] name = "redis" version = "7.1.0" @@ -1545,6 +1572,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/6a/210a302e8025ac492cbaea58d3720d66b7d8034c5d747ac5e4d2d235aa25/tree_sitter_c-0.24.1-cp310-abi3-win_arm64.whl", hash = "sha256:d46bbda06f838c2dcb91daf767813671fd366b49ad84ff37db702129267b46e1", size = 82715, upload-time = "2025-05-24T17:32:57.248Z" }, ] +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/85/a61c782afbb706a47d990eaee6977e7c2bd013771c5bf5c81c617684f286/tree_sitter_c_sharp-0.23.1.tar.gz", hash = "sha256:322e2cfd3a547a840375276b2aea3335fa6458aeac082f6c60fec3f745c967eb", size = 1317728, upload-time = "2024-11-11T05:25:32.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/04/f6c2df4c53a588ccd88d50851155945cff8cd887bd70c175e00aaade7edf/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2b612a6e5bd17bb7fa2aab4bb6fc1fba45c94f09cb034ab332e45603b86e32fd", size = 372235, upload-time = "2024-11-11T05:25:19.424Z" }, + { url = "https://files.pythonhosted.org/packages/99/10/1aa9486f1e28fc22810fa92cbdc54e1051e7f5536a5e5b5e9695f609b31e/tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a8b98f62bc53efcd4d971151950c9b9cd5cbe3bacdb0cd69fdccac63350d83e", size = 419046, upload-time = "2024-11-11T05:25:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/0f/21/13df29f8fcb9ba9f209b7b413a4764b673dfd58989a0dd67e9c7e19e9c2e/tree_sitter_c_sharp-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:986e93d845a438ec3c4416401aa98e6a6f6631d644bbbc2e43fcb915c51d255d", size = 415999, upload-time = "2024-11-11T05:25:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/ca/72/fc6846795bcdae2f8aa94cc8b1d1af33d634e08be63e294ff0d6794b1efc/tree_sitter_c_sharp-0.23.1-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8024e466b2f5611c6dc90321f232d8584893c7fb88b75e4a831992f877616d2", size = 402830, upload-time = "2024-11-11T05:25:24.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3a/b6028c5890ce6653807d5fa88c72232c027c6ceb480dbeb3b186d60e5971/tree_sitter_c_sharp-0.23.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7f9bf876866835492281d336b9e1f9626ab668737f74e914c31d285261507da7", size = 397880, upload-time = "2024-11-11T05:25:25.937Z" }, + { url = "https://files.pythonhosted.org/packages/47/d2/4facaa34b40f8104d8751746d0e1cd2ddf0beb9f1404b736b97f372bd1f3/tree_sitter_c_sharp-0.23.1-cp39-abi3-win_amd64.whl", hash = "sha256:ae9a9e859e8f44e2b07578d44f9a220d3fa25b688966708af6aa55d42abeebb3", size = 377562, upload-time = "2024-11-11T05:25:27.539Z" }, + { url = "https://files.pythonhosted.org/packages/d8/88/3cf6bd9959d94d1fec1e6a9c530c5f08ff4115a474f62aedb5fedb0f7241/tree_sitter_c_sharp-0.23.1-cp39-abi3-win_arm64.whl", hash = "sha256:c81548347a93347be4f48cb63ec7d60ef4b0efa91313330e69641e49aa5a08c5", size = 375157, upload-time = "2024-11-11T05:25:30.839Z" }, +] + [[package]] name = "tree-sitter-java" version = "0.23.5"