Files
graphify/tests/test_pascal.py
T
simeonbodurov 32bf8b4a37 feat(extract): add Pascal/Delphi and Lazarus IDE support (#781)
* feat(extract): add Pascal/Delphi language support

Adds full AST extraction for Pascal and Delphi source files using
tree-sitter-pascal (https://github.com/Isopod/tree-sitter-pascal).

Supported file extensions: .pas, .pp, .dpr, .dpk, .inc

Extracted nodes:
- File node (the .pas file itself)
- unit / program / library declarations
- class, interface, and helper type declarations
- procedure and function implementations

Extracted edges:
- file --contains--> module
- module --imports--> dependency (via uses clause, resolved to path-based IDs)
- class --inherits--> base class / interface
- class/module --contains/method--> procedure or function
- procedure --calls--> procedure (in-file call resolution)

Key design: uses clause targets are resolved to path-based node IDs by
scanning all Pascal files under the project root (_pascal_project_root +
_pascal_resolve_unit helpers). This avoids dangling import edges that
result from resolving bare unit names like "SysUtils" to IDs that never
match any file node.

Bare procedure calls (e.g. `Reset;` without parentheses) are detected
by inspecting statement nodes whose sole named child is an identifier,
in addition to the standard exprCall nodes used for calls with arguments.

Requires: pip install tree-sitter-pascal
(https://github.com/Isopod/tree-sitter-pascal)
If not installed, extract_pascal returns {"nodes":[], "edges":[], "error": ...}
so the rest of the pipeline is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(extract): add Lazarus .lfm and .lpk file support

Adds two new extractors for Lazarus IDE-specific file formats:

extract_lazarus_form() — .lfm (Lazarus Form files)
  .lfm files are text-based UI component trees. The extractor parses
  `object Name: TClassName ... end` blocks to build a containment graph
  of form components, and captures `OnXxx = HandlerName` event bindings
  as `references` edges (context: "event") linking each component to
  its handler procedure.

extract_lazarus_package() — .lpk (Lazarus Package files)
  .lpk files are XML package definitions. The extractor reads the
  package name, required package dependencies (→ imports edges), and
  listed unit files (→ contains edges). Unit names are resolved to
  path-based node IDs via _pascal_resolve_unit so they connect to the
  same nodes produced by extract_pascal on .pas files.

Both extensions added to CODE_EXTENSIONS in detect.py and to _DISPATCH.
13 new tests in test_pascal.py cover both extractors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(pascal): fix dangling inherits-edge targets and capture all base classes

The declType/typeref handler built inherits edge targets with
_make_id(_read(child)) — just the bare class name. But class nodes
use _make_id(stem, type_name), so targets never matched, making the
entire class hierarchy invisible in the graph.

Add _pascal_class_stem_cache and _pascal_resolve_class(): strips the
conventional T/I prefix, locates the defining file by stem lookup
(same cache mechanism as _pascal_resolve_unit), and returns the
correct _make_id(file_stem, class_name) ID. RTL/unresolvable bases
(e.g. TObject) fall back to _make_id(bare_name) with an explicit
stub node, following the same pattern as the Python extractor.

Also remove the `break` that stopped after the first typeref, so
all parents are captured (e.g. class(TBase, IInterface)).

Extend test_pascal_no_dangling_edges to also assert that within-file
edge targets (contains, method, inherits, calls) resolve to real nodes.

* feat(extract): add Delphi .dfm form file support

Adds extract_delphi_form() for Delphi Form files (.dfm), which use the
same `object Name: TClassName ... end` text syntax as Lazarus .lfm files.

Binary .dfm files (FF 0A magic header) are skipped gracefully with an
informative error message so the pipeline is unaffected.  Text .dfm files
are parsed identically to .lfm: component containment (`contains` edges)
and event handler references (`references`, context "event").

Adds .dfm to _DISPATCH and CODE_EXTENSIONS.
10 new tests in test_pascal.py, including a regression test for the
binary-format detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Add .lpr extension and fix removesuffix in Pascal extractor

Address review feedback from @safishamsi:
- Add .lpr (Lazarus program file, identical syntax to .dpr) to _DISPATCH
  in extract.py and CODE_EXTENSIONS in detect.py so Lazarus project entry
  points are indexed. Completes the promised Lazarus IDE support.
- Replace rstrip("()") with removesuffix("()") in the call-resolution
  dict comprehension for precise suffix removal (rstrip strips individual
  characters, not the literal string "()").
- Add .lpr assertions to test_pascal_dispatch_registered and
  test_pascal_detect_extensions_registered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Simeon Bodurov <simeon.bodurov@speedy.bg>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 14:44:58 +01:00

353 lines
11 KiB
Python

"""Tests for the Pascal/Delphi extractor."""
from __future__ import annotations
import pytest
from pathlib import Path
FIXTURES = Path(__file__).parent / "fixtures"
def _try_import():
try:
import tree_sitter_pascal # noqa: F401
return True
except ImportError:
return False
pascal_required = pytest.mark.skipif(
not _try_import(),
reason="tree_sitter_pascal not installed",
)
def _labels(r):
return [n["label"] for n in r["nodes"]]
def _relations(r):
return {e["relation"] for e in r["edges"]}
def _edges_with_relation(r, *relations):
return [e for e in r["edges"] if e["relation"] in relations]
@pascal_required
def test_pascal_no_error():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
assert "error" not in r
@pascal_required
def test_pascal_finds_unit():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
assert any("SampleUnit" in l for l in _labels(r))
@pascal_required
def test_pascal_finds_classes():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
labels = _labels(r)
assert any("TBaseProcessor" in l for l in labels)
assert any("TDataProcessor" in l for l in labels)
@pascal_required
def test_pascal_finds_interface():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
assert any("IProcessor" in l for l in _labels(r))
@pascal_required
def test_pascal_finds_methods():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
labels = _labels(r)
assert any("Process" in l for l in labels)
assert any("Initialize" in l for l in labels)
assert any("GetCount" in l for l in labels)
assert any("Reset" in l for l in labels)
@pascal_required
def test_pascal_finds_imports():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
assert "imports" in _relations(r)
@pascal_required
def test_pascal_import_edges_have_import_context():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
import_edges = _edges_with_relation(r, "imports")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
@pascal_required
def test_pascal_finds_inherits():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
assert "inherits" in _relations(r)
@pascal_required
def test_pascal_inherits_from_base():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
node_by_id = {n["id"]: n["label"] for n in r["nodes"]}
inherits = [e for e in r["edges"] if e["relation"] == "inherits"]
found = any(
"TDataProcessor" in node_by_id.get(e["source"], "")
for e in inherits
)
assert found, "TDataProcessor should have at least one inherits edge"
@pascal_required
def test_pascal_finds_calls():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
assert "calls" in _relations(r)
@pascal_required
def test_pascal_call_edges_have_call_context():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
call_edges = _edges_with_relation(r, "calls")
assert call_edges
assert all(e.get("context") == "call" for e in call_edges)
@pascal_required
def test_pascal_all_edges_extracted():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
structural = {"contains", "method", "inherits", "imports"}
for e in r["edges"]:
if e["relation"] in structural:
assert e["confidence"] == "EXTRACTED", f"Expected EXTRACTED: {e}"
@pascal_required
def test_pascal_no_dangling_edges():
from graphify.extract import extract_pascal
r = extract_pascal(FIXTURES / "sample.pas")
node_ids = {n["id"] for n in r["nodes"]}
# imports edges are cross-file by design; only check within-file edge targets
within_file_relations = {"contains", "method", "inherits", "calls"}
for e in r["edges"]:
assert e["source"] in node_ids, f"Dangling source: {e}"
if e["relation"] in within_file_relations:
assert e["target"] in node_ids, f"Dangling target: {e}"
@pascal_required
def test_pascal_dispatch_registered():
from graphify.extract import _DISPATCH
assert ".pas" in _DISPATCH
assert ".pp" in _DISPATCH
assert ".dpr" in _DISPATCH
assert ".dpk" in _DISPATCH
assert ".lpr" in _DISPATCH
assert ".inc" in _DISPATCH
assert ".lfm" in _DISPATCH
assert ".lpk" in _DISPATCH
@pascal_required
def test_pascal_detect_extensions_registered():
from graphify.detect import CODE_EXTENSIONS
assert ".pas" in CODE_EXTENSIONS
assert ".pp" in CODE_EXTENSIONS
assert ".dpr" in CODE_EXTENSIONS
assert ".lpr" in CODE_EXTENSIONS
assert ".lfm" in CODE_EXTENSIONS
assert ".lpk" in CODE_EXTENSIONS
# ── Lazarus Form (.lfm) ───────────────────────────────────────────────────────
def test_lfm_no_error():
from graphify.extract import extract_lazarus_form
r = extract_lazarus_form(FIXTURES / "sample.lfm")
assert "error" not in r
def test_lfm_finds_root_form_class():
from graphify.extract import extract_lazarus_form
r = extract_lazarus_form(FIXTURES / "sample.lfm")
assert any("TSampleForm" in l for l in _labels(r))
def test_lfm_finds_component_classes():
from graphify.extract import extract_lazarus_form
r = extract_lazarus_form(FIXTURES / "sample.lfm")
labels = _labels(r)
assert any("TPanel" in l for l in labels)
assert any("TButton" in l for l in labels)
assert any("TLabel" in l for l in labels)
assert any("TTimer" in l for l in labels)
def test_lfm_finds_event_handlers():
from graphify.extract import extract_lazarus_form
r = extract_lazarus_form(FIXTURES / "sample.lfm")
labels = _labels(r)
assert any("ButtonOKClick" in l for l in labels)
assert any("TimerRefreshTimer" in l for l in labels)
def test_lfm_event_edges_have_event_context():
from graphify.extract import extract_lazarus_form
r = extract_lazarus_form(FIXTURES / "sample.lfm")
ref_edges = [e for e in r["edges"] if e["relation"] == "references"]
assert ref_edges
assert all(e.get("context") == "event" for e in ref_edges)
def test_lfm_contains_edges_form_hierarchy():
from graphify.extract import extract_lazarus_form
r = extract_lazarus_form(FIXTURES / "sample.lfm")
assert "contains" in _relations(r)
def test_lfm_no_dangling_edges():
from graphify.extract import extract_lazarus_form
r = extract_lazarus_form(FIXTURES / "sample.lfm")
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in node_ids, f"Dangling source: {e}"
# ── Lazarus Package (.lpk) ───────────────────────────────────────────────────
def test_lpk_no_error():
from graphify.extract import extract_lazarus_package
r = extract_lazarus_package(FIXTURES / "sample.lpk")
assert "error" not in r
def test_lpk_finds_package_name():
from graphify.extract import extract_lazarus_package
r = extract_lazarus_package(FIXTURES / "sample.lpk")
assert any("SamplePackage" in l for l in _labels(r))
def test_lpk_finds_required_packages():
from graphify.extract import extract_lazarus_package
r = extract_lazarus_package(FIXTURES / "sample.lpk")
labels = _labels(r)
assert any("FCL" in l for l in labels)
assert any("LCL" in l for l in labels)
def test_lpk_imports_edges_have_import_context():
from graphify.extract import extract_lazarus_package
r = extract_lazarus_package(FIXTURES / "sample.lpk")
import_edges = _edges_with_relation(r, "imports")
assert import_edges
assert all(e.get("context") == "import" for e in import_edges)
def test_lpk_contains_listed_units():
from graphify.extract import extract_lazarus_package
r = extract_lazarus_package(FIXTURES / "sample.lpk")
labels = _labels(r)
assert any("sample" in l.lower() for l in labels)
assert any("sampleutils" in l.lower() for l in labels)
def test_lpk_no_dangling_edges():
from graphify.extract import extract_lazarus_package
r = extract_lazarus_package(FIXTURES / "sample.lpk")
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in node_ids, f"Dangling source: {e}"
# ── Delphi Form (.dfm) ───────────────────────────────────────────────────────
def test_dfm_no_error():
from graphify.extract import extract_delphi_form
r = extract_delphi_form(FIXTURES / "sample.dfm")
assert "error" not in r
def test_dfm_finds_root_form_class():
from graphify.extract import extract_delphi_form
r = extract_delphi_form(FIXTURES / "sample.dfm")
assert any("TMainForm" in l for l in _labels(r))
def test_dfm_finds_component_classes():
from graphify.extract import extract_delphi_form
r = extract_delphi_form(FIXTURES / "sample.dfm")
labels = _labels(r)
assert any("TPanel" in l for l in labels)
assert any("TButton" in l for l in labels)
assert any("TMemo" in l for l in labels)
assert any("TStatusBar" in l for l in labels)
def test_dfm_finds_event_handlers():
from graphify.extract import extract_delphi_form
r = extract_delphi_form(FIXTURES / "sample.dfm")
labels = _labels(r)
assert any("FormCreate" in l for l in labels)
assert any("ButtonOKClick" in l for l in labels)
def test_dfm_event_edges_have_event_context():
from graphify.extract import extract_delphi_form
r = extract_delphi_form(FIXTURES / "sample.dfm")
ref_edges = [e for e in r["edges"] if e["relation"] == "references"]
assert ref_edges
assert all(e.get("context") == "event" for e in ref_edges)
def test_dfm_contains_edges_form_hierarchy():
from graphify.extract import extract_delphi_form
r = extract_delphi_form(FIXTURES / "sample.dfm")
assert "contains" in _relations(r)
def test_dfm_no_dangling_edges():
from graphify.extract import extract_delphi_form
r = extract_delphi_form(FIXTURES / "sample.dfm")
node_ids = {n["id"] for n in r["nodes"]}
for e in r["edges"]:
assert e["source"] in node_ids, f"Dangling source: {e}"
def test_dfm_binary_returns_empty_not_crash():
from graphify.extract import extract_delphi_form
import tempfile, pathlib
# Write a fake binary DFM (FF 0A magic header)
with tempfile.NamedTemporaryFile(suffix=".dfm", delete=False) as f:
f.write(b"\xff\x0a\x00\x00some binary data")
tmp = pathlib.Path(f.name)
try:
r = extract_delphi_form(tmp)
assert r["nodes"] == []
assert r["edges"] == []
assert "error" in r
finally:
tmp.unlink()
def test_dfm_dispatch_registered():
from graphify.extract import _DISPATCH
assert ".dfm" in _DISPATCH
def test_dfm_detect_extension_registered():
from graphify.detect import CODE_EXTENSIONS
assert ".dfm" in CODE_EXTENSIONS