#!/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())