Skip to content

Commit 9d998cc

Browse files
committed
Add Zig and PowerShell language support
1 parent f2423bc commit 9d998cc

7 files changed

Lines changed: 388 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ Works with any mix of file types:
163163

164164
| Type | Extensions | Extraction |
165165
|------|-----------|------------|
166-
| Code | `.py .ts .js .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua` | AST via tree-sitter + call-graph + docstring/comment rationale |
166+
| Code | `.py .ts .js .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1` | AST via tree-sitter + call-graph + docstring/comment rationale |
167167
| Docs | `.md .txt .rst` | Concepts + relationships + design rationale via Claude |
168168
| Papers | `.pdf` | Citation mining + concept extraction |
169169
| Images | `.png .jpg .webp .gif` | Claude vision - screenshots, diagrams, any language |

graphify/detect.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class FileType(str, Enum):
1717

1818
_MANIFEST_PATH = "graphify-out/manifest.json"
1919

20-
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc'}
20+
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1'}
2121
DOC_EXTENSIONS = {'.md', '.txt', '.rst'}
2222
PAPER_EXTENSIONS = {'.pdf'}
2323
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}

graphify/extract.py

Lines changed: 314 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1482,6 +1482,317 @@ def walk_calls(node, caller_nid: str) -> None:
14821482
return {"nodes": nodes, "edges": clean_edges}
14831483

14841484

