Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Safely append or replace one BSL routine inside a 1C patch workspace."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from validate_1c_patch_workspace_semantics import validate_workspace
|
||||
|
||||
|
||||
WORD = r"А-Яа-яA-Za-z0-9_"
|
||||
ROUTINE_RE = re.compile(rf"(?<![{WORD}])(Процедура|Функция)\s+([А-Яа-яA-Za-z_][{WORD}]*)", re.IGNORECASE)
|
||||
END_RE = {
|
||||
"процедура": re.compile(rf"(?<![{WORD}])КонецПроцедуры(?![{WORD}])", re.IGNORECASE),
|
||||
"функция": re.compile(rf"(?<![{WORD}])КонецФункции(?![{WORD}])", re.IGNORECASE),
|
||||
}
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def read_text(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8-sig")
|
||||
except UnicodeDecodeError:
|
||||
return path.read_text(encoding="cp1251", errors="replace")
|
||||
|
||||
|
||||
def normalize(value: str | None) -> str:
|
||||
return re.sub(r"[\s._-]+", "", str(value or "")).casefold()
|
||||
|
||||
|
||||
def line_starts(text: str) -> list[int]:
|
||||
starts = [0]
|
||||
for match in re.finditer(r"\n", text):
|
||||
starts.append(match.end())
|
||||
return starts
|
||||
|
||||
|
||||
def offset_to_line(starts: list[int], offset: int) -> int:
|
||||
line = 1
|
||||
for index, start in enumerate(starts, start=1):
|
||||
if start > offset:
|
||||
break
|
||||
line = index
|
||||
return line
|
||||
|
||||
|
||||
def routine_blocks(text: str) -> list[dict[str, Any]]:
|
||||
starts = line_starts(text)
|
||||
blocks = []
|
||||
matches = list(ROUTINE_RE.finditer(text))
|
||||
for match in matches:
|
||||
kind = match.group(1)
|
||||
name = match.group(2)
|
||||
end_match = END_RE[kind.casefold()].search(text, match.end())
|
||||
end = end_match.end() if end_match else len(text)
|
||||
blocks.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"name": name,
|
||||
"normalized_name": normalize(name),
|
||||
"start": match.start(),
|
||||
"declaration_start": match.start(),
|
||||
"end": end,
|
||||
"line_start": offset_to_line(starts, match.start()),
|
||||
"line_end": offset_to_line(starts, end),
|
||||
}
|
||||
)
|
||||
return blocks
|
||||
|
||||
|
||||
def directive_start(text: str, declaration_start: int) -> int:
|
||||
prefix = text[:declaration_start]
|
||||
lines = prefix.splitlines(keepends=True)
|
||||
start_offset = len(prefix)
|
||||
index = len(lines) - 1
|
||||
while index >= 0:
|
||||
line = lines[index]
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("&"):
|
||||
start_offset -= len(line)
|
||||
index -= 1
|
||||
continue
|
||||
if stripped == "":
|
||||
candidate = index - 1
|
||||
while candidate >= 0 and lines[candidate].strip() == "":
|
||||
candidate -= 1
|
||||
if candidate >= 0 and lines[candidate].strip().startswith("&"):
|
||||
start_offset -= len(line)
|
||||
index -= 1
|
||||
continue
|
||||
break
|
||||
return start_offset
|
||||
|
||||
|
||||
def one_routine_from_text(routine_text: str) -> dict[str, Any]:
|
||||
blocks = routine_blocks(routine_text)
|
||||
if len(blocks) != 1:
|
||||
raise SystemExit(f"Routine text must contain exactly one procedure/function, found {len(blocks)}.")
|
||||
block = blocks[0]
|
||||
if block["end"] < len(routine_text.rstrip()):
|
||||
suffix = routine_text[block["end"] :].strip()
|
||||
if suffix:
|
||||
raise SystemExit("Routine text must not contain extra code after the routine end.")
|
||||
return block
|
||||
|
||||
|
||||
def decode_routine_text(args: argparse.Namespace) -> str:
|
||||
sources = [bool(args.routine_text), bool(args.routine_text_b64), bool(args.routine_file)]
|
||||
if sum(sources) != 1:
|
||||
raise SystemExit("Use exactly one of --routine-text, --routine-text-b64, or --routine-file.")
|
||||
if args.routine_text is not None:
|
||||
return args.routine_text
|
||||
if args.routine_text_b64:
|
||||
return base64.b64decode(args.routine_text_b64).decode("utf-8")
|
||||
return Path(args.routine_file).read_text(encoding="utf-8-sig")
|
||||
|
||||
|
||||
def safe_relative(relative_path: str) -> str:
|
||||
normalized = relative_path.replace("\\", "/")
|
||||
path = Path(normalized)
|
||||
if path.is_absolute() or ".." in path.parts or not normalized:
|
||||
raise SystemExit(f"Unsafe relative path: {relative_path}")
|
||||
return normalized
|
||||
|
||||
|
||||
def manifest_record(workspace: Path, relative_path: str) -> dict[str, Any]:
|
||||
manifest = load_json(workspace / "manifest.json")
|
||||
wanted = safe_relative(relative_path)
|
||||
for record in manifest.get("files") or []:
|
||||
record_relative = str(record.get("relative_path") or "").replace("\\", "/")
|
||||
if record_relative == wanted:
|
||||
if record.get("kind") != "bsl_module":
|
||||
raise SystemExit(f"Manifest target is not a BSL module: {relative_path}")
|
||||
return record
|
||||
raise SystemExit(f"BSL module is not present in workspace manifest: {relative_path}")
|
||||
|
||||
|
||||
def ensure_inside_working(workspace: Path, relative_path: str) -> Path:
|
||||
working_root = (workspace / "working").resolve()
|
||||
path = (working_root / Path(safe_relative(relative_path))).resolve()
|
||||
if not str(path).casefold().startswith(str(working_root).casefold()):
|
||||
raise SystemExit(f"Refusing path outside working/: {path}")
|
||||
if not path.exists():
|
||||
raise SystemExit(f"Working BSL module does not exist: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def apply_edit(path: Path, routine_text: str, *, operation: str) -> dict[str, Any]:
|
||||
new_block = one_routine_from_text(routine_text)
|
||||
text = read_text(path)
|
||||
blocks = routine_blocks(text)
|
||||
matches = [block for block in blocks if block["normalized_name"] == new_block["normalized_name"]]
|
||||
if len(matches) > 1:
|
||||
raise SystemExit(f"Target module already has duplicate routine: {new_block['name']}")
|
||||
exists = bool(matches)
|
||||
if operation == "append" and exists:
|
||||
raise SystemExit(f"Routine already exists, append refused: {new_block['name']}")
|
||||
if operation == "replace" and not exists:
|
||||
raise SystemExit(f"Routine does not exist, replace refused: {new_block['name']}")
|
||||
|
||||
replacement = routine_text.strip() + "\n"
|
||||
if exists:
|
||||
old = matches[0]
|
||||
start = directive_start(text, int(old["declaration_start"]))
|
||||
end = int(old["end"])
|
||||
while end < len(text) and text[end : end + 1] in {"\r", "\n"}:
|
||||
end += 1
|
||||
updated = text[:start].rstrip() + "\n\n" + replacement + text[end:].lstrip("\r\n")
|
||||
status = "replaced"
|
||||
span = {"old_line_start": old["line_start"], "old_line_end": old["line_end"]}
|
||||
else:
|
||||
separator = "\n\n" if text.strip() else ""
|
||||
updated = text.rstrip() + separator + replacement
|
||||
status = "appended"
|
||||
span = {}
|
||||
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
return {
|
||||
"status": status,
|
||||
"routine": {"kind": new_block["kind"], "name": new_block["name"]},
|
||||
"path": str(path),
|
||||
**span,
|
||||
}
|
||||
|
||||
|
||||
def edit_workspace(workspace: Path, relative_path: str, routine_text: str, *, operation: str, keep_on_failure: bool) -> dict[str, Any]:
|
||||
record = manifest_record(workspace, relative_path)
|
||||
path = ensure_inside_working(workspace, str(record.get("relative_path") or relative_path))
|
||||
before = read_text(path)
|
||||
edit = apply_edit(path, routine_text, operation=operation)
|
||||
semantic = validate_workspace(workspace)
|
||||
rolled_back = False
|
||||
if not semantic.get("passed") and not keep_on_failure:
|
||||
path.write_text(before, encoding="utf-8")
|
||||
rolled_back = True
|
||||
return {
|
||||
"schema": "onec_bsl_routine_edit.v1",
|
||||
"workspace": str(workspace),
|
||||
"relative_path": str(record.get("relative_path")),
|
||||
"operation": operation,
|
||||
"edit": edit,
|
||||
"rolled_back": rolled_back,
|
||||
"semantic_validation": {
|
||||
"schema": semantic.get("schema"),
|
||||
"passed": semantic.get("passed"),
|
||||
"counts": semantic.get("counts"),
|
||||
"findings": semantic.get("findings"),
|
||||
},
|
||||
"passed": bool(semantic.get("passed")) and not rolled_back,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Append/replace one BSL routine in a 1C patch workspace working file.")
|
||||
parser.add_argument("--workspace", type=Path, required=True)
|
||||
parser.add_argument("--relative-path", required=True)
|
||||
parser.add_argument("--operation", choices=["append", "replace", "upsert"], default="upsert")
|
||||
parser.add_argument("--routine-text")
|
||||
parser.add_argument("--routine-text-b64")
|
||||
parser.add_argument("--routine-file", type=Path)
|
||||
parser.add_argument("--keep-on-failure", action="store_true", help="Keep the edit even when semantic validation fails.")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
result = edit_workspace(args.workspace, args.relative_path, decode_routine_text(args), operation=args.operation, keep_on_failure=args.keep_on_failure)
|
||||
if args.output:
|
||||
write_json(args.output, result)
|
||||
print(json.dumps({"output": str(args.output) if args.output else None, "passed": result["passed"], "rolled_back": result["rolled_back"], "edit": result["edit"], "semantic": result["semantic_validation"]["counts"]}, ensure_ascii=False))
|
||||
return 0 if result["passed"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user