338 lines
9.7 KiB
Python
338 lines
9.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate a GraalVM/Quarkus reflection-config.json from Java source files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
|
|
JAVA_FILE_PATTERN = "*.java"
|
|
TYPE_KEYWORDS = {"class", "interface", "enum", "record"}
|
|
|
|
|
|
def normalize_package_filter(raw_filter: str) -> str:
|
|
normalized = raw_filter.strip()
|
|
if not normalized:
|
|
return ""
|
|
if normalized.endswith(".*"):
|
|
normalized = normalized[:-2]
|
|
return normalized.rstrip(".")
|
|
|
|
|
|
def parse_package_filters(values: Iterable[str]) -> list[str]:
|
|
filters: list[str] = []
|
|
for value in values:
|
|
for part in value.split(","):
|
|
normalized = normalize_package_filter(part)
|
|
if normalized:
|
|
filters.append(normalized)
|
|
return list(dict.fromkeys(filters))
|
|
|
|
|
|
def package_matches(package_name: str, package_filters: list[str]) -> bool:
|
|
if not package_filters:
|
|
return True
|
|
for prefix in package_filters:
|
|
if package_name == prefix or package_name.startswith(prefix + "."):
|
|
return True
|
|
return False
|
|
|
|
|
|
def strip_comments_and_literals(source: str) -> str:
|
|
result: list[str] = []
|
|
i = 0
|
|
state = "normal"
|
|
length = len(source)
|
|
|
|
while i < length:
|
|
char = source[i]
|
|
nxt = source[i + 1] if i + 1 < length else ""
|
|
|
|
if state == "normal":
|
|
if char == "/" and nxt == "/":
|
|
state = "line_comment"
|
|
result.append(" ")
|
|
i += 2
|
|
continue
|
|
if char == "/" and nxt == "*":
|
|
state = "block_comment"
|
|
result.append(" ")
|
|
i += 2
|
|
continue
|
|
if char == '"':
|
|
state = "string"
|
|
result.append(" ")
|
|
i += 1
|
|
continue
|
|
if char == "'":
|
|
state = "char"
|
|
result.append(" ")
|
|
i += 1
|
|
continue
|
|
result.append(char)
|
|
i += 1
|
|
continue
|
|
|
|
if state == "line_comment":
|
|
if char == "\n":
|
|
state = "normal"
|
|
result.append("\n")
|
|
else:
|
|
result.append(" ")
|
|
i += 1
|
|
continue
|
|
|
|
if state == "block_comment":
|
|
if char == "*" and nxt == "/":
|
|
state = "normal"
|
|
result.append(" ")
|
|
i += 2
|
|
else:
|
|
result.append("\n" if char == "\n" else " ")
|
|
i += 1
|
|
continue
|
|
|
|
if state == "string":
|
|
if char == "\\" and i + 1 < length:
|
|
result.append(" ")
|
|
i += 2
|
|
continue
|
|
if char == '"':
|
|
state = "normal"
|
|
result.append(" ")
|
|
else:
|
|
result.append("\n" if char == "\n" else " ")
|
|
i += 1
|
|
continue
|
|
|
|
if state == "char":
|
|
if char == "\\" and i + 1 < length:
|
|
result.append(" ")
|
|
i += 2
|
|
continue
|
|
if char == "'":
|
|
state = "normal"
|
|
result.append(" ")
|
|
else:
|
|
result.append("\n" if char == "\n" else " ")
|
|
i += 1
|
|
continue
|
|
|
|
return "".join(result)
|
|
|
|
|
|
def extract_package(cleaned_source: str) -> str | None:
|
|
match = re.search(
|
|
r"^\s*package\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*;",
|
|
cleaned_source,
|
|
flags=re.MULTILINE,
|
|
)
|
|
if not match:
|
|
return None
|
|
return match.group(1)
|
|
|
|
|
|
def extract_top_level_types(cleaned_source: str) -> list[str]:
|
|
tokens = list(re.finditer(r"[A-Za-z_][A-Za-z0-9_]*|[{}]", cleaned_source))
|
|
depth = 0
|
|
types: list[str] = []
|
|
i = 0
|
|
|
|
while i < len(tokens):
|
|
token = tokens[i].group(0)
|
|
if token == "{":
|
|
depth += 1
|
|
i += 1
|
|
continue
|
|
if token == "}":
|
|
depth = max(0, depth - 1)
|
|
i += 1
|
|
continue
|
|
|
|
if depth == 0 and token in TYPE_KEYWORDS:
|
|
if i + 1 < len(tokens):
|
|
candidate = tokens[i + 1].group(0)
|
|
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", candidate):
|
|
types.append(candidate)
|
|
i += 2
|
|
continue
|
|
i += 1
|
|
|
|
return list(dict.fromkeys(types))
|
|
|
|
|
|
def discover_classes(java_file: Path, package_filters: list[str]) -> list[str]:
|
|
source = java_file.read_text(encoding="utf-8")
|
|
cleaned = strip_comments_and_literals(source)
|
|
package_name = extract_package(cleaned)
|
|
if not package_name or not package_matches(package_name, package_filters):
|
|
return []
|
|
classes = extract_top_level_types(cleaned)
|
|
return [f"{package_name}.{name}" for name in classes]
|
|
|
|
|
|
def collect_java_files(source_roots: list[Path]) -> list[Path]:
|
|
java_files: list[Path] = []
|
|
for root in source_roots:
|
|
java_files.extend(root.rglob(JAVA_FILE_PATTERN))
|
|
return java_files
|
|
|
|
|
|
def build_reflection_entry(
|
|
class_name: str,
|
|
include_declared: bool,
|
|
include_public: bool,
|
|
include_constructors: bool,
|
|
include_methods: bool,
|
|
include_fields: bool,
|
|
) -> dict[str, object]:
|
|
entry: dict[str, object] = {"name": class_name}
|
|
|
|
if include_declared:
|
|
if include_constructors:
|
|
entry["allDeclaredConstructors"] = True
|
|
if include_methods:
|
|
entry["allDeclaredMethods"] = True
|
|
if include_fields:
|
|
entry["allDeclaredFields"] = True
|
|
|
|
if include_public:
|
|
if include_constructors:
|
|
entry["allPublicConstructors"] = True
|
|
if include_methods:
|
|
entry["allPublicMethods"] = True
|
|
if include_fields:
|
|
entry["allPublicFields"] = True
|
|
|
|
return entry
|
|
|
|
|
|
def build_arg_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"Scan Java source files and generate reflection-config.json "
|
|
"for selected packages."
|
|
)
|
|
)
|
|
parser.add_argument(
|
|
"--source-root",
|
|
action="append",
|
|
default=[],
|
|
help="Source root directory to scan (repeatable). Defaults to current directory.",
|
|
)
|
|
parser.add_argument(
|
|
"--package",
|
|
action="append",
|
|
default=[],
|
|
help=(
|
|
"Package prefix filter, e.g. com.acme or com.acme.* "
|
|
"(repeatable or comma-separated)."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--output",
|
|
default="reflection-config.json",
|
|
help="Output path for generated JSON (default: reflection-config.json).",
|
|
)
|
|
parser.add_argument(
|
|
"--stdout",
|
|
action="store_true",
|
|
help="Print JSON to stdout instead of writing to --output.",
|
|
)
|
|
parser.add_argument(
|
|
"--include-declared",
|
|
action=argparse.BooleanOptionalAction,
|
|
default=True,
|
|
help="Include declared constructors/methods/fields (default: true).",
|
|
)
|
|
parser.add_argument(
|
|
"--include-public",
|
|
action=argparse.BooleanOptionalAction,
|
|
default=False,
|
|
help="Include public constructors/methods/fields (default: false).",
|
|
)
|
|
parser.add_argument(
|
|
"--include-constructors",
|
|
action=argparse.BooleanOptionalAction,
|
|
default=True,
|
|
help="Include constructor metadata (default: true).",
|
|
)
|
|
parser.add_argument(
|
|
"--include-methods",
|
|
action=argparse.BooleanOptionalAction,
|
|
default=True,
|
|
help="Include method metadata (default: true).",
|
|
)
|
|
parser.add_argument(
|
|
"--include-fields",
|
|
action=argparse.BooleanOptionalAction,
|
|
default=True,
|
|
help="Include field metadata (default: true).",
|
|
)
|
|
parser.add_argument(
|
|
"--no-sort",
|
|
action="store_true",
|
|
help="Keep discovery order instead of sorting by class name.",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = build_arg_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
source_roots = [Path(p).resolve() for p in args.source_root] or [Path.cwd().resolve()]
|
|
missing_roots = [str(root) for root in source_roots if not root.exists()]
|
|
if missing_roots:
|
|
parser.error("Missing source roots: " + ", ".join(missing_roots))
|
|
|
|
package_filters = parse_package_filters(args.package)
|
|
|
|
class_names: list[str] = []
|
|
for java_file in collect_java_files(source_roots):
|
|
try:
|
|
class_names.extend(discover_classes(java_file, package_filters))
|
|
except UnicodeDecodeError:
|
|
print(f"Skipping non-UTF8 file: {java_file}", file=sys.stderr)
|
|
except OSError as exc:
|
|
print(f"Skipping unreadable file {java_file}: {exc}", file=sys.stderr)
|
|
|
|
class_names = list(dict.fromkeys(class_names))
|
|
if not args.no_sort:
|
|
class_names.sort()
|
|
|
|
reflection_entries = [
|
|
build_reflection_entry(
|
|
class_name=class_name,
|
|
include_declared=args.include_declared,
|
|
include_public=args.include_public,
|
|
include_constructors=args.include_constructors,
|
|
include_methods=args.include_methods,
|
|
include_fields=args.include_fields,
|
|
)
|
|
for class_name in class_names
|
|
]
|
|
|
|
output_json = json.dumps(reflection_entries, indent=2)
|
|
|
|
if args.stdout:
|
|
print(output_json)
|
|
else:
|
|
output_path = Path(args.output).resolve()
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_text(output_json + "\n", encoding="utf-8")
|
|
print(f"Wrote {len(reflection_entries)} entries to {output_path}")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|