Skip to content

Commit f13faed

Browse files
hughpyletob-scott-aclaude
authored
Public parse api (#23)
* Add public parse-only API Expose a public trailmark.parse module for parse-only workflows and root-package imports. The new API provides parse_file(), parse_directory(), detect_languages(), and supported_languages(), while QueryEngine.from_directory() now delegates to the parse layer instead of owning parser selection logic directly. Keep auto-detection aligned with parser behavior by reusing the shared directory-skip rules from parsers._common, narrowing JavaScript detection to the extensions the parser actually handles, and adding focused tests for parse-only usage, polyglot detection, and README coverage. Update contributor documentation to describe the parse layer in the architecture overview and to point new parser registration at src/trailmark/parse.py. * style: ruff format src/trailmark/{__init__,parse}.py CI's ruff format --check was failing on two files in this PR: - src/trailmark/__init__.py: extra blank line after the module docstring - src/trailmark/parse.py: missing blank line before supported_languages() Whitespace only; 1038 tests still green, ruff/format/ty clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Scott Arciszewski <scott.arciszewski@trailofbits.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 47cb85c commit f13faed

9 files changed

Lines changed: 383 additions & 187 deletions

File tree

CONTRIBUTING.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ Linux (CI runs on Ubuntu without issue).
5050

5151
## Architecture
5252

53-
Trailmark has a three-layer architecture:
53+
Trailmark has a parse layer followed by a three-layer analysis stack:
5454

55+
0. **Parse API** (`src/trailmark/parse.py`) -- public entry point for
56+
parsing files or directories into a raw `CodeGraph`. Lazily imports
57+
the appropriate language parser. Used directly for parse-only
58+
workflows and internally by `QueryEngine`.
5559
1. **CodeGraph** (`src/trailmark/models/graph.py`) -- mutable data
5660
container holding nodes, edges, annotations, and entrypoints.
5761
2. **GraphStore** (`src/trailmark/storage/graph_store.py`) -- wraps
@@ -75,6 +79,7 @@ Trailmark has a three-layer architecture:
7579

7680
1. Create `src/trailmark/parsers/<lang>/parser.py` implementing the
7781
`BaseParser` protocol from `src/trailmark/parsers/base.py`.
78-
2. Register the language in `src/trailmark/parsers/__init__.py`.
82+
2. Register the parser in `_PARSER_MAP` and its file extensions in
83+
`_LANGUAGE_EXTENSIONS`, both in `src/trailmark/parse.py`.
7984
3. Add tests in `tests/test_<lang>_parser.py`.
8085
4. Update the language table in `README.md`.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,8 +367,13 @@ See [docs/entrypoint-patterns.md](docs/entrypoint-patterns.md) for the full refe
367367
### Programmatic API
368368

369369
```python
370+
from trailmark.parse import parse_directory, parse_file
370371
from trailmark.query.api import QueryEngine
371372

373+
# Parse-only API: get the raw CodeGraph without building GraphStore/QueryEngine.
374+
graph = parse_file("path/to/file.py")
375+
graph = parse_directory("path/to/project", language="auto")
376+
372377
# Single-language (default) or auto-detect + merge across all languages
373378
engine = QueryEngine.from_directory("path/to/project")
374379
engine = QueryEngine.from_directory("path/to/project", language="auto")

src/trailmark/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
11
"""Trailmark: Parse source code into queryable graphs for security analysis."""
22

3+
from trailmark.parse import (
4+
detect_languages,
5+
parse_directory,
6+
parse_file,
7+
supported_languages,
8+
)
9+
10+
__all__ = [
11+
"detect_languages",
12+
"parse_directory",
13+
"parse_file",
14+
"supported_languages",
15+
]
316
__version__ = "0.2.2"

src/trailmark/parse.py

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
"""Public parse-only API for building raw Trailmark code graphs."""
2+
3+
from __future__ import annotations
4+
5+
import importlib
6+
import os
7+
from pathlib import Path
8+
9+
from trailmark.models.graph import CodeGraph
10+
from trailmark.parsers._common import should_skip_dir
11+
from trailmark.parsers.base import LanguageParser
12+
13+
_PARSER_MAP: dict[str, tuple[str, str]] = {
14+
"python": ("trailmark.parsers.python", "PythonParser"),
15+
"javascript": ("trailmark.parsers.javascript", "JavaScriptParser"),
16+
"typescript": ("trailmark.parsers.typescript", "TypeScriptParser"),
17+
"php": ("trailmark.parsers.php", "PHPParser"),
18+
"ruby": ("trailmark.parsers.ruby", "RubyParser"),
19+
"c": ("trailmark.parsers.c", "CParser"),
20+
"cpp": ("trailmark.parsers.cpp", "CppParser"),
21+
"c_sharp": ("trailmark.parsers.csharp", "CSharpParser"),
22+
"java": ("trailmark.parsers.java", "JavaParser"),
23+
"go": ("trailmark.parsers.go", "GoParser"),
24+
"rust": ("trailmark.parsers.rust", "RustParser"),
25+
"solidity": ("trailmark.parsers.solidity", "SolidityParser"),
26+
"cairo": ("trailmark.parsers.cairo", "CairoParser"),
27+
"circom": ("trailmark.parsers.circom", "CircomParser"),
28+
"haskell": ("trailmark.parsers.haskell", "HaskellParser"),
29+
"erlang": ("trailmark.parsers.erlang", "ErlangParser"),
30+
"masm": ("trailmark.parsers.masm", "MasmParser"),
31+
"swift": ("trailmark.parsers.swift", "SwiftParser"),
32+
"objc": ("trailmark.parsers.objc", "ObjCParser"),
33+
"kotlin": ("trailmark.parsers.kotlin", "KotlinParser"),
34+
"dart": ("trailmark.parsers.dart", "DartParser"),
35+
}
36+
37+
# Extensions used for language auto-detection. Keep these aligned with each
38+
# parser's internal _EXTENSIONS tuple. Shared extensions (e.g., `.h` between C
39+
# and C++) are handled by prioritizing the more specific language — C++ is
40+
# tried before plain C when both report files.
41+
_LANGUAGE_EXTENSIONS: dict[str, tuple[str, ...]] = {
42+
"python": (".py",),
43+
"javascript": (".js", ".jsx", ".mjs", ".cjs"),
44+
"typescript": (".ts", ".tsx"),
45+
"php": (".php",),
46+
"ruby": (".rb",),
47+
"c": (".c",),
48+
"cpp": (".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"),
49+
"c_sharp": (".cs",),
50+
"java": (".java",),
51+
"go": (".go",),
52+
"rust": (".rs",),
53+
"solidity": (".sol",),
54+
"cairo": (".cairo",),
55+
"circom": (".circom",),
56+
"haskell": (".hs",),
57+
"erlang": (".erl",),
58+
"masm": (".masm",),
59+
"swift": (".swift",),
60+
# `.h` files can be C, ObjC, or C++; auto-detect only fires on .m/.mm.
61+
# The parser itself still walks .h when invoked with language="objc".
62+
"objc": (".m", ".mm"),
63+
"kotlin": (".kt", ".kts"),
64+
"dart": (".dart",),
65+
}
66+
67+
_SUPPORTED_LANGUAGES = tuple(_PARSER_MAP.keys())
68+
69+
70+
def supported_languages() -> tuple[str, ...]:
71+
"""Return the supported Trailmark parser language names."""
72+
return _SUPPORTED_LANGUAGES
73+
74+
75+
def parse_file(path: str, language: str | None = None) -> CodeGraph:
76+
"""Parse a single file into a raw ``CodeGraph``.
77+
78+
Args:
79+
path: Source file to parse.
80+
language: Optional explicit parser language. If omitted, language is
81+
inferred from the file extension.
82+
83+
Raises:
84+
ValueError: If the language is unsupported or cannot be inferred.
85+
"""
86+
file_path = Path(path)
87+
resolved_language = _resolve_file_language(file_path, language)
88+
return _get_parser(resolved_language).parse_file(str(file_path))
89+
90+
91+
def parse_directory(path: str, language: str = "python") -> CodeGraph:
92+
"""Parse a directory into a raw ``CodeGraph``.
93+
94+
``language`` accepts a specific language name (e.g. ``"python"``),
95+
``"auto"`` to detect and merge all supported languages found under the
96+
directory, or a comma-separated list like ``"python,rust"``.
97+
"""
98+
languages = _resolve_directory_languages(path, language)
99+
return _parse_and_merge(path, languages)
100+
101+
102+
def detect_languages(path: str) -> list[str]:
103+
"""Return the sorted languages with at least one file under ``path``.
104+
105+
Detection walks the directory once, classifies each file by extension,
106+
and returns the languages that have at least one match. Order is the
107+
order languages are registered in ``_LANGUAGE_EXTENSIONS``, which
108+
roughly corresponds to popularity and keeps deterministic behavior.
109+
"""
110+
root = Path(path)
111+
if not root.exists():
112+
return []
113+
114+
ext_to_language: dict[str, str] = {}
115+
for lang, exts in _LANGUAGE_EXTENSIONS.items():
116+
for ext in exts:
117+
# When languages share an extension (none currently do, but
118+
# guard against it), the FIRST registration wins.
119+
ext_to_language.setdefault(ext, lang)
120+
121+
found: set[str] = set()
122+
for _dirpath, dirs, files in os.walk(root):
123+
# Keep detection aligned with the parser walk rules.
124+
dirs[:] = [d for d in dirs if not should_skip_dir(d)]
125+
for name in files:
126+
ext = _file_extension(name)
127+
if ext in ext_to_language:
128+
found.add(ext_to_language[ext])
129+
if len(found) == len(_LANGUAGE_EXTENSIONS):
130+
break
131+
132+
return [lang for lang in _LANGUAGE_EXTENSIONS if lang in found]
133+
134+
135+
def _get_parser(language: str) -> LanguageParser:
136+
"""Lazily import and instantiate a parser for the given language."""
137+
entry = _PARSER_MAP.get(language)
138+
if entry is None:
139+
msg = f"Unsupported language: {language}"
140+
raise ValueError(msg)
141+
module = importlib.import_module(entry[0])
142+
cls = getattr(module, entry[1])
143+
return cls()
144+
145+
146+
def _resolve_file_language(path: Path, language: str | None) -> str:
147+
"""Resolve a single-file parse request to one concrete language."""
148+
if language is None or language == "auto":
149+
detected = _detect_file_language(path)
150+
if detected is None:
151+
msg = f"Could not infer supported language from file extension: {path}"
152+
raise ValueError(msg)
153+
return detected
154+
if "," in language:
155+
msg = f"Single-file parse requires one language, got: {language}"
156+
raise ValueError(msg)
157+
if language not in _PARSER_MAP:
158+
msg = f"Unsupported language: {language}"
159+
raise ValueError(msg)
160+
return language
161+
162+
163+
def _resolve_directory_languages(path: str, spec: str) -> list[str]:
164+
"""Expand a directory ``language`` argument into concrete languages."""
165+
if spec == "auto":
166+
detected = detect_languages(path)
167+
if not detected:
168+
msg = f"No supported languages detected under {path}"
169+
raise ValueError(msg)
170+
return detected
171+
names = [name.strip() for name in spec.split(",") if name.strip()] if "," in spec else [spec]
172+
for name in names:
173+
if name not in _PARSER_MAP:
174+
msg = f"Unsupported language: {name}"
175+
raise ValueError(msg)
176+
return names
177+
178+
179+
def _parse_and_merge(path: str, languages: list[str]) -> CodeGraph:
180+
"""Parse ``path`` with each language parser and merge into one graph."""
181+
if len(languages) == 1:
182+
# Preserves pre-polyglot behavior exactly for the common case.
183+
return _get_parser(languages[0]).parse_directory(path)
184+
185+
merged = CodeGraph(language="polyglot", root_path=str(Path(path).resolve()))
186+
for lang in languages:
187+
sub = _get_parser(lang).parse_directory(path)
188+
merged.merge(sub)
189+
# merge() doesn't touch `language`; preserve the polyglot marker.
190+
merged.language = "polyglot"
191+
return merged
192+
193+
194+
def _detect_file_language(path: Path) -> str | None:
195+
"""Infer one supported language from a file extension."""
196+
ext = _file_extension(path.name)
197+
for language, exts in _LANGUAGE_EXTENSIONS.items():
198+
if ext in exts:
199+
return language
200+
return None
201+
202+
203+
def _file_extension(name: str) -> str:
204+
"""Return the lowercase extension including leading dot, or ``''``."""
205+
dot = name.rfind(".")
206+
if dot < 0:
207+
return ""
208+
return name[dot:].lower()
209+
210+
211+
__all__ = [
212+
"detect_languages",
213+
"parse_directory",
214+
"parse_file",
215+
"supported_languages",
216+
]

src/trailmark/parsers/_common.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ def _escape_module_path_part(part: str) -> str:
104104
)
105105

106106

107+
def should_skip_dir(dirname: str) -> bool:
108+
"""Return whether a directory should be skipped during source walks."""
109+
return dirname in _EXCLUDED_DIRS or dirname.startswith(".")
110+
111+
107112
def walk_source_files(
108113
dir_path: str,
109114
extensions: tuple[str, ...],
@@ -114,7 +119,7 @@ def walk_source_files(
114119
and does not follow symlinks.
115120
"""
116121
for root, dirs, files in os.walk(dir_path, followlinks=False):
117-
dirs[:] = [d for d in dirs if d not in _EXCLUDED_DIRS and not d.startswith(".")]
122+
dirs[:] = [d for d in dirs if not should_skip_dir(d)]
118123
for fname in sorted(files):
119124
if any(fname.endswith(ext) for ext in extensions):
120125
yield os.path.join(root, fname)

0 commit comments

Comments
 (0)