from __future__ import annotations
import argparse
import re
from pathlib import Path
TYPE_RE = re.compile(r"^(\s*)(-\s*)?type:\s*([\"']?)([^\"'#\s]+)([\"']?)(\s*(#.*)?)$")
FORMAT_RE = re.compile(r"^\s*format:\s*([\"']?)([^\"'#\s]+)([\"']?)(\s*(#.*)?)$")
CONST_RE = re.compile(r"^\s*(?:-\s+)*([\"']?)const\1:\s+(?![#{[])\S.*$")
BLOCK_SCALAR_RE = re.compile(r"^(?:[^:#]+:\s*)?[|>][+\-0-9]*\s*(#.*)?$")
def _leading_spaces(line: str) -> int:
return len(line) - len(line.lstrip(" "))
def _content_indent(line: str) -> int:
i = _leading_spaces(line)
length = len(line)
while i + 1 < length and line[i] == "-" and line[i + 1] == " ":
i += 2
while i < length and line[i] == " ":
i += 1
return i
def _strip_quotes(value: str) -> str:
return value.strip().strip('"').strip("'")
def normalize_text(text: str) -> tuple[str, int]:
lines = text.splitlines(keepends=True)
changes = 0
for i, line in enumerate(lines):
type_match = TYPE_RE.match(line.rstrip("\n"))
if not type_match:
continue
type_value = _strip_quotes(type_match.group(4)).lower()
if type_value != "integer":
continue
base_indent = len(type_match.group(1))
is_list_item = type_match.group(2) is not None
sibling_indent = base_indent + 2 if is_list_item else base_indent
found_float_format = False
for j in range(i + 1, len(lines)):
candidate = lines[j]
stripped = candidate.strip()
if stripped == "":
continue
indent = _leading_spaces(candidate)
if indent < sibling_indent:
break
if (
is_list_item
and indent == base_indent
and candidate.lstrip(" ").startswith("- ")
):
break
if indent != sibling_indent:
continue
fmt_match = FORMAT_RE.match(candidate.rstrip("\n"))
if not fmt_match:
continue
fmt_value = _strip_quotes(fmt_match.group(2)).lower()
if fmt_value in {"float", "double"}:
found_float_format = True
break
if not found_float_format:
continue
start, end = type_match.span(4)
lines[i] = line[:start] + "number" + line[end:]
changes += 1
return "".join(lines), changes
def strip_const_keyword(text: str) -> tuple[str, int]:
lines = text.splitlines(keepends=True)
kept = []
changes = 0
block_indent: int | None = None
for line in lines:
stripped = line.rstrip("\n")
if block_indent is not None:
if stripped.strip() == "" or _leading_spaces(stripped) > block_indent:
kept.append(line)
continue
block_indent = None
content_col = _content_indent(stripped)
content = stripped[content_col:]
if BLOCK_SCALAR_RE.match(content):
block_indent = (
_leading_spaces(stripped) if content[0] in "|>" else content_col
)
kept.append(line)
continue
if CONST_RE.match(stripped):
changes += 1
continue
kept.append(line)
return "".join(kept), changes
def main() -> int:
parser = argparse.ArgumentParser(
description="Normalize OpenAPI schema constructs for Rust generation."
)
parser.add_argument("spec", type=Path, help="Path to OpenAPI YAML file")
args = parser.parse_args()
content = args.spec.read_text(encoding="utf-8")
normalized, numeric_changes = normalize_text(content)
normalized, const_changes = strip_const_keyword(normalized)
changes = numeric_changes + const_changes
if changes > 0:
args.spec.write_text(normalized, encoding="utf-8")
details = []
if numeric_changes:
details.append(f"{numeric_changes} numeric type(s)")
if const_changes:
details.append(f"{const_changes} const keyword(s)")
print(f"Normalized {', '.join(details)} in {args.spec}")
else:
print(f"No schema normalization needed in {args.spec}")
return 0
if __name__ == "__main__":
raise SystemExit(main())