"""Lightweight structural checks for 1C BSL text.""" from __future__ import annotations import re import hashlib from typing import Any WORD = r"А-Яа-яA-Za-z0-9_" ROUTINE_START_RE = re.compile(r"(?im)^\s*(?:Асинх\s+)?(Процедура|Функция)\s+([A-Za-zА-Яа-яЁё_][\wА-Яа-яЁё]*)\s*\(") ROUTINE_END_RE = re.compile(r"(?im)^\s*(КонецПроцедуры|КонецФункции)\b") ROUTINE_RE = re.compile(rf"(?im)^\s*(?:Асинх\s+)?(Процедура|Функция)\s+([А-Яа-яA-Za-z_][{WORD}]*)\s*\(") END_RE = { "процедура": re.compile(rf"(? 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]]: blocks = [] lines = text.splitlines(keepends=True) line_offsets: list[int] = [] offset = 0 for line in lines: line_offsets.append(offset) offset += len(line) for line_index, line in enumerate(lines): code = strip_line_comment(line) match = ROUTINE_RE.match(code) if not match: continue kind = match.group(1) name = match.group(2) declaration_start = line_offsets[line_index] + match.start() end = len(text) line_end = len(lines) or 1 end_re = END_RE[kind.casefold()] for end_line_index in range(line_index + 1, len(lines)): end_code = strip_line_comment(lines[end_line_index]) end_match = end_re.search(end_code) if end_match: end = line_offsets[end_line_index] + end_match.end() line_end = end_line_index + 1 break blocks.append( { "kind": kind, "name": name, "normalized_name": normalize_name(name), "start": declaration_start, "declaration_start": declaration_start, "end": end, "line_start": line_index + 1, "line_end": line_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 ValueError(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 ValueError("routine_text must not contain extra code after the routine end") return block def text_sha1(value: str) -> str: return hashlib.sha1(value.encode("utf-8")).hexdigest() def dominant_eol(text: str) -> str: crlf = text.count("\r\n") without_crlf = text.replace("\r\n", "") lf = without_crlf.count("\n") cr = without_crlf.count("\r") if crlf >= lf and crlf >= cr and crlf > 0: return "\r\n" if cr > lf and cr > 0: return "\r" return "\n" def normalize_eol(text: str, eol: str) -> str: normalized = text.replace("\r\n", "\n").replace("\r", "\n") return normalized.replace("\n", eol) def replace_routine_text( text: str, routine_text: str, *, operation: str = "replace", name: str | None = None, expected_old_sha1: str | None = None, expected_old_contains: str | None = None, ) -> tuple[str, dict[str, Any]]: if operation not in {"replace", "append", "upsert"}: raise ValueError("routine operation must be replace, append, or upsert") new_block = one_routine_from_text(routine_text) wanted = normalize_name(name or new_block["name"]) blocks = routine_blocks(text) matches = [block for block in blocks if block["normalized_name"] == wanted] if len(matches) > 1: raise ValueError(f"target module has duplicate routine: {name or new_block['name']}") exists = bool(matches) if operation == "append" and exists: raise ValueError(f"routine already exists: {new_block['name']}") if operation == "replace" and not exists: raise ValueError(f"routine does not exist: {name or new_block['name']}") eol = dominant_eol(text) replacement = normalize_eol(routine_text.strip(), eol) if exists: old = matches[0] start = directive_start(text, int(old["declaration_start"])) end = int(old["end"]) old_text = text[start:end] old_sha1 = text_sha1(old_text) if expected_old_sha1 and expected_old_sha1.lower() != old_sha1: raise ValueError("routine expected_old_sha1 does not match current routine text") if expected_old_contains and expected_old_contains not in old_text: raise ValueError("routine expected_old_contains was not found in current routine text") updated = text[:start] + replacement + text[end:] status = "replaced" span = { "old_line_start": old["line_start"], "old_line_end": old["line_end"], "old_sha1": old_sha1, } else: if expected_old_sha1 or expected_old_contains: raise ValueError("routine old preconditions require an existing routine") separator = eol + eol if text.strip() else "" updated = text.rstrip("\r\n") + separator + replacement + eol status = "appended" span = {} return updated, { "status": status, "routine": {"kind": new_block["kind"], "name": new_block["name"]}, **span, } def strip_line_comment(line: str) -> str: in_string = False index = 0 while index < len(line): char = line[index] if char == '"': if in_string and index + 1 < len(line) and line[index + 1] == '"': index += 2 continue in_string = not in_string if not in_string and line[index : index + 2] == "//": return line[:index] index += 1 return line def code_lines(text: str) -> list[str]: return [strip_line_comment(line) for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n")] def validate_bsl_text(text: str) -> dict[str, Any]: lines = code_lines(text) clean = "\n".join(lines) starts = ROUTINE_START_RE.findall(clean) ends = ROUTINE_END_RE.findall(clean) region_starts = REGION_START_RE.findall(clean) region_ends = REGION_END_RE.findall(clean) preproc_ifs = PREPROC_IF_RE.findall(clean) preproc_ends = PREPROC_ENDIF_RE.findall(clean) issues = [] if len(starts) != len(ends): issues.append( { "severity": "error", "code": "routine_balance", "message": "Routine start/end count mismatch.", "starts": len(starts), "ends": len(ends), } ) if len(region_starts) != len(region_ends): issues.append( { "severity": "warning", "code": "region_balance", "message": "Region start/end count mismatch.", "starts": len(region_starts), "ends": len(region_ends), } ) if len(preproc_ifs) != len(preproc_ends): issues.append( { "severity": "warning", "code": "preprocessor_if_balance", "message": "Preprocessor #Если/#КонецЕсли count mismatch.", "starts": len(preproc_ifs), "ends": len(preproc_ends), } ) return { "schema": "onec_bsl_structural_validation.v1", "status": "ok" if not any(issue["severity"] == "error" for issue in issues) else "error", "counts": { "lines": len(lines), "routine_starts": len(starts), "routine_ends": len(ends), "regions": len(region_starts), "region_ends": len(region_ends), "preprocessor_ifs": len(preproc_ifs), "preprocessor_ends": len(preproc_ends), }, "routines_sample": [{"kind": kind, "name": name} for kind, name in starts[:80]], "issues": issues, }