498 lines
19 KiB
Python
498 lines
19 KiB
Python
"""条件层:COBOL条件表达式解析 + MC/DC枚举 + 约束合并"""
|
||
|
||
import re
|
||
from .models import CondLeaf, CondAnd, CondOr, CondNot, PicInfo
|
||
|
||
|
||
# ── 条件解析 ──
|
||
|
||
def _split_at_operator(text, operator):
|
||
"""Split text on operator word, respecting parentheses."""
|
||
result = []
|
||
current = []
|
||
depth = 0
|
||
# Normalize so parentheses are space-delimited tokens
|
||
normalized = text.replace('(', ' ( ').replace(')', ' ) ')
|
||
for token in normalized.split():
|
||
if not token:
|
||
continue
|
||
if token == '(':
|
||
depth += 1
|
||
current.append(token)
|
||
elif token == ')':
|
||
depth -= 1
|
||
current.append(token)
|
||
elif token == operator and depth == 0:
|
||
result.append(' '.join(current).strip())
|
||
current = []
|
||
else:
|
||
current.append(token)
|
||
result.append(' '.join(current).strip())
|
||
return result
|
||
|
||
|
||
def parse_single_condition(text, fields=None):
|
||
"""Parse a COBOL condition into (field, operator, value) 3-tuple.
|
||
|
||
Handles:
|
||
- Basic: AMOUNT > 1000 → (AMOUNT, '>', '1000')
|
||
- 88-lev: STATUS-APPROVED → (parent, '=', value)
|
||
- NOT =: X NOT = 5 → (X, '<>', '5') (NOT = means <>)
|
||
- NOT >: X NOT > 5 → (X, '<=', '5')
|
||
- NOT <: X NOT < 5 → (X, '>=', '5')
|
||
- NOT 88: NOT WS-EOF-Y → (parent, '<>', value)
|
||
- Bare: WS-EOF → (WS-EOF, '=', 'Y')
|
||
- NOT bare: NOT WS-EOF → (WS-EOF, '<>', 'Y')
|
||
- NOT arith: A+B NOT = C → ('A+B', '<>', 'C')
|
||
- SQLCODE: SQLCODE = 100 → ('SQLCODE', '=', '100')
|
||
- SQLSTATE: SQLSTATE <> '02000' → ('SQLSTATE', '<>', '02000')
|
||
|
||
Returns None for compound (AND/OR) conditions.
|
||
"""
|
||
if ' AND ' in text or ' OR ' in text:
|
||
return None
|
||
text = text.strip()
|
||
if not text:
|
||
return None
|
||
text = re.sub(r'(\w)\s*\(', r'\1(', text)
|
||
field_name = text.split()[0] if text else ''
|
||
|
||
# AT END: synthetic condition from READ blocks
|
||
if text.upper() == 'AT END':
|
||
return ('_FILE_STATUS', '=', '10')
|
||
|
||
# SQLCODE special handling
|
||
if field_name.upper() == 'SQLCODE':
|
||
text_upper = text.upper()
|
||
if 'GREATER THAN 0' in text_upper or 'GREATER THAN ZERO' in text_upper:
|
||
return ('SQLCODE', '>', '0')
|
||
if 'LESS THAN 0' in text_upper:
|
||
return ('SQLCODE', '<', '0')
|
||
if '= 100' in text_upper:
|
||
return ('SQLCODE', '=', '100')
|
||
if 'NOT = 100' in text_upper:
|
||
return ('SQLCODE', '<>', '100')
|
||
|
||
# SQLSTATE special handling
|
||
if field_name.upper() == 'SQLSTATE':
|
||
normalized_sql = re.sub(r'\bNOT\s*=', '<>', text, flags=re.IGNORECASE)
|
||
m = re.match(r"SQLSTATE\s*(>=|<=|<>|>|<|=)\s*['\"]?(.+?)['\"]?\s*$", normalized_sql, re.IGNORECASE)
|
||
if m:
|
||
return ('SQLSTATE', m.group(1), m.group(2).strip().strip("'\""))
|
||
|
||
# Resolve 88-level condition names
|
||
if fields:
|
||
for f in fields:
|
||
if isinstance(f, dict):
|
||
if f.get('is_88') and f['name'] == text.upper():
|
||
return (f.get('parent', ''), '=', f.get('value', ''))
|
||
if f.get('is_88') and text.upper().startswith('NOT ') and f['name'] == text[4:].strip().upper():
|
||
return (f.get('parent', ''), '<>', f.get('value', ''))
|
||
else:
|
||
if f.is_88 and f.name == text.upper():
|
||
return (f.parent or '', '=', f.value or '')
|
||
if f.is_88 and text.upper().startswith('NOT ') and f.name == text[4:].strip().upper():
|
||
return (f.parent or '', '<>', f.value or '')
|
||
|
||
# Strip OF qualifier: "STD-KEY OF MASTER-REC" → "STD-KEY"
|
||
if ' OF ' in text.upper():
|
||
text = text.split(' OF ')[0].strip()
|
||
|
||
# COBOL class condition: WS-KEY-DGT-N [IS] [NOT] NUMERIC/ALPHABETIC/...
|
||
# Return (field, 'IS', CLASS, want) — want=True → field must satisfy the class,
|
||
# want=False → field must NOT satisfy the class (IS NOT / NOT).
|
||
m = re.match(
|
||
r'^([A-Z][A-Z0-9_-]*(?:\([^)]*\))?)\s+(?:IS\s+)?'
|
||
r'(NOT\s+)?(NUMERIC|ALPHABETIC|ALPHABETIC-UPPER|ALPHABETIC-LOWER|POSITIVE|NEGATIVE|ZERO)\s*$',
|
||
text, re.IGNORECASE)
|
||
if m:
|
||
return (m.group(1), 'IS', m.group(3).upper(), not bool(m.group(2)))
|
||
|
||
# Bare field reference (no operator, no NOT): WS-EOF → WS-EOF = 'Y'
|
||
if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s*$', text, re.IGNORECASE):
|
||
bare = re.match(r'^[A-Z][A-Z0-9_-]*', text, re.IGNORECASE)
|
||
field = bare.group(0) if bare else text
|
||
return (field, '=', 'Y')
|
||
|
||
# Bare NOT field reference (no operator): NOT WS-EOF → WS-EOF <> 'Y'
|
||
if text.upper().startswith('NOT ') and not re.search(r'(>=|<=|<>|>|<|=)', text):
|
||
fn = text[4:].strip()
|
||
if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?$', fn, re.IGNORECASE):
|
||
return (fn, '<>', 'Y')
|
||
|
||
|
||
# NOT at start of condition: NOT WS-X > 50 → WS-X <= 50
|
||
# Strip leading NOT, parse the inner condition, invert the operator
|
||
if text.upper().startswith('NOT '):
|
||
inner = text[4:].strip()
|
||
inner_parsed = None
|
||
# Try standard regex on inner text
|
||
m_inner = re.match(r"^(\w[\w-]*(?:\s*\([^)]*\))?)\s*(>=|<=|<>|>|<|=)\s*(.*)$", inner)
|
||
if m_inner:
|
||
inv_op_map = {'=': '<>', '<>': '=', '>': '<=', '<': '>=', '>=': '<', '<=': '>'}
|
||
f = re.sub(r'\s*([(),])\s*', r'\1', m_inner.group(1))
|
||
bare = re.match(r"^(\w[\w-]*)", f)
|
||
if bare:
|
||
f = bare.group(1)
|
||
op = m_inner.group(2)
|
||
val = m_inner.group(3).strip().strip("'").strip('"')
|
||
inv = inv_op_map.get(op, op)
|
||
return (f, inv, val)
|
||
|
||
# Normalize COBOL NOT-operators: X NOT = Y → X <> Y
|
||
normalized = text
|
||
not_map = [
|
||
(r'\bNOT\s+>=', '<'), (r'\bNOT\s+<=', '>'),
|
||
(r'\bNOT\s+<>', '='), (r'\bNOT\s+=', '<>'),
|
||
(r'\bNOT\s+>', '<='), (r'\bNOT\s+<', '>='),
|
||
]
|
||
for pat, repl in not_map:
|
||
if re.search(pat, text, re.IGNORECASE):
|
||
normalized = re.sub(pat, repl, text, flags=re.IGNORECASE)
|
||
break
|
||
|
||
# FUNCTION call as left value: FUNCTION MOD(X, 2) NOT = 0 → _FUNC_MOD <> 0
|
||
if text.upper().startswith('FUNCTION '):
|
||
# After not_map normalization, NOT = has been converted to <>
|
||
func_match = re.match(
|
||
r'^FUNCTION\s+(\w+)\s*\(([^)]*)\)\s*(>=|<=|<>|>|<|=)\s*(.*)$',
|
||
normalized, re.IGNORECASE
|
||
)
|
||
if func_match:
|
||
func_name = func_match.group(1).upper()
|
||
op = func_match.group(3)
|
||
val = func_match.group(4).strip().strip("'").strip('"')
|
||
return ('_FUNC_' + func_name, op, val)
|
||
|
||
# Arithmetic expression regex (lazy match allows spaces in field expr)
|
||
m = re.match(
|
||
r"^(\w[\w\s+\-*/().-]+?)\s*(>=|<=|<>|>|<|=)\s*(.+)$",
|
||
normalized
|
||
)
|
||
if m:
|
||
field = re.sub(r'\s*([(),])\s*', r'\1', m.group(1)).strip()
|
||
# Clean trailing ' NOT' that got swallowed by lazy match
|
||
if field.upper().endswith(' NOT'):
|
||
field = field[:-4].strip()
|
||
# Strip subscript: WS-KEY-DUP-CNT(WS-J) -> WS-KEY-DUP-CNT
|
||
bare_m = re.match(r'^(\w[\w-]*)', field)
|
||
if bare_m:
|
||
field = bare_m.group(1)
|
||
return (field, m.group(2), m.group(3).strip().strip("'").strip('"'))
|
||
|
||
# Standard regex: FIELD OP VALUE
|
||
m = re.match(
|
||
r"^(\w[\w-]*(?:\s*\([^)]*\))?)\s*(>=|<=|<>|>|<|=)\s*(.*)$",
|
||
normalized
|
||
)
|
||
if m:
|
||
field = re.sub(r'\s*([(),])\s*', r'\1', m.group(1))
|
||
# Strip subscript/substring for matching: CDR-ID(1:3) -> CDR-ID
|
||
bare_m = re.match(r'^\w[\w-]*', field)
|
||
if bare_m:
|
||
field = bare_m.group(0)
|
||
return (field, m.group(2), m.group(3).strip().strip("'").strip('"'))
|
||
|
||
# Bare field: WS-EOF (no operator) -> WS-EOF = 'Y'
|
||
if re.match(r'^[A-Z][A-Z0-9_-]*(?:\([^)]*\))?\s*$', text, re.IGNORECASE):
|
||
bare = re.match(r'^[A-Z][A-Z0-9_-]*', text, re.IGNORECASE)
|
||
field = bare.group(0) if bare else text
|
||
return (field, '=', 'Y')
|
||
|
||
return None
|
||
|
||
|
||
def parse_compound_condition(text, fields=None):
|
||
"""Parse a COBOL condition into a condition tree (AND/OR/LEAF).
|
||
Handles AND > OR precedence and parentheses.
|
||
"""
|
||
text = text.strip()
|
||
if not text:
|
||
return None
|
||
# Normalize parentheses to be space-delimited for reliable tokenization
|
||
text = text.replace('(', ' ( ').replace(')', ' ) ')
|
||
text = re.sub(r'\s+', ' ', text).strip()
|
||
# Strip outer parentheses
|
||
if text.startswith('(') and text.endswith(')'):
|
||
depth = 0
|
||
wrapped = True
|
||
for i, c in enumerate(text):
|
||
if c == '(':
|
||
depth += 1
|
||
elif c == ')':
|
||
depth -= 1
|
||
if depth == 0 and i < len(text) - 1:
|
||
wrapped = False
|
||
break
|
||
if wrapped:
|
||
inner = parse_compound_condition(text[1:-1], fields)
|
||
if inner:
|
||
return inner
|
||
# Split on OR (lowest precedence)
|
||
parts = _split_at_operator(text, 'OR')
|
||
if len(parts) > 1:
|
||
node = parse_compound_condition(parts[0], fields)
|
||
for p in parts[1:]:
|
||
node = CondOr(node, parse_compound_condition(p, fields))
|
||
return node
|
||
# Split on AND
|
||
parts = _split_at_operator(text, 'AND')
|
||
if len(parts) > 1:
|
||
node = parse_compound_condition(parts[0], fields)
|
||
for p in parts[1:]:
|
||
node = CondAnd(node, parse_compound_condition(p, fields))
|
||
return node
|
||
# NOT prefix (highest precedence, after AND/OR splitting)
|
||
if text.upper().startswith('NOT '):
|
||
inner = parse_compound_condition(text[4:].strip(), fields)
|
||
return CondNot(inner) if inner else None
|
||
# Leaf condition
|
||
parsed = parse_single_condition(text, fields)
|
||
if parsed:
|
||
if len(parsed) == 4:
|
||
# class condition (field, 'IS', CLASS, want): represent negation via CondNot
|
||
field, op, cls, want = parsed
|
||
leaf = CondLeaf(field, op, cls)
|
||
return leaf if want else CondNot(leaf)
|
||
return CondLeaf(*parsed)
|
||
return None
|
||
|
||
|
||
def collect_leaves(tree):
|
||
"""Return list of all CondLeaf nodes in the tree."""
|
||
if isinstance(tree, CondLeaf):
|
||
return [tree]
|
||
elif isinstance(tree, CondNot):
|
||
return collect_leaves(tree.child)
|
||
elif isinstance(tree, (CondAnd, CondOr)):
|
||
return collect_leaves(tree.left) + collect_leaves(tree.right)
|
||
return []
|
||
|
||
|
||
def evaluate_tree(tree, assignment):
|
||
"""Evaluate condition tree given leaf→bool assignment dict."""
|
||
if isinstance(tree, CondLeaf):
|
||
return assignment[tree]
|
||
elif isinstance(tree, CondNot):
|
||
return not evaluate_tree(tree.child, assignment)
|
||
elif isinstance(tree, CondAnd):
|
||
return evaluate_tree(tree.left, assignment) and evaluate_tree(tree.right, assignment)
|
||
elif isinstance(tree, CondOr):
|
||
return evaluate_tree(tree.left, assignment) or evaluate_tree(tree.right, assignment)
|
||
return False
|
||
|
||
|
||
def is_field(name, fields):
|
||
bare = re.sub(r'\s*\(.*?\)\s*$', '', name).strip()
|
||
for f in fields:
|
||
fname = f['name'] if isinstance(f, dict) else f.name
|
||
if fname == bare.upper():
|
||
return True
|
||
return False
|
||
|
||
|
||
# ── MC/DC ──
|
||
|
||
def mcdc_sets(tree, fields=None):
|
||
"""Generate MC/DC constraint sets.
|
||
Returns list of (constraints_list, decision_outcome) or None for simple conditions.
|
||
Each constraint is (field, op, value, want_true).
|
||
"""
|
||
leaves = collect_leaves(tree)
|
||
n = len(leaves)
|
||
if n <= 1:
|
||
return None
|
||
# Evaluate all 2^n truth assignments
|
||
all_results = []
|
||
for bits in range(1 << n):
|
||
assignment = {}
|
||
for i, leaf in enumerate(leaves):
|
||
assignment[leaf] = bool(bits & (1 << i))
|
||
result = evaluate_tree(tree, assignment)
|
||
all_results.append((assignment, result))
|
||
# For each leaf, find a pair showing independent effect on decision
|
||
needed_pairs = {}
|
||
for leaf in leaves:
|
||
for a1, r1 in all_results:
|
||
if leaf in needed_pairs:
|
||
break
|
||
for a2, r2 in all_results:
|
||
if a1[leaf] != a2[leaf] and r1 != r2:
|
||
if all(a1[o] == a2[o] for o in leaves if o != leaf):
|
||
needed_pairs[leaf] = (dict(a1), r1, dict(a2), r2)
|
||
break
|
||
# Convert leaf assignments to constraint tuples
|
||
result = []
|
||
added = set()
|
||
for leaf, (a1, r1, a2, r2) in needed_pairs.items():
|
||
for assignment, decision in [(a1, r1), (a2, r2)]:
|
||
key = frozenset((l, assignment[l]) for l in leaves)
|
||
if key not in added:
|
||
added.add(key)
|
||
constraints = []
|
||
for l in leaves:
|
||
want = assignment[l]
|
||
constraints.append((l.field, l.op, l.value, want))
|
||
result.append((constraints, decision))
|
||
return result
|
||
|
||
|
||
|
||
|
||
|
||
# ── 值计算 ──
|
||
|
||
def evaluate_class_value(value, class_name):
|
||
"""COBOL class-condition evaluation on a raw field value.
|
||
|
||
Returns True if `value` belongs to `class_name`.
|
||
class_name ∈ {NUMERIC, ALPHABETIC, ALPHABETIC-UPPER, ALPHABETIC-LOWER,
|
||
POSITIVE, NEGATIVE, ZERO}. Empty / all-space values never
|
||
satisfy the ALPHABETIC* / NUMERIC classes (COBOL class-condition semantics).
|
||
"""
|
||
s = str(value)
|
||
cls = str(class_name).upper()
|
||
if cls == 'ALPHABETIC-UPPER':
|
||
return bool(s) and all('A' <= ch <= 'Z' for ch in s)
|
||
if cls == 'ALPHABETIC-LOWER':
|
||
return bool(s) and all('a' <= ch <= 'z' for ch in s)
|
||
if cls == 'ALPHABETIC':
|
||
return bool(s) and all(ch.isalpha() for ch in s)
|
||
if cls == 'NUMERIC':
|
||
return bool(s) and all('0' <= ch <= '9' for ch in s)
|
||
if cls in ('POSITIVE', 'NEGATIVE', 'ZERO'):
|
||
try:
|
||
n = float(str(s).strip())
|
||
except (ValueError, TypeError):
|
||
return False
|
||
if cls == 'POSITIVE':
|
||
return n > 0
|
||
if cls == 'NEGATIVE':
|
||
return n < 0
|
||
return n == 0
|
||
return False
|
||
|
||
|
||
def satisfying_value(field_info: dict, operator: str, value, want_true: bool) -> str:
|
||
ftype = field_info.get('type', 'unknown')
|
||
digits = field_info.get('digits', 0)
|
||
decimal = field_info.get('decimal', 0)
|
||
total = digits + decimal
|
||
|
||
# COBOL class-condition constraint: generate a value that is / is not in the class
|
||
if operator == 'IS':
|
||
cls = str(value).upper()
|
||
length = field_info.get('length', 1)
|
||
if cls in ('ALPHABETIC', 'ALPHABETIC-UPPER'):
|
||
if want_true:
|
||
return 'A' * length
|
||
return ('A' * (length - 1) + '0') if length > 1 else '0'
|
||
if cls == 'ALPHABETIC-LOWER':
|
||
if want_true:
|
||
return 'a' * length
|
||
return ('a' * (length - 1) + '0') if length > 1 else '0'
|
||
if cls == 'NUMERIC':
|
||
if want_true:
|
||
return '0' * length
|
||
return ('0' * max(length - 1, 1) + 'A') if length > 1 else 'A'
|
||
if cls == 'ZERO':
|
||
return '0' * max(total, 1)
|
||
if cls == 'POSITIVE':
|
||
return str(1).zfill(total) if total else '1'
|
||
if cls == 'NEGATIVE':
|
||
if field_info.get('signed'):
|
||
return '-' + '1'.zfill(max(total - 1, 1))
|
||
return '0' * max(total, 1)
|
||
return '0'.zfill(max(total, 1))
|
||
|
||
if ftype == 'numeric':
|
||
try:
|
||
val_str = str(value)
|
||
val_float = float(val_str)
|
||
val_int = int(val_float * (10 ** decimal) + 0.5)
|
||
except (ValueError, TypeError):
|
||
val_int = 0
|
||
|
||
if want_true:
|
||
if operator == '>':
|
||
val_int = val_int + 1
|
||
elif operator in ('>=', '=', '<='):
|
||
pass
|
||
elif operator == '<':
|
||
val_int = max(0, val_int - 1)
|
||
elif operator == '<>':
|
||
val_int = (val_int + 1) % (10 ** total)
|
||
else:
|
||
if operator in ('>', '>='):
|
||
val_int = 0
|
||
elif operator == '=':
|
||
val_int = (val_int + 1) % (10 ** total)
|
||
elif operator == '<':
|
||
pass
|
||
elif operator == '<=':
|
||
val_int = val_int + 1
|
||
elif operator == '<>':
|
||
pass
|
||
|
||
val_int = val_int % (10 ** total)
|
||
int_part = str(val_int // (10 ** decimal)).zfill(digits)
|
||
dec_part = str(val_int % (10 ** decimal)).zfill(decimal)
|
||
if decimal == 0:
|
||
return int_part
|
||
return int_part + dec_part
|
||
|
||
elif ftype in ('alphanumeric', 'alphabetic'):
|
||
length = field_info.get('length', 1)
|
||
# 图式常量解析:SPACES/SPACE→空格、ZERO(S)/ZEROES→'0'、LOW-VALUES→\x00、
|
||
# HIGH-VALUES→\xff、QUOTE(S)→"'"。否则按值首字符作基础字符。
|
||
_uv = str(value).strip().upper() if isinstance(value, str) else ''
|
||
_FIG = {
|
||
'SPACE': ' ', 'SPACES': ' ',
|
||
'ZERO': '0', 'ZEROS': '0', 'ZEROES': '0',
|
||
'LOW-VALUE': '\x00', 'LOW-VALUES': '\x00',
|
||
'HIGH-VALUE': '\xff', 'HIGH-VALUES': '\xff',
|
||
'QUOTE': "'", 'QUOTES': "'",
|
||
}
|
||
base_chr = _FIG.get(_uv) or (value[0].upper() if isinstance(value, str) and value else 'A')
|
||
# 精确值目标:字面值(或图式常量)截断/右对齐到字段长。
|
||
# 例如 (= '0001', want=True) / (<> '0001', want=False) 应得到 '0001',
|
||
# 而非按首字符填充的 '0000'(旧行为错误)。
|
||
if _uv in _FIG:
|
||
_exact = (_FIG[_uv] * length)[:length]
|
||
else:
|
||
_exact = str(value)[:length].ljust(length)
|
||
_other = chr(65 + (ord(base_chr) - 64) % 26)
|
||
_diff = _other.ljust(length, _other)
|
||
if want_true:
|
||
if operator in ('=', '=='):
|
||
return _exact
|
||
elif operator in ('<>', '!='):
|
||
return _diff
|
||
elif operator == '>':
|
||
sv = str(value)[:length].ljust(length)
|
||
chars = list(sv)
|
||
last = chars[-1]
|
||
if last not in '9Zz':
|
||
chars[-1] = chr(ord(last) + 1)
|
||
return ''.join(chars)
|
||
elif operator == '<':
|
||
sv = str(value)[:length].ljust(length)
|
||
chars = list(sv)
|
||
last = chars[-1]
|
||
if last == ' ':
|
||
pass
|
||
elif last in '0Aa':
|
||
chars[-1] = ' '
|
||
else:
|
||
chars[-1] = chr(ord(last) - 1)
|
||
return ''.join(chars)
|
||
else:
|
||
if operator in ('=', '=='):
|
||
return _diff
|
||
elif operator in ('<>', '!='):
|
||
return _exact
|
||
elif operator in ('>', '<'):
|
||
return str(value)[:length].ljust(length)
|
||
|
||
return '0'.zfill(total)
|