1485+
# ── Zig ───────────────────────────────────────────────────────────────────────
1486+
1487+
def extract_zig(path: Path) -> dict:
1488+
"""Extract functions, structs, enums, unions, and imports from a .zig file."""
1489+
try:
1490+
import tree_sitter_zig as tszig
1491+
from tree_sitter import Language, Parser
1492+
except ImportError:
1493+
return {"nodes": [], "edges": [], "error": "tree_sitter_zig not installed"}
1494+
1495+
try:
1496+
language = Language(tszig.language())
1497+
parser = Parser(language)
1498+
source = path.read_bytes()
1499+
tree = parser.parse(source)
1500+
root = tree.root_node
1501+
except Exception as e:
1502+
return {"nodes": [], "edges": [], "error": str(e)}
1503+
1504+
stem = path.stem
1505+
str_path = str(path)
1506+
nodes: list[dict] = []
1507+
edges: list[dict] = []
1508+
seen_ids: set[str] = set()
1509+
function_bodies: list[tuple[str, Any]] = []
1510+
1511+
def add_node(nid: str, label: str, line: int) -> None:
1512+
if nid not in seen_ids:
1513+
seen_ids.add(nid)
1514+
nodes.append({"id": nid, "label": label, "file_type": "code",
1515+
"source_file": str_path, "source_location": f"L{line}"})
1516+
1517+
def add_edge(src: str, tgt: str, relation: str, line: int,
1518+
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
1519+
edges.append({"source": src, "target": tgt, "relation": relation,
1520+
"confidence": confidence, "source_file": str_path,
1521+
"source_location": f"L{line}", "weight": weight})
1522+
1523+
file_nid = _make_id(stem)
1524+
add_node(file_nid, path.name, 1)
1525+
1526+
def _extract_import(node) -> None:
1527+
for child in node.children:
1528+
if child.type == "builtin_function":
1529+
bi = None
1530+
args = None
1531+
for c in child.children:
1532+
if c.type == "builtin_identifier":
1533+
bi = _read_text(c, source)
1534+
elif c.type == "arguments":
1535+
args = c
1536+
if bi in ("@import", "@cImport") and args:
1537+
for arg in args.children:
1538+
if arg.type in ("string_literal", "string"):
1539+
raw = _read_text(arg, source).strip('"')
1540+
module_name = raw.split("/")[-1].split(".")[0]
1541+
if module_name:
1542+
tgt_nid = _make_id(module_name)
1543+
add_edge(file_nid, tgt_nid, "imports_from",
1544+
node.start_point[0] + 1)
1545+
return
1546+
elif child.type == "field_expression":
1547+
_extract_import(child)
1548+
return
1549+
1550+
def walk(node, parent_struct_nid: str | None = None) -> None:
1551+
t = node.type
1552+
1553+
if t == "function_declaration":
1554+
name_node = node.child_by_field_name("name")
1555+
if name_node:
1556+
func_name = _read_text(name_node, source)
1557+
line = node.start_point[0] + 1
1558+
if parent_struct_nid:
1559+
func_nid = _make_id(parent_struct_nid, func_name)
1560+
add_node(func_nid, f".{func_name}()", line)
1561+
add_edge(parent_struct_nid, func_nid, "method", line)
1562+
else:
1563+
func_nid = _make_id(stem, func_name)
1564+
add_node(func_nid, f"{func_name}()", line)
1565+
add_edge(file_nid, func_nid, "contains", line)
1566+
body = node.child_by_field_name("body")
1567+
if body:
1568+
function_bodies.append((func_nid, body))
1569+
return
1570+
1571+
if t == "variable_declaration":
1572+
name_node = None
1573+
value_node = None
1574+
for child in node.children:
1575+
if child.type == "identifier":
1576+
name_node = child
1577+
elif child.type in ("struct_declaration", "enum_declaration",
1578+
"union_declaration", "builtin_function",
1579+
"field_expression"):
1580+
value_node = child
1581+
1582+
if value_node and value_node.type == "struct_declaration":
1583+
if name_node:
1584+
struct_name = _read_text(name_node, source)
1585+
line = node.start_point[0] + 1
1586+
struct_nid = _make_id(stem, struct_name)
1587+
add_node(struct_nid, struct_name, line)
1588+
add_edge(file_nid, struct_nid, "contains", line)
1589+
for child in value_node.children:
1590+
walk(child, parent_struct_nid=struct_nid)
1591+
return
1592+
1593+
if value_node and value_node.type in ("enum_declaration", "union_declaration"):
1594+
if name_node:
1595+
type_name = _read_text(name_node, source)
1596+
line = node.start_point[0] + 1
1597+
type_nid = _make_id(stem, type_name)
1598+
add_node(type_nid, type_name, line)
1599+
add_edge(file_nid, type_nid, "contains", line)
1600+
return
1601+
1602+
if value_node and value_node.type in ("builtin_function", "field_expression"):
1603+
_extract_import(node)
1604+
return
1605+
1606+
for child in node.children:
1607+
walk(child, parent_struct_nid)
1608+
1609+
walk(root)
1610+
1611+
seen_call_pairs: set[tuple[str, str]] = set()
1612+
1613+
def walk_calls(node, caller_nid: str) -> None:
1614+
if node.type == "function_declaration":
1615+
return
1616+
if node.type == "call_expression":
1617+
fn = node.child_by_field_name("function")
1618+
if fn:
1619+
callee = _read_text(fn, source).split(".")[-1]
1620+
tgt_nid = next((n["id"] for n in nodes if n["label"] in
1621+
(f"{callee}()", f".{callee}()")), None)
1622+
if tgt_nid and tgt_nid != caller_nid:
1623+
pair = (caller_nid, tgt_nid)
1624+
if pair not in seen_call_pairs:
1625+
seen_call_pairs.add(pair)
1626+
add_edge(caller_nid, tgt_nid, "calls",
1627+
node.start_point[0] + 1,
1628+
confidence="INFERRED", weight=0.8)
1629+
for child in node.children:
1630+
walk_calls(child, caller_nid)
1631+
1632+
for caller_nid, body_node in function_bodies:
1633+
walk_calls(body_node, caller_nid)
1634+
1635+
clean_edges = [e for e in edges if e["source"] in seen_ids and
1636+
(e["target"] in seen_ids or e["relation"] == "imports_from")]
1637+
return {"nodes": nodes, "edges": clean_edges}
1638+
1639+
1640+
# ── PowerShell ────────────────────────────────────────────────────────────────
1641+
1642+
def extract_powershell(path: Path) -> dict:
1643+
"""Extract functions, classes, methods, and using statements from a .ps1 file."""
1644+
try:
1645+
import tree_sitter_powershell as tsps
1646+
from tree_sitter import Language, Parser
1647+
except ImportError:
1648+
return {"nodes": [], "edges": [], "error": "tree_sitter_powershell not installed"}
1649+
1650+
try:
1651+
language = Language(tsps.language())
1652+
parser = Parser(language)
1653+
source = path.read_bytes()
1654+
tree = parser.parse(source)
1655+
root = tree.root_node
1656+
except Exception as e:
1657+
return {"nodes": [], "edges": [], "error": str(e)}
1658+
1659+
stem = path.stem
1660+
str_path = str(path)
1661+
nodes: list[dict] = []
1662+
edges: list[dict] = []
1663+
seen_ids: set[str] = set()
1664+
function_bodies: list[tuple[str, Any]] = []
1665+
1666+
def add_node(nid: str, label: str, line: int) -> None:
1667+
if nid not in seen_ids:
1668+
seen_ids.add(nid)
1669+
nodes.append({"id": nid, "label": label, "file_type": "code",
1670+
"source_file": str_path, "source_location": f"L{line}"})
1671+
1672+
def add_edge(src: str, tgt: str, relation: str, line: int,
1673+
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
1674+
edges.append({"source": src, "target": tgt, "relation": relation,
1675+
"confidence": confidence, "source_file": str_path,
1676+
"source_location": f"L{line}", "weight": weight})
1677+
1678+
file_nid = _make_id(stem)
1679+
add_node(file_nid, path.name, 1)
1680+
1681+
_PS_SKIP = frozenset({
1682+
"using", "return", "if", "else", "elseif", "foreach", "for",
1683+
"while", "do", "switch", "try", "catch", "finally", "throw",
1684+
"break", "continue", "exit", "param", "begin", "process", "end",
1685+
})
1686+
1687+
def _find_script_block_body(node):
1688+
for child in node.children:
1689+
if child.type == "script_block":
1690+
for sc in child.children:
1691+
if sc.type == "script_block_body":
1692+
return sc
1693+
return child
1694+
return None
1695+
1696+
def walk(node, parent_class_nid: str | None = None) -> None:
1697+
t = node.type
1698+
1699+
if t == "function_statement":
1700+
name_node = next((c for c in node.children if c.type == "function_name"), None)
1701+
if name_node:
1702+
func_name = _read_text(name_node, source)
1703+
line = node.start_point[0] + 1
1704+
func_nid = _make_id(stem, func_name)
1705+
add_node(func_nid, f"{func_name}()", line)
1706+
add_edge(file_nid, func_nid, "contains", line)
1707+
body = _find_script_block_body(node)
1708+
if body:
1709+
function_bodies.append((func_nid, body))
1710+
return
1711+
1712+
if t == "class_statement":
1713+
name_node = next((c for c in node.children if c.type == "simple_name"), None)
1714+
if name_node:
1715+
class_name = _read_text(name_node, source)
1716+
line = node.start_point[0] + 1
1717+
class_nid = _make_id(stem, class_name)
1718+
add_node(class_nid, class_name, line)
1719+
add_edge(file_nid, class_nid, "contains", line)
1720+
for child in node.children:
1721+
walk(child, parent_class_nid=class_nid)
1722+
return
1723+
1724+
if t == "class_method_definition":
1725+
name_node = next((c for c in node.children if c.type == "simple_name"), None)
1726+
if name_node:
1727+
method_name = _read_text(name_node, source)
1728+
line = node.start_point[0] + 1
1729+
if parent_class_nid:
1730+
method_nid = _make_id(parent_class_nid, method_name)
1731+
add_node(method_nid, f".{method_name}()", line)
1732+
add_edge(parent_class_nid, method_nid, "method", line)
1733+
else:
1734+
method_nid = _make_id(stem, method_name)
1735+
add_node(method_nid, f"{method_name}()", line)
1736+
add_edge(file_nid, method_nid, "contains", line)
1737+
body = _find_script_block_body(node)
1738+
if body:
1739+
function_bodies.append((method_nid, body))
1740+
return
1741+
1742+
if t == "command":
1743+
cmd_name_node = next((c for c in node.children if c.type == "command_name"), None)
1744+
if cmd_name_node:
1745+
cmd_text = _read_text(cmd_name_node, source).lower()
1746+
if cmd_text == "using":
1747+
tokens = []
1748+
for child in node.children:
1749+
if child.type == "command_elements":
1750+
for el in child.children:
1751+
if el.type == "generic_token":
1752+
tokens.append(_read_text(el, source))
1753+
module_tokens = [t for t in tokens
1754+
if t.lower() not in ("namespace", "module", "assembly")]
1755+
if module_tokens:
1756+
module_name = module_tokens[-1].split(".")[-1]
1757+
add_edge(file_nid, _make_id(module_name), "imports_from",
1758+
node.start_point[0] + 1)
1759+
return
1760+
1761+
for child in node.children:
1762+
walk(child, parent_class_nid)
1763+
1764+
walk(root)
1765+
1766+
label_to_nid = {n["label"].strip("()").lstrip(".").lower(): n["id"] for n in nodes}
1767+
seen_call_pairs: set[tuple[str, str]] = set()
1768+
1769+
def walk_calls(node, caller_nid: str) -> None:
1770+
if node.type in ("function_statement", "class_statement"):
1771+
return
1772+
if node.type == "command":
1773+
cmd_name_node = next((c for c in node.children if c.type == "command_name"), None)
1774+
if cmd_name_node:
1775+
cmd_text = _read_text(cmd_name_node, source)
1776+
if cmd_text.lower() not in _PS_SKIP:
1777+
tgt_nid = label_to_nid.get(cmd_text.lower())
1778+
if tgt_nid and tgt_nid != caller_nid:
1779+
pair = (caller_nid, tgt_nid)
1780+
if pair not in seen_call_pairs:
1781+
seen_call_pairs.add(pair)
1782+
add_edge(caller_nid, tgt_nid, "calls",
1783+
node.start_point[0] + 1,
1784+
confidence="INFERRED", weight=0.8)
1785+
for child in node.children:
1786+
walk_calls(child, caller_nid)
1787+
1788+
for caller_nid, body_node in function_bodies:
1789+
walk_calls(body_node, caller_nid)
1790+
1791+
clean_edges = [e for e in edges if e["source"] in seen_ids and
1792+
(e["target"] in seen_ids or e["relation"] == "imports_from")]
1793+
return {"nodes": nodes, "edges": clean_edges}
1794+
1795+
14851796
# ── Cross-file import resolution ──────────────────────────────────────────────
14861797

14871798
def _resolve_cross_file_imports(
@@ -1667,6 +1978,8 @@ def extract(paths: list[Path]) -> dict:
16671978
".swift": extract_swift,
16681979
".lua": extract_lua,
16691980
".toc": extract_lua,
1981+
".zig": extract_zig,
1982+
".ps1": extract_powershell,
16701983
}
16711984

16721985
for path in paths:
@@ -1709,7 +2022,7 @@ def collect_files(target: Path) -> list[Path]:
17092022
"*.py", "*.js", "*.ts", "*.tsx", "*.go", "*.rs",
17102023
"*.java", "*.c", "*.h", "*.cpp", "*.cc", "*.cxx", "*.hpp",
17112024
"*.rb", "*.cs", "*.kt", "*.kts", "*.scala", "*.php", "*.swift",
1712-
"*.lua", "*.toc",
2025+
"*.lua", "*.toc", "*.zig", "*.ps1",
17132026
)
17142027
results: list[Path] = []
17152028
for pattern in _EXTENSIONS:

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ dependencies = [
2828
"tree-sitter-php",
2929
"tree-sitter-swift",
3030
"tree-sitter-lua",
31+
"tree-sitter-zig",
32+
"tree-sitter-powershell",
3133
]
3234

3335
[project.urls]

tests/fixtures/sample.ps1

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using namespace System.IO
2+
using module MyModule
3+
4+
function Get-Data {
5+
param(
6+
[string]$Name,
7+
[int]$Count = 10
8+
)
9+
$result = Process-Items -Name $Name -Count $Count
10+
return $result
11+
}
12+
13+
function Process-Items {
14+
param([string]$Name, [int]$Count)
15+
Write-Output "Processing $Count items for $Name"
16+
}
17+
18+
class DataProcessor {
19+
[string]$Source
20+
21+
DataProcessor([string]$source) {
22+
$this.Source = $source
23+
}
24+
25+
[string] Transform([string]$input) {
26+
return $input.ToUpper()
27+
}
28+
29+
[void] Save([string]$path) {
30+
Set-Content -Path $path -Value $this.Source
31+
}
32+
}

0 commit comments

Comments
 (0)