diff --git a/README.md b/README.md index 272c5f6f81..ba2cd0432b 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| -| Code (37 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .ml .mli .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| Code (38 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .ml .mli .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .zsh .json .sas .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`, `.ml`/`.mli` requires `uv tool install graphifyy[ocaml]`, `.sas` requires `uv tool install graphifyy[sas]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar, `.zsh` reuses the Bash grammar) | | Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | OCaml | `.ml .mli` (requires `uv tool install graphifyy[ocaml]`) | diff --git a/graphify/build.py b/graphify/build.py index 470f2c6ba6..b52a3a2685 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -79,6 +79,7 @@ def _is_ast_tier(item: dict) -> bool: ".cxx": "c", ".hh": "c", ".hxx": "c", ".cu": "c", ".cuh": "c", ".metal": "c", ".m": "c", ".mm": "c", ".rb": "rb", ".rake": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua", + ".sh": "shell", ".bash": "shell", ".zsh": "shell", ".sas": "sas", } diff --git a/graphify/detect.py b/graphify/detect.py index 4cb123104c..e8f391e584 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -42,7 +42,7 @@ class FileType(str, Enum): _MTIME_COARSE_S = 2.0 _MTIME_SUBSECOND_S = 0.05 -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.zsh', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger', '.lisp', '.cl', '.lsp', '.asd', '.sas'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index a113d4a7ac..70d13be6bf 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -52,6 +52,7 @@ from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 from graphify.extractors.razor import extract_razor # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 +from graphify.extractors.sas import extract_sas # noqa: F401 from graphify.extractors.sln import extract_sln # noqa: F401 from graphify.extractors.sql import extract_sql # noqa: F401 from graphify.extractors.terraform import extract_terraform # noqa: F401 @@ -2128,7 +2129,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool: ".ex": "elixir", ".exs": "elixir", ".jl": "julia", ".dart": "dart", - ".sh": "shell", ".bash": "shell", + ".sh": "shell", ".bash": "shell", ".zsh": "shell", ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell", } @@ -4948,6 +4949,7 @@ def add_existing_edge(edge: dict) -> None: ".lpk": extract_lazarus_package, ".sh": extract_bash, ".bash": extract_bash, + ".zsh": extract_bash, ".json": extract_json, ".tf": extract_terraform, ".tfvars": extract_terraform, @@ -4967,6 +4969,7 @@ def add_existing_edge(edge: dict) -> None: ".cshtml": extract_razor, ".cls": extract_apex, ".trigger": extract_apex, + ".sas": extract_sas, } @@ -4987,6 +4990,7 @@ def add_existing_edge(edge: dict) -> None: ".cl": "commonlisp", ".lsp": "commonlisp", ".asd": "commonlisp", + ".sas": "sas", } # Substrings an extractor's error carries to classify why a dependency-backed @@ -6196,7 +6200,7 @@ def _looks_like_bash(result: object) -> bool: sh_pairs = [ (r, p) for r, p in zip(per_file, paths) - if p.suffix in (".sh", ".bash") or _looks_like_bash(r) + if p.suffix in (".sh", ".bash", ".zsh") or _looks_like_bash(r) ] if sh_pairs: sh_results = [r for r, _ in sh_pairs] diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index 68ff3340c3..082c0e9185 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -28,6 +28,7 @@ from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest from graphify.extractors.razor import extract_razor from graphify.extractors.rust import extract_rust +from graphify.extractors.sas import extract_sas from graphify.extractors.sln import extract_sln from graphify.extractors.sql import extract_sql from graphify.extractors.terraform import extract_terraform @@ -58,6 +59,7 @@ "powershell_manifest": extract_powershell_manifest, "razor": extract_razor, "rust": extract_rust, + "sas": extract_sas, "sln": extract_sln, "sql": extract_sql, "terraform": extract_terraform, diff --git a/graphify/extractors/bash.py b/graphify/extractors/bash.py index 3c387be1ae..6abdf48a73 100644 --- a/graphify/extractors/bash.py +++ b/graphify/extractors/bash.py @@ -9,6 +9,12 @@ from graphify.extractors.base import _file_stem, _make_id, _read_text +# Shell-script suffixes the bash extractor resolves as source/invocation +# targets. `.zsh` is included so zsh files route through the same source-edge +# pass (#2825); `.ksh` is accepted for symmetry with the shebang dispatcher. +_SHELL_SUFFIXES = (".sh", ".bash", ".zsh", ".ksh") + + # Leading `${VAR}` / `$VAR` expansion segment(s) of a `source` path argument. The # canonical `BENCH_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` idiom makes # such a variable resolve to the script's own directory, so the literal suffix that @@ -456,10 +462,10 @@ def walk(node, parent_nid: str) -> None: add_edge(file_nid, tgt_nid, "imports", line, context="import") elif cmd and cmd not in defined_functions: - raw = cmd if cmd.endswith(".sh") else None + raw = cmd if cmd.endswith(_SHELL_SUFFIXES) else None if cmd in _BASH_SCRIPT_RUNNERS and args: raw = literal(args[0]) - if raw and raw.endswith(".sh"): + if raw and raw.endswith(_SHELL_SUFFIXES): resolved = (path.parent / raw).resolve() if resolved.is_file(): target_path = resolved diff --git a/graphify/extractors/sas.py b/graphify/extractors/sas.py new file mode 100644 index 0000000000..dcf0b86ea6 --- /dev/null +++ b/graphify/extractors/sas.py @@ -0,0 +1,131 @@ +"""SAS extractor (tree-sitter). + +Extracts data steps, proc steps, and %macro definitions from a .sas file, +plus calls edges from %macro call sites to macros defined in the same file. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from graphify.extractors.base import _file_stem, _make_id, _read_text + + +def extract_sas(path: Path) -> dict: + """Extract data/proc steps and macro definitions from a .sas file.""" + try: + import tree_sitter_sas as tssas + from tree_sitter import Language, Parser + except ImportError as e: + import importlib.util + # Distinguish a genuinely-absent grammar from an installed-but-broken + # one (e.g. a C extension built for a different Python ABI, #2602) so + # the #1745 warning does not send the user to a no-op install. + if importlib.util.find_spec("tree_sitter_sas") is None: + return {"nodes": [], "edges": [], + "error": "tree_sitter_sas not installed"} + return {"nodes": [], "edges": [], + "error": f"tree_sitter_sas is installed but failed to load: {e}"} + + try: + language = Language(tssas.language()) + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = _file_stem(path) + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + seen_edges: set[tuple[str, str, str]] = set() + macro_defs: dict[str, str] = {} + + def add_node(nid: str, label: str, line: int) -> None: + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": str_path, "source_location": f"L{line}"}) + + def add_edge(src: str, tgt: str, relation: str, line: int, + context: str | None = None) -> None: + key = (src, tgt, relation) + if key in seen_edges: + return + seen_edges.add(key) + edge = {"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "source_file": str_path, + "source_location": f"L{line}", "weight": 1.0} + if context: + edge["context"] = context + edges.append(edge) + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1) + + def _child_text(node: Any, child_types: tuple[str, ...]) -> str | None: + for child in node.children: + if child.type in child_types: + return _read_text(child, source).strip() + return None + + def _macro_name_text(node: Any) -> str | None: + return _child_text(node, ("macro_name",)) + + # First pass: collect macro definitions (case-insensitively, per SAS) so + # call sites resolve regardless of where the definition appears. + for node in root.children: + if node.type == "macro_definition": + name = _macro_name_text(node) + if name: + macro_defs[name.casefold()] = _make_id(stem, name) + + def _step_label(node: Any) -> str | None: + text = _child_text(node, ("data_step_header", "proc_step_header")) + # strip the trailing `;` so the label reads `data work.customers` + return text.rstrip(";").strip() if text else None + + def _emit_macro_calls(node: Any) -> None: + """Emit calls edges for macro call statements anywhere in the subtree.""" + stack = [node] + while stack: + current = stack.pop() + if current.type == "macro_call_statement": + name = _macro_name_text(current) + if name: + nid = macro_defs.get(name.casefold()) + if nid: + add_edge(file_nid, nid, "calls", + current.start_point.row + 1, context="call") + stack.extend(current.children) + + for node in root.children: + if node.type == "macro_definition": + name = _macro_name_text(node) + if not name: + continue + nid = macro_defs[name.casefold()] + add_node(nid, f"%{name}", node.start_point.row + 1) + add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="macro") + _emit_macro_calls(node) + elif node.type == "data_step": + label = _step_label(node) or "data" + # disambiguate by byte offset so multiple steps (even on one line) + # stay distinct + nid = _make_id(stem, "data", str(node.start_byte)) + add_node(nid, label, node.start_point.row + 1) + add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="data_step") + _emit_macro_calls(node) + elif node.type == "proc_step": + label = _step_label(node) or "proc" + nid = _make_id(stem, "proc", str(node.start_byte)) + add_node(nid, label, node.start_point.row + 1) + add_edge(file_nid, nid, "defines", node.start_point.row + 1, context="proc_step") + _emit_macro_calls(node) + else: + _emit_macro_calls(node) + + return {"nodes": nodes, "edges": edges} diff --git a/pyproject.toml b/pyproject.toml index 882ef533cb..274ccddc07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,10 @@ ocaml = ["tree-sitter-ocaml"] # tree-sitter-commonlisp ships prebuilt abi3 wheels for every platform; optional # because Common Lisp is a niche corpus language. commonlisp = ["tree-sitter-commonlisp"] -all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp"] +# tree-sitter-sas ships prebuilt wheels for every platform; optional because SAS +# is a niche corpus language. +sas = ["tree-sitter-sas"] +all = ["mcp>=1,<3", "starlette>=1.3.1,<2", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal", "tree-sitter-ocaml", "tree-sitter-commonlisp", "tree-sitter-sas"] [project.scripts] graphify = "graphify.__main__:main" @@ -116,6 +119,7 @@ dev = [ "tree-sitter-hcl>=1.2.0", "tree-sitter-ocaml>=0.25.0", "tree-sitter-commonlisp>=0.4.1", + "tree-sitter-sas>=0.4.2", ] [tool.uv] diff --git a/tests/fixtures/sample.sas b/tests/fixtures/sample.sas new file mode 100644 index 0000000000..9c8b5eca1b --- /dev/null +++ b/tests/fixtures/sample.sas @@ -0,0 +1,14 @@ +%macro greet(name); + %put Hello &name; +%mend greet; + +data work.customers; + set raw.import; + length name $ 50; +run; + +proc sort data=work.customers; + by name; +run; + +%greet(World); diff --git a/tests/fixtures/sample.zsh b/tests/fixtures/sample.zsh new file mode 100644 index 0000000000..ce6ef984c2 --- /dev/null +++ b/tests/fixtures/sample.zsh @@ -0,0 +1,14 @@ +#!/usr/bin/env zsh +set -euo pipefail + +greet() { + print "Hello, $1" +} + +deploy() { + local env_name="${1:-production}" + greet "$env_name" + print "Deploying to $env_name" +} + +deploy staging diff --git a/tests/test_detect.py b/tests/test_detect.py index 5be697ce72..9bdcbbc469 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -42,6 +42,14 @@ def test_classify_powershell_manifest(): # #1331: .psd1 manifests must be classified as CODE so the manifest extractor runs. assert classify_file(Path("MyModule.psd1")) == FileType.CODE +def test_classify_zsh(): + # #2825: .zsh was missing from CODE_EXTENSIONS so zsh scripts were unclassified. + assert classify_file(Path("script.zsh")) == FileType.CODE + +def test_classify_sas(): + # #2681: .sas files should be classified as CODE so the SAS extractor runs. + assert classify_file(Path("model.sas")) == FileType.CODE + def test_classify_markdown(): assert classify_file(Path("README.md")) == FileType.DOCUMENT diff --git a/tests/test_extract.py b/tests/test_extract.py index 416d358280..2bab4e9bc6 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -7,7 +7,7 @@ import pytest from graphify.build import build_from_json -from graphify.extract import extract_python, extract, collect_files, _make_id, extract_bash, extract_json, _DISPATCH +from graphify.extract import extract_python, extract, collect_files, _make_id, extract_bash, extract_json, extract_sas, _DISPATCH FIXTURES = Path(__file__).parent / "fixtures" @@ -2234,9 +2234,164 @@ def wrapped_sequential(uncached_work, per_file, *args, **kwargs): def test_dispatch_includes_sh_and_json(): assert ".sh" in _DISPATCH assert ".bash" in _DISPATCH + assert ".zsh" in _DISPATCH assert ".json" in _DISPATCH +def test_dispatch_routes_zsh_to_bash(): + from graphify.extract import _get_extractor + assert _get_extractor(Path("script.zsh")) is extract_bash + + +def test_extract_bash_handles_zsh_extension(): + # #2825: a suffixed .zsh file must route through the shell extractor and + # produce the same node shape as .sh — not be silently dropped. + result = extract_bash(FIXTURES / "sample.zsh") + assert "error" not in result + labels = {n["label"] for n in result["nodes"]} + assert "greet()" in labels + assert "deploy()" in labels + + +# --------------------------------------------------------------------------- +# SAS extractor tests (#2681) +# --------------------------------------------------------------------------- + +def test_dispatch_includes_sas(): + assert ".sas" in _DISPATCH + + +def test_dispatch_routes_sas_to_sas_extractor(): + from graphify.extract import _get_extractor + assert _get_extractor(Path("model.sas")) is extract_sas + + +def test_extract_sas_emits_step_and_macro_nodes(): + result = extract_sas(FIXTURES / "sample.sas") + assert "error" not in result + labels = {n["label"] for n in result["nodes"]} + assert "data work.customers" in labels + assert any(lbl.startswith("proc sort") for lbl in labels) + assert "%greet" in labels + +def test_extract_sas_nodes_have_source_location(): + result = extract_sas(FIXTURES / "sample.sas") + for n in result["nodes"]: + assert n["file_type"] == "code" + assert n["source_file"].endswith("sample.sas") + assert n["source_location"].startswith("L") + + +def test_extract_sas_emits_defines_edges(): + result = extract_sas(FIXTURES / "sample.sas") + defines = [(e["source"], e["target"], e.get("context")) for e in result["edges"] + if e["relation"] == "defines"] + # file node defines each step/macro + assert len(defines) == 3 + assert any(ctx == "data_step" for _, _, ctx in defines) + assert any(ctx == "proc_step" for _, _, ctx in defines) + assert any(ctx == "macro" for _, _, ctx in defines) + + +def test_extract_sas_multiple_data_steps_stay_distinct(tmp_path): + # Two data steps in one file must not collapse into a single node (#2681). + f = tmp_path / "multi.sas" + f.write_text("data work.a;\nrun;\n\ndata work.b;\nrun;\n") + result = extract_sas(f) + labels = {n["label"] for n in result["nodes"]} + assert "data work.a" in labels + assert "data work.b" in labels + data_ids = [n["id"] for n in result["nodes"] if n["label"].startswith("data")] + assert len(data_ids) == 2 + assert len(set(data_ids)) == 2 + + +def test_extract_sas_macro_call_resolves_to_same_file_definition(): + result = extract_sas(FIXTURES / "sample.sas") + calls = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"] + assert len(calls) == 1 + source, target = calls[0] + assert source == result["nodes"][0]["id"] # the file node + assert "greet" in target + + +def test_extract_sas_macro_call_inside_step_resolves(tmp_path): + # #2681: a macro invoked inside a data step (the common inline-function + # idiom) must still emit a calls edge to its same-file definition. + f = tmp_path / "inline.sas" + f.write_text( + "%macro gen_cols();\n" + " %put generating;\n" + "%mend gen_cols;\n" + "data work.x;\n" + " %gen_cols();\n" + "run;\n" + ) + result = extract_sas(f) + calls = [(e["source"], e["target"]) for e in result["edges"] if e["relation"] == "calls"] + assert len(calls) == 1 + assert "gen_cols" in calls[0][1] + + +def test_extract_sas_macro_case_insensitive(tmp_path): + # SAS macro names are case-insensitive: %greet() must resolve %macro Greet. + f = tmp_path / "case.sas" + f.write_text( + "%macro Greet(name);\n" + " %put Hello &name;\n" + "%mend Greet;\n" + "%greet(World);\n" + ) + result = extract_sas(f) + calls = [e for e in result["edges"] if e["relation"] == "calls"] + assert len(calls) == 1 + assert "greet" in calls[0]["target"] + + +def test_extract_sas_duplicate_macro_defs_dedup_edges(tmp_path): + # Legal SAS redefinition of %macro util must not emit duplicate defines edges. + f = tmp_path / "dup.sas" + f.write_text("%macro util;\n%mend util;\n%macro util;\n%mend util;\n") + result = extract_sas(f) + defines = [e for e in result["edges"] if e["relation"] == "defines"] + assert len(defines) == 1 + + +def test_extract_sas_same_line_steps_stay_distinct(tmp_path): + # Two data steps packed on one line must not collapse into one node. + f = tmp_path / "oneline.sas" + f.write_text("data work.a; run; data work.b; run;\n") + result = extract_sas(f) + data_nodes = [n for n in result["nodes"] if n["label"].startswith("data")] + assert len(data_nodes) == 2 + + +def test_extract_sas_missing_dependency_returns_error_marker(monkeypatch): + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "tree_sitter_sas": + raise ImportError("tree_sitter_sas not installed") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + result = extract_sas(Path("model.sas")) + assert result["nodes"] == [] + assert "error" in result + + +def test_extract_sas_via_extract_no_1689_warning(tmp_path, capsys): + # A .sas file routed through extract() must not fire the #1689 + # no-AST-extractor warning and must produce a non-empty node set. + f = tmp_path / "model.sas" + f.write_text("data work.t;\nrun;\n") + result = extract([f]) + assert result["nodes"] + out = capsys.readouterr().err + assert "no AST extractor" not in out + + def test_extract_bash_finds_functions(): result = extract_bash(FIXTURES / "sample.sh") assert "error" not in result diff --git a/uv.lock b/uv.lock index 8e8313f133..b911c7c90c 100644 --- a/uv.lock +++ b/uv.lock @@ -1150,6 +1150,7 @@ all = [ { name = "tree-sitter-hcl" }, { name = "tree-sitter-ocaml" }, { name = "tree-sitter-pascal" }, + { name = "tree-sitter-sas" }, { name = "tree-sitter-sql" }, { name = "watchdog" }, { name = "yt-dlp" }, @@ -1217,6 +1218,9 @@ pdf = [ postgres = [ { name = "psycopg", extra = ["binary"] }, ] +sas = [ + { name = "tree-sitter-sas" }, +] sql = [ { name = "tree-sitter-sql" }, ] @@ -1253,6 +1257,7 @@ dev = [ { name = "tree-sitter-commonlisp" }, { name = "tree-sitter-hcl" }, { name = "tree-sitter-ocaml" }, + { name = "tree-sitter-sas" }, { name = "wheel" }, ] @@ -1333,6 +1338,8 @@ requires-dist = [ { name = "tree-sitter-python", specifier = ">=0.23,<0.26" }, { name = "tree-sitter-ruby", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-rust", specifier = ">=0.23,<0.25" }, + { name = "tree-sitter-sas", marker = "extra == 'all'" }, + { name = "tree-sitter-sas", marker = "extra == 'sas'" }, { name = "tree-sitter-scala", specifier = ">=0.23,<0.27" }, { name = "tree-sitter-sql", marker = "extra == 'all'" }, { name = "tree-sitter-sql", marker = "extra == 'sql'" }, @@ -1345,7 +1352,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.6.9" }, { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.6.9" }, ] -provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "all"] +provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "ocaml", "commonlisp", "sas", "all"] [package.metadata.requires-dev] dev = [ @@ -1365,6 +1372,7 @@ dev = [ { name = "tree-sitter-commonlisp", specifier = ">=0.4.1" }, { name = "tree-sitter-hcl", specifier = ">=1.2.0" }, { name = "tree-sitter-ocaml", specifier = ">=0.25.0" }, + { name = "tree-sitter-sas", specifier = ">=0.4.2" }, { name = "wheel", specifier = ">=0.47.0" }, ] @@ -4878,6 +4886,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/d8/050a781172745bc345f98abb7c56e72022ea0790f8e793de981c83c2ef15/tree_sitter_rust-0.24.2-cp39-abi3-win_arm64.whl", hash = "sha256:66ba90f61bd54f4c4f5d30434957daf64507c16b0313df76becb37d63f70a227", size = 128245, upload-time = "2026-03-27T21:08:54.803Z" }, ] +[[package]] +name = "tree-sitter-sas" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/7b/5644acdbef190098d022ca2feb747e0b9de8bd0aed974e0bfba1f11fd6ff/tree_sitter_sas-0.4.2.tar.gz", hash = "sha256:0bf60b6434015b36a0105bbb8b78b2f46568566b5849a74bee3000d39f3926cb", size = 86250, upload-time = "2026-05-26T02:35:18.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/d8/92640ff898e0de9ed85b11ec0b8e1dc2913de9f61082a3cb95c0899a5613/tree_sitter_sas-0.4.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4bf2d35f18920a1eb2d7088b2a49462e75a496edb4b8b56696bc7e0399f52cd5", size = 53795, upload-time = "2026-05-26T02:35:11.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/fa/6f91ae2c330c9266d11fe19a616a582ecfa6cceb57088d3d5cbe1f3b7c26/tree_sitter_sas-0.4.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:684197a93b0d3df37740b3186a150107abeffa5577173d6a0e04b9ee726549a0", size = 56709, upload-time = "2026-05-26T02:35:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/3b/12/f3508c424a57970717569962d7563db1fe8070f73670fc7fb016b7917dcc/tree_sitter_sas-0.4.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:46931772298910d76cf1f3bc64bd49eaca6e6d954646ebfb6f6c02470975a308", size = 69010, upload-time = "2026-05-26T02:35:12.957Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/b9a577b563fb6f0cd5b6f280e83290190be8080a4ed580c20224325b1d31/tree_sitter_sas-0.4.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:074fd05168c7d8ec9e173d4d98ba9698a94b9a7d0a78b5896cb7d110a0d43158", size = 69320, upload-time = "2026-05-26T02:35:13.82Z" }, + { url = "https://files.pythonhosted.org/packages/c0/73/c6bfe22d61537015a90d3b2c13749ea8db0524ce1b94e4cb728c3a344179/tree_sitter_sas-0.4.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0d6a4e7518a1465ed4f2e783ae0901f6e7b660c892379f9447466f94ab8011e2", size = 68687, upload-time = "2026-05-26T02:35:14.848Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3b/2295752b3ef6863ef0e1039748f0b8922527ba287017a4f55dcf1a7c30f8/tree_sitter_sas-0.4.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1be73663c7385e3e894f8b10ecdae636c1d77ea5e921b2d2aee32999c416914d", size = 68387, upload-time = "2026-05-26T02:35:15.823Z" }, + { url = "https://files.pythonhosted.org/packages/db/e0/fa793931bc57c6dccf10cd2ffdc2a96366d157a2f8cc612e7f0829370f3f/tree_sitter_sas-0.4.2-cp310-abi3-win_amd64.whl", hash = "sha256:ca96574dfae47f5a94e2273e38ece25863fd48a4ae5a3d2c03ac2056935a2ce5", size = 57347, upload-time = "2026-05-26T02:35:16.667Z" }, + { url = "https://files.pythonhosted.org/packages/3a/93/1a638a8df76af05abb73ef6e2b22d35e898d3cb15ccbe446b8d125288887/tree_sitter_sas-0.4.2-cp310-abi3-win_arm64.whl", hash = "sha256:d3d4676498418e4d622c244ad36b898d9aaf23ce4cf656ec266673af933642da", size = 55433, upload-time = "2026-05-26T02:35:17.567Z" }, +] + [[package]] name = "tree-sitter-scala" version = "0.26.0"