diff --git a/graphify/extract.py b/graphify/extract.py index dea5bd0b3..652261ba6 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -16063,6 +16063,20 @@ _CPP_HEADER_MARKERS = ( ) +def _is_objc_source(path: Path) -> bool: + """Whether a `.m` file is Objective-C rather than MATLAB/Octave (#1702). + + `.m` is shared by Objective-C implementation files and MATLAB (also Octave). + The suffix map routes `.m` to extract_objc unconditionally, which force-parses + MATLAB through the Objective-C tree-sitter grammar and emits garbage nodes/edges + (worse than skipping). A genuine ObjC `.m` always carries an ObjC directive + (@implementation/@interface/@import/#import); MATLAB has none of them. Reuses + the same marker set as the `.h` sniff. `.mm` is unambiguously Objective-C++ and + is not sniffed. + """ + return _is_objc_header(path) + + def _is_cpp_header(path: Path) -> bool: """Whether a `.h` file is C++ rather than plain C (#1547). @@ -16106,6 +16120,13 @@ def _get_extractor(path: Path) -> Any | None: # grammar has no class_specifier). Reroute to extract_cpp (#1547). if _is_cpp_header(path): return extract_cpp + # `.m` is Objective-C OR MATLAB. extract_objc unconditionally would force-parse + # MATLAB through the ObjC grammar into garbage (#1702). Route to extract_objc + # only when the file actually looks like Objective-C; otherwise leave it without + # an extractor (surfaced by the no-AST-extractor warning, #1689) rather than + # mis-parsed. `.mm` is unambiguously Objective-C++ and stays on extract_objc. + if suffix == ".m" and not _is_objc_source(path): + return None # Extensionless files: resolve by shebang, mirroring detect.classify_file. # Without this, detect labels e.g. `#!/usr/bin/env bash` CLIs as code but # extraction returns no extractor and the file silently contributes nothing. diff --git a/tests/test_extract.py b/tests/test_extract.py index 7cae30776..04baf3926 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1813,3 +1813,34 @@ def test_extract_progress_final_line_uses_consistent_denominator(tmp_path, capsy # final progress line reports the uncached count (100), not the total (105) assert "100/100 uncached files (100%)" in out assert "105/105 files" not in out, "final line must not switch to total_files (#1693)" + + +def test_get_extractor_routes_matlab_m_away_from_objc(tmp_path): + # #1702: .m is shared by Objective-C and MATLAB. A real ObjC .m still routes to + # extract_objc, but a MATLAB .m must NOT be force-parsed by the ObjC grammar + # (which produces garbage) — it gets no extractor instead. + from graphify.extract import _get_extractor, extract_objc + + objc = tmp_path / "Foo.m" + objc.write_text('#import "Foo.h"\n@implementation Foo\n- (void)bar {}\n@end\n') + matlab_fn = tmp_path / "solver.m" + matlab_fn.write_text("function y = solver(x)\n y = x + 1;\nend\n") + matlab_cls = tmp_path / "Model.m" + matlab_cls.write_text("classdef Model\n methods\n function run(obj); end\n end\nend\n") + mm = tmp_path / "x.mm" + mm.write_text("#import \n@implementation X\n@end\n") + + assert _get_extractor(objc) is extract_objc # real ObjC .m -> objc + assert _get_extractor(matlab_fn) is None # MATLAB function -> no garbage + assert _get_extractor(matlab_cls) is None # MATLAB classdef -> no garbage + assert _get_extractor(mm) is extract_objc # .mm is unambiguously ObjC++ + + +def test_matlab_m_not_extracted_as_garbage(tmp_path, capsys): + # End to end: a MATLAB .m produces no (garbage) nodes and is surfaced by the + # no-AST-extractor warning (#1702 + #1689), rather than mis-parsed as ObjC. + m = tmp_path / "controller.m" + m.write_text("function u = controller(x)\n u = -x;\nend\n") + result = extract([m], cache_root=tmp_path) + assert result["nodes"] == [] # no garbage ObjC nodes + assert "no AST extractor" in capsys.readouterr().err # surfaced, not silent