Initial commit: ABAP indexing pipeline (ingest, parser, summarize, index, query, wiki)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
"""심볼 선언 + 쓰기/읽기 지점 추출. (계획서 §4.4)
|
||||
|
||||
"gt_head 가 어디서 채워지는가" 질문의 근간. 정확한 의미 분석이 아니라
|
||||
문장 패턴별 쓰기 대상 변수를 결정론적으로 뽑는다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .declarations import type_name_after
|
||||
from .statements import Statement, assignment_eq_index
|
||||
|
||||
_NAME = re.compile(r"^[A-Za-z_/][A-Za-z0-9_/]*")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SymbolDecl:
|
||||
name: str
|
||||
scope: str # global | unit
|
||||
unit_id: str | None
|
||||
decl_include: str
|
||||
decl_line: int
|
||||
type_text: str = ""
|
||||
ddic_ref: str = ""
|
||||
kind: str = "data" # data/types/constants/tables/field-symbol/parameter/select-option
|
||||
|
||||
|
||||
@dataclass
|
||||
class WritePoint:
|
||||
symbol: str
|
||||
unit_id: str
|
||||
include: str
|
||||
line: int
|
||||
kind: str
|
||||
source_symbols: list[str] = field(default_factory=list)
|
||||
source_tables: list[str] = field(default_factory=list)
|
||||
stmt_text: str = ""
|
||||
callee: str = "" # kind=via_perform 일 때
|
||||
|
||||
|
||||
def base_symbol(token: str) -> str:
|
||||
"""gs_head-belnr → GS_HEAD, gt_tab[] → GT_TAB, <fs>-f → <FS>"""
|
||||
t = token.upper()
|
||||
if t.startswith("<"):
|
||||
return t.split(">")[0] + ">"
|
||||
t = t.split("(")[0].split("[")[0].split("-")[0].split("->")[0].split("=>")[0]
|
||||
return t
|
||||
|
||||
|
||||
def _decl_from_stmt(st: Statement, unit_id: str | None, scope: str) -> SymbolDecl | None:
|
||||
up = st.upper
|
||||
head = up[0]
|
||||
kind_map = {
|
||||
"DATA": "data", "TYPES": "types", "CONSTANTS": "constants", "STATICS": "data",
|
||||
"TABLES": "tables", "FIELD-SYMBOLS": "field-symbol", "CLASS-DATA": "data",
|
||||
"PARAMETERS": "parameter", "PARAMETER": "parameter",
|
||||
"SELECT-OPTIONS": "select-option", "RANGES": "data",
|
||||
"NODES": "tables",
|
||||
}
|
||||
if head not in kind_map or len(up) < 2:
|
||||
return None
|
||||
name = base_symbol(up[1])
|
||||
if not name or name in {"BEGIN", "END"}: # DATA BEGIN OF ... 구조 선언은 v1 스킵
|
||||
return None
|
||||
type_text = ""
|
||||
for kw in ("TYPE", "LIKE"):
|
||||
if kw in up:
|
||||
ki = up.index(kw)
|
||||
type_text = " ".join(st.tokens[ki : ki + 5])[:120]
|
||||
break
|
||||
ddic_ref = ""
|
||||
m = re.search(r"(?:TYPE|LIKE)\s+(?:TABLE\s+OF\s+|STANDARD\s+TABLE\s+OF\s+|SORTED\s+TABLE\s+OF\s+|HASHED\s+TABLE\s+OF\s+)?([A-Z0-9_/]+-?[A-Z0-9_/]*)", type_text.upper())
|
||||
if m:
|
||||
ddic_ref = m.group(1)
|
||||
return SymbolDecl(
|
||||
name=name, scope=scope, unit_id=unit_id, decl_include=st.include,
|
||||
decl_line=st.line_start, type_text=type_text, ddic_ref=ddic_ref, kind=kind_map[head],
|
||||
)
|
||||
|
||||
|
||||
def extract_declarations(statements: list[Statement], stmt_indexes: list[int],
|
||||
unit_id: str | None, scope: str,
|
||||
unit_type: str = "", signature: str = "") -> list[SymbolDecl]:
|
||||
decls: list[SymbolDecl] = []
|
||||
for idx in stmt_indexes:
|
||||
d = _decl_from_stmt(statements[idx], unit_id, scope)
|
||||
if d:
|
||||
decls.append(d)
|
||||
# FORM 파라미터 (USING/CHANGING/TABLES)
|
||||
#
|
||||
# 타입을 붙인 파라미터가 여러 개면(`USING p_date LIKE x p_days LIKE y …`) **타입 이름만 건너뛰고
|
||||
# 계속 읽어야** 한다. 예전 구현은 TYPE/LIKE 를 만나면 멈춰서 첫 파라미터만 잡았고, 나머지는
|
||||
# 선언 없는 이름으로 남았다 (정의부 조립에서 '선언을 못 찾음'으로 새어 나와 드러났다).
|
||||
if unit_type == "FORM" and signature:
|
||||
sig_up = signature.upper().split()
|
||||
section = ""
|
||||
i = 0
|
||||
while i < len(sig_up):
|
||||
t = sig_up[i].strip(".")
|
||||
if t in {"USING", "CHANGING", "TABLES"}:
|
||||
section = t
|
||||
elif t in {"TYPE", "LIKE", "STRUCTURE"}:
|
||||
_type, i = type_name_after([x.strip(".") for x in sig_up], i)
|
||||
continue # 타입식(이름이 없을 수도 있다)을 건너뛴다
|
||||
elif t in {"VALUE", "REFERENCE", "(", ")"}:
|
||||
pass # VALUE(p_x) 표기
|
||||
elif section:
|
||||
name = base_symbol(t)
|
||||
if _NAME.match(name):
|
||||
decls.append(SymbolDecl(name=name, scope="unit", unit_id=unit_id,
|
||||
decl_include="", decl_line=0, kind="param"))
|
||||
i += 1
|
||||
return decls
|
||||
|
||||
|
||||
_ASSIGN_KINDS = [
|
||||
# (조건 함수, kind, 대상 위치 결정)
|
||||
# 아래 extract_writes 에서 절차적으로 처리
|
||||
]
|
||||
|
||||
_CLEAR_HEADS = {"CLEAR": "clear", "REFRESH": "clear", "FREE": "clear", "SORT": "sort"}
|
||||
|
||||
|
||||
def extract_writes(statements: list[Statement], stmt_indexes: list[int], unit_id: str,
|
||||
known_symbols: set[str]) -> tuple[list[WritePoint], list[dict]]:
|
||||
"""(writes, reads). reads 는 {symbol, unit_id, include, line} 수준."""
|
||||
writes: list[WritePoint] = []
|
||||
reads: list[dict] = []
|
||||
|
||||
def known(sym: str) -> bool:
|
||||
return sym in known_symbols
|
||||
|
||||
def add_write(sym_token: str, st: Statement, kind: str, sources: list[str] | None = None,
|
||||
tables: list[str] | None = None, callee: str = "") -> None:
|
||||
sym = base_symbol(sym_token)
|
||||
if not sym or not re.match(r"^[<A-Z_/]", sym):
|
||||
return
|
||||
field = ""
|
||||
tu = sym_token.upper()
|
||||
if tu.startswith("<"):
|
||||
# <fs>-comp / <fs>->attr — 필드심볼 이름(<...>) 뒤의 접근자만 떼낸다
|
||||
rest = tu.split(">", 1)[1] if ">" in tu else ""
|
||||
field = rest.lstrip("->")
|
||||
elif "-" in tu:
|
||||
field = tu.split("-", 1)[1]
|
||||
wp = WritePoint(symbol=sym, unit_id=unit_id, include=st.include, line=st.line_start,
|
||||
kind=kind if not field else f"{kind}:field={field[:30]}",
|
||||
source_symbols=sorted({s for s in (sources or []) if known(s)}),
|
||||
source_tables=sorted(set(tables or [])),
|
||||
stmt_text=st.raw_text[:200], callee=callee)
|
||||
writes.append(wp)
|
||||
|
||||
def add_read(sym: str, st: Statement) -> None:
|
||||
if known(sym):
|
||||
reads.append({"symbol": sym, "unit_id": unit_id, "include": st.include, "line": st.line_start})
|
||||
|
||||
for idx in stmt_indexes:
|
||||
st = statements[idx]
|
||||
up = st.upper
|
||||
if not up:
|
||||
continue
|
||||
head = up[0]
|
||||
rhs_syms = [base_symbol(t) for t in up[1:] if re.match(r"^[<A-Z_/]", t)]
|
||||
|
||||
# SELECT ... INTO [CORRESPONDING FIELDS OF] [TABLE] x / APPENDING TABLE x
|
||||
if head in {"SELECT", "FETCH"}:
|
||||
tables = []
|
||||
for j, t in enumerate(up):
|
||||
if t in {"FROM", "JOIN"} and j + 1 < len(up):
|
||||
cand = up[j + 1].lstrip("@")
|
||||
if re.match(r"^[A-Z0-9_/]+$", cand):
|
||||
tables.append(cand)
|
||||
for kw in ("INTO", "APPENDING"):
|
||||
if kw in up:
|
||||
j = up.index(kw)
|
||||
k = j + 1
|
||||
while k < len(up) and up[k] in {"CORRESPONDING", "FIELDS", "OF", "TABLE", "(", "@"}:
|
||||
k += 1
|
||||
if k < len(up):
|
||||
add_write(up[k].lstrip("@("), st, "select", tables=tables)
|
||||
break
|
||||
continue
|
||||
|
||||
# APPEND ... TO x / INSERT ... INTO [TABLE] x / COLLECT ... INTO x
|
||||
if head in {"APPEND", "COLLECT"}:
|
||||
kw = "TO" if "TO" in up else ("INTO" if "INTO" in up else None)
|
||||
if kw:
|
||||
j = up.index(kw)
|
||||
if j + 1 < len(up):
|
||||
add_write(up[j + 1], st, "append", sources=rhs_syms)
|
||||
elif len(up) >= 2: # APPEND x. (헤더라인)
|
||||
add_write(up[1], st, "append", sources=rhs_syms)
|
||||
continue
|
||||
if head == "INSERT" and ("INTO" in up):
|
||||
j = up.index("INTO")
|
||||
k = j + 1
|
||||
if k < len(up) and up[k] == "TABLE":
|
||||
k += 1
|
||||
if k < len(up) and base_symbol(up[k]) in known_symbols:
|
||||
add_write(up[k], st, "insert", sources=rhs_syms)
|
||||
continue
|
||||
# 아니면 DB 쓰기 → refs 에서 처리
|
||||
if head == "MODIFY" and len(up) >= 2 and base_symbol(up[1]) in known_symbols:
|
||||
add_write(up[1], st, "modify", sources=rhs_syms)
|
||||
continue
|
||||
if head == "DELETE" and len(up) >= 2:
|
||||
t1 = up[1]
|
||||
if t1 == "ADJACENT" and "FROM" in up:
|
||||
j = up.index("FROM")
|
||||
if j + 1 < len(up):
|
||||
add_write(up[j + 1], st, "delete")
|
||||
continue
|
||||
if base_symbol(t1) in known_symbols:
|
||||
add_write(t1, st, "delete")
|
||||
continue
|
||||
if head in _CLEAR_HEADS:
|
||||
for t in up[1:]:
|
||||
if re.match(r"^[<A-Z_/]", t) and t not in {"BY", "ASCENDING", "DESCENDING", "STABLE", "WITH", "INITIAL", "LINE", "OF", "TABLE"}:
|
||||
add_write(t, st, _CLEAR_HEADS[head])
|
||||
continue
|
||||
|
||||
# READ TABLE ... INTO x / ASSIGNING <fs>
|
||||
if head == "READ" and len(up) >= 2 and up[1] == "TABLE":
|
||||
src = base_symbol(up[2]) if len(up) >= 3 else ""
|
||||
if src:
|
||||
add_read(src, st)
|
||||
for kw, kind in (("INTO", "read_into"), ("ASSIGNING", "assign_fs")):
|
||||
if kw in up:
|
||||
j = up.index(kw)
|
||||
if j + 1 < len(up):
|
||||
add_write(up[j + 1], st, kind, sources=[src] if src else [])
|
||||
continue
|
||||
|
||||
# LOOP AT x INTO y / ASSIGNING <fs>
|
||||
if head == "LOOP" and "AT" in up:
|
||||
j = up.index("AT")
|
||||
src = base_symbol(up[j + 1]) if j + 1 < len(up) else ""
|
||||
if src:
|
||||
add_read(src, st)
|
||||
for kw in ("INTO", "ASSIGNING"):
|
||||
if kw in up:
|
||||
k = up.index(kw)
|
||||
if k + 1 < len(up):
|
||||
add_write(up[k + 1], st, "iterate", sources=[src] if src else [])
|
||||
continue
|
||||
|
||||
# MOVE / MOVE-CORRESPONDING ... TO x
|
||||
if head in {"MOVE", "MOVE-CORRESPONDING"} and "TO" in up:
|
||||
j = up.index("TO")
|
||||
if j + 1 < len(up):
|
||||
add_write(up[j + 1], st, "assign", sources=rhs_syms[:j])
|
||||
continue
|
||||
|
||||
# SPLIT/CONCATENATE ... INTO x1 x2...
|
||||
if head in {"SPLIT", "CONCATENATE"} and "INTO" in up:
|
||||
j = up.index("INTO")
|
||||
for t in up[j + 1 :]:
|
||||
if t in {"SEPARATED", "BY", "IN", "CHARACTER", "BYTE", "MODE", "TABLE"}:
|
||||
continue
|
||||
if re.match(r"^[<A-Z_/]", t):
|
||||
add_write(t, st, "assign", sources=rhs_syms[:j])
|
||||
continue
|
||||
|
||||
# CALL FUNCTION ... IMPORTING a = x / TABLES t = x / CHANGING c = x
|
||||
if head == "CALL" and len(up) >= 3 and up[1] == "FUNCTION":
|
||||
fname = up[2].strip("'")
|
||||
section = ""
|
||||
k = 3
|
||||
while k < len(up):
|
||||
t = up[k]
|
||||
if t in {"EXPORTING", "IMPORTING", "TABLES", "CHANGING", "EXCEPTIONS", "DESTINATION", "STARTING", "PERFORMING"}:
|
||||
section = t
|
||||
elif t == "=" and k + 1 < len(up) and section in {"IMPORTING", "TABLES", "CHANGING"}:
|
||||
add_write(up[k + 1], st, "call_function", callee=fname)
|
||||
elif t == "=" and k + 1 < len(up) and section == "EXPORTING":
|
||||
add_read(base_symbol(up[k + 1]), st)
|
||||
k += 1
|
||||
continue
|
||||
|
||||
# PERFORM f USING a CHANGING x / TABLES x
|
||||
if head == "PERFORM":
|
||||
callee = up[1] if len(up) >= 2 else ""
|
||||
section = ""
|
||||
for t in up[2:]:
|
||||
if t in {"USING", "CHANGING", "TABLES", "IN", "PROGRAM", "IF", "FOUND"}:
|
||||
section = t
|
||||
continue
|
||||
if section in {"CHANGING", "TABLES"} and re.match(r"^[<A-Z_/]", t):
|
||||
add_write(t, st, "via_perform", callee=callee)
|
||||
elif section == "USING" and re.match(r"^[<A-Z_/]", t):
|
||||
add_read(base_symbol(t), st)
|
||||
continue
|
||||
|
||||
# IMPORT ... FROM MEMORY / GET PARAMETER ID ... FIELD x
|
||||
if head == "IMPORT" and "FROM" in up:
|
||||
j = up.index("FROM")
|
||||
for t in up[1:j]:
|
||||
if t not in {"TO", "="} and re.match(r"^[<A-Z_/]", t):
|
||||
add_write(t, st, "import_memory")
|
||||
continue
|
||||
if head == "GET" and len(up) >= 2 and up[1] == "PARAMETER" and "FIELD" in up:
|
||||
j = up.index("FIELD")
|
||||
if j + 1 < len(up):
|
||||
add_write(up[j + 1], st, "get_parameter")
|
||||
continue
|
||||
|
||||
# 일반 대입: x = ... / x[] = ... / x-f = ... / <fs>-f = ...
|
||||
eq = assignment_eq_index(up)
|
||||
if eq is not None:
|
||||
rhs = up[eq + 1 :]
|
||||
kind = "assign"
|
||||
joined = " ".join(rhs)
|
||||
if "VALUE #(" in joined or (rhs and rhs[0].startswith("VALUE")):
|
||||
kind = "assign_value"
|
||||
elif rhs and rhs[0].startswith("CORRESPONDING"):
|
||||
kind = "assign_corresponding"
|
||||
add_write(up[0], st, kind, sources=[base_symbol(t) for t in rhs if re.match(r"^[<A-Z_/]", t)])
|
||||
continue
|
||||
|
||||
# 그 밖의 문장에서 알려진 심볼 등장 → read
|
||||
for t in up:
|
||||
b = base_symbol(t)
|
||||
if known(b):
|
||||
add_read(b, st)
|
||||
|
||||
return writes, reads
|
||||
@@ -0,0 +1,287 @@
|
||||
"""정의부(선언 블록) 추출 — 조각 코드를 **붙여넣을 때 함께 가야 하는 코드**.
|
||||
|
||||
인덱스가 담는 코드 원문은 **로직만**이다. ABAP 은 내부테이블·스트럭처·상수·필드심볼을
|
||||
TOP 인클루드(또는 FORM 머리)에 따로 선언하므로, 로직만 복사해 붙여넣으면 컴파일되지 않는다.
|
||||
그래서 선언을 **별도로** 수집해 둔다. 무엇을 쓸지(이미 있으면 버리고, 이름이 겹치면 바꾸고)는
|
||||
붙여넣는 쪽 LLM 이 정한다 — 여기서는 판단하지 않고 **원문 그대로, 붙여넣을 수 있는 형태로** 모은다.
|
||||
|
||||
parser/dataflow.py 의 `SymbolDecl` 과 겹쳐 보이지만 목적이 다르다:
|
||||
- `symbol` : "gt_head 가 어디서 채워지는가" 추적용 — 이름·타입 문자열만 있으면 된다.
|
||||
- `declaration` : 붙여넣기용 — **선언 원문**, **줄 범위**, **의존하는 다른 선언**이 필요하다.
|
||||
그리고 `DATA: BEGIN OF ... END OF ...` 구조 선언을 반드시 한 덩어리로 잡아야 한다
|
||||
(dataflow 는 이걸 건너뛴다).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .statements import Statement
|
||||
from .tokenizer import split_comment
|
||||
|
||||
# 선언 문장의 머리 키워드 → 선언 종류
|
||||
DECL_HEADS = {
|
||||
"TYPES": "types",
|
||||
"DATA": "data",
|
||||
"CLASS-DATA": "data",
|
||||
"STATICS": "data",
|
||||
"CONSTANTS": "constants",
|
||||
"TABLES": "tables",
|
||||
"NODES": "tables",
|
||||
"FIELD-SYMBOLS": "field-symbol",
|
||||
"PARAMETERS": "parameter",
|
||||
"PARAMETER": "parameter", # 단수형도 유효한 ABAP 이다 (실측 119건)
|
||||
"CONTROLS": "controls",
|
||||
"SELECT-OPTIONS": "select-option",
|
||||
"RANGES": "ranges",
|
||||
"TYPE-POOLS": "type-pool",
|
||||
"TYPE-POOL": "type-pool",
|
||||
}
|
||||
|
||||
# TYPE/LIKE 뒤에서 건너뛰는 수식어 — 이 다음에 오는 식별자가 실제 참조 대상이다
|
||||
TYPE_MODIFIERS = {
|
||||
"STANDARD", "SORTED", "HASHED", "INDEX", "ANY", "TABLE", "OF", "LINE", "RANGE",
|
||||
"REF", "TO", "OCCURS", "WITH", "HEADER", "INITIAL", "SIZE", "DEFAULT", "VALUE",
|
||||
"READ-ONLY", "UNIQUE", "NON-UNIQUE", "KEY", "EMPTY", "LENGTH", "DECIMALS",
|
||||
}
|
||||
|
||||
# 내장 타입 · 시스템 구조 — 참조로 기록하지 않는다 (어디에나 있으므로 붙여넣기에 방해만 된다)
|
||||
BUILTIN_TYPES = {
|
||||
"C", "N", "I", "F", "P", "D", "T", "X", "B", "S", "INT1", "INT2", "INT4", "INT8",
|
||||
"STRING", "XSTRING", "DECFLOAT16", "DECFLOAT34", "UTCLONG", "FLOAT",
|
||||
"SY", "SYST", "SPACE", "ABAP_BOOL", "ABAP_TRUE", "ABAP_FALSE",
|
||||
}
|
||||
|
||||
_IDENT = re.compile(r"^[A-Z_/][A-Z0-9_/]*$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Declaration:
|
||||
"""붙여넣기 가능한 선언 한 건."""
|
||||
|
||||
name: str
|
||||
kind: str # types/data/constants/tables/field-symbol/parameter/select-option/
|
||||
# ranges/type-pool/class/interface/macro
|
||||
scope: str # global | unit
|
||||
unit_id: str | None
|
||||
include: str
|
||||
line_start: int
|
||||
line_end: int
|
||||
code: str # 원문 (체인 항목이면 헤드를 다시 붙인 **유효한 한 문장**)
|
||||
type_text: str = ""
|
||||
depends: list[str] = field(default_factory=list) # 이 선언이 참조하는 다른 이름(대문자)
|
||||
|
||||
|
||||
def base_name(token: str) -> str:
|
||||
"""GS_HEAD-BELNR → GS_HEAD, GT_TAB[] → GT_TAB, <FS>-F → <FS>, ZCL_X=>TY → ZCL_X"""
|
||||
t = token.upper()
|
||||
if t.startswith("<"):
|
||||
return t.split(">")[0] + ">"
|
||||
t = t.split("(")[0].split("[")[0].split("->")[0].split("=>")[0].split("-")[0]
|
||||
return t.rstrip(",.")
|
||||
|
||||
|
||||
def _is_ident(t: str) -> bool:
|
||||
return bool(_IDENT.match(t)) or (t.startswith("<") and t.endswith(">"))
|
||||
|
||||
|
||||
def clean_ref(token: str) -> str:
|
||||
"""참조 토큰을 정규화 — `GT_DATA[]` → GT_DATA, `BKPF-BELNR` → BKPF-BELNR (컴포넌트 유지)."""
|
||||
t = token.upper().split("(")[0].split("[")[0].rstrip(",.")
|
||||
return t
|
||||
|
||||
|
||||
# 타입식의 머리 — 이 뒤에는 보통 `OF`/`TO` 로 실제 타입 이름이 이어진다 (없을 수도 있다)
|
||||
_TABLE_HEADS = {"STANDARD", "SORTED", "HASHED", "INDEX", "ANY", "TABLE", "LINE", "RANGE", "REF"}
|
||||
|
||||
|
||||
def type_name_after(up: list[str], i: int) -> tuple[str | None, int]:
|
||||
"""`up[i]` 가 TYPE/LIKE 일 때 (참조하는 타입 이름, 다음에 읽을 위치).
|
||||
|
||||
타입식에 이름이 없을 수도 있다 — `TYPE ANY`, `TYPE ANY TABLE`, `TYPE C` 처럼.
|
||||
그때 `None` 과 함께 **수식어 바로 다음 위치**를 돌려줘야 그 뒤 토큰(다음 FORM 파라미터 등)을
|
||||
타입 이름으로 잘못 삼키지 않는다.
|
||||
"""
|
||||
j = i + 1
|
||||
if j >= len(up):
|
||||
return None, j
|
||||
if up[j] not in _TABLE_HEADS:
|
||||
return up[j], j + 1
|
||||
k = j
|
||||
while k < len(up) and up[k] not in {"OF", "TO"}:
|
||||
if up[k] not in _TABLE_HEADS:
|
||||
return None, k # `TYPE ANY` 뒤에 다른 토큰이 온 경우
|
||||
k += 1
|
||||
if k + 1 < len(up): # … OF <타입> / REF TO <클래스>
|
||||
return up[k + 1], k + 2
|
||||
return None, k
|
||||
|
||||
|
||||
def type_refs(up: list[str]) -> list[str]:
|
||||
"""선언 토큰열이 참조하는 다른 이름들 — TYPE/LIKE/INCLUDE STRUCTURE/FOR 뒤의 식별자.
|
||||
|
||||
`LIKE GT_DATA OCCURS 0` → [GT_DATA], `TYPE TABLE OF TY_ITEM` → [TY_ITEM],
|
||||
`TYPE C` → [] (내장 타입).
|
||||
|
||||
**컴포넌트까지 그대로 남긴다**: `LIKE BKPF-BELNR` → [BKPF-BELNR]. 이건 DDIC 사전의 필드
|
||||
타입을 빌려 쓰는 것이지 `TABLES: BKPF` 작업영역이 있어야 한다는 뜻이 아니다 — 정의부를 조립할
|
||||
때(index/decls.py) 이 둘을 구분해야 TOP 의 TABLES 선언이 딸려 오지 않는다.
|
||||
같은 이름을 여러 번 참조하면(구조체 필드들이 전부 같은 테이블을 볼 때) 첫 참조만 남긴다.
|
||||
"""
|
||||
out: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(token: str) -> None:
|
||||
ref = clean_ref(token)
|
||||
base = base_name(ref)
|
||||
if not _is_ident(base) or base in BUILTIN_TYPES or base in seen:
|
||||
return
|
||||
seen.add(base)
|
||||
out.append(ref)
|
||||
|
||||
i = 0
|
||||
while i < len(up):
|
||||
t = up[i]
|
||||
if t in {"TYPE", "LIKE"}:
|
||||
name, nxt = type_name_after(up, i)
|
||||
if name:
|
||||
add(name)
|
||||
i = nxt
|
||||
continue
|
||||
elif t == "INCLUDE" and i + 1 < len(up) and up[i + 1] in {"STRUCTURE", "TYPE"}:
|
||||
if i + 2 < len(up):
|
||||
add(up[i + 2])
|
||||
i += 2
|
||||
elif t == "FOR" and i + 1 < len(up): # SELECT-OPTIONS s_bukrs FOR bkpf-bukrs
|
||||
add(up[i + 1])
|
||||
i += 1
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def _terminate(line: str) -> str:
|
||||
"""체인에서 잘라낸 마지막 줄의 ',' 를 '.' 로 바꾼다 (주석은 보존)."""
|
||||
code, comment, full = split_comment(line)
|
||||
if full:
|
||||
return line
|
||||
stripped = code.rstrip()
|
||||
if stripped.endswith(","):
|
||||
stripped = stripped[:-1] + "."
|
||||
elif not stripped.endswith("."):
|
||||
stripped = stripped + "."
|
||||
return stripped + (f' " {comment}' if comment else "")
|
||||
|
||||
|
||||
def render_code(lines: list[str], st_start: int, st_end: int,
|
||||
chain_head: str, chain_start: int, chain_end: int) -> str:
|
||||
"""선언 원문을 **그대로 붙여넣을 수 있는 한 문장**으로 잘라낸다.
|
||||
|
||||
- 문장이 체인 전체와 같으면 원문 그대로 (이미 '.' 로 끝난다)
|
||||
- 체인의 첫 항목이면 헤드가 첫 줄에 있으므로 그대로 잘라 '.' 로 닫는다
|
||||
- 중간·마지막 항목이면 헤드(`DATA:`)가 없으므로 앞에 다시 붙인다
|
||||
"""
|
||||
body = [lines[no - 1] for no in range(st_start, st_end + 1) if 1 <= no <= len(lines)]
|
||||
if not body:
|
||||
return ""
|
||||
if st_start == chain_start and st_end == chain_end:
|
||||
return "\n".join(body).rstrip()
|
||||
body[-1] = _terminate(body[-1])
|
||||
if st_start > chain_start and chain_head:
|
||||
return f"{chain_head}:\n" + "\n".join(body).rstrip()
|
||||
return "\n".join(body).rstrip()
|
||||
|
||||
|
||||
def _block_name(up: list[str]) -> str:
|
||||
"""['DATA','BEGIN','OF','GT_UPLOAD','OCCURS','0'] → GT_UPLOAD"""
|
||||
return base_name(up[3]) if len(up) >= 4 else ""
|
||||
|
||||
|
||||
def extract_declaration_blocks(
|
||||
statements: list[Statement], stmt_indexes: list[int], lines: list[str],
|
||||
include: str, scope: str, unit_id: str | None = None,
|
||||
) -> list[Declaration]:
|
||||
"""선언 문장들 → Declaration 목록. `BEGIN OF … END OF` 는 한 덩어리로 묶는다."""
|
||||
out: list[Declaration] = []
|
||||
depth = 0
|
||||
block: list[Statement] = []
|
||||
block_kind = ""
|
||||
block_name = ""
|
||||
|
||||
for idx in stmt_indexes:
|
||||
st = statements[idx]
|
||||
up = st.upper
|
||||
if not up:
|
||||
continue
|
||||
kind = DECL_HEADS.get(up[0])
|
||||
if kind is None:
|
||||
# 구조 블록 안에서는 선언 키워드가 없는 문장(INCLUDE STRUCTURE …)도 함께 모은다
|
||||
if depth and up[0] == "INCLUDE":
|
||||
block.append(st)
|
||||
continue
|
||||
|
||||
is_begin = len(up) >= 3 and up[1] == "BEGIN" and up[2] == "OF"
|
||||
is_end = len(up) >= 3 and up[1] == "END" and up[2] == "OF"
|
||||
|
||||
if is_begin:
|
||||
if depth == 0:
|
||||
block, block_kind, block_name = [], kind, _block_name(up)
|
||||
depth += 1
|
||||
block.append(st)
|
||||
continue
|
||||
if depth:
|
||||
block.append(st)
|
||||
if is_end:
|
||||
depth -= 1
|
||||
if depth == 0 and block:
|
||||
first, last = block[0], block[-1]
|
||||
tokens: list[str] = []
|
||||
for b in block:
|
||||
tokens += b.upper
|
||||
out.append(Declaration(
|
||||
name=block_name or _block_name(first.upper), kind=block_kind, scope=scope,
|
||||
unit_id=unit_id, include=include,
|
||||
line_start=first.line_start, line_end=last.line_end,
|
||||
code=render_code(lines, first.line_start, last.line_end,
|
||||
first.chain_head, first.chain_start, last.chain_end),
|
||||
type_text="BEGIN OF …",
|
||||
depends=[d for d in type_refs(tokens) if base_name(d) != block_name],
|
||||
))
|
||||
block = []
|
||||
continue
|
||||
|
||||
if len(up) < 2:
|
||||
continue
|
||||
if kind == "type-pool": # TYPE-POOLS: slis, vrm → 항목마다 한 건
|
||||
name = base_name(up[1])
|
||||
else:
|
||||
name = base_name(up[1])
|
||||
if not name or name in {"BEGIN", "END"} or not _is_ident(name):
|
||||
continue
|
||||
type_text = ""
|
||||
for kw in ("TYPE", "LIKE"):
|
||||
if kw in up:
|
||||
ki = up.index(kw)
|
||||
type_text = " ".join(st.tokens[ki : ki + 5])[:120]
|
||||
break
|
||||
out.append(Declaration(
|
||||
name=name, kind=kind, scope=scope, unit_id=unit_id, include=include,
|
||||
line_start=st.line_start, line_end=st.line_end,
|
||||
code=render_code(lines, st.line_start, st.line_end,
|
||||
st.chain_head, st.chain_start, st.chain_end),
|
||||
type_text=type_text,
|
||||
depends=[d for d in type_refs(up) if base_name(d) != name],
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
def unit_declaration(unit, lines: list[str], kind: str) -> Declaration:
|
||||
"""CLASS … DEFINITION / DEFINE … END-OF-DEFINITION 처럼 **unit 통째가 정의부**인 것.
|
||||
|
||||
로컬 클래스를 쓰는 조각도, 매크로를 호출하는 조각도 그 정의가 없으면 붙여넣어 봐야 안 돈다.
|
||||
"""
|
||||
body = "\n".join(lines[unit.line_start - 1 : unit.line_end]).rstrip()
|
||||
return Declaration(
|
||||
name=unit.name.upper(), kind=kind, scope="global", unit_id=None, include=unit.include,
|
||||
line_start=unit.line_start, line_end=unit.line_end, code=body,
|
||||
type_text=kind, depends=[],
|
||||
)
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
"""참조 추출 — unit 별 DB 접근/호출/권한/메시지/선택화면/UI 신호/텍스트심볼. (계획서 §4.3)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .statements import Statement
|
||||
|
||||
_SQL_WRITE = {"INSERT", "UPDATE", "MODIFY", "DELETE"}
|
||||
_UI_FM = re.compile(r"^(REUSE_ALV_|SSF_|FP_JOB_|GUI_DOWNLOAD|GUI_UPLOAD)")
|
||||
|
||||
# "외부 호출"로 볼 call kind. 요약·위키·엔티티 페이지가 같은 기준을 써야 한다.
|
||||
# 제외하는 것들과 이유:
|
||||
# perform / perform_unresolved — 프로그램 내부 호출
|
||||
# call_method / method_ref — `CL_GUI_ALV_GRID=>MC_FC_AUF` 처럼 상수 읽기가 대량 섞인다
|
||||
# call_screen — 대상이 화면번호('100')라 호출 대상 이름이 아니다
|
||||
EXTERNAL_CALL_KINDS = (
|
||||
"perform_external", "call_function", "call_function_rfc", "call_function_task",
|
||||
"submit", "call_transaction", "call_dialog",
|
||||
)
|
||||
|
||||
# functions/ 엔티티 페이지를 만드는 호출 종류 (FM · BAPI · RFC)
|
||||
FUNCTION_CALL_KINDS = ("call_function", "call_function_rfc", "call_function_task")
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnitRefs:
|
||||
tables_read: list[str] = field(default_factory=list)
|
||||
tables_write: list[str] = field(default_factory=list)
|
||||
calls: list[dict] = field(default_factory=list) # {kind, target, extra?, line}
|
||||
authority_checks: list[str] = field(default_factory=list)
|
||||
messages: list[str] = field(default_factory=list)
|
||||
select_params: list[dict] = field(default_factory=list) # {kind, name, for}
|
||||
output_signals: list[str] = field(default_factory=list)
|
||||
text_symbols: list[str] = field(default_factory=list)
|
||||
macro_calls: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _strip_quote(t: str) -> str:
|
||||
return t[1:-1] if len(t) >= 2 and t[0] == "'" and t[-1] == "'" else t
|
||||
|
||||
|
||||
def _is_name(t: str) -> bool:
|
||||
return bool(re.match(r"^[A-Z_/][A-Z0-9_/]*$", t))
|
||||
|
||||
|
||||
def extract_refs(statements: list[Statement], stmt_indexes: list[int],
|
||||
macro_names: set[str], known_symbols: set[str]) -> UnitRefs:
|
||||
r = UnitRefs()
|
||||
|
||||
def add(lst: list, v) -> None:
|
||||
if v and v not in lst:
|
||||
lst.append(v)
|
||||
|
||||
for idx in stmt_indexes:
|
||||
st = statements[idx]
|
||||
up = st.upper
|
||||
if not up:
|
||||
continue
|
||||
head = up[0]
|
||||
line = st.line_start
|
||||
|
||||
# --- DB 읽기: SELECT/OPEN CURSOR ... FROM t, JOIN t ---
|
||||
if head in {"SELECT", "OPEN"} or (head == "WITH"):
|
||||
for j, t in enumerate(up):
|
||||
if t in {"FROM", "JOIN"} and j + 1 < len(up):
|
||||
cand = up[j + 1]
|
||||
if cand in {"(", "@"}:
|
||||
continue
|
||||
cand = cand.lstrip("@")
|
||||
if _is_name(cand) and cand not in known_symbols:
|
||||
add(r.tables_read, cand)
|
||||
|
||||
# --- DB 쓰기: INSERT/UPDATE/MODIFY/DELETE <db테이블> ---
|
||||
if head in _SQL_WRITE and len(up) >= 2:
|
||||
t1 = up[1]
|
||||
if t1 == "INTO" and len(up) >= 3: # INSERT INTO t VALUES ...
|
||||
t1 = up[2]
|
||||
if t1 == "FROM" and head == "DELETE" and len(up) >= 3: # DELETE FROM t
|
||||
t1 = up[2]
|
||||
if _is_name(t1) and t1 not in known_symbols and t1 not in {"TABLE", "LINES", "ADJACENT", "REPORT", "DATASET", "SCREEN"}:
|
||||
add(r.tables_write, t1)
|
||||
|
||||
# --- 호출 ---
|
||||
if head == "PERFORM" and len(up) >= 2:
|
||||
target = up[1]
|
||||
m = re.match(r"^([A-Z0-9_]+)\(([A-Z0-9_]+)\)$", target) # 구문 f(prog)
|
||||
if m:
|
||||
r.calls.append({"kind": "perform_external", "target": m.group(1), "program": m.group(2), "line": line})
|
||||
elif "IN" in up and "PROGRAM" in up:
|
||||
pi = up.index("PROGRAM")
|
||||
prog = up[pi + 1] if pi + 1 < len(up) else ""
|
||||
r.calls.append({"kind": "perform_external", "target": target, "program": _strip_quote(prog), "line": line})
|
||||
else:
|
||||
r.calls.append({"kind": "perform", "target": target, "line": line})
|
||||
|
||||
if head == "CALL" and len(up) >= 2:
|
||||
what = up[1]
|
||||
if what == "FUNCTION" and len(up) >= 3:
|
||||
fname = _strip_quote(up[2])
|
||||
kind = "call_function"
|
||||
if "DESTINATION" in up:
|
||||
kind = "call_function_rfc"
|
||||
add(r.output_signals, "RFC")
|
||||
if "TASK" in up:
|
||||
kind = "call_function_task"
|
||||
r.calls.append({"kind": kind, "target": fname, "line": line})
|
||||
if _UI_FM.match(fname):
|
||||
add(r.output_signals, "ALV" if fname.startswith("REUSE_ALV_") else fname.split("_")[0])
|
||||
if fname.startswith(("GUI_DOWNLOAD", "GUI_UPLOAD")):
|
||||
add(r.output_signals, "FILE")
|
||||
elif what == "METHOD" and len(up) >= 3:
|
||||
r.calls.append({"kind": "call_method", "target": up[2], "line": line})
|
||||
elif what == "TRANSACTION" and len(up) >= 3:
|
||||
r.calls.append({"kind": "call_transaction", "target": _strip_quote(up[2]), "line": line})
|
||||
if "USING" in up:
|
||||
add(r.output_signals, "BDC")
|
||||
elif what == "SCREEN" and len(up) >= 3:
|
||||
r.calls.append({"kind": "call_screen", "target": up[2], "line": line})
|
||||
add(r.output_signals, "SCREEN")
|
||||
elif what == "DIALOG" and len(up) >= 3:
|
||||
r.calls.append({"kind": "call_dialog", "target": _strip_quote(up[2]), "line": line})
|
||||
|
||||
if head == "SUBMIT" and len(up) >= 2:
|
||||
r.calls.append({"kind": "submit", "target": up[1], "line": line})
|
||||
|
||||
# 함수형 메서드 호출: zcl_x=>meth( ... ) / lo_obj->meth( ... )
|
||||
for t in st.tokens:
|
||||
tu = t.upper()
|
||||
if "=>" in tu or "->" in tu:
|
||||
base = tu.split("(")[0]
|
||||
if re.match(r"^[A-Z0-9_/<>]+(=>|->)[A-Z0-9_~]+$", base):
|
||||
if not any(c["target"] == base for c in r.calls):
|
||||
r.calls.append({"kind": "method_ref", "target": base, "line": line})
|
||||
if base.startswith("CL_GUI_ALV_GRID") or "ALV" in base.split("=>")[0].split("->")[0]:
|
||||
add(r.output_signals, "ALV_GRID")
|
||||
|
||||
# --- 권한/메시지 ---
|
||||
if head == "AUTHORITY-CHECK" and "OBJECT" in up:
|
||||
oi = up.index("OBJECT")
|
||||
if oi + 1 < len(up):
|
||||
add(r.authority_checks, _strip_quote(up[oi + 1]))
|
||||
if head == "MESSAGE" and len(up) >= 2:
|
||||
add(r.messages, _strip_quote(up[1])[:40])
|
||||
|
||||
# --- 선택화면 ---
|
||||
if head == "PARAMETERS" and len(up) >= 2:
|
||||
r.select_params.append({"kind": "parameter", "name": up[1].split("(")[0], "line": line})
|
||||
if head == "SELECT-OPTIONS" and len(up) >= 2:
|
||||
target = ""
|
||||
if "FOR" in up:
|
||||
fi = up.index("FOR")
|
||||
target = up[fi + 1] if fi + 1 < len(up) else ""
|
||||
r.select_params.append({"kind": "select_option", "name": up[1], "for": target, "line": line})
|
||||
|
||||
# --- UI 신호 ---
|
||||
if head == "WRITE":
|
||||
add(r.output_signals, "LIST_WRITE")
|
||||
if head == "CALL" and len(up) >= 3 and up[1] == "SCREEN":
|
||||
add(r.output_signals, "SCREEN")
|
||||
|
||||
# --- 텍스트 심볼 / 매크로 ---
|
||||
for t in up:
|
||||
if t.startswith("TEXT-"):
|
||||
add(r.text_symbols, t.replace("TEXT-", ""))
|
||||
if head in macro_names:
|
||||
add(r.macro_calls, head)
|
||||
|
||||
return r
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
"""Stage 2 실행 — data/normalized/<PROGRAM>/ → data/parsed/<PROGRAM>.parse.json
|
||||
|
||||
python -m parser.run [--program ZFIR10070] [--limit N] [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from .dataflow import SymbolDecl, extract_declarations, extract_writes
|
||||
from .declarations import Declaration, extract_declaration_blocks, unit_declaration
|
||||
from .refs import extract_refs
|
||||
from .statements import Statement, assignment_eq_index, split_statements
|
||||
from .units import Unit, extract_units, finalize_units
|
||||
|
||||
# 파서가 의미를 아는 문장 머리 키워드 (미인식 리포트용 — 계획서 §4.6)
|
||||
RECOGNIZED_HEADS = {
|
||||
"REPORT", "PROGRAM", "INCLUDE", "TYPE-POOLS", "TYPES", "DATA", "CONSTANTS", "STATICS",
|
||||
"TABLES", "NODES", "FIELD-SYMBOLS", "RANGES", "CLASS-DATA", "PARAMETERS", "PARAMETER",
|
||||
"CONTROLS", "SELECT-OPTIONS",
|
||||
"SELECTION-SCREEN", "INITIALIZATION", "START-OF-SELECTION", "END-OF-SELECTION",
|
||||
"TOP-OF-PAGE", "END-OF-PAGE", "LOAD-OF-PROGRAM", "AT", "GET", "FORM", "ENDFORM",
|
||||
"FUNCTION", "ENDFUNCTION", "MODULE", "ENDMODULE", "METHOD", "ENDMETHOD", "CLASS",
|
||||
"ENDCLASS", "INTERFACE", "ENDINTERFACE", "DEFINE", "END-OF-DEFINITION",
|
||||
"SELECT", "ENDSELECT", "FETCH", "OPEN", "CLOSE", "INSERT", "UPDATE", "MODIFY", "DELETE",
|
||||
"COMMIT", "ROLLBACK", "APPEND", "COLLECT", "READ", "LOOP", "ENDLOOP", "SORT", "CLEAR",
|
||||
"REFRESH", "FREE", "MOVE", "MOVE-CORRESPONDING", "SPLIT", "CONCATENATE", "CONDENSE",
|
||||
"TRANSLATE", "REPLACE", "SHIFT", "OVERLAY", "SEARCH", "FIND", "ASSIGN", "UNASSIGN",
|
||||
"IF", "ELSEIF", "ELSE", "ENDIF", "CASE", "WHEN", "ENDCASE", "DO", "ENDDO", "WHILE",
|
||||
"ENDWHILE", "CHECK", "EXIT", "CONTINUE", "RETURN", "LEAVE", "STOP", "REJECT",
|
||||
"PERFORM", "CALL", "SUBMIT", "EXPORT", "IMPORT", "SET", "AUTHORITY-CHECK", "MESSAGE",
|
||||
"WRITE", "ULINE", "SKIP", "NEW-LINE", "NEW-PAGE", "FORMAT", "HIDE", "WINDOW",
|
||||
"DESCRIBE", "ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "COMPUTE", "CONVERT", "CREATE",
|
||||
"RAISE", "TRY", "CATCH", "CLEANUP", "ENDTRY", "WAIT", "SUPPRESS", "SCROLL",
|
||||
"PRINT-CONTROL", "BREAK-POINT", "ASSERT", "FIELD", "CHAIN", "ENDCHAIN", "ENDAT",
|
||||
"SUMMARY", "DETAIL", "BACK", "RESERVE", "POSITION", "EDITOR-CALL", "RETURN",
|
||||
# 클래스/인터페이스 정의부
|
||||
"METHODS", "CLASS-METHODS", "CLASS-EVENTS", "EVENTS", "INTERFACES", "ALIASES",
|
||||
"PUBLIC", "PROTECTED", "PRIVATE", "SECTION", "IMPLEMENTATION", "DEFINITION",
|
||||
"TYPE-POOL", "FUNCTION-POOL",
|
||||
}
|
||||
|
||||
EVENT_ORDER = ["LOAD-OF-PROGRAM", "INITIALIZATION", "AT SELECTION-SCREEN", "START-OF-SELECTION",
|
||||
"GET", "END-OF-SELECTION", "TOP-OF-PAGE", "END-OF-PAGE", "AT LINE-SELECTION",
|
||||
"AT USER-COMMAND"]
|
||||
|
||||
|
||||
def _event_rank(u: Unit) -> int:
|
||||
for i, ev in enumerate(EVENT_ORDER):
|
||||
if u.name.startswith(ev):
|
||||
return i
|
||||
return len(EVENT_ORDER)
|
||||
|
||||
|
||||
def parse_program(prog_dir: Path) -> dict:
|
||||
program = prog_dir.name
|
||||
meta = json.loads((prog_dir / f"{program}.meta.json").read_text(encoding="utf-8"))
|
||||
|
||||
code_by_include: dict[str, str] = {}
|
||||
statements_by_include: dict[str, list[Statement]] = {}
|
||||
for inc in meta["includes"]:
|
||||
name = inc["include"]
|
||||
code = (prog_dir / f"{name}.abap").read_text(encoding="utf-8")
|
||||
code_by_include[name] = code
|
||||
statements_by_include[name], _ = split_statements(code, name)
|
||||
|
||||
# 1) 매크로 이름 선수집 (매크로 호출 인식용)
|
||||
macro_names: set[str] = set()
|
||||
for sts in statements_by_include.values():
|
||||
for st in sts:
|
||||
if st.upper and st.upper[0] == "DEFINE" and len(st.upper) >= 2:
|
||||
macro_names.add(st.upper[1])
|
||||
|
||||
# 2) unit 추출
|
||||
units: list[Unit] = []
|
||||
for name, sts in statements_by_include.items():
|
||||
units.extend(extract_units(program, name, sts, code_by_include[name].count("\n") + 1))
|
||||
finalize_units(units, statements_by_include, code_by_include)
|
||||
|
||||
# unit_id 유일화 — 같은 이벤트(AT SELECTION-SCREEN ON VALUE-REQUEST 등)가 반복되면 라인 번호를 붙인다
|
||||
seen_ids: set[str] = set()
|
||||
for u in units:
|
||||
if u.unit_id in seen_ids:
|
||||
u.unit_id = f"{u.unit_id}@L{u.line_start}"
|
||||
seen_ids.add(u.unit_id)
|
||||
|
||||
# 3) 심볼 선언 — 전역(DECLARATION unit) + unit 로컬
|
||||
symbols: list[SymbolDecl] = []
|
||||
for u in units:
|
||||
sts = statements_by_include[u.include]
|
||||
if u.unit_type == "DECLARATION":
|
||||
symbols.extend(extract_declarations(sts, u.stmt_indexes, None, "global"))
|
||||
elif u.unit_type in {"FORM", "METHOD", "FUNCTION", "MODULE", "EVENT"}:
|
||||
symbols.extend(extract_declarations(sts, u.stmt_indexes, u.unit_id, "unit",
|
||||
unit_type=u.unit_type, signature=u.signature))
|
||||
# `TABLES: zfit0030.` / `NODES: ...` 는 **DDIC 테이블 작업영역** 선언이다. 이 이름을
|
||||
# known_symbols 에 넣으면 extract_refs 의 "심볼이면 DB 테이블이 아니다" 가드가
|
||||
# `SELECT ... FROM zfit0030` 을 걸러버린다 — 정작 업무의 핵심인 커스텀 Z 테이블이 통째로
|
||||
# 누락된다(실측: ZFIR0010 의 tables_read 에 ZFIT0030 이 없었다).
|
||||
# 가드의 목적은 내부테이블(DATA gt_x TYPE TABLE OF ...)을 DB 테이블로 오인하지 않는 것이므로
|
||||
# DDIC 작업영역은 제외한다.
|
||||
DDIC_WORK_AREA = {"tables", "nodes"}
|
||||
global_syms = {s.name for s in symbols if s.scope == "global" and s.kind not in DDIC_WORK_AREA}
|
||||
local_syms_by_unit: dict[str, set[str]] = {}
|
||||
for s in symbols:
|
||||
if s.scope == "unit" and s.unit_id:
|
||||
local_syms_by_unit.setdefault(s.unit_id, set()).add(s.name)
|
||||
|
||||
# 3-b) 정의부(선언 블록) — 붙여넣기용 원문. symbol 과 목적이 다르다(parser/declarations.py)
|
||||
lines_by_include = {name: code.split("\n") for name, code in code_by_include.items()}
|
||||
declarations: list[Declaration] = []
|
||||
for u in units:
|
||||
sts = statements_by_include[u.include]
|
||||
lns = lines_by_include[u.include]
|
||||
if u.unit_type == "DECLARATION":
|
||||
declarations.extend(extract_declaration_blocks(sts, u.stmt_indexes, lns, u.include, "global"))
|
||||
elif u.unit_type == "CLASS_DEF":
|
||||
declarations.append(unit_declaration(u, lns, "class"))
|
||||
elif u.unit_type == "MACRO":
|
||||
declarations.append(unit_declaration(u, lns, "macro"))
|
||||
elif u.unit_type in {"FORM", "METHOD", "FUNCTION", "MODULE", "EVENT"}:
|
||||
declarations.extend(
|
||||
extract_declaration_blocks(sts, u.stmt_indexes, lns, u.include, "unit", u.unit_id))
|
||||
|
||||
# 4) unit 별 참조/데이터플로우
|
||||
all_writes, all_reads, unit_refs = [], [], {}
|
||||
for u in units:
|
||||
if u.unit_type in {"DECLARATION", "CLASS_DEF"}:
|
||||
continue
|
||||
sts = statements_by_include[u.include]
|
||||
known = global_syms | local_syms_by_unit.get(u.unit_id, set())
|
||||
r = extract_refs(sts, u.stmt_indexes, macro_names, known)
|
||||
unit_refs[u.unit_id] = r
|
||||
writes, reads = extract_writes(sts, u.stmt_indexes, u.unit_id, known)
|
||||
all_writes.extend(writes)
|
||||
all_reads.extend(reads)
|
||||
|
||||
# 5) 호출 그래프
|
||||
form_by_name = {u.name: u.unit_id for u in units if u.unit_type == "FORM"}
|
||||
module_by_name = {u.name.split(" ")[0]: u.unit_id for u in units if u.unit_type == "MODULE"}
|
||||
edges: list[dict] = []
|
||||
for u in units:
|
||||
r = unit_refs.get(u.unit_id)
|
||||
if not r:
|
||||
continue
|
||||
for c in r.calls:
|
||||
kind, target = c["kind"], c["target"]
|
||||
edge = {"from_unit": u.unit_id, "to_unit": None, "external_name": None,
|
||||
"call_type": kind, "line": c.get("line", 0)}
|
||||
if kind == "perform":
|
||||
if target in form_by_name:
|
||||
edge["to_unit"] = form_by_name[target]
|
||||
else:
|
||||
edge["external_name"] = target
|
||||
edge["call_type"] = "perform_unresolved"
|
||||
elif kind == "perform_external":
|
||||
edge["external_name"] = f"{c.get('program', '?')}:{target}"
|
||||
else:
|
||||
edge["external_name"] = target
|
||||
edges.append(edge)
|
||||
|
||||
# 6) 실행 순서(topo_order) — 이벤트 순 → PERFORM 체인 DFS (순환은 끊음)
|
||||
adj: dict[str, list[str]] = {}
|
||||
for e in edges:
|
||||
if e["to_unit"]:
|
||||
adj.setdefault(e["from_unit"], []).append(e["to_unit"])
|
||||
topo: list[str] = []
|
||||
visited: set[str] = set()
|
||||
|
||||
def dfs(uid: str) -> None:
|
||||
if uid in visited:
|
||||
return
|
||||
visited.add(uid)
|
||||
topo.append(uid)
|
||||
for nxt in adj.get(uid, []):
|
||||
dfs(nxt)
|
||||
|
||||
for u in sorted([x for x in units if x.unit_type == "EVENT"], key=_event_rank):
|
||||
dfs(u.unit_id)
|
||||
for u in units: # 이벤트에서 도달 못한 unit (PBO/PAI 모듈 등)
|
||||
if u.unit_type in {"MODULE", "FORM", "METHOD", "FUNCTION"} and u.unit_id not in visited:
|
||||
dfs(u.unit_id)
|
||||
|
||||
# 7) 미인식 문장 리포트
|
||||
unknown = Counter()
|
||||
total_stmts = 0
|
||||
for sts in statements_by_include.values():
|
||||
for st in sts:
|
||||
if not st.upper:
|
||||
continue
|
||||
total_stmts += 1
|
||||
head = st.upper[0]
|
||||
base = head.split("(")[0]
|
||||
if (
|
||||
base in RECOGNIZED_HEADS
|
||||
or base in macro_names
|
||||
or base.startswith("&") # 매크로 치환 파라미터(&1 = …) — 템플릿이라 분석 대상 아님
|
||||
or "->" in base or "=>" in base # 메서드 호출식 문장
|
||||
or assignment_eq_index(st.upper) is not None
|
||||
):
|
||||
continue
|
||||
unknown[base] += 1
|
||||
|
||||
return {
|
||||
"program": program,
|
||||
"description": meta.get("description", ""),
|
||||
"includes": meta["includes"],
|
||||
"text_symbols": meta.get("text_symbols", []),
|
||||
"units": [
|
||||
{**{k: v for k, v in asdict(u).items() if k != "stmt_indexes"},
|
||||
"refs": asdict(unit_refs[u.unit_id]) if u.unit_id in unit_refs else None}
|
||||
for u in units
|
||||
],
|
||||
"symbols": [asdict(s) for s in symbols],
|
||||
"declarations": [asdict(d) for d in declarations],
|
||||
"writes": [asdict(w) for w in all_writes],
|
||||
"reads": all_reads,
|
||||
"call_edges": edges,
|
||||
"topo_order": topo,
|
||||
"stats": {
|
||||
"units": len(units),
|
||||
"statements": total_stmts,
|
||||
"symbols": len(symbols),
|
||||
"declarations": len(declarations),
|
||||
"writes": len(all_writes),
|
||||
"unknown_statements": sum(unknown.values()),
|
||||
"unknown_ratio": round(sum(unknown.values()) / total_stmts, 4) if total_stmts else 0,
|
||||
"unknown_top": unknown.most_common(15),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="Stage 2 — 구조 파싱")
|
||||
ap.add_argument("--program", default=None, help="특정 프로그램만")
|
||||
ap.add_argument("--limit", type=int, default=None)
|
||||
ap.add_argument("--dry-run", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
from config.settings import settings
|
||||
|
||||
norm, out = settings.data_normalized, settings.data_parsed
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
prog_dirs = sorted(d for d in norm.iterdir() if d.is_dir()) if norm.exists() else []
|
||||
if args.program:
|
||||
prog_dirs = [d for d in prog_dirs if d.name == args.program.upper()]
|
||||
if args.limit:
|
||||
prog_dirs = prog_dirs[: args.limit]
|
||||
|
||||
agg = {"programs": 0, "units": 0, "statements": 0, "unknown": 0, "errors": 0}
|
||||
for d in prog_dirs:
|
||||
try:
|
||||
result = parse_program(d)
|
||||
except Exception as e: # noqa: BLE001
|
||||
agg["errors"] += 1
|
||||
print(f"[FAIL] {d.name}: {type(e).__name__}: {e}")
|
||||
continue
|
||||
agg["programs"] += 1
|
||||
agg["units"] += result["stats"]["units"]
|
||||
agg["statements"] += result["stats"]["statements"]
|
||||
agg["unknown"] += result["stats"]["unknown_statements"]
|
||||
if not args.dry_run:
|
||||
(out / f"{d.name}.parse.json").write_text(
|
||||
json.dumps(result, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
print(f"[OK] {d.name}: units={result['stats']['units']} stmts={result['stats']['statements']} "
|
||||
f"unknown={result['stats']['unknown_statements']} ({result['stats']['unknown_ratio']:.1%}) "
|
||||
f"top={result['stats']['unknown_top'][:5]}")
|
||||
print(json.dumps(agg, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,174 @@
|
||||
"""문장 분리 — '.' 종결, 체인(:` `,`) 전개. (계획서 §4.1)"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .tokenizer import Line, read_lines, tokenize_code
|
||||
|
||||
_NAME_HEAD = re.compile(r"^[<A-Za-z_/]")
|
||||
|
||||
|
||||
def assignment_eq_index(up: list[str]) -> int | None:
|
||||
"""평문 대입이면 최상위 '=' 토큰의 위치, 아니면 None.
|
||||
|
||||
받아들이는 형태 — 쓰기 지점 추출(dataflow)과 미인식 리포트(run)가 같은 판정을 써야 하므로
|
||||
여기 한 곳에만 둔다:
|
||||
X = ... → 1
|
||||
X[] = ... → 3 (내부테이블 본문 대입 — 흔한 관용구)
|
||||
X[ key = v ] = ... → 대괄호를 건너뛴 위치 (테이블 표현식)
|
||||
X(3) = ... → 괄호를 건너뛴 위치 (오프셋·길이 쓰기)
|
||||
X+4(2) = ... → 위와 같음
|
||||
"""
|
||||
if len(up) < 3 or not _NAME_HEAD.match(up[0]):
|
||||
return None
|
||||
i = 1
|
||||
# 오프셋 표기 X+4(2) — '+'와 숫자를 먼저 건너뛴다
|
||||
if i + 1 < len(up) and up[i] == "+" and up[i + 1].isdigit():
|
||||
i += 2
|
||||
for opener, closer in (("[", "]"), ("(", ")")):
|
||||
if i < len(up) and up[i] == opener:
|
||||
depth = 0
|
||||
while i < len(up):
|
||||
if up[i] == opener:
|
||||
depth += 1
|
||||
elif up[i] == closer:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
i += 1
|
||||
break
|
||||
i += 1
|
||||
return i if i < len(up) and up[i] == "=" else None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Statement:
|
||||
include: str
|
||||
line_start: int
|
||||
line_end: int
|
||||
tokens: list[str] # 원문 케이스
|
||||
comment_text: str = "" # 같은 줄 / 직전 주석
|
||||
raw_text: str = ""
|
||||
# --- 체인(`DATA: a, b, c.`) 항목의 원문 복원용 (parser/declarations.py) ---
|
||||
chain_head: str = "" # ':' 앞 토큰들 ("DATA") — 체인에서 전개된 항목만
|
||||
chain_start: int = 0 # 체인 전체의 시작 줄
|
||||
chain_end: int = 0 # 체인 전체의 끝 줄('.' 이 있는 줄)
|
||||
|
||||
@property
|
||||
def upper(self) -> list[str]:
|
||||
return [t.upper() for t in self.tokens]
|
||||
|
||||
def text(self) -> str:
|
||||
return " ".join(self.tokens)
|
||||
|
||||
|
||||
def _expand_chain(tokens: list[str], token_lines: list[int]) -> list[tuple[list[str], int, int]]:
|
||||
"""'DATA: a TYPE i, b TYPE i' → [(['DATA','a','TYPE','i'], 줄, 줄), (['DATA','b',...], 줄, 줄)]
|
||||
|
||||
괄호 안의 ','는 분리하지 않는다. 항목마다 **자기 토큰이 놓인 줄 범위**를 함께 돌려준다 —
|
||||
체인 항목의 선언 원문을 그 항목만큼만 잘라내려면(정의부 수집) 항목별 줄이 있어야 한다.
|
||||
체인이 아니면 빈 목록을 돌려준다(호출 측이 문장 전체 범위를 쓴다).
|
||||
"""
|
||||
if ":" not in tokens:
|
||||
return []
|
||||
ci = tokens.index(":")
|
||||
head, head_lines = tokens[:ci], token_lines[:ci]
|
||||
rest, rest_lines = tokens[ci + 1 :], token_lines[ci + 1 :]
|
||||
groups: list[tuple[list[str], list[int]]] = []
|
||||
cur: list[str] = []
|
||||
cur_lines: list[int] = []
|
||||
depth = 0
|
||||
for t, ln in zip(rest, rest_lines):
|
||||
if t == "(":
|
||||
depth += 1
|
||||
elif t == ")":
|
||||
depth = max(0, depth - 1)
|
||||
if t == "," and depth == 0:
|
||||
groups.append((cur, cur_lines))
|
||||
cur, cur_lines = [], []
|
||||
else:
|
||||
cur.append(t)
|
||||
cur_lines.append(ln)
|
||||
groups.append((cur, cur_lines))
|
||||
|
||||
out: list[tuple[list[str], int, int]] = []
|
||||
fallback = head_lines[0] if head_lines else (token_lines[0] if token_lines else 0)
|
||||
for g, gl in groups:
|
||||
if not (head or g):
|
||||
continue
|
||||
start = gl[0] if gl else fallback
|
||||
end = gl[-1] if gl else start
|
||||
out.append((head + g, start, end))
|
||||
return out
|
||||
|
||||
|
||||
def split_statements(text: str, include: str) -> tuple[list[Statement], list[Line]]:
|
||||
"""인클루드 소스 → 문장 목록. 주석 줄은 직후 문장의 comment_text 로 전달."""
|
||||
lines = read_lines(text)
|
||||
statements: list[Statement] = []
|
||||
pending_tokens: list[str] = []
|
||||
pending_lines: list[int] = [] # pending_tokens 와 같은 길이 — 토큰이 놓인 줄
|
||||
pending_start: int | None = None
|
||||
pending_comments: list[str] = []
|
||||
inline_comments: list[str] = []
|
||||
|
||||
def flush(end_line: int) -> None:
|
||||
nonlocal pending_tokens, pending_lines, pending_start, pending_comments, inline_comments
|
||||
if pending_tokens:
|
||||
comment = " ".join(c for c in (pending_comments + inline_comments) if c).strip()
|
||||
start = pending_start or end_line
|
||||
items = _expand_chain(pending_tokens, pending_lines)
|
||||
if not items: # 체인이 아니면 문장 = 전체 범위
|
||||
items = [(pending_tokens, start, end_line)]
|
||||
chain_head = ""
|
||||
else:
|
||||
chain_head = " ".join(pending_tokens[: pending_tokens.index(":")])
|
||||
for i, (toks, ls, le) in enumerate(items):
|
||||
if not toks:
|
||||
continue
|
||||
statements.append(
|
||||
Statement(
|
||||
include=include,
|
||||
line_start=ls,
|
||||
# 마지막 항목은 종결 '.' 이 있는 줄까지 — 원문을 잘라도 문장이 닫힌다
|
||||
line_end=end_line if i == len(items) - 1 else le,
|
||||
tokens=toks,
|
||||
comment_text=comment[:500],
|
||||
chain_head=chain_head,
|
||||
chain_start=start,
|
||||
chain_end=end_line,
|
||||
)
|
||||
)
|
||||
pending_tokens = []
|
||||
pending_lines = []
|
||||
pending_start = None
|
||||
pending_comments = []
|
||||
inline_comments = []
|
||||
|
||||
for ln in lines:
|
||||
if ln.is_full_comment:
|
||||
if pending_tokens:
|
||||
inline_comments.append(ln.comment)
|
||||
else:
|
||||
pending_comments.append(ln.comment)
|
||||
continue
|
||||
if ln.comment:
|
||||
inline_comments.append(ln.comment)
|
||||
toks = tokenize_code(ln.code)
|
||||
if not toks:
|
||||
if not pending_tokens:
|
||||
# 빈 줄이 이어지면 이전 주석은 다음 문장과 무관해질 수 있으나, 그대로 유지(요약 컨텍스트용)
|
||||
pass
|
||||
continue
|
||||
for t in toks:
|
||||
if t == ".":
|
||||
flush(ln.no)
|
||||
else:
|
||||
if pending_start is None:
|
||||
pending_start = ln.no
|
||||
pending_tokens.append(t)
|
||||
pending_lines.append(ln.no)
|
||||
flush(len(lines))
|
||||
for st in statements:
|
||||
st.raw_text = st.text()[:400]
|
||||
return statements, lines
|
||||
@@ -0,0 +1,98 @@
|
||||
"""ABAP 라인 → (코드, 주석) 분리와 토큰화.
|
||||
|
||||
목표는 완전한 ABAP 렉서가 아니라 (계획서 §11.3) 문장 패턴 매칭에 충분한 토큰열이다.
|
||||
- 줄 첫 문자 '*' → 전체 주석 줄
|
||||
- 코드 중 '"' 부터 줄 끝 → 인라인 주석 (단, '...' / `...` / |...| 리터럴 내부 제외)
|
||||
- 식별자는 -, ->, =>, / 를 포함해 한 토큰으로 취급한다
|
||||
(gs_head-belnr, zcl_x=>meth, lo_obj->meth, TEXT-004, /bic/xxx 등)
|
||||
- 필드심볼도 컴포넌트까지 한 토큰이다 (<ls_fcat>-fieldname). 이걸 쪼개면 일반 대입
|
||||
판정(up[1] == "=")이 깨져 쓰기 지점이 누락되고 미인식 문장으로 잡힌다 — ALV 필드카탈로그를
|
||||
채우는 관용구라 프로그램에 따라 미인식률을 12% 까지 끌어올렸다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class Line:
|
||||
no: int # 1-based
|
||||
code: str # 주석 제거된 코드 부분
|
||||
comment: str # 이 줄의 주석 텍스트 (전체주석/인라인 모두)
|
||||
is_full_comment: bool
|
||||
|
||||
|
||||
_TOKEN_RE = re.compile(
|
||||
r"""
|
||||
'(?:[^']|'')*' # '문자열' ('' 이스케이프)
|
||||
| `[^`]*` # `문자열`
|
||||
| \|[^|]*\| # |템플릿|
|
||||
| <[A-Za-z_][A-Za-z0-9_]*>(?:->|=>|[A-Za-z0-9_~/-])* # <필드심볼>[-컴포넌트 | ->메서드]
|
||||
| &[0-9]+ # 매크로(DEFINE) 치환 파라미터 &1 &2 …
|
||||
| [A-Za-z_/](?:->|=>|[A-Za-z0-9_~/-])*\(? # 식별자(-, ->, =>, /, ~ 포함; 단독 = 는 제외), 직결 여는괄호 허용
|
||||
| [0-9]+
|
||||
| ##[A-Za-z_]+ # pragma
|
||||
| . # 그 외 1문자 ( ) . , : = 등
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def split_comment(line: str) -> tuple[str, str, bool]:
|
||||
"""한 줄을 (코드, 주석, 전체주석여부)로 분리."""
|
||||
if line.startswith("*"):
|
||||
return "", line[1:].strip(), True
|
||||
code_chars: list[str] = []
|
||||
i, n = 0, len(line)
|
||||
while i < n:
|
||||
ch = line[i]
|
||||
if ch == "'":
|
||||
j = i + 1
|
||||
while j < n:
|
||||
if line[j] == "'":
|
||||
if j + 1 < n and line[j + 1] == "'":
|
||||
j += 2
|
||||
continue
|
||||
break
|
||||
j += 1
|
||||
code_chars.append(line[i : min(j + 1, n)])
|
||||
i = j + 1
|
||||
elif ch == "`":
|
||||
j = line.find("`", i + 1)
|
||||
j = n - 1 if j < 0 else j
|
||||
code_chars.append(line[i : j + 1])
|
||||
i = j + 1
|
||||
elif ch == "|":
|
||||
j = line.find("|", i + 1)
|
||||
j = n - 1 if j < 0 else j
|
||||
code_chars.append(line[i : j + 1])
|
||||
i = j + 1
|
||||
elif ch == '"':
|
||||
return "".join(code_chars), line[i + 1 :].strip(), False
|
||||
else:
|
||||
code_chars.append(ch)
|
||||
i += 1
|
||||
return "".join(code_chars), "", False
|
||||
|
||||
|
||||
def tokenize_code(code: str) -> list[str]:
|
||||
tokens = []
|
||||
for m in _TOKEN_RE.finditer(code):
|
||||
t = m.group(0)
|
||||
if t.strip():
|
||||
# 식별자에 붙은 여는 괄호는 분리한다: "meth(" → "meth", "("
|
||||
if len(t) > 1 and t.endswith("(") and not t.startswith(("'", "`", "|")):
|
||||
tokens.append(t[:-1])
|
||||
tokens.append("(")
|
||||
else:
|
||||
tokens.append(t)
|
||||
return tokens
|
||||
|
||||
|
||||
def read_lines(text: str) -> list[Line]:
|
||||
out: list[Line] = []
|
||||
for i, raw in enumerate(text.split("\n"), start=1):
|
||||
code, comment, full = split_comment(raw.rstrip("\r"))
|
||||
out.append(Line(no=i, code=code, comment=comment, is_full_comment=full))
|
||||
return out
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
"""코드 단위(unit) 추출. (계획서 §4.2)
|
||||
|
||||
unit_type: FORM / METHOD / FUNCTION / MODULE / EVENT / CLASS_DEF / DECLARATION / MACRO
|
||||
unit_id : PROGRAM#INCLUDE#TYPE#NAME
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .statements import Statement
|
||||
|
||||
EVENT_KEYWORDS = {
|
||||
"INITIALIZATION",
|
||||
"START-OF-SELECTION",
|
||||
"END-OF-SELECTION",
|
||||
"TOP-OF-PAGE",
|
||||
"END-OF-PAGE",
|
||||
"AT", # AT SELECTION-SCREEN..., AT LINE-SELECTION, AT USER-COMMAND
|
||||
"GET", # 논리DB GET
|
||||
"LOAD-OF-PROGRAM",
|
||||
}
|
||||
|
||||
DECL_KEYWORDS = {
|
||||
"TYPES", "DATA", "CONSTANTS", "TABLES", "STATICS", "FIELD-SYMBOLS",
|
||||
"SELECT-OPTIONS", "PARAMETERS", "PARAMETER", "SELECTION-SCREEN", "RANGES", "CONTROLS",
|
||||
"CLASS-DATA", "INCLUDE", "TYPE-POOLS", "REPORT", "PROGRAM", "NODES",
|
||||
}
|
||||
|
||||
SUB_CHUNK_LIMIT = 300 # 이 줄 수를 넘으면 최상위 IF/CASE/LOOP 경계로 서브청크 기록
|
||||
|
||||
|
||||
@dataclass
|
||||
class Unit:
|
||||
unit_id: str
|
||||
program: str
|
||||
include: str
|
||||
unit_type: str
|
||||
name: str
|
||||
line_start: int
|
||||
line_end: int
|
||||
signature: str = ""
|
||||
header_comment: str = ""
|
||||
code_hash: str = ""
|
||||
loc: int = 0
|
||||
stmt_indexes: list[int] = field(default_factory=list) # 이 unit 에 속한 문장 인덱스
|
||||
sub_chunks: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def _event_name(up: list[str]) -> str | None:
|
||||
head = up[0]
|
||||
if head in {"INITIALIZATION", "START-OF-SELECTION", "END-OF-SELECTION",
|
||||
"TOP-OF-PAGE", "END-OF-PAGE", "LOAD-OF-PROGRAM"}:
|
||||
return head
|
||||
if head == "AT" and len(up) >= 2 and up[1] in {"SELECTION-SCREEN", "LINE-SELECTION", "USER-COMMAND"}:
|
||||
# AT NEW/END/FIRST/LAST 는 LOOP 제어문이므로 이벤트가 아니다
|
||||
return " ".join(up[:4])[:60]
|
||||
if head == "GET" and len(up) == 2 and up[1] not in {
|
||||
"PARAMETER", "TIME", "CURSOR", "BADI", "REFERENCE", "PF-STATUS", "RUN"
|
||||
} and not up[1].startswith("'"):
|
||||
return f"GET {up[1]}" # 논리DB 이벤트
|
||||
return None
|
||||
|
||||
|
||||
def extract_units(program: str, include: str, statements: list[Statement], source_lines: int) -> list[Unit]:
|
||||
units: list[Unit] = []
|
||||
open_unit: Unit | None = None # FORM/FUNCTION/MODULE/MACRO/METHOD
|
||||
open_event: Unit | None = None
|
||||
open_class: Unit | None = None # CLASS_DEF (DEFINITION)
|
||||
in_class_impl: str | None = None # CLASS ... IMPLEMENTATION 의 클래스명
|
||||
decl_stmts: list[int] = []
|
||||
|
||||
def uid(utype: str, name: str) -> str:
|
||||
return f"{program}#{include}#{utype}#{name.upper()}"
|
||||
|
||||
def close_decl() -> None:
|
||||
nonlocal decl_stmts
|
||||
if decl_stmts:
|
||||
first, last = decl_stmts[0], decl_stmts[-1]
|
||||
u = Unit(
|
||||
unit_id=uid("DECLARATION", f"DECL_{statements[first].line_start}"),
|
||||
program=program, include=include, unit_type="DECLARATION",
|
||||
name=f"DECL_{statements[first].line_start}",
|
||||
line_start=statements[first].line_start,
|
||||
line_end=statements[last].line_end,
|
||||
stmt_indexes=list(decl_stmts),
|
||||
)
|
||||
units.append(u)
|
||||
decl_stmts = []
|
||||
|
||||
def close_event(end_line: int) -> None:
|
||||
nonlocal open_event
|
||||
if open_event:
|
||||
open_event.line_end = end_line
|
||||
units.append(open_event)
|
||||
open_event = None
|
||||
|
||||
for i, st in enumerate(statements):
|
||||
up = st.upper
|
||||
head = up[0] if up else ""
|
||||
|
||||
# ---- 열려 있는 유닛 닫기 판단 ----
|
||||
if open_unit:
|
||||
open_unit.stmt_indexes.append(i)
|
||||
end_kw = {"FORM": "ENDFORM", "FUNCTION": "ENDFUNCTION", "MODULE": "ENDMODULE",
|
||||
"METHOD": "ENDMETHOD", "MACRO": "END-OF-DEFINITION"}[open_unit.unit_type]
|
||||
if head == end_kw:
|
||||
open_unit.line_end = st.line_end
|
||||
units.append(open_unit)
|
||||
open_unit = None
|
||||
continue
|
||||
|
||||
if open_class:
|
||||
open_class.stmt_indexes.append(i)
|
||||
if head == "ENDCLASS":
|
||||
open_class.line_end = st.line_end
|
||||
units.append(open_class)
|
||||
open_class = None
|
||||
continue
|
||||
|
||||
# ---- 새 유닛 시작 ----
|
||||
if head == "FORM" and len(up) >= 2:
|
||||
close_decl(); close_event(st.line_start - 1)
|
||||
open_unit = Unit(uid("FORM", up[1]), program, include, "FORM", up[1],
|
||||
st.line_start, st.line_end,
|
||||
signature=" ".join(st.tokens[2:])[:300],
|
||||
header_comment=st.comment_text, stmt_indexes=[i])
|
||||
continue
|
||||
if head == "FUNCTION" and len(up) >= 2:
|
||||
close_decl(); close_event(st.line_start - 1)
|
||||
open_unit = Unit(uid("FUNCTION", up[1]), program, include, "FUNCTION", up[1],
|
||||
st.line_start, st.line_end, header_comment=st.comment_text,
|
||||
stmt_indexes=[i])
|
||||
continue
|
||||
if head == "MODULE" and len(up) >= 2:
|
||||
close_decl(); close_event(st.line_start - 1)
|
||||
mode = up[2] if len(up) >= 3 and up[2] in {"OUTPUT", "INPUT"} else ""
|
||||
open_unit = Unit(uid("MODULE", f"{up[1]}_{mode}" if mode else up[1]), program, include,
|
||||
"MODULE", f"{up[1]} {mode}".strip(), st.line_start, st.line_end,
|
||||
header_comment=st.comment_text, stmt_indexes=[i])
|
||||
continue
|
||||
if head == "DEFINE" and len(up) >= 2:
|
||||
close_decl(); close_event(st.line_start - 1)
|
||||
open_unit = Unit(uid("MACRO", up[1]), program, include, "MACRO", up[1],
|
||||
st.line_start, st.line_end, stmt_indexes=[i])
|
||||
continue
|
||||
if head == "METHOD" and in_class_impl and len(up) >= 2:
|
||||
close_decl()
|
||||
name = f"{in_class_impl}~{up[1]}"
|
||||
open_unit = Unit(uid("METHOD", name), program, include, "METHOD", name,
|
||||
st.line_start, st.line_end, header_comment=st.comment_text,
|
||||
stmt_indexes=[i])
|
||||
continue
|
||||
if head == "CLASS" and len(up) >= 3:
|
||||
# `CLASS lcl_x DEFINITION DEFERRED.` / `… DEFINITION LOAD.` 는 **한 줄 선언**이다 —
|
||||
# ENDCLASS 가 없다. 블록으로 열면 그 뒤 인클루드 전체가 CLASS_DEF 하나로 삼켜져
|
||||
# TOP 의 선언이 통째로 사라진다 (실측: ZCO_ALV 의 GO_ALV1 이하 전부).
|
||||
if "DEFERRED" in up[2:] or "LOAD" in up[2:]:
|
||||
if open_event:
|
||||
open_event.stmt_indexes.append(i)
|
||||
open_event.line_end = st.line_end
|
||||
else:
|
||||
decl_stmts.append(i)
|
||||
continue
|
||||
if "DEFINITION" in up[2:4]:
|
||||
close_decl(); close_event(st.line_start - 1)
|
||||
open_class = Unit(uid("CLASS_DEF", up[1]), program, include, "CLASS_DEF", up[1],
|
||||
st.line_start, st.line_end, stmt_indexes=[i])
|
||||
continue
|
||||
if "IMPLEMENTATION" in up[2:4]:
|
||||
close_decl(); close_event(st.line_start - 1)
|
||||
in_class_impl = up[1]
|
||||
continue
|
||||
if head == "ENDCLASS":
|
||||
in_class_impl = None
|
||||
continue
|
||||
|
||||
ev = _event_name(up) if up else None
|
||||
if ev:
|
||||
close_decl(); close_event(st.line_start - 1)
|
||||
open_event = Unit(uid("EVENT", ev.replace(" ", "_")), program, include, "EVENT", ev,
|
||||
st.line_start, st.line_end, header_comment=st.comment_text,
|
||||
stmt_indexes=[i])
|
||||
continue
|
||||
|
||||
# ---- 유닛 밖 문장 ----
|
||||
if open_event:
|
||||
open_event.stmt_indexes.append(i)
|
||||
open_event.line_end = st.line_end
|
||||
continue
|
||||
if head.split("-")[0].split(":")[0] in DECL_KEYWORDS or head in DECL_KEYWORDS:
|
||||
decl_stmts.append(i)
|
||||
continue
|
||||
# 그 밖의 최상위 문장(드묾)은 DECLARATION 블록에 편입하지 않고 무시하되 카운트는 파서 리포트에서
|
||||
decl_stmts.append(i)
|
||||
|
||||
# 파일 끝 정리
|
||||
if open_unit:
|
||||
open_unit.line_end = statements[-1].line_end if statements else source_lines
|
||||
units.append(open_unit)
|
||||
if open_class:
|
||||
open_class.line_end = statements[-1].line_end if statements else source_lines
|
||||
units.append(open_class)
|
||||
close_event(statements[-1].line_end if statements else source_lines)
|
||||
close_decl()
|
||||
|
||||
for u in units:
|
||||
u.loc = u.line_end - u.line_start + 1
|
||||
return units
|
||||
|
||||
|
||||
def finalize_units(units: list[Unit], statements_by_include: dict[str, list[Statement]],
|
||||
code_by_include: dict[str, str]) -> None:
|
||||
"""code_hash / sub_chunks 계산."""
|
||||
for u in units:
|
||||
code = code_by_include.get(u.include, "")
|
||||
lines = code.split("\n")
|
||||
body = "\n".join(lines[u.line_start - 1 : u.line_end])
|
||||
u.code_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||||
if u.loc > SUB_CHUNK_LIMIT:
|
||||
u.sub_chunks = _sub_chunks(u, statements_by_include.get(u.include, []))
|
||||
|
||||
|
||||
def _sub_chunks(u: Unit, statements: list[Statement]) -> list[dict]:
|
||||
"""최상위 IF/CASE/LOOP/WHILE/DO 블록 경계로 unit 을 서브청크 목록으로 나눈다."""
|
||||
opens = {"IF": "ENDIF", "CASE": "ENDCASE", "LOOP": "ENDLOOP", "WHILE": "ENDWHILE", "DO": "ENDDO"}
|
||||
boundaries: list[int] = [u.line_start]
|
||||
depth = 0
|
||||
for idx in u.stmt_indexes:
|
||||
st = statements[idx]
|
||||
head = st.upper[0] if st.tokens else ""
|
||||
if head in opens:
|
||||
if depth == 0:
|
||||
boundaries.append(st.line_start)
|
||||
depth += 1
|
||||
elif head in opens.values():
|
||||
depth = max(0, depth - 1)
|
||||
if depth == 0:
|
||||
boundaries.append(st.line_end + 1)
|
||||
boundaries.append(u.line_end + 1)
|
||||
bounds = sorted(set(boundaries))
|
||||
chunks = []
|
||||
for a, b in zip(bounds, bounds[1:]):
|
||||
if b - a > 0:
|
||||
chunks.append({"line_start": a, "line_end": b - 1})
|
||||
# 너무 잘게 쪼개지면 SUB_CHUNK_LIMIT 안쪽으로 병합
|
||||
merged: list[dict] = []
|
||||
for c in chunks:
|
||||
if merged and (c["line_end"] - merged[-1]["line_start"]) <= SUB_CHUNK_LIMIT:
|
||||
merged[-1]["line_end"] = c["line_end"]
|
||||
else:
|
||||
merged.append(dict(c))
|
||||
return merged
|
||||
Reference in New Issue
Block a user