"""정의부 조립 — 조각(또는 unit) 코드를 붙여넣을 때 함께 가야 하는 선언을 모은다. 인덱스에 든 코드 원문은 **로직만**이다. ABAP 은 내부테이블·스트럭처·상수·필드심볼을 TOP 인클루드나 FORM 머리에 따로 선언하므로, 로직만 복사하면 그대로는 컴파일되지 않는다. 여기서 하는 일: 1. 조각 코드에 등장하는 이름을 모은다 (파서 토크나이저) 2. 그 이름의 선언을 `declaration` 테이블에서 찾는다 (unit 로컬 → 전역 순) 3. 선언이 참조하는 다른 선언을 **재귀로** 끌어온다 (`LT_DATA LIKE GT_DATA` → GT_DATA, `GT_ITEMS TYPE TY_ITEMS` → TY_ITEMS → TY_ITEM) 4. 의존이 먼저 오도록 정렬해 **붙여넣을 수 있는 한 덩어리**로 만든다 **쓸지 말지는 판단하지 않는다.** 붙여넣는 쪽 LLM 이 정한다(이미 있으면 버리고, 이름이 겹치면 바꾸고). 그래서 분류만 붙여 넘긴다: 가져가야 할 선언 / DDIC 외부 참조 / FORM 파라미터 / 못 찾은 이름. 조각→선언 대응을 따로 저장하지 않고 **읽을 때 계산**한다. 결정론적이라 언제 계산해도 같고, 조각이 재추출되거나 선언이 바뀌어도 엇갈리지 않는다 (logic_chunk 에 캐시하면 둘이 따로 늙는다). """ from __future__ import annotations import re import sqlite3 from parser.declarations import BUILTIN_TYPES, base_name, clean_ref, type_name_after from parser.statements import split_statements from .db import loads MAX_DECLS = 80 # 이 이상은 붙여넣기용으로 의미가 없다 (TOP 전체를 퍼오는 꼴) MAX_CODE_LINES = 500 # 선언을 못 찾았을 때 "변수처럼 생겼는가" 판정 — 한국 SAP 관행의 접두사(GV_/LT_/LS_/P_ …). # DDIC 이름(LVC_T_FCAT, W3_QVALUE)이나 키워드가 섞여 들어오지 않게 접두사를 2자 이내로 제한한다. _VARLIKE = re.compile(r"^[A-Z]{1,2}_[A-Z0-9_]*$") _FIELD_SYMBOL = re.compile(r"^<[A-Z0-9_]+>$") # 전역 리포지토리 객체(클래스·인터페이스·예외) — 프로그램 선언이 아니므로 '못 찾았다'고 보고하지 않는다 _REPO_OBJECT = re.compile(r"^(Z?CL|Z?CX|Z?IF|Y?CL)_") # `X-Y` 형태의 참조가 가리킬 수 있는 선언 종류. `TABLES: BSEG` 작업영역이나 타입풀은 여기 없다 — # `LIKE BSEG-WRBTR` 은 사전에서 타입을 빌려 쓴 것이라 그 선언을 함께 가져갈 필요가 없다. _COMPONENT_KINDS = {"data", "types", "constants", "ranges", "select-option", "field-symbol"} # 호출문의 파라미터 구획 — 이 뒤의 `이름 = 값` 에서 왼쪽 이름은 호출 대상의 인터페이스다 _SECTION_KEYWORDS = {"EXPORTING", "IMPORTING", "CHANGING", "TABLES", "RECEIVING", "EXCEPTIONS"} def _row(d: sqlite3.Row, via: str) -> dict: return { "name": d["name"], "kind": d["kind"], "scope": d["scope"], "include": d["include"], "line_start": d["line_start"], "line_end": d["line_end"], "code": d["code"], "type_text": d["type_text"] or "", "depends": loads(d["depends_json"]) or [], "via": via, } def _index(con: sqlite3.Connection, program: str) -> tuple[dict, dict]: """(전역 선언 {이름: row}, unit 로컬 선언 {(unit_id, 이름): row}) 같은 이름이 여러 번 선언돼 있으면(공용 인클루드 중복) 먼저 나온 것을 쓴다 — 결정론적이면 된다. """ glob: dict[str, sqlite3.Row] = {} local: dict[tuple[str, str], sqlite3.Row] = {} for d in con.execute( "SELECT * FROM declaration WHERE program=? ORDER BY include, line_start", (program,) ): if d["scope"] == "unit" and d["unit_id"]: local.setdefault((d["unit_id"], d["name"]), d) else: glob.setdefault(d["name"], d) return glob, local def used_names(code: str) -> list[str]: """코드에 등장하는 식별자 (등장 순서 유지). 보통은 기본 이름만 남기지만(`GT_LOAD-SEQNO` → GT_LOAD), **TYPE/LIKE 뒤의 참조는 컴포넌트를 붙인 채로** 돌려준다(`LIKE BSEG-WRBTR` → BSEG-WRBTR). 사전 타입을 빌려 쓴 것과 작업영역을 실제로 쓰는 것을 뒤에서 구분하기 위해서다. """ out: list[str] = [] def emit(ref: str) -> None: if base_name(ref) in BUILTIN_TYPES: return if (_FIELD_SYMBOL.match(base_name(ref)) or re.match(r"^[A-Z_/][A-Z0-9_/-]*$", ref)) \ and ref not in out: out.append(ref) # 문장 단위로 본다 — `CALL FUNCTION` 의 **형식 파라미터 이름**(I_BUKRS = gv_bukrs 의 왼쪽)과 # 예외 이름(NO_RATE_FOUND)은 이 프로그램의 변수가 아니라 호출 대상의 인터페이스다. # 이름만 보고는 변수와 구분되지 않아(둘 다 I_/P_/NO_ …) 여기서 걸러야 한다. for st in split_statements(code, "chunk")[0]: up = st.upper # 파라미터 구획 키워드가 있으면 `name =` 의 왼쪽은 형식 파라미터다. # `CALL FUNCTION` 뿐 아니라 함수형 메서드 호출(`cl_x=>f( IMPORTING r = v )`)도 여기 걸린다. is_call = bool(up) and (up[0] == "CALL" or bool(_SECTION_KEYWORDS & set(up))) in_exceptions = False i = 0 while i < len(up): t = up[i] if t in {"TYPE", "LIKE"}: name, i = type_name_after(up, i) if name: emit(clean_ref(name)) continue if is_call and t == "EXCEPTIONS": in_exceptions = True elif in_exceptions or (is_call and i + 1 < len(up) and up[i + 1] == "="): pass # 예외 이름 / 형식 파라미터 이름 — 건너뛴다 else: emit(base_name(t)) i += 1 return out def resolve(con: sqlite3.Connection, program: str, unit_id: str | None, code: str, self_include: str | None = None, self_range: tuple[int, int] | None = None) -> dict: """코드 한 덩어리 → 정의부. self_include/self_range 를 주면 **그 범위 안에 있는 선언은 뺀다** — 이미 코드에 들어 있으므로 앞에 또 붙이면 중복 선언이 된다. """ glob, local = _index(con, program) params = {r["name"] for r in con.execute( "SELECT name FROM symbol WHERE program=? AND unit_id=? AND kind='param'", (program, unit_id))} def lookup(name: str, scoped: bool) -> sqlite3.Row | None: if scoped and unit_id and (unit_id, name) in local: return local[(unit_id, name)] return glob.get(name) def inside_self(d: sqlite3.Row) -> bool: return bool(self_range and self_include == d["include"] and d["line_start"] >= self_range[0] and d["line_end"] <= self_range[1]) picked: dict[tuple[str, str, int], dict] = {} # (include, name, line_start) → row external: list[str] = [] unresolved: list[str] = [] param_hits: list[str] = [] ordered: list[dict] = [] visiting: set[str] = set() def walk(ref: str, via: str, scoped: bool) -> None: """의존을 먼저 방문(post-order)해 TYPES 가 DATA 보다 앞에 오게 한다. `ref` 는 `GT_DATA` 이거나 컴포넌트까지 붙은 `BKPF-BELNR` 이다. 후자가 DDIC 테이블을 가리키면(= 지역 선언이 `TABLES:` 작업영역뿐이면) 사전 타입을 빌려 쓴 것이므로 **작업영역 선언을 끌어오지 않는다** — 붙여넣는 코드에는 필요 없다. """ name = base_name(ref) if name in visiting: return d = lookup(name, scoped) if d is not None and "-" in ref and d["kind"] not in _COMPONENT_KINDS: if name not in external: external.append(name) return if d is None: if name in params: if name not in param_hits: param_hits.append(name) elif via == "dependency": if name not in external: external.append(name) # DDIC 타입/구조 — 선언을 가져갈 필요가 없다 elif (_VARLIKE.match(name) or _FIELD_SYMBOL.match(name)) \ and not _REPO_OBJECT.match(name) and name not in unresolved: unresolved.append(name) # 변수처럼 생겼는데 선언이 없다 (수집 누락 가능) return key = (d["include"], d["name"], d["line_start"]) if key in picked or inside_self(d): return visiting.add(name) for dep in loads(d["depends_json"]) or []: walk(dep, "dependency", d["scope"] == "unit") visiting.discard(name) if key not in picked: row = _row(d, via) picked[key] = row ordered.append(row) for n in used_names(code): walk(n, "used", True) truncated = len(ordered) > MAX_DECLS ordered = ordered[:MAX_DECLS] return { "count": len(ordered), "declarations": ordered, "code": render(ordered, program), "external_refs": external, "params": param_hits, "unresolved": unresolved, "truncated": truncated, } def render(decls: list[dict], program: str = "") -> str: """선언 목록 → 그대로 붙여넣을 수 있는 정의부 한 덩어리 (출처를 주석으로 남긴다).""" if not decls: return "" out = [ "*&---------------------------------------------------------------------*", f"*& 정의부 — {program} 에서 가져온 선언. 이 로직을 옮겨 붙일 때 함께 필요하다.", "*& 이미 같은 선언이 있으면 버리고, 이름이 겹치면 바꿔서 쓸 것.", "*&---------------------------------------------------------------------*", ] seen_span: set[tuple[str, int, int]] = set() n_lines = 0 for d in decls: span = (d["include"], d["line_start"], d["line_end"]) if span in seen_span: # 한 줄 체인(`TABLES: a, b.`)에서 온 형제들 continue seen_span.add(span) body = d["code"] n_lines += body.count("\n") + 1 if n_lines > MAX_CODE_LINES: out.append(f"* … (정의부가 길어 생략 — 나머지는 GET /programs/{program}/declarations)") break out.append(f"* {d['include']} L{d['line_start']}-L{d['line_end']} · {d['kind']}" f"{' · unit 로컬' if d['scope'] == 'unit' else ''}") out.append(body) return "\n".join(out) def for_chunk(con: sqlite3.Connection, chunk: sqlite3.Row, code: str) -> dict: """logic_chunk 행 + 조각 코드 → 정의부.""" return resolve(con, chunk["program"], chunk["unit_id"], code, self_include=chunk["include"], self_range=(chunk["line_start"], chunk["line_end"])) def program_declarations(con: sqlite3.Connection, program: str, scope: str | None = None) -> list[dict]: """프로그램의 선언 전체 (위키 `## 정의부` 섹션용).""" sql = "SELECT * FROM declaration WHERE program=?" params: list = [program.upper()] if scope: sql += " AND scope=?" params.append(scope) return [_row(d, "catalog") for d in con.execute(sql + " ORDER BY include, line_start", params)]