Skip to content

Commit 90f2f16

Browse files
committed
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.
1 parent 8899f1c commit 90f2f16

9 files changed

Lines changed: 381 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
@@ -349,8 +349,13 @@ See [docs/entrypoint-patterns.md](docs/entrypoint-patterns.md) for the full refe
349349
### Programmatic API
350350

351351
```python
352+
from trailmark.parse import parse_directory, parse_file
352353
from trailmark.query.api import QueryEngine
353354

355+
# Parse-only API: get the raw CodeGraph without building GraphStore/QueryEngine.
356+
graph = parse_file("path/to/file.py")
357+
graph = parse_directory("path/to/project", language="auto")
358+
354359
# Single-language (default) or auto-detect + merge across all languages
355360
engine = QueryEngine.from_directory("path/to/project")
356361
engine = QueryEngine.from_directory("path/to/project", language="auto")

src/trailmark/__init__.py

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

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

src/trailmark/parse.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
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"),
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+
def supported_languages() -> tuple[str, ...]:
70+
"""Return the supported Trailmark parser language names."""
71+
return _SUPPORTED_LANGUAGES
72+
73+
74+
def parse_file(path: str, language: str | None = None) -> CodeGraph:
75+
"""Parse a single file into a raw ``CodeGraph``.
76+
77+
Args:
78+
path: Source file to parse.
79+
language: Optional explicit parser language. If omitted, language is
80+
inferred from the file extension.
81+
82+
Raises:
83+
ValueError: If the language is unsupported or cannot be inferred.
84+
"""
85+
file_path = Path(path)
86+
resolved_language = _resolve_file_language(file_path, language)
87+
return _get_parser(resolved_language).parse_file(str(file_path))
88+
89+
90+
def parse_directory(path: str, language: str = "python") -> CodeGraph:
91+
"""Parse a directory into a raw ``CodeGraph``.
92+
93+
``language`` accepts a specific language name (e.g. ``"python"``),
94+
``"auto"`` to detect and merge all supported languages found under the
95+
directory, or a comma-separated list like ``"python,rust"``.
96+
"""
97+
languages = _resolve_directory_languages(path, language)
98+
return _parse_and_merge(path, languages)
99+
100+
101+
def detect_languages(path: str) -> list[str]:
102+
"""Return the sorted languages with at least one file under ``path``.
103+
104+
Detection walks the directory once, classifies each file by extension,
105+
and returns the languages that have at least one match. Order is the
106+
order languages are registered in ``_LANGUAGE_EXTENSIONS``, which
107+
roughly corresponds to popularity and keeps deterministic behavior.
108+
"""
109+
root = Path(path)
110+
if not root.exists():
111+
return []
112+
113+
ext_to_language: dict[str, str] = {}
114+
for lang, exts in _LANGUAGE_EXTENSIONS.items():
115+
for ext in exts:
116+
# When languages share an extension (none currently do, but
117+
# guard against it), the FIRST registration wins.
118+
ext_to_language.setdefault(ext, lang)
119+
120+
found: set[str] = set()
121+
for _dirpath, dirs, files in os.walk(root):
122+
# Keep detection aligned with the parser walk rules.
123+
dirs[:] = [d for d in dirs if not should_skip_dir(d)]
124+
for name in files:
125+
ext = _file_extension(name)
126+
if ext in ext_to_language:
127+
found.add(ext_to_language[ext])
128+
if len(found) == len(_LANGUAGE_EXTENSIONS):
129+
break
130+
131+
return [lang for lang in _LANGUAGE_EXTENSIONS if lang in found]
132+
133+
134+
def _get_parser(language: str) -> LanguageParser:
135+
"""Lazily import and instantiate a parser for the given language."""
136+
entry = _PARSER_MAP.get(language)
137+
if entry is None:
138+
msg = f"Unsupported language: {language}"
139+
raise ValueError(msg)
140+
module = importlib.import_module(entry[0])
141+
cls = getattr(module, entry[1])
142+
return cls()
143+
144+
145+
def _resolve_file_language(path: Path, language: str | None) -> str:
146+
"""Resolve a single-file parse request to one concrete language."""
147+
if language is None or language == "auto":
148+
detected = _detect_file_language(path)
149+
if detected is None:
150+
msg = f"Could not infer supported language from file extension: {path}"
151+
raise ValueError(msg)
152+
return detected
153+
if "," in language:
154+
msg = f"Single-file parse requires one language, got: {language}"
155+
raise ValueError(msg)
156+
if language not in _PARSER_MAP:
157+
msg = f"Unsupported language: {language}"
158+
raise ValueError(msg)
159+
return language
160+
161+
162+
def _resolve_directory_languages(path: str, spec: str) -> list[str]:
163+
"""Expand a directory ``language`` argument into concrete languages."""
164+
if spec == "auto":
165+
detected = detect_languages(path)
166+
if not detected:
167+
msg = f"No supported languages detected under {path}"
168+
raise ValueError(msg)
169+
return detected
170+
names = [name.strip() for name in spec.split(",") if name.strip()] if "," in spec else [spec]
171+
for name in names:
172+
if name not in _PARSER_MAP:
173+
msg = f"Unsupported language: {name}"
174+
raise ValueError(msg)
175+
return names
176+
177+
178+
def _parse_and_merge(path: str, languages: list[str]) -> CodeGraph:
179+
"""Parse ``path`` with each language parser and merge into one graph."""
180+
if len(languages) == 1:
181+
# Preserves pre-polyglot behavior exactly for the common case.
182+
return _get_parser(languages[0]).parse_directory(path)
183+
184+
merged = CodeGraph(language="polyglot", root_path=str(Path(path).resolve()))
185+
for lang in languages:
186+
sub = _get_parser(lang).parse_directory(path)
187+
merged.merge(sub)
188+
# merge() doesn't touch `language`; preserve the polyglot marker.
189+
merged.language = "polyglot"
190+
return merged
191+
192+
193+
def _detect_file_language(path: Path) -> str | None:
194+
"""Infer one supported language from a file extension."""
195+
ext = _file_extension(path.name)
196+
for language, exts in _LANGUAGE_EXTENSIONS.items():
197+
if ext in exts:
198+
return language
199+
return None
200+
201+
202+
def _file_extension(name: str) -> str:
203+
"""Return the lowercase extension including leading dot, or ``''``."""
204+
dot = name.rfind(".")
205+
if dot < 0:
206+
return ""
207+
return name[dot:].lower()
208+
209+
210+
__all__ = [
211+
"detect_languages",
212+
"parse_directory",
213+
"parse_file",
214+
"supported_languages",
215+
]

src/trailmark/parsers/_common.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ def module_id_from_path(file_path: str) -> str:
6767
)
6868

6969

70+
def should_skip_dir(dirname: str) -> bool:
71+
"""Return whether a directory should be skipped during source walks."""
72+
return dirname in _EXCLUDED_DIRS or dirname.startswith(".")
73+
74+
7075
def walk_source_files(
7176
dir_path: str,
7277
extensions: tuple[str, ...],
@@ -77,7 +82,7 @@ def walk_source_files(
7782
and does not follow symlinks.
7883
"""
7984
for root, dirs, files in os.walk(dir_path, followlinks=False):
80-
dirs[:] = [d for d in dirs if d not in _EXCLUDED_DIRS and not d.startswith(".")]
85+
dirs[:] = [d for d in dirs if not should_skip_dir(d)]
8186
for fname in sorted(files):
8287
if any(fname.endswith(ext) for ext in extensions):
8388
yield os.path.join(root, fname)

0 commit comments

Comments
 (0)