diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15aae4b..4ff73ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,14 @@ jobs: run: | python3.12 -m pytest tests/test_pr37_regression.py -v --tb=short + - name: Run topic09 DSL-error regressions + run: | + python3.12 -m pytest \ + tests/test_dsl_errors.py \ + tests/test_dsl_errors_integration.py \ + tests/test_topic09_errors_case_report.py \ + -v --tb=short + - name: Generate test visualization page if: github.ref == 'refs/heads/main' run: | @@ -218,6 +226,14 @@ jobs: --json benchmark_reports/const_merge_report.json \ --markdown benchmark_reports/const_merge_report.md + # ── 3.1.2 课题09:DSL 错误诊断 case 报告(多诊断 + 渲染) ────────── + - name: Topic 09 DSL error case report + run: | + mkdir -p benchmark_reports + python3.12 benchmarks/run_topic09_errors_case.py \ + --json benchmark_reports/dsl_error_report.json \ + --markdown benchmark_reports/dsl_error_report.md + # ── 3.2 DSL 用例编译 + 模拟基准 ──────────────────────────────────── - name: DSL case compilation benchmarks run: | @@ -363,6 +379,9 @@ jobs: if [ -f benchmark_reports/const_merge_report.md ]; then cat benchmark_reports/const_merge_report.md >> $GITHUB_STEP_SUMMARY fi + if [ -f benchmark_reports/dsl_error_report.md ]; then + cat benchmark_reports/dsl_error_report.md >> $GITHUB_STEP_SUMMARY + fi echo "" >> $GITHUB_STEP_SUMMARY if [ -f benchmark_reports/github_summary.md ]; then cat benchmark_reports/github_summary.md >> $GITHUB_STEP_SUMMARY diff --git a/benchmarks/run_topic09_errors_case.py b/benchmarks/run_topic09_errors_case.py new file mode 100644 index 0000000..8fd3c04 --- /dev/null +++ b/benchmarks/run_topic09_errors_case.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +"""Run one Topic 09 DSL-error feature case and emit auditable CI reports. + +The checked-in case is *intentionally invalid*. The report proves four +separate facts: + +1. the configured compiler pipeline rejects the case through its + pre-validation pass (``CompilerDriver.compile(dsl_source=...)`` returns + ``success=False`` with multiple structured diagnostics) and writes no + assembly output; +2. the rich collector mode reports the same failures with richer error + codes (E3xx), spelling/arity suggestions and end positions; +3. rendered diagnostics use gcc/clang-style gutters and caret spans aligned + with the offending token (embedded verbatim in the Markdown report); +4. collector capacity semantics (``suppressed_count`` / ``limit_reached``) + and strict fail-fast semantics (first ``DSLSyntaxError``) are correct. + +This is a deterministic error-path feature/integration case, not a +real-workload performance claim. Real benchmark numbers remain separate in +``run_benchmark.py``. +""" + +from __future__ import annotations + +import argparse +import io +import json +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.frontend.dsl_errors import ( + DSLSyntaxError, + ErrorCode, + ErrorCollector, + format_error, + render_error, +) +from scratchv.frontend.dsl_extended import ExtendedDSLParser + +SCHEMA_VERSION = "topic09-dsl-error-case/1" +#: Intentionally invalid DSL fixture. Kept under ``tests/data`` (not +#: ``benchmarks/cases``) so the CI bench runner never treats a fixture that +#: must fail to parse as a benchmark case. +DEFAULT_CASE = ( + Path(__file__).resolve().parents[1] + / "tests" / "data" / "topic09_dsl_errors_feature.dsl" +) +DEFAULT_JSON = Path("benchmark_reports/dsl_error_report.json") +DEFAULT_MARKDOWN = Path("benchmark_reports/dsl_error_report.md") + +#: Program-generated overflow input for collector capacity semantics. +LIMIT_CASE_LINES = 12 +LIMIT_CASE_MAX_ERRORS = 5 + +#: Exact diagnostics the compiler pre-validation path must emit for the +#: checked-in case, as ``(line, column, error_code)`` in the validator code +#: space (E1xx lexical / E2xx syntax). Any drift fails a hard check. +EXPECTED_COMPILER_DIAGNOSTICS: tuple[tuple[int, int, str], ...] = ( + (5, 5, "E200"), # unsupported operation 'retrun' + (6, 9, "E201"), # operation 'add' expects 2 positional argument(s) + (9, 1, "E110"), # 'endwhile' without matching 'while' +) + +#: Exact diagnostics the rich collector path must emit, as +#: ``(line, column, error_code)`` in the rich code space (E3xx etc.). +EXPECTED_RICH_DIAGNOSTICS: tuple[tuple[int, int, str], ...] = ( + (5, 5, ErrorCode.SEM_UNKNOWN_OP), # E301 unknown operation + (6, 5, ErrorCode.SEM_ARITY), # E302 arity mismatch + (7, 1, ErrorCode.SYN_MISSING_TERMINATOR), # E203 missing 'endif' + (9, 1, ErrorCode.SYN_STRAY_TERMINATOR), # E204 stray 'endwhile' +) + +EXPECTED_SPELLING_SUGGESTION = "did you mean 'return'?" +EXPECTED_ARITY_SUGGESTION = "add() requires exactly 2 arguments" +EXPECTED_COLUMN_MARKER = "^~~~~~" + + +def source_lines(source: str) -> list[str]: + """Split source into physical lines with CRLF/CR normalized.""" + return source.replace("\r\n", "\n").replace("\r", "\n").split("\n") + + +def diagnostic_payload(err: DSLSyntaxError, source: str) -> dict[str, Any]: + """Convert an error into a JSON-friendly, auditable payload.""" + rendered = format_error(err, use_color=False) + rendered_lines = rendered.splitlines() + marker = rendered_lines[2] if len(rendered_lines) > 2 else "" + lines = source_lines(source) + return { + "error_code": err.error_code, + "line": err.line, + "col": err.col, + "end_col": err.end_col, + "message": err.message, + "suggestion": err.fix_hint, + "source_line": err.source_line, + "rendered": rendered, + "marker": marker, + "has_marker": "^" in marker and "|" in marker, + "source_line_matches_case": ( + 0 < err.line <= len(lines) and err.source_line == lines[err.line - 1] + ), + } + + +def compile_case(case_path: Path, source: str) -> dict[str, Any]: + """Compile inline source and record the failure diagnostics. + + The case path is passed for diagnostic filenames only; the source text is + supplied through ``dsl_source`` as required. + """ + with tempfile.TemporaryDirectory() as tmp: + output_path = Path(tmp) / "topic09_dsl_error_case.s" + result = CompilerDriver(CompilerConfig()).compile( + str(case_path), str(output_path), dsl_source=source, + ) + output_written = output_path.exists() + return { + "success": result.success, + "errors": list(result.errors), + "diagnostics": [ + diagnostic_payload(err, source) for err in result.diagnostics + ], + "diagnostic_limit_reached": result.diagnostic_limit_reached, + "diagnostic_limit": result.diagnostic_limit, + "output_written": output_written, + } + + +def collect_rich(source: str, filename: str) -> ErrorCollector: + """Parse in collector mode and return the populated collector.""" + collector = ErrorCollector( + filename=filename, use_color=False, source=source, + ) + ExtendedDSLParser().parse(source, filename=filename, collector=collector) + return collector + + +def check_collector_limit() -> dict[str, Any]: + """Feed a generated input larger than ``max_errors`` and record limits.""" + generated = "\n".join( + f"v{i} = retrun(x, {i})" for i in range(LIMIT_CASE_LINES) + ) + "\n" + collector = ErrorCollector( + filename="", + use_color=False, + max_errors=LIMIT_CASE_MAX_ERRORS, + ) + ExtendedDSLParser().parse( + generated, filename="", collector=collector, + ) + return { + "generated_lines": LIMIT_CASE_LINES, + "max_errors": LIMIT_CASE_MAX_ERRORS, + "error_count": collector.error_count, + "suppressed_count": collector.suppressed_count, + "limit_reached": collector.limit_reached, + "expected_suppressed": LIMIT_CASE_LINES - LIMIT_CASE_MAX_ERRORS, + "report_note": collector.report().splitlines()[-1], + } + + +def check_strict_mode(source: str, filename: str) -> dict[str, Any]: + """Parse with ``collector=None``; the first error must raise.""" + try: + ExtendedDSLParser().parse(source, filename=filename) + except DSLSyntaxError as err: + return { + "raised": True, + "is_dsl_syntax_error": True, + "exception_type": type(err).__name__, + "error_code": err.error_code, + "line": err.line, + "col": err.col, + "message": err.message, + "suggestion": err.fix_hint, + "rendered": format_error(err, use_color=False), + } + except Exception as err: # pragma: no cover - defensive + return { + "raised": True, + "is_dsl_syntax_error": False, + "exception_type": type(err).__name__, + "error_code": None, + "line": None, + "col": None, + "message": str(err), + "suggestion": None, + "rendered": "", + } + return { + "raised": False, + "is_dsl_syntax_error": False, + "exception_type": None, + "error_code": None, + "line": None, + "col": None, + "message": "", + "suggestion": None, + "rendered": "", + } + + +def render_via_api(err: DSLSyntaxError) -> dict[str, Any]: + """Render one diagnostic through the stream-aware public API. + + ``render_error`` picks the color mode from the destination stream; a + plain ``io.StringIO`` is not a TTY, so the output must stay ANSI-free. + The caret is compared against the token visible in the rendered source + line, which is exactly the gcc/clang column-alignment contract. + """ + stream = io.StringIO() + text = render_error(err, stream=stream, use_color=False) + rendered_lines = text.splitlines() + source_display = rendered_lines[1] if len(rendered_lines) > 1 else "" + marker = rendered_lines[2] if len(rendered_lines) > 2 else "" + caret_col = marker.index("^") if "^" in marker else None + token_col = source_display.find("retrun") + return { + "api": "scratchv.frontend.dsl_errors.render_error", + "stream_isatty": bool(getattr(stream, "isatty", lambda: False)()), + "contains_ansi": "\033[" in text, + "text": text, + "source_display": source_display, + "marker": marker, + "caret_col": caret_col, + "token_col": token_col, + "has_column_marker": EXPECTED_COLUMN_MARKER in marker, + "caret_aligned_with_token": ( + caret_col is not None and caret_col == token_col + ), + "note_line": rendered_lines[3] if len(rendered_lines) > 3 else "", + } + + +def evaluate(case_path: Path) -> dict[str, Any]: + """Build the full report payload and run the hard invariants.""" + source = case_path.read_text() + filename = str(case_path) + + compiler = compile_case(case_path, source) + collector = collect_rich(source, filename) + rich_diagnostics = [ + diagnostic_payload(err, source) for err in collector.errors + ] + limit = check_collector_limit() + strict = check_strict_mode(source, filename) + + spelling_err = next( + (err for err in collector.errors + if err.error_code == ErrorCode.SEM_UNKNOWN_OP), + None, + ) + render = ( + render_via_api(spelling_err) if spelling_err is not None + else { + "api": "scratchv.frontend.dsl_errors.render_error", + "stream_isatty": False, + "contains_ansi": False, + "text": "", + "source_display": "", + "marker": "", + "caret_col": None, + "token_col": None, + "has_column_marker": False, + "caret_aligned_with_token": False, + "note_line": "", + } + ) + + compiler_positions = tuple( + (d["line"], d["col"], d["error_code"]) + for d in compiler["diagnostics"] + ) + rich_positions = tuple( + (d["line"], d["col"], d["error_code"]) for d in rich_diagnostics + ) + first_expected_line, first_expected_col, first_expected_code = ( + EXPECTED_COMPILER_DIAGNOSTICS[0] + ) + + hard_checks = { + "compile_fails": compiler["success"] is False, + "compile_writes_no_output": ( + compiler["success"] is False and not compiler["output_written"] + ), + "at_least_three_diagnostics": ( + len(compiler["diagnostics"]) >= 3 + ), + "at_least_two_error_codes": ( + len({d["error_code"] for d in compiler["diagnostics"]}) >= 2 + ), + "all_diagnostics_have_positions": all( + d["line"] >= 1 and d["col"] >= 1 + for d in compiler["diagnostics"] + ), + "compiler_positions_match_case": ( + compiler_positions == EXPECTED_COMPILER_DIAGNOSTICS + ), + "rich_positions_match_case": ( + rich_positions == EXPECTED_RICH_DIAGNOSTICS + ), + "diagnostic_source_lines_match_case": all( + d["source_line_matches_case"] + for d in compiler["diagnostics"] + rich_diagnostics + ), + "spelling_suggestion_present": bool( + spelling_err is not None + and any( + d["error_code"] == ErrorCode.SEM_UNKNOWN_OP + and d["suggestion"] == EXPECTED_SPELLING_SUGGESTION + for d in rich_diagnostics + ) + and any( + d["error_code"] == "E200" + and EXPECTED_SPELLING_SUGGESTION in d["rendered"] + for d in compiler["diagnostics"] + ) + ), + "arity_suggestion_present": any( + d["error_code"] == ErrorCode.SEM_ARITY + and d["suggestion"] == EXPECTED_ARITY_SUGGESTION + for d in rich_diagnostics + ), + "render_gutter_and_caret_aligned": bool( + render["has_column_marker"] + and render["caret_aligned_with_token"] + ), + "render_uses_stream_api_without_ansi": bool( + render["api"] == "scratchv.frontend.dsl_errors.render_error" + and not render["contains_ansi"] + and EXPECTED_COLUMN_MARKER in render["text"] + ), + "all_rich_diagnostics_render_markers": all( + d["has_marker"] for d in rich_diagnostics + ), + "collector_limit_accounting": ( + limit["error_count"] == limit["max_errors"] + and limit["suppressed_count"] == limit["expected_suppressed"] + and limit["limit_reached"] is True + ), + "strict_mode_raises_first_error": bool( + strict["raised"] + and strict["is_dsl_syntax_error"] + and ( + strict["error_code"], strict["line"], strict["col"], + ) == ( + first_expected_code, first_expected_line, first_expected_col, + ) + ), + } + failed = sorted(name for name, ok in hard_checks.items() if not ok) + + return { + "schema_version": SCHEMA_VERSION, + "topic": "topic09-dsl-errors", + "generated_at": datetime.now(timezone.utc).isoformat(), + "case": str(case_path), + "expected_compiler_diagnostics": [ + list(item) for item in EXPECTED_COMPILER_DIAGNOSTICS + ], + "expected_rich_diagnostics": [ + list(item) for item in EXPECTED_RICH_DIAGNOSTICS + ], + "compiler": compiler, + "rich_collector": { + "error_count": collector.error_count, + "suppressed_count": collector.suppressed_count, + "limit_reached": collector.limit_reached, + "max_errors": collector.max_errors, + "diagnostics": rich_diagnostics, + }, + "render": render, + "collector_limit": limit, + "strict": strict, + "hard_checks": hard_checks, + "hard_failures": failed, + "honesty": ( + "Deterministic error-path feature case: the checked-in DSL is " + "intentionally invalid, so the audited facts are diagnostic " + "counts, positions, suggestions and rendering -- not runtime or " + "performance data. Strict pre-validation uses the E1xx/E2xx " + "code space while rich collection uses E2xx/E3xx; both are " + "recorded so the two code spaces stay distinguishable." + ), + } + + +def render_markdown(report: dict[str, Any]) -> str: + compiler = report["compiler"] + rich = report["rich_collector"] + limit = report["collector_limit"] + strict = report["strict"] + render = report["render"] + lines = [ + "# Topic 09 DSL-Error Feature Case", + "", + f"- Schema: `{report['schema_version']}`", + f"- Case: `{report['case']}`", + f"- Generated: {report['generated_at']}", + f"- Hard checks: " + f"{'PASS' if not report['hard_failures'] else 'FAIL'} " + f"({len(report['hard_checks']) - len(report['hard_failures'])}" + f"/{len(report['hard_checks'])})", + "", + "## Compiler diagnostics (pre-validation path)", + "", + f"- `CompilerDriver.compile(dsl_source=...)`: " + f"success={compiler['success']}, " + f"output_written={compiler['output_written']}, " + f"diagnostics={len(compiler['diagnostics'])}", + "", + "| code | line | col | end_col | suggestion | message |", + "|------|-----:|----:|--------:|------------|---------|", + ] + for d in compiler["diagnostics"]: + end_col = d["end_col"] if d["end_col"] is not None else "-" + lines.append( + f"| `{d['error_code']}` | {d['line']} | {d['col']} | " + f"{end_col} | {d['suggestion'] or '-'} | {d['message']} |" + ) + lines += [ + "", + "## Rich collector diagnostics", + "", + "| code | line | col | end_col | suggestion | message |", + "|------|-----:|----:|--------:|------------|---------|", + ] + for d in rich["diagnostics"]: + end_col = d["end_col"] if d["end_col"] is not None else "-" + lines.append( + f"| `{d['error_code']}` | {d['line']} | {d['col']} | " + f"{end_col} | {d['suggestion'] or '-'} | {d['message']} |" + ) + lines += [ + "", + f"- errors={rich['error_count']}, " + f"suppressed={rich['suppressed_count']}, " + f"limit_reached={rich['limit_reached']}", + "", + "## Render sample (spelling error via render_error)", + "", + "```text", + render["text"], + "```", + "", + f"- marker=`{render['marker']}` " + f"caret_col={render['caret_col']} token_col={render['token_col']} " + f"aligned={render['caret_aligned_with_token']} " + f"ansi={render['contains_ansi']}", + "", + "## Collector capacity (generated overflow case)", + "", + f"- generated_lines={limit['generated_lines']}, " + f"max_errors={limit['max_errors']}, " + f"errors={limit['error_count']}, " + f"suppressed={limit['suppressed_count']} " + f"(expected {limit['expected_suppressed']}), " + f"limit_reached={limit['limit_reached']}", + f"- {limit['report_note']}", + "", + "## Strict mode (collector=None)", + "", + f"- raised={strict['raised']} type={strict['exception_type']} " + f"code={strict['error_code']} line={strict['line']} " + f"col={strict['col']}", + f"- message: {strict['message']}", + f"- suggestion: {strict['suggestion'] or '-'}", + "", + "## Hard checks", + "", + ] + for name, ok in report["hard_checks"].items(): + lines.append(f"- [{'x' if ok else ' '}] {name}") + lines += [ + "", + "## Honesty", + "", + report["honesty"], + "", + ] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", type=Path, default=DEFAULT_CASE) + parser.add_argument("--json", type=Path, default=DEFAULT_JSON) + parser.add_argument("--markdown", type=Path, default=DEFAULT_MARKDOWN) + args = parser.parse_args(argv) + if not args.case.is_file(): + parser.error(f"feature case not found: {args.case}") + + report = evaluate(args.case) + args.json.parent.mkdir(parents=True, exist_ok=True) + args.json.write_text(json.dumps(report, indent=2) + "\n") + args.markdown.parent.mkdir(parents=True, exist_ok=True) + args.markdown.write_text(render_markdown(report) + "\n") + print(render_markdown(report)) + if report["hard_failures"]: + print("HARD FAILURES: " + ", ".join(report["hard_failures"])) + return 1 + print(f"reports written: {args.json}, {args.markdown}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243-v2.md" "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243-v2.md" new file mode 100644 index 0000000..1696d79 --- /dev/null +++ "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\345\274\200\345\217\221\346\226\207\346\241\243-v2.md" @@ -0,0 +1,637 @@ +# ScratchV 课题 09「DSL 错误提示美化器」开发文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 涉及模块:`scratchv/frontend/dsl_errors.py`、`scratchv/frontend/dsl_parser.py`、`scratchv/frontend/dsl_extended.py`、`scratchv/frontend/__init__.py`、`scratchv/compiler.py`、`tests/` +> 功能范围:接通 `DSLSyntaxError` → `format_error` → `ErrorCollector` → 两个解析器 → 编译驱动的完整错误报告链路 +> 取代关系:本文档(v2)取代同目录 `09-DSL错误提示美化器-开发文档.md`(v1);接口契约、收集策略与实现结果一律以本文档为准,v1 与本文档冲突处以本文档为准。 +> 行号锚点基于 2026-09-14 仓库快照;实施时以"行号 ± 函数/代码内容"双锚点定位,若行号漂移以内容为准。 + +--- + +## 0. 接口契约 + +> 本节为唯一权威契约。任何实现与本节不一致视为缺陷。 + +### 0.1 模块依赖方向(消除循环导入的关键) + +``` +dsl_errors.py ← 不 import 任何 frontend 模块(仅标准库) + ↑ +dsl_parser.py ← from scratchv.frontend.dsl_errors import (...) + ↑ +dsl_extended.py ← from scratchv.frontend.dsl_parser import (...) + ↑ +compiler.py +``` + +- `DSLParseError` 的**定义位置从 `dsl_parser.py` 迁移到 `dsl_errors.py`**; +- `dsl_parser.py` 必须 `from scratchv.frontend.dsl_errors import DSLParseError, DSLSyntaxError, ErrorCollector, ErrorCode` 并**重导出**,保证 `from scratchv.frontend.dsl_parser import DSLParseError` 继续可用(现存 `tests/test_dsl_extended.py:4` 依赖此路径)。 + +### 0.2 异常类契约 + +```python +class DSLParseError(Exception): + """DSL 解析错误基类(向后兼容,可由调用方通用捕获)。""" + pass + + +@dataclass(init=False) +class DSLSyntaxError(DSLParseError): + line: int + col: int + message: str + source_line: str = "" + filename: Optional[str] = None + fix_hint: Optional[str] = None + error_code: Optional[str] = None + + def __init__( + self, + line: int, + col: int, + message: str, + source_line: str = "", + filename: Optional[str] = None, + fix_hint: Optional[str] = None, + error_code: Optional[str] = None, + *, + suggestion: Optional[str] = None, + ) -> None: ... + + @property + def suggestion(self) -> Optional[str]: ... # fix_hint 读写别名 + @suggestion.setter + def suggestion(self, value: Optional[str]) -> None: ... + + def __str__(self) -> str: ... # return format_error(self, use_color=False) +``` + +| 字段 | 类型 | 契约 | +|------|------|------| +| `line` | `int` | 1-based 物理行号;`0` 仅用于文件级/聚合提示 | +| `col` | `int` | 1-based 字符列(code point 计数,tab 记 1 列) | +| `message` | `str` | 已格式化消息,**不含** `error:` 前缀、无尾随句点 | +| `source_line` | `str` | 出错行原文(不 strip、无换行符) | +| `filename` | `Optional[str]` | `None` 时渲染为 `` | +| `fix_hint` | `Optional[str]` | 建议文本(渲染为 `note:`),可为 `None` | +| `suggestion` | 属性 | `fix_hint` 的读写别名;构造时 `suggestion=` 等价 `fix_hint=`(两者同时给出时以 `fix_hint` 为准) | +| `error_code` | `Optional[str]` | `E1xx`/`E2xx`/`E3xx`,渲染为 `error[E301]:` 前缀 | + +兼容性硬约束: + +1. 类继承自 `DSLParseError`(`except DSLParseError` 可捕获); +2. 位置参数顺序 = 字段顺序,`DSLSyntaxError(1, 1, "msg")` 必须可用; +3. 现有关键字调用 `DSLSyntaxError(line=..., col=..., message=..., source_line=..., filename=..., fix_hint=..., error_code=...)` 全部保持; +4. `__init__` 内部调用 `Exception.__init__(self, message)` 使 `args == (message,)`。 + +### 0.3 错误码契约 + +```python +class ErrorCode: + # 词法(lexical) + LEX_ILLEGAL_CHAR = "E101" + # 语法(syntax) + SYN_INVALID_STATEMENT = "E201" + SYN_INVALID_CONDITION = "E202" + SYN_MISSING_TERMINATOR = "E203" + SYN_STRAY_TERMINATOR = "E204" + SYN_NESTED_CALL = "E205" + # 语义(semantic) + SEM_UNKNOWN_OP = "E301" + SEM_ARITY = "E302" + SEM_UNKNOWN_KWARG = "E304" +``` + +- 保留但不定义常量:`E206`(缺冒号)、`E303`(未定义变量); +- 任何新码只能追加,不得改义。 + +### 0.4 公共函数契约 + +```python +def format_error( + err: DSLSyntaxError, + use_color: bool = True, + context_lines: int = 0, + show_column_marker: bool = True, + source: Optional[str] = None, # 新增:完整源码文本,用于真实上下文行 +) -> str: ... + + +def make_error( + line: int, + col: int, + message: str, + source_line: str = "", + filename: Optional[str] = None, + fix_hint: Optional[str] = None, + error_code: Optional[str] = None, +) -> DSLSyntaxError: ... + + +def suggest_spelling( + source_line: str, + col: int = 0, # 0 表示未指定,退化为全行扫描 +) -> Optional[str]: ... # 返回如 "did you mean 'return'?" + + +def suggest_op( + op: str, + candidates: Iterable[str], + cutoff: float = 0.6, +) -> Optional[str]: ... # 返回如 "did you mean 'mul'?" +``` + +内部函数(允许测试直接调用,但不保证跨版本稳定): + +```python +def _compute_suggestion( + message: str, + source_line: str, + error_code: Optional[str] = None, # 新增参数,默认 None +) -> Optional[str]: ... + +def _identifier_at(source_line: str, col: int) -> Optional[str]: ... +def _estimate_token_length(source_line: str, col_start: int) -> int: ... # 内部语义修正 +``` + +### 0.5 `ErrorCollector` 契约 + +```python +class ErrorCollector: + def __init__( + self, + filename: Optional[str] = None, + use_color: bool = True, + max_errors: int = 20, # 必须 >= 1,否则 ValueError + source: Optional[str] = None, # 新增 + context_lines: int = 0, # 新增 + ) -> None: ... + + @property + def errors(self) -> list[DSLSyntaxError]: ... # 副本 + @property + def has_errors(self) -> bool: ... + @property + def error_count(self) -> int: ... # 仅真实错误,不含 overflow 提示 + @property + def suppressed_count(self) -> int: ... # 新增:被上限抑制的错误数 + + def add(self, err: DSLSyntaxError) -> None: ... + def add_error(self, line: int, col: int, message: str, + source_line: str = "", fix_hint: Optional[str] = None, + error_code: Optional[str] = None) -> None: ... + def report(self) -> str: ... + def report_and_exit(self, exit_code: int = 1) -> None: ... + def clear(self) -> None: ... +``` + +- `report()` 无错误时返回 `""`;有错误时首行 `--- N error(s) found ---`; +- `errors` 按 `(line, col, error_code)` 排序返回;`add` 按 `(filename, line, col, error_code, message)` 去重,被抑制的错误同样参与去重; +- 发生抑制时,报告末尾追加一行:`note: error limit ({max_errors}) reached; {suppressed_count} further errors suppressed`; +- `max_errors < 1` 抛 `ValueError`(0 会导致"全部抑制但 `has_errors` 为假"的静默误判); +- 不再向 `_errors` 注入 line=0 的"伪错误"哨兵(旧实现会污染 `error_count`)。 + +### 0.6 解析器契约 + +```python +# dsl_parser.py +class DSLParser: + def parse( + self, + text: str, + filename: Optional[str] = None, + collector: Optional[ErrorCollector] = None, + ) -> Program: ... + + def _parse_line(self, line: str, line_no: int = 0) -> None: ... + + # 新增内部辅助(供两个解析器共用) + def _report_error( + self, line_no: int, col: int, message: str, + error_code: str, fix_hint: Optional[str] = None, + ) -> None: ... + + def _col_of(self, line_no: int, needle: str, fallback: int = 1) -> int: ... + + def _report_unclosed_for(self) -> None: ... + + +# dsl_extended.py +class ExtendedDSLParser(DSLParser): + def parse( + self, text: str, filename: Optional[str] = None, + collector: Optional[ErrorCollector] = None, + ) -> Program: ... + + def _parse_if_block(self, lines: list[str], start_idx: int) -> int: ... + def _parse_while_block(self, lines: list[str], start_idx: int) -> int: ... + + # 新增 + def _parse_block( + self, + lines: list[str], + start_idx: int, + terminators: tuple[str, ...], + opener_kind: str, # "if" | "while" + opener_line: int, # 1-based + opener_col: int, # 1-based + ) -> tuple[int, Optional[str]]: ... # (下一索引, 命中的终结符或 None) + + def _recover_after_bad_header( + self, lines: list[str], start_idx: int, opener_kind: str, + ) -> int: ... +``` + +**模式语义(两个 `parse` 一致)**: + +| `collector` | 行为 | +|-------------|------| +| `None`(默认,严格模式) | 遇到第一个错误立即 `raise DSLSyntaxError`;合法输入行为与改动前逐字节一致 | +| 非 `None`(收集模式) | 错误写入 collector,跳过坏行/坏块继续解析;返回**可能不完整**的 `Program`,调用方必须先检查 `collector.has_errors` 再决定是否使用 | + +### 0.7 内部状态约定 + +两个解析器共用以下实例状态(`__init__` / `parse` 中初始化): + +```python +self._raw_lines: list[str] = [] # text 归一化(\r\n/\r → \n)后 split("\n"),行号-1 即索引 +self._filename: Optional[str] = None # 透传给 DSLSyntaxError.filename +self._collector: Optional[ErrorCollector] = None +self._for_positions: list[tuple[int, int]] = [] # 新增:(line, col) 栈,与 _loop_stack 同步 push/pop +self._line_no: int = 0 # 当前行(严格模式抛错时兜底) +``` + +- `_report_error` 的严格/收集分支: + ```python + def _report_error(self, line_no, col, message, error_code, fix_hint=None): + raw = self._raw_lines[line_no - 1] if 0 < line_no <= len(self._raw_lines) else "" + err = DSLSyntaxError( + line=line_no, col=max(col, 1), message=message, source_line=raw, + filename=self._filename, fix_hint=fix_hint, error_code=error_code, + ) + if self._collector is not None: + self._collector.add(err) + return + raise err + ``` +- 收集模式下调用方在 `_report_error` 之后**必须显式 `return`/跳过当前行**,避免带着半成品状态继续本行。 + +--- + +## 1. 逐文件改动清单(精确锚点) + +### 1.1 `scratchv/frontend/dsl_errors.py`(444 行) + +| # | 锚点(快照行号,内容锚点) | 现状 | 改动 | +|---|---------------------------|------|------| +| 1 | L17-22 imports | `enum, sys, dataclass, Optional` | 增加 `difflib`, `re`, `Iterable`(仅标准库) | +| 2 | L27-45 `Color` / `_color` | 无需变 | 不变 | +| 3 | L52-71 `_SUGGESTIONS` | 含 L63-70 `"add("`, `"mul("`, `"sub("`, `"div("`, `"matmul("` 键;`clean` 去掉括号后**永不可达** | 删除 L63-70 五个键;保留拼写键;新增 `_ARITY_HINTS: dict[str, str]`(键为裸算子名,值为 `add() requires exactly 2 arguments` 等) | +| 4 | L73-86 `_COMMON_FIXES` | 可用 | 保留;`old keys` 可继续作为兜底 | +| 5 | L90-116 `DSLSyntaxError` | 定义于 `dsl_errors.py`,继承 `Exception`,dataclass 自动 `__init__` | 上方新增 `class DSLParseError(Exception)`;改为 `@dataclass(init=False)` + 自定义 `__init__`(见 0.2);新增 `suggestion` 属性;`Exception.__init__(self, message)` | +| 6 | L123-155 `_compute_suggestion` | `source_line.split()` 分词;`clean.strip("(){},:=* ")` 导致括号键不可达;仅按消息关键词 | 重写:`re.findall(r"[A-Za-z_]\w*", source_line)` 分词;优先 `_identifier_at(source_line, col)`;拼写库大小写不敏感精确匹配;函数签名加 `error_code=None` | +| 7 | L158-246 `format_error` | 插入符 `" " * (err.col + 3)`(未计行号位数);`context_lines` 输出空行;filename 缺省时 `":5:12:"`;无 `source` 参数 | 按 2.1 公式重写 gutter 对齐;`col` clamp;新增 `source` 参数;filename 兜底 ``;旧行为测试兼容(`^`/`note:` 仍存在) | +| 8 | L249-264 `_estimate_token_length` | 只认 `isalnum()`,忽略 `_` | 用 `[A-Za-z0-9_]` 扫描;起始越界返回 1;clamp 到行尾 | +| 9 | L271-406 `ErrorCollector` | 溢出时注入 line=0 伪错误;无去重;无 `suppressed_count`;无 `source` | 改为 `_suppressed: int` 计数 + 报告尾部 note;按 `(line, col, error_code, message)` 去重;新增 `source` 参数并传给 `format_error`;`error_count` 只计真实错误 | +| 10 | L413-444 `make_error` | 可用 | 签名不变;docstring 标注与 `DSLSyntaxError` 对齐 | +| 11 | 文件末尾新增 | — | `class ErrorCode`;`suggest_op`;`suggest_spelling`;`_identifier_at`;`_ARITY_HINTS` | + +### 1.2 `scratchv/frontend/dsl_parser.py`(168 行) + +| # | 锚点 | 现状 | 改动 | +|---|------|------|------| +| 1 | L22-24 imports | `re`、`IRBuilder`、`Value/Program` | 增加 dsl_errors 导入(见 0.1) | +| 2 | L27-28 `class DSLParseError(Exception): pass` | 本地定义 | **删除**,改为从 dsl_errors 导入 + 重导出 | +| 3 | L34-37 `__init__` | 3 个字段 | 增加 0.7 的状态字段 | +| 4 | L39-58 `parse` | `text.strip().split("\n")`(丢物理行号);`for line in lines` 无行号;EOF `_loop_stack` 非空静默;无 filename/collector | 新签名(0.6);`raw_lines = text.split("\n")`;索引循环 `for i, raw in enumerate(raw_lines)` 传 `i+1`;EOF 调用 `_report_unclosed_for()`;构建器重置 `_raw_lines/_filename/_collector/_for_positions` | +| 5 | L60-94 `_parse_line` | 精确串匹配 `line == "endfor"`;`DSLParseError(f"Cannot parse line: {line}")`;正则 `(\w+)\s*=\s*(\w+)\((.+)\)` 不锚定、可静默吞尾、嵌套调用被错误拆参 | 增加 `line_no` 参数;先剥内联注释(`" #"` 与扩展解析器一致);语句分支见第 2 节 | +| 6 | L62-67 `for` 分支 | 只 push `_loop_stack` | 同步 push `_for_positions.append((line_no, col))` | +| 7 | L69-74 `endfor` 分支 | 无配对 → `raise DSLParseError(...)` | 无配对 → E204;正常 → 同步 pop `_for_positions` | +| 8 | L96-108 `_resolve` | 未定义变量自动建值 | **保持不变**(E303 保留);在 docstring 注明 | +| 9 | L110-131 `_parse_kwargs` / `_parse_value` | 可用 | 修复轮:`_parse_kwargs(args, op, line_no, col)` 按 `OP_SIGNATURES` 校验未知 kwarg 与数值 kwarg(E304),校验失败返回 `None` | +| 10 | L133-168 `_dispatch_op` | 未知算子 `raise DSLParseError("Unsupported op: ...")`;参数不足泄漏裸 `IndexError`;参数过多静默忽略 | 改为:`op` 不在 `handlers` → E301(**先于实参解析**,避免副作用建值);按 `_ARITY` 表校验普通实参个数 → E302;多余参数 → E302;返回 `Optional[Value]`(失败返回 `None`) | + +### 1.3 `scratchv/frontend/dsl_extended.py`(379 行) + +| # | 锚点 | 现状 | 改动 | +|---|------|------|------| +| 1 | L27-30 imports | `DSLParser, DSLParseError, IRBuilder, OpCode, Program, Value` | 增加 `DSLSyntaxError, ErrorCollector, ErrorCode`(从 dsl_errors 或经 dsl_parser 重导出) | +| 2 | L80-85 `__init__` | 3 个字段 | 增加 0.7 状态字段(`_for_positions` 由基类 `__init__` 提供) | +| 3 | L100-164 `parse` | 固定签名;注释剥除保留行位(可用);顶层 `line.startswith("if ")` / `"while "`(`if(a>b)` 识别不到);EOF 仅跳过 auto-ret,不报错 | 新签名;设置/重置状态;分发改 `re.match(r"^if\b", line)` / `r"^while\b"`;EOF 依次 `_report_unclosed_for()`、`self._report_unclosed_while()`(若栈非空) | +| 4 | L170-247 `_parse_if_block` | 内联三段重复扫描;L176-178 无定位抛错;L195-211 EOF 无 `endif` 静默收尾;L216-236 `else` 后二遇 `else` 静默;L243-244 `endif` 缺失静默 | 用 `_parse_block` 重构(见 2 节流程);E202/E203/E204 规则见设计文档 2.5 | +| 5 | L253-311 `_parse_while_block` | 同样问题;L259-261 无定位;L288-301 EOF 无 `endwhile` 静默;L306-307 静默 | 同上 | +| 6 | L317-330 `_parse_condition` | 模式 `^(?:if\|while)\s*\(\s*(.+?)\s*(==\|...)\s*(.+?)\s*\)\s*:?\s*$` | **不变**(冒号继续可选;课题 15 才收紧) | +| 7 | L336-354 `_emit_cmp` | 未被调用(死代码) | 不删不改为本课题可选项(避免无关 diff) | +| 8 | L360-367 `_parse_line` | `endif/endwhile/else/else:` 与 `if /while ` 前缀直接 `return`(静默吞) | 签名改 `(self, line, line_no=0)`;`endif`/`endwhile` → E204 `'{tok}' without matching '{opener}'`;`else`/`else:` → E204 `'else' without matching 'if'`(块解析器不会把合法终结符路由到这里);`if `/`while ` 前缀仍 return(由块分发处理) | +| 9 | 新增方法 | — | `_parse_block`、`_recover_after_bad_header`、`_report_unclosed_while` | + +### 1.4 `scratchv/frontend/__init__.py`(13 行) + +```python +from .dsl_errors import ( + DSLParseError, DSLSyntaxError, ErrorCode, + format_error, make_error, ErrorCollector, +) +``` + +`__all__` 增加 `"DSLParseError"`, `"ErrorCode"`, `"make_error"`(保留现有项)。 + +### 1.5 `scratchv/compiler.py` + +| # | 锚点 | 现状 | 改动 | +|---|------|------|------| +| 1 | L243-249 `compile` 解析段 | `except Exception as e: errors=[f"Parse error: {e}"]` | 前插 `except DSLSyntaxError as e: return CompileResult(success=False, errors=[str(e)])`;`str(e)` 已是多行 gcc 风格 | +| 2 | L325-346 `_parse` | `try: ExtendedDSLParser().parse(source) except Exception: DSLParser().parse(source)` —— **定位错误被 fallback 吞掉**,用户最终看到的是无行号的基础解析器报错 | 改为:`parse(source, filename=...)`;`except DSLSyntaxError: raise`;`except DSLParseError: 回退 DSLParser().parse(source, filename=...)`;仅对"扩展解析器不适用"的旧场景保留 fallback | + +### 1.6 测试文件 + +| 文件 | 动作 | +|------|------| +| `tests/test_dsl_errors.py` | 追加单测(不改已有断言语义) | +| `tests/test_dsl_errors_integration.py` | **新增**(解析器错误分支 + 恢复 + golden 回归) | +| `tests/test_dsl_extended.py` | 不改;其 L228-241 `test_invalid_if_missing_parens` 依赖 `DSLParseError` 捕获,由继承关系保证通过 | +| `scratchv/ci/test_page.py` | 不改(`test_dsl_errors.py` 已有映射;新增文件可选登记) | + +--- + +## 2. Parser 各错误分支改造清单 + +### 2.1 基础解析器 `dsl_parser.py` + +| # | 位置(快照) | 现状 | 新行为 | 错误码 | +|---|--------------|------|--------|--------| +| B1 | L44-48 `parse` 主循环 | `for line in lines: self._parse_line(line)` | `for i, raw in enumerate(raw_lines): self._parse_line(raw.strip(), i+1)` | — | +| B2 | L50-57 EOF 检查 | `_loop_stack` 非空则跳过 auto-ret(静默) | 先 `_report_unclosed_for()`(LIFO,每条定位其 `for` 行),再走原 auto-ret 逻辑 | E203 | +| B3 | L69-74 `endfor` | 无配对抛裸 `DSLParseError` | `_report_error(line_no, col_of("endfor"), "'endfor' without matching 'for'", E204, hint="remove this line or add a matching 'for'")`;若有配对则 pop 两个栈 | E204 | +| B4 | **L83-91 赋值语句(含嵌套调用)** | `m = re.match(r"(\w+)\s*=\s*(\w+)\((.+)\)", line)`;不匹配即抛无定位异常;不锚定可吞尾;`c = add(mul(a,b), d)` 被拆成 `mul(a`、`b)`、`d` 三个"变量"**静默生成错误 IR** | 先剥内联注释;用锚定正则 `r"^(\w+)\s*=\s*(\w+)\s*\((.*)\)\s*$"`;实参文本 `args`:含 `(` → E205(col=内层 `(`,hint=`assign the inner call to a temporary variable first`);含 `)` 而括号不平衡 → E201(hint=`missing opening '('`);括号不平衡(`(` 多于 `)`)→ E201(hint=`missing closing ')'`);无匹配 → E101/E201(B7) | E205/E201 | +| B5 | L84-91 实参解析 | `args.split(",")` 直接喂 `_dispatch_op` | 保持不变;空实参留给 B8 的 E302(`add()` → got 0) | — | +| B6 | L165-167 未知算子 | `raise DSLParseError(f"Unsupported op: {op}")` 且已解析实参(副作用建值) | 在 `_parse_kwargs/_resolve` **之前**判 `op not in handlers` → `_report_error(line_no, col_base + m.start(2), f"unknown operation '{op}'", E301, hint=suggest_spelling(...) or suggest_op(op, handlers))`,返回 `None` | E301 | +| B7 | L86 无匹配兜底 | `Cannot parse line` 无定位 | 扫描非法字符 `re.search(r"[^A-Za-z0-9_(),:=.+\-*/#%\s]", line)`:命中 → E101(col=字符位置,message=`unexpected character '{ch}'`,hint=`remove or replace '{ch}'`);否则 → E201(col=语句首字符,hint 由括号平衡/`_compute_suggestion` 链给出) | E101/E201 | +| B8 | L133-164 `_dispatch_op` 参数校验 | `resolved[0], resolved[1]` 越界 → 裸 `IndexError`;多余实参静默忽略 | 新增模块级 `_ARITY: dict[str, int]`(add/sub/mul/div=2;neg/exp/relu/gelu=1;dot/matmul=2;softmax/maxpool=1);`len(resolved) != _ARITY[op]` → `_report_error(line_no, col_of_op, f"{op}() expects {n} argument(s), got {m}", E302, hint=_ARITY_HINTS.get(op))`,返回 `None` | E302 | +| B9 | L93-94 结果登记 | `self._vars[dest_name] = result` | `result is None`(错误已报)时 `return`,不登记 dest,避免污染后续解析 | — | + +**严格模式下的抛出点**:B3/B4/B6/B7/B8 经 `_report_error` 在 `collector is None` 时抛 `DSLSyntaxError`,异常即首错。 + +### 2.2 扩展解析器 `dsl_extended.py` + +| # | 位置(快照) | 现状 | 新行为 | 错误码 | +|---|--------------|------|--------|--------| +| E1 | L137-150 顶层分发 | `startswith("if ")` / `"while "` | `re.match(r"^if\b", line)` / `r"^while\b"`,使 `if(a>b)` 走条件解析并报 E202 而非 E201 | — | +| E2 | L175-178 `if` 头非法 | `raise DSLParseError(f"Invalid if condition: {line}")` | `_report_error(opener_line, opener_col, f"invalid condition in 'if'; expected 'if () ():'", E202, hint=...)`;`_recover_after_bad_header` 返回下一行索引,**不产生派生错误**(括号不平衡时 hint 改为 `missing closing ')'`) | E202 | +| E3 | L195-211 then 分支扫描 | 三段重复循环;EOF 无 `endif` 静默补块;`endwhile` 落入 `_parse_line` 被吞 | 调 `_parse_block(lines, start+1, terminators=("else", "else:", "endif"), ...)`;返回 `None` → E203 定位 `if` 行;返回 `endwhile`(外来终结符)→ E203 且**不消费** | E203 | +| E4 | L216-236 else 分支 | 遇第二个 `else` 静默;EOF 无 `endif` 静默 | `_parse_block(terminators=("endif",))`;`else` 非法位置 → E204 跳过;EOF → E203 | E204/E203 | +| E5 | L238-241 无 else 补空块 | 正常 | 保留(`test_if_without_else_block` 的 IR 块数断言依赖) | — | +| E6 | L243-244 `endif` 消费 | `if lines[idx] == "endif": idx += 1` 否则静默 | 由 `_parse_block` 返回值驱动:`term == "endif"` 才 `return idx + 1`;`term is None` 或外来终结符时 `return idx`(不消费) | — | +| E7 | L259-261 `while` 头非法 | 同 E2 | E202 定位 `while`;`_recover_after_bad_header(kind="while")` | E202 | +| E8 | L287-301 while 体扫描 | EOF 无 `endwhile` 静默 | `_parse_block(terminators=("endwhile",))`;`None` → E203;外来 `endif` → E203 且不消费 | E203 | +| E9 | L303-311 收尾 | `while_stack.pop()` 仅在正常路径 | 错误路径也必须 pop(`try/finally` 或两处显式),保证 EOF 检查不重复报 | — | +| E10 | **L363-367 `_parse_line` 覆写** | 终结符与 `else` 直接 `return`(静默吞游离终结符) | 签名补 `line_no=0`;`endif`/`endwhile` → E204(`'{tok}' without matching '{opener}'`);`else`/`else:` → E204(`'else' without matching 'if'`) | E204 | +| E11 | L152-162 EOF auto-ret | `_loop_stack`/`_while_stack` 非空则静默跳过 | 非空时先报 E203(`for` 由 `_report_unclosed_for`,`while` 由 `_report_unclosed_while`),再走原条件 | E203 | + +### 2.3 `_parse_block` 恢复流程(伪代码,两个块函数共用) + +```python +def _parse_block(self, lines, start_idx, terminators, opener_kind, opener_line, opener_col): + idx = start_idx + while idx < len(lines): + line = lines[idx] + if not line: + idx += 1 + continue + if line in ("endif", "endwhile"): + return idx, line # 不管是否本块终结符,均不消费 + if line in ("else", "else:"): + if line in terminators: + return idx, line + self._report_error(idx + 1, self._col_of(idx + 1, "else"), + "'else' without matching 'if'", ErrorCode.SYN_STRAY_TERMINATOR, + hint="remove this line or add a matching 'if'") + idx += 1 + continue + if line == "endfor": + self._parse_line(line, idx + 1) # 交基础解析器:配对/游离判定 + idx += 1 + continue + if re.match(r"^if\b", line): + idx = self._parse_if_block(lines, idx) + elif re.match(r"^while\b", line): + idx = self._parse_while_block(lines, idx) + else: + self._parse_line(line, idx + 1) + idx += 1 + return idx, None +``` + +调用侧处理: + +```python +idx, term = self._parse_block(lines, start_idx + 1, ("else", "else:", "endif"), "if", line_no, col) +if term is None: + self._report_error(line_no, col, "missing 'endif' for 'if' opened here", + ErrorCode.SYN_MISSING_TERMINATOR, hint="add 'endif' to close this block") + idx = len(lines) +elif term == "endwhile": + self._report_error(line_no, col, "missing 'endif' for 'if' opened here", + ErrorCode.SYN_MISSING_TERMINATOR, hint="add 'endif' to close this block") + # 不消费 endwhile,交外层;外层若已闭合则顶层兜底报 E204 +``` + +`_recover_after_bad_header(lines, start_idx, kind)`: + +```python +# 从 start_idx+1 起扫描;depth 统计 if/while 开块; +# depth==0 且遇到与本块匹配的终结符 → 消费并返回 idx+1; +# depth==0 且遇到其他终结符 → 返回 idx(不消费); +# EOF → 返回 len(lines)。本函数不报错(E202 已报),避免级联。 +``` + +--- + +## 3. ErrorCollector 多错误收集策略 + +### 3.1 收集边界 + +| 错误类型 | 恢复粒度 | 后续行为 | +|----------|----------|----------| +| E101/E201/E205/E301/E302 | 行级 | 跳过当前行,下一行继续 | +| E202 | 块级 | `_recover_after_bad_header` 跳到块终结符后继续 | +| E203 | 块级/EOF | 补齐标签、结束该块,继续外层 | +| E204 | 行级 | 跳过当前行 | + +### 3.2 规则 + +1. **同一行最多一条错误**:错误分支命中即跳过该行,杜绝连锁。 +2. **去重**:`(filename, line, col, error_code, message)` 五元组相同不重复加入(被抑制的错误同样参与去重)。 +3. **上限**:默认 `max_errors=20`,必须 ≥1(否则 `ValueError`);达到上限后新的(未去重的)错误只递增 `suppressed_count`,不再存储;报告末尾输出 `note: error limit ({max_errors}) reached; {N} further errors suppressed`。validator 达上限后不再提前 `break`,因此 `suppressed_count` 统计全部未报告错误。 +4. **顺序**:`errors` 属性按 `(line, col, error_code)` 排序返回,不保持插入顺序。 +5. **部分 Program 契约**:`collector.has_errors` 为真时,`parse()` 返回的 `Program` 仅用于诊断/继续收集,**禁止**用于代码生成;调用方(`compiler.py`)不进入收集模式。 +6. **状态一致**:`for`/`while` 栈在错误路径也须 pop;`_vars[dest]` 在失败时不得登记。 + +### 3.3 报告格式 + +``` +--- {error_count} error(s) found --- +{format_error(err, use_color=..., source=self.source) for err in errors} +[note: error limit ({max_errors}) reached; {suppressed_count} further errors suppressed] +``` + +--- + +## 4. `format_error` 修复清单 + +| # | Bug | 旧实现 | 修复 | +|---|-----|--------|------| +| F1 | 插入符偏移(未计行号位数) | `marker = " " * (err.col + 3) + "^"` | `gutter_src = f" {err.line} \| "`;`gutter_mark = " " * (3 + len(str(err.line))) + "\| "`;`marker = gutter_mark + " " * (col-1) + "^" + "~" * (token_len-1)`;要求 `len(gutter_src) == len(gutter_mark)` | +| F2 | token 长度不含 `_`、未 clamp | `while ... .isalnum()` | 改 `[A-Za-z0-9_]`;`col_start >= len(source_line)` 返回 1;波浪线长度 `max(token_len-1, 1)` | +| F3 | `context_lines` 输出空行 | 只打行号无内容 | 新增 `source` 参数;有源码时按行切分渲染 `line-context .. line-1`;无源码则忽略 `context_lines` | +| F4 | 无 filename 时前缀以冒号开头 | `location = ":5:12: "` | `f"{err.filename or ''}:{err.line}:{err.col}: "` | +| F5 | `col` 越界导致插入符超出行宽 | 未 clamp | `col_eff = min(max(err.col, 1), len(err.source_line) + 1)` 参与 marker 计算(header 仍显示原 col) | +| F6 | 彩色 gutter 与 marker 宽度不一致风险 | marker 独立硬编码 | marker 由**未着色**的 `gutter_mark` 构造,仅对 `^` 着色 | +| F7 | 不可达建议键 | `_SUGGESTIONS` 含 `add(` 等 | 删除;E302 用 `_ARITY_HINTS`;`_compute_suggestion` 改用标识符正则分词 | +| F8 | 空 `source_line` 时仍尝试建议 | `_compute_suggestion` 返回 None 无碍 | 保持;但 `format_error` 对空 `source_line` 不输出 marker(现状已如此) | + +--- + +## 5. 测试文件与用例 + +### 5.1 `tests/test_dsl_errors.py`(追加) + +| 用例 | 断言要点 | +|------|----------| +| `test_syntax_error_is_parse_error` | `issubclass(DSLSyntaxError, DSLParseError)`;`except DSLParseError` 可捕获 | +| `test_suggestion_alias` | `err.suggestion` 读写与 `fix_hint` 同步;`DSLSyntaxError(..., suggestion="x")` 生效 | +| `test_marker_alignment_single_digit` | 输入 line=2, col=5, source=`b = retrun(a, 1)` → 输出含精确行 `" | ^~~~~~"` | +| `test_marker_alignment_double_digit` | line=10 同 col → 精确行 `" | ^~~~~~"`(验证行号位数修复) | +| `test_token_length_includes_underscore` | `_estimate_token_length("foo_bar(x)", 0) == 7` | +| `test_no_filename_uses_placeholder` | 无 filename → 输出以 `:1:1: ` 开头 | +| `test_suggestion_arity_hint` | `_compute_suggestion("add() expects 2 arguments, got 1", "a = add(1)", "E302")` → 含 `requires exactly 2 arguments` | +| `test_collector_dedup` | 两次加入相同 `(line,col,code,message)` → `error_count == 1` | +| `test_collector_suppressed_count` | `max_errors=3`,加 10 条互异 → `error_count == 3`,`suppressed_count == 7`,report 含 `7 further errors suppressed` | +| `test_collector_source_context` | 传 `source` 且 `context_lines=1` → 输出含上一行原文且不含空上下文行 | +| `test_format_context_without_source_ignored` | 不传 `source`、`context_lines=2` → 不出现空上下文行 | + +### 5.2 `tests/test_dsl_errors_integration.py`(新增) + +| 用例 | 输入/操作 | 关键断言 | +|------|-----------|----------| +| `test_unknown_op_location_and_suggestion` | 设计文档测试 1 | `line/col/code/hint`;marker 精确行;`str(e)` 逐字节含 header | +| `test_missing_endif_reported_at_opener` | 设计文档测试 2 | E203、定位 `if` 行;collector 模式 `error_count == 1` | +| `test_missing_endwhile` / `test_missing_endfor` | 同上(while/for) | E203 定位开块行;`endfor` 用例验证 `_loop_stack` 清空后不再 auto-ret | +| `test_stray_terminators` | 顶层裸 `endif`/`endwhile`/`endfor`/`else:` | 各 E204,col=1 | +| `test_invalid_condition_is_e202_and_parse_error` | `if a > b:` | `isinstance(e, DSLParseError)`;code E202 | +| `test_arity_and_nested_call` | `a = add(1)`;`c = add(mul(a,b), d)` | E302 got 1;E205 col=内层 `(` | +| `test_multi_error_collection_and_recovery` | 设计文档测试 3 | `error_count == 4`;顺序;`--- 4 error(s) found ---`;严格模式首错 E302 | +| `test_legal_dsl_zero_diagnostics` | `examples/**/*.dsl` + `benchmarks/cases/*.dsl` | 收集模式 0 错误;`Program.dump()` 与 `git show HEAD` 基线一致(可用改动前输出作 golden) | +| `test_parse_default_signature_compat` | `DSLParser().parse(text)` / `ExtendedDSLParser().parse(text)` | 旧调用方式可用;合法输入无异常 | + +### 5.3 回归 + +- `tests/test_dsl_extended.py` 全部通过(重点 L228-241 `test_invalid_if_missing_parens`); +- `tests/test_parser.py`、`tests/test_cfg_builder.py`、`tests/test_ir_verifier.py`、`tests/test_llvm_codegen.py` 中所有 `DSLParser/ExtendedDSLParser` 调用不受影响; +- `make test` 全量通过。 + +--- + +## 6. 实施顺序 + +1. **`dsl_errors.py` 独立改造**(异常迁移/ErrorCode/format 修复/collector),跑 `pytest tests/test_dsl_errors.py`; +2. **`dsl_parser.py`** 接入行号与语句级错误分支,跑 `pytest tests/test_parser.py tests/test_dsl_errors_integration.py -k "op or arity or nested"`; +3. **`dsl_extended.py`** 块重构(`_parse_block` 抽取 → E202 → E203 → E204),跑扩展测试与集成测试; +4. **`compiler.py` + `__init__.py`** 集成,CLI 冒烟; +5. **补全测试**(单测 + golden 回归),全量 `make test`; +6. **L2 验证**:`python .claude/harness/verify/run.py --level L2`; +7. **文档回填**:设计文档 2.2 表状态列、错误码扩展指南。 + +依赖关系:步骤 1 必须先于 2/3(异常基类迁移);2 与 3 可并行(同文件不同区);4 依赖 2/3。 + +--- + +## 7. 验收标准 + +- [ ] `DSLSyntaxError` 是 `DSLParseError` 子类;`from scratchv.frontend.dsl_parser import DSLParseError` 仍可用;`DSLSyntaxError(1, 1, "msg")` 与全部旧关键字调用可用。 +- [ ] 两个解析器 `parse(text)` 默认严格模式:首错抛 `DSLSyntaxError`,携带 `line/col/message/source_line/filename/fix_hint/error_code`。 +- [ ] `parse(text, collector=...)` 收集模式可报告 ≥4 条错误且能恢复(设计文档测试 3 逐字节通过)。 +- [ ] 插入符与源码列严格对齐:单位/双位数行号用例均通过;`_SUGGESTIONS` 中 `add(` 类键已移除且 E302 建议可达。 +- [ ] 缺 `endif`/`endwhile`/`endfor` 均报 E203 且定位开块行;游离终结符报 E204。 +- [ ] 合法 DSL 全量 golden:`examples/**/*.dsl`、`benchmarks/cases/*.dsl` 零诊断,IR 输出不变。 +- [ ] `python3 -m pytest tests/ -q` 全绿;`python .claude/harness/verify/run.py --level L2` 通过。 +- [ ] CLI 冒烟:含错 DSL 输出 gcc 风格多行错误、退出码 1;含 `--dsl` 的内联源码正常路径不受影响。 +- [ ] 零新第三方依赖(仅 `re`/`difflib`)。 + +验收命令: + +```bash +python3 -m pytest tests/test_dsl_errors.py tests/test_dsl_errors_integration.py -v +python3 -m pytest tests/test_dsl_extended.py tests/test_parser.py -q +make test +python .claude/harness/verify/run.py --level L2 +printf 'a = add(1)\nb = retrun(a, 2)\n' > /tmp/bad.dsl +python -m scratchv --dsl /tmp/bad.dsl -o /tmp/out.s; echo "exit=$?" +``` + +--- + +## 8. 风险与回退 + +| # | 风险 | 等级 | 缓解 | +|---|------|------|------| +| R1 | 原先被静默容忍的畸形输入(缺终结符、游离终结符、嵌套调用)改为报错,属行为变更 | 中 | 仅影响非法输入;合法 DSL golden 全量回归;`compiler.py` 仅对 DSL 路径生效,ONNX 路径不受影响 | +| R2 | 循环导入(dsl_errors ↔ dsl_parser) | 高 | 严格单向依赖:`DSLParseError` 迁至 `dsl_errors.py`,`dsl_parser` 仅导入不反向;`__init__.py` 保持导入顺序 | +| R3 | 基础解析器行号计算改变(`strip()` → 原文切分)导致错误行内容与之前不同 | 低 | 只影响错误输出;语义解析用 `raw.strip()`,IR 不变;golden 比对保证 | +| R4 | 锚定赋值正则改变合法输入接受范围 | 低 | 合法语句必然匹配锚定式;行内注释在匹配前剥离(与扩展解析器一致);golden 覆盖 | +| R5 | `_parse_block` 重构引入 IR 结构差异(块数量/跳转) | 中 | 保留"无 else 也建空 else 块"的既有形状;`test_dsl_extended.py` IR 块数断言 + 全量 goldens | +| R6 | dataclass 异常自定义 `__init__` 与既有测试冲突 | 低 | 保持 7 个位置参数顺序;`suggestion` 仅关键字;追加单测锁定 | +| R7 | 收集模式下部分 IR 被误用 | 中 | 在 docstring/文档明确契约;`compiler.py` 不使用收集模式 | + +**回退方案**: + +1. 全部改动限定在单个 commit(建议 message:`feat(dsl): wire gcc-style DSL diagnostics into parsers (#9)`),失败时 `git revert ` 即可; +2. 异常继承链保证回退前后 `except DSLParseError` 调用方语义连续,不需要同步回退调用方; +3. 不引入环境变量/开关;若需灰度,可先在 `compiler.py` 侧不接 E203 报错(临时 `except DSLSyntaxError` 后回落旧文案),但**不推荐**,会破坏验收 5。 + +--- + +## 实现结果(2026-09-14 集成) + +> **分支集成 commit**:`795067f`(`feat(topic09): integrate DSL syntax diagnostics with gcc-style error reporting`),文档提交 `6248f15`,基于 main `73c3926` +> **分支全量**:`PYTHONPATH=. python3.11 -m pytest tests/ -q` → **716 passed / 0 failed**(评审基线) + +### 实现文件与要点 + +| 文件 | 要点 | +|------|------| +| `scratchv/frontend/dsl_errors.py` | 异常契约:`DSLSyntaxError(DSLParseError)`、`ErrorCode`、`format_error`、`ErrorCollector` | +| `scratchv/frontend/dsl_parser.py` | 行号/列号定位 + 语句级错误分支(严格模式首错抛出) | +| `scratchv/frontend/dsl_extended.py` | `_parse_block` 重构 + E202/E203/E204(缺 `endif`/`endwhile`/`endfor`、游离终结符) | +| `scratchv/compiler.py` | 错误传播:gcc 风格多行诊断、CLI 退出码 1,DSL 路径生效、ONNX 路径不受影响 | +| `scratchv/frontend/__init__.py` | 导出顺序调整(保持单向依赖) | +| `tests/test_dsl_errors.py`(追加)、`tests/test_dsl_errors_integration.py`(新增)、`tests/data/dsl_golden_ir.json` | 单测 + 集成 + 合法 DSL golden 回归 | + +### 修复轮(2026-09-14,评审后) + +评审 `topic09-review.md` 的 P1/P2 修复与本轮代码同步: + +- **F1**:`for`/`return` 语句改 `fullmatch` 锚定;`_parse_kwargs` 按 `OP_SIGNATURES` 校验未知 kwarg 与数值 kwarg(E304)。collector 模式不再静默放行 `return x junk`、`for ... junk`、未知/非数值 kwargs。 +- **F2**:`DSLValidator.validate` 达到 `max_errors` 后不再提前 `break`,`suppressed_count` 统计全部未报告错误(50 错 / max=3 → 47)。 +- **F5/F6**:`ErrorCollector(max_errors<1)` 抛 `ValueError`;抑制分支同样写入去重键,重复的被抑制错误不重复计数。 +- **F7**:两个解析器 `_raw_lines` 统一 `\r\n`/`\r` → `\n` 归一化,CRLF 源 strict/collector 的 `source_line` 一致。 +- **F3/F4**:设计文档 §2.2 补双错误码空间映射表;v2 文档按实现校正格式前缀、排序、note 文案、`context_lines`、E304 状态等。 + +### 测试数字(修复轮后) + +| 口径 | 结果 | +|------|------| +| 定向(`test_dsl_errors*.py` + `test_dsl_validator.py` + `test_dsl_extended.py` + `test_parser.py` 等) | 231 passed | +| 分支全量 | **736 passed / 0 failed**(修复前 716,新增 20 条回归用例) | + +### 与本文档的偏差 / 未完成项 + +- `examples/cnn_model.dsl` 基线本来就不可解析:实现保持报错行为并将其纳入 golden 锁定(**不是**本课题引入的回归)。 +- `$` 错误列号以实现为 15 列,设计文档示例已同步为 15。 + +### 已知限制 + +- 原先被静默容忍的畸形输入(缺终结符、游离终结符、嵌套调用、畸形 `for`/`return`、非法 kwarg)改为报错,属行为变更;仅影响非法 DSL,合法 DSL golden 全量回归。 +- `collector=` 收集模式仅用于测试/诊断,`compiler.py` 不使用收集模式。 +- strict 与 collector 的错误码空间不同(见设计文档 §2.2 映射表);strict 下 validator 漏检的形态仍可能抛富码(如 E205)。 diff --git "a/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243-v2.md" "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243-v2.md" new file mode 100644 index 0000000..c49840d --- /dev/null +++ "b/docs/topics/09-DSL\351\224\231\350\257\257\346\217\220\347\244\272\347\276\216\345\214\226\345\231\250-\350\256\276\350\256\241\346\226\207\346\241\243-v2.md" @@ -0,0 +1,522 @@ +# ScratchV DSL 错误提示美化器技术设计文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 涉及模块:`scratchv/frontend/dsl_errors.py`(错误对象 / 格式化 / 收集器)、`scratchv/frontend/dsl_parser.py`(基础语句解析器)、`scratchv/frontend/dsl_extended.py`(if/while 块解析器)、`scratchv/compiler.py`(编译驱动集成) +> 功能范围:DSL 词法/语法/语义错误的精确定位(`file:line:col`)、gcc 风格渲染、修复建议、多错误收集、块结构缺失(缺 `endif`/`endwhile`/`endfor`)诊断。**不含**表达式解析器重写(课题 15)与 ONNX 侧诊断;**保证**合法 DSL 的解析行为与生成 IR 逐字节不变。 +> 取代关系:本文档(v2)取代同目录 `09-DSL错误提示美化器-设计文档.md`(v1);错误码、消息格式与收集策略一律以本文档为准,v1 与本文档冲突处以本文档为准。 + +--- + +## 一、功能介绍 + +### 1.1 功能概述 + +- **现状**:`dsl_errors.py` 已实现 `DSLSyntaxError`、`ErrorCollector`、`format_error`、`_SUGGESTIONS`,但两个解析器从不抛出 `DSLSyntaxError`(只抛无行号的 `DSLParseError`),`ErrorCollector` 零集成;`format_error` 的插入符列号未计入行号位数,拼写库中 `add(`/`mul(` 等键不可达(被 `strip("(){},:=* ")` 去掉括号后永远匹配不到)。课题 9 名义完成、实际未落地。 +- **本次目标**:接通"错误产出(parser)→ 错误承载(exception)→ 错误渲染(format)→ 错误聚合(collector)→ 驱动集成(compiler)"整条链路。改动只做**错误报告链路**,不重写表达式解析。 + +效果对比: + +``` +# 现状(无行号、无源码行、无建议) +DSLParseError: Cannot parse line: d = retrun(c) + +# 本次交付(gcc 风格) +bad.dsl:4:5: error[E301]: unknown operation 'retrun' + 4 | d = retrun(c) + | ^~~~~~ +note: did you mean 'return'? +``` + +### 1.2 设计目标 + +1. **定位精确**:1-based 行号/列号(列按源码字符计),展示错误行原文,插入符与源码列严格对齐。 +2. **分类稳定**:错误码 `E1xx`(词法)/`E2xx`(语法)/`E3xx`(语义),消息模板固定,便于测试断言与文档化。 +3. **建议有效**:显式 hint 优先,其次拼写库,再其次相似算子(difflib),最后通用修复;一条错误至多一行 `note:`。 +4. **可恢复收集**:collector 模式下跳过坏行/坏块继续解析,默认最多 20 条,超出显示 suppressed 计数;严格模式(默认)保持首错即抛。 +5. **向后兼容**:`DSLSyntaxError` 是 `DSLParseError` 的子类,既有 `except DSLParseError` 代码不受影响;`parse(text)` 默认行为与合法输入完全不变。 +6. **零新依赖**:只用标准库 `re`、`difflib`。 + +--- + +## 二、设计规范 + +### 2.1 错误消息格式 + +gcc/clang 风格,逐字节格式定义(BNF): + +``` +diagnostic ::= header NL source_display [NL marker] [NL note] + +header ::= location error_label ": " message +error_label ::= "error" [ "[" error_code "]" ] +location ::= (filename | "") ":" line ":" col ": " +source_display ::= gutter_src source_line +marker ::= gutter_mark (col-1)*SP "^" ("~")* +note ::= "note: " suggestion + +gutter_src ::= 2*SP line_str " | " +gutter_mark ::= (3 + len(line_str))*SP "| " +``` + +| 元素 | 规则 | +|------|------| +| `line` / `col` | 十进制无前导零;`line` 1-based,`col` 1-based | +| `filename` | 为 `None` 时渲染 ``,冒号 prefix 永不为空 | +| `error_code` | 可选,大写,渲染为 `error[E201]:` 前缀(无码时仅 `error:`) | +| `message` | 全小写、无句点、token 用单引号包裹(如 `unknown operation 'retrun'`) | +| `source_line` | 原文行(不 strip、不含换行符) | +| 列对齐 | `len(gutter_src) == len(gutter_mark)`;插入符起始显示列 = `len(gutter_src) + (col-1)` | +| 波浪线 | `token_len` = 从 `col` 起向后扫描 `[A-Za-z0-9_]` 的长度;长度 `max(token_len-1, 1)`;`col` 超出行尾时 clamp 到行尾+1 且 `token_len=1` | +| `note` | 仅在存在建议时输出;同一错误最多一行 | +| `context_lines` | `>0` 时必须同时提供 `source`,否则忽略(禁止输出无内容的上下文行) | +| 颜色 | `use_color=True` 时:location=BOLD、`error`=RED、gutter=GRAY、`^`=GREEN、`note`=CYAN;颜色码不参与列宽计算 | +| 换行 | `format_error` 返回的字符串不含尾随换行 | + +**逐字节样例**(`use_color=False`): + +``` +unknown_op.dsl:2:5: error[E301]: unknown operation 'retrun' + 2 | b = retrun(a, 1) + | ^~~~~~ +note: did you mean 'return'? +``` + +对齐推导:`gutter_src = " 2 | "`(6 字符),`gutter_mark = " | "`(6 字符),`col=5` 故插入符在第 `6+(5-1)=10` 个字符位,正对 `retrun` 首字母 `r`。 + +**双位数行号样例**(L=10,验证行号位数修复): + +``` +test.dsl:10:5: error[E301]: unknown operation 'retrun' + 10 | d = retrun(c) + | ^~~~~~ +``` + +`gutter_src = " 10 | "`(7 字符),`gutter_mark = " | "`(7 字符),竖线与插入符仍严格对齐(这是现状 bug:旧实现 `" " * (col + 3)` 在双位数行号下会左偏 1 列)。 + +### 2.2 错误分类与错误码表 + +| 错误码 | 类别 | 常量名 | 触发条件 | 消息模板 | 定位(line:col) | 本轮 | +|--------|------|--------|----------|----------|------------------|------| +| E101 | 词法 | `LEX_ILLEGAL_CHAR` | 语句无法解析且含非法字符 | `unexpected character '{ch}'` | 非法字符处 | ✅ | +| E201 | 语法 | `SYN_INVALID_STATEMENT` | 行不匹配任何语句形式 | `cannot parse statement; expected 'name = op(args)'` | 语句首字符 | ✅ | +| E202 | 语法 | `SYN_INVALID_CONDITION` | `if`/`while` 头不匹配条件模式 | `invalid condition in '{kw}'; expected '{kw} () ():'` | `if`/`while` 关键字 | ✅ | +| E203 | 语法 | `SYN_MISSING_TERMINATOR` | 缺少 `endif`/`endwhile`/`endfor` | `missing '{end}' for '{kw}' opened here` | 开块关键字 | ✅ | +| E204 | 语法 | `SYN_STRAY_TERMINATOR` | 游离终结符 / 无配对开块 | `'{tok}' without matching '{opener}'` | 终结符 | ✅ | +| E205 | 语法 | `SYN_NESTED_CALL` | 实参文本含内层 `(`/`)` | `nested function call is not supported` | 内层 `(` | ✅ | +| E206 | 语法 | `SYN_MISSING_COLON` | `if`/`while` 缺 `:` | 预留(当前语法容忍可选冒号,不报错) | — | ◻ | +| E301 | 语义 | `SEM_UNKNOWN_OP` | 未知算子名 | `unknown operation '{op}'` | 算子名 | ✅ | +| E302 | 语义 | `SEM_ARITY` | 实参个数不符 | `{op}() expects {n} argument(s), got {m}` | 算子名 | ✅ | +| E303 | 语义 | `SEM_UNDEFINED_VAR` | 使用未赋值变量 | 预留(`_resolve` 首次出现即建值,保持现状) | — | ◻ | +| E304 | 语义 | `SEM_UNKNOWN_KWARG` | 未知 `key:value` 实参,或数值 kwarg 值非数字 | `invalid keyword argument '{k}'` / `'{k}' requires a numeric value` | kwarg 名首字符 | ✅ | + +**双错误码空间映射(strict validator ↔ collector 富解析器)**:同一输入在 strict 模式下先由 `DSLValidator` 预校验(`E1xx`/`E2xx`),collector 模式跳过预校验、由富解析器报右侧码。**同一个数字码在两个空间含义不同**,跨模式比较错误码没有意义,下表为显式映射与歧义标注: + +| strict(validator) | collector(富解析器) | 说明 / 歧义 | +|---------------------|----------------------|-------------| +| `E100` cannot parse statement / for statement | `E201` invalid statement | 含 `return x junk`、`for i = 0, 4 junk` 等畸形语句 | +| `E100` return requires a value | `E201`(兜底分支) | 富解析器不单列"return 缺值"码 | +| `E101`(括号不平衡) | `E201`(带括号修复 hint) | **歧义**:`E101` 在 strict 为括号错误,在富空间为非法字符 | +| `E101`(`if`/`while` 缺括号条件) | `E202` invalid condition | — | +| `E103` invalid identifier / loop variable | `E201` | 富解析器不区分标识符合法性 | +| `E110` 游离/错配终结符 | `E204` stray terminator | — | +| `E111` unterminated block | `E203` missing terminator | — | +| `E112` else 问题 | `E204` | — | +| `E200` unsupported operation | `E301` unknown operation | **歧义**:`E200` 仅存在于 strict 空间 | +| `E201` arity(positional 个数) | `E302` arity | **歧义**:`E201` 在 strict 为 arity,在富空间为 invalid statement | +| `E202` 未知/缺少 keyword | `E304` invalid keyword argument | — | +| `E203` 值缺失/非数字 | `E304` requires a numeric value | **歧义**:`E203` 在 strict 为参数值错误,在富空间为 missing terminator | +| (strict 归入 `E100`) | `E101` illegal character | — | +| `E201`(实参被逗号拆分后计数不符)或 `E205`(validator 漏检走富路径) | `E205` nested call | 嵌套调用 strict 下码不稳定(如 `c = add(mul(a, b), d)` → `E201`,`a = add(mul(b, c))` → `E205`) | + +> 另注:strict 模式在 validator 未命中时会继续走富解析器并抛出富码(上表 E205 一行的后一种情况),因此 strict 观测到的码不全是 `E1xx`/`E2xx`。若后续要求"错误码跨模式稳定",需将 validator 码重编号或映射进统一命名空间(本课题不做)。 + +**算子实参个数基准**(E302 判定,仅统计普通实参,`k:v` kwargs 不计): + +| 算子 | 普通实参期望个数 | 备注 | +|------|------------------|------| +| `add` `sub` `mul` `div` | 2 | `add() requires exactly 2 arguments` | +| `neg` `exp` `relu` `gelu` | 1 | | +| `dot` | 2 | 另接受 `len:`/`length:` | +| `matmul` | 2 | 另接受 `rows:/cols:/inner:` 或 `m:/n:/k:` | +| `softmax` | 1 | 另接受 `axis:` | +| `maxpool` | 1 | 另接受 `kernel:/stride:` | + +### 2.3 位置信息契约 + +- `line`:物理行号,1-based,等于 `text.split("\n")` 下标 + 1;`line=0` 仅保留给文件级错误(如收集器溢出提示)。 +- `col`:字符列,1-based,按 Unicode code point 计数;**tab 记 1 列**,不做 tab 展开。 +- `source_line`:该行原始文本(不 strip、不含 `\n`);由解析器在构造错误时从 `self._raw_lines[line-1]` 取得。 +- `filename`:`parse(filename=...)` 透传;CLI 传输入路径;内联字符串为 `None`,渲染为 ``。 +- 所有错误必须经统一入口 `_report_error(line_no, col, message, error_code, fix_hint)` 构造,禁止在解析器内手工拼装消息或多行字符串。 + +**各类错误定位基准表**: + +| 错误码 | col 定位 | +|--------|----------| +| E101 | 首个不在 `[A-Za-z0-9_(),:=.+\-*/#%\s]` 内的字符 | +| E201 | 语句缩进后首字符(通常为 1) | +| E202 | `if`/`while` 关键字首字符 | +| E203 | 对应 `if`/`while`/`for` 关键字首字符 | +| E204 | 终结符/`else` 首字符 | +| E205 | 实参文本中内层 `(` 的位置 | +| E301 / E302 | 算子名首字符(由正则 `m.start(2)` 精确定位) | + +### 2.4 修复建议生成规则 + +`note:` 文本按以下优先级链产生,命中即停止: + +1. **解析器显式 `fix_hint`**:E202/E203/E204/E205/E302 由解析器直接给出确定建议。 +2. **拼写库 `_SUGGESTIONS`**:对 `source_line` 用 `[A-Za-z_]\w*` 正则分词(不再用 `str.split()`),优先匹配 `col` 所在 token,其次全行扫描;大小写不敏感精确匹配。**本次修复**:删除现有不可达的 `add(`/`mul(`/`sub(`/`div(`/`matmul(` 键,改由 `_ARITY_HINTS`(键为裸算子名)服务 E302。 +3. **相似算子 `suggest_op(op, candidates, cutoff=0.6)`**:`difflib.get_close_matches` 在支持算子表内找最近者,输出 `did you mean '{cand}'?`。 +4. **通用修复 `_COMMON_FIXES`**:按消息关键词(unterminated / undefined / paren / operator)兜底。 +5. 全不命中 → 不输出 `note:` 行。 + +注:E301 的建议顺序为"拼写库优先、difflib 次之",保证 `retrun` 稳定提示 `return`,不会被 difflib 误配为 `relu`。 + +### 2.5 DSL 块结构缺失报错规则 + +块级结构由 `ExtendedDSLParser` 的 `_parse_if_block`/`_parse_while_block` + 新公共例程 `_parse_block` 管理;`for` 由基础解析器 `_loop_stack` 管理。 + +| 场景 | 触发点 | 错误码 | 定位 | 恢复动作 | +|------|--------|--------|------|----------| +| 缺 `endif` 直到 EOF | `_parse_block` 返回 `(len, None)` | E203 | `if` 关键字 | 收尾 emitted `endif` 标签,结束该块 | +| 缺 `endif` 却遇 `endwhile` | 内层块扫描遇非本块终结符 | E203 | `if` 关键字 | **不消费**该终结符,返回给外层 `while` 处理 | +| 缺 `endwhile` 直到 EOF / 遇 `endif` | 同上 | E203 | `while` 关键字 | 不消费外来终结符,交给外层 | +| 缺 `endfor` 直到 EOF | 基础解析器 `_loop_stack` 非空 | E203 | `for` 行 | 逐条上报后清空循环栈;不再追加自动 `ret` | +| 游离 `endif`/`endwhile` | 顶层 `_parse_line` | E204 | 终结符 | 跳过该行 | +| 游离 `else` / `else:` | 非 `if` 上下文(`else` 分支内再遇 `else`) | E204 | 关键字 | 跳过该行 | +| 游离 `endfor` | `_loop_stack` 为空 | E204 | 关键字 | 跳过该行 | +| `if`/`while` 头非法 | `_parse_condition` 返回 `None` | E202 | 关键字 | `_recover_after_bad_header`:扫描至本块匹配终结符或 EOF;期间不再报派生错误 | + +约束规则: + +- 同一未闭合块**只报一条** E203,避免级联噪音。 +- 嵌套多个未闭合块时按"发现顺序"上报(递归自内向外,后进先出);收集器按 `(line, col, error_code)` 排序后输出,不保持插入顺序。 +- 缺终结符时,块内已成功的合法语句保留其 IR;块结束时补齐跳转标签(与正常解析的块结构一致),避免 IR verifier 报未终结块。 +- E203 的插入符覆盖关键字本身(`if` → `^~`,`while` → `^~~~~`,`for` → `^~~`)。 + +### 2.6 合法/非法示例 + +**合法示例(必须 0 诊断、IR 不变)**: + +``` +# 1. 无 else 的 if +if (a > b): + c = add(a, b) +endif +return c + +# 2. else / else: 两种写法 + 嵌套 if/while +if (a > 0): + while (a < 10): + a = add(a, 1) + endwhile +else: + a = sub(a, 1) +endif +return a + +# 3. 冒号可省(E206 预留,不报错) +if (a > b) + c = mul(a, b) +endif +return c + +# 4. for 循环(基础解析器) +for i = 0, 4 + acc = add(acc, i) +endfor +return acc + +# 5. 行内注释与空行 +# 注释行 +x = add(a, b) # 行内注释 +return x +``` + +**非法示例与预期诊断**: + +| 输入(关键行) | 预期 header | +|----------------|-------------| +| `d = retrun(c)` | `f.dsl:1:5: error[E301]: unknown operation 'retrun'` | +| `a = add(1)` | `f.dsl:1:5: error[E302]: add() expects 2 arguments, got 1` | +| `a = add()` | `f.dsl:1:5: error[E302]: add() expects 2 arguments, got 0` | +| `if a > b:` | `f.dsl:1:1: error[E202]: invalid condition in 'if'; expected 'if () ():'` | +| `if (a > b)` 无 endif 到 EOF | `f.dsl::1: error[E203]: missing 'endif' for 'if' opened here` | +| `while (i < 9)` 无 endwhile 到 EOF | `f.dsl::1: error[E203]: missing 'endwhile' for 'while' opened here` | +| `for i = 0, 4` 无 endfor 到 EOF | `f.dsl::1: error[E203]: missing 'endfor' for 'for' opened here` | +| 顶层裸 `endwhile` | `f.dsl:1:1: error[E204]: 'endwhile' without matching 'while'` | +| 顶层裸 `endfor` | `f.dsl:1:1: error[E204]: 'endfor' without matching 'for'` | +| `if` 块外裸 `else:` | `f.dsl:1:1: error[E204]: 'else' without matching 'if'` | +| `c = add(mul(a, b), d)` | `f.dsl:1:12: error[E205]: nested function call is not supported` | +| `a = add(b, c) $` | `f.dsl:1:15: error[E101]: unexpected character '$'` | +| `a = add(x, y, foo:1)` | `f.dsl:1:15: error[E304]: invalid keyword argument 'foo'` | +| `m = matmul(a, b, rows:abc)` | `f.dsl:1:18: error[E304]: 'rows' requires a numeric value` | + +(注:`c = add(mul(a, b), d)` 中第 12 列是内层 `(`:`c = add(` 占 8 列,`mul` 占 9–11 列。) + +--- + +## 三、测试设计 + +测试分两层:`tests/test_dsl_errors.py`(单元:异常/格式化/收集器)与新增 `tests/test_dsl_errors_integration.py`(集成:解析器错误分支 + 多错误恢复 + 合法 DSL 回归)。以下至少 3 个核心用例。 + +### 测试用例 1:未知算子(E301,列号 + 插入符 + 拼写建议) + +**文件**:`tests/test_dsl_errors_integration.py::test_unknown_op_location_and_suggestion` + +**输入 DSL**(`unknown_op.dsl`): + +``` +1: a = add(x, y) +2: b = retrun(a, 1) +``` + +**执行**:`ExtendedDSLParser().parse(source, filename="unknown_op.dsl")`,严格模式捕获异常。 + +**预期错误消息**(`format_error(e, use_color=False)` 逐字节): + +``` +unknown_op.dsl:2:5: error[E301]: unknown operation 'retrun' + 2 | b = retrun(a, 1) + | ^~~~~~ +note: did you mean 'return'? +``` + +**验证点**: + +- `e.line == 2`、`e.col == 5`、`e.error_code == "E301"`、`e.filename == "unknown_op.dsl"`; +- `e.source_line == "b = retrun(a, 1)"`; +- `e.fix_hint == "did you mean 'return'?"`(拼写库优先于 difflib); +- 输出含精确 marker 行 `" | ^~~~~~"`(4 空格 + `| ` + 4 空格 + `^` + 5 个 `~`),列号与 `retrun` 首字符对齐; +- `isinstance(e, DSLParseError) is True`(向后兼容)。 + +### 测试用例 2:缺 `endif`(E203,块结构缺失) + +**文件**:`tests/test_dsl_errors_integration.py::test_missing_endif_reported_at_opener` + +**输入 DSL**(`missing_endif.dsl`): + +``` +1: i = add(x, 1) +2: if (i > 0): +3: y = mul(i, 2) +4: return y +``` + +**执行**:严格模式 `ExtendedDSLParser().parse(source, filename="missing_endif.dsl")`。 + +**预期错误消息**: + +``` +missing_endif.dsl:2:1: error[E203]: missing 'endif' for 'if' opened here + 2 | if (i > 0): + | ^~ +note: add 'endif' to close this block +``` + +**验证点**: + +- `e.line == 2`、`e.col == 1`、`e.error_code == "E203"`(定位在开块关键字而非 EOF); +- 消息不含大写开头、无句点;插入符 `^~` 覆盖 `if` 两个字符; +- 收集模式下 `collector.error_count == 1`(块内合法语句 `y = mul(...)` 不产生派生错误); +- 对比用例:`while (i < 9):` 无 `endwhile` → `missing 'endwhile' for 'while' opened here`;顶层 `for` 无 `endfor` → `missing 'endfor' for 'for' opened here`。 + +### 测试用例 3:多错误收集(E302 + E301 + E202 + E204,恢复能力) + +**文件**:`tests/test_dsl_errors_integration.py::test_multi_error_collection_and_recovery` + +**输入 DSL**(`multi_error.dsl`): + +``` +1: a = add(1) +2: b = retrun(a, 2) +3: if a > 0: +4: c = mul(a, 2) +5: endwhile +``` + +**执行**:`collector = ErrorCollector(filename="multi_error.dsl", use_color=False)`,`ExtendedDSLParser().parse(source, filename="multi_error.dsl", collector=collector)`;解析返回部分 Program(不抛异常)。 + +**预期输出**(`collector.report()`): + +``` +--- 4 error(s) found --- +multi_error.dsl:1:5: error[E302]: add() expects 2 arguments, got 1 + 1 | a = add(1) + | ^~~ +note: add() requires exactly 2 arguments +multi_error.dsl:2:5: error[E301]: unknown operation 'retrun' + 2 | b = retrun(a, 2) + | ^~~~~~ +note: did you mean 'return'? +multi_error.dsl:3:1: error[E202]: invalid condition in 'if'; expected 'if () ():' + 3 | if a > 0: + | ^~ +note: expected one of ==, !=, <, >, <=, >= and parentheses around each operand +multi_error.dsl:5:1: error[E204]: 'endwhile' without matching 'while' + 5 | endwhile + | ^~~~~~~~ +note: remove this line or add a matching 'while' +``` + +**验证点**: + +- `collector.error_count == 4`,顺序为发现顺序(行 1 → 2 → 3 → 5); +- 第 3 行的坏块恢复:扫描至 `endwhile` 时**不消费**(因它不属于该 `if`),第 5 行独立报 E204; +- 每行最多一条错误(同行不叠加); +- `collector.has_errors is True`,`report()` 含 `--- 4 error(s) found ---` 头; +- 部分 Program 不可信但可构造(`program.functions` 存在); +- 严格模式对照:同一输入首错抛 `DSLSyntaxError(line=1, col=5, error_code="E302")`。 + +### 测试用例 4(回归):合法 DSL 零诊断 golden + +**文件**:`tests/test_dsl_errors_integration.py::test_legal_dsl_zero_diagnostics` + +**输入**:`examples/**/*.dsl` 与 `benchmarks/cases/*.dsl` 全量文件,按是否含扩展关键字选择 `ExtendedDSLParser` 或 `DSLParser`。 + +**预期输出**:collector 模式下全部 `has_errors == False`,且与改动前解析得到的指令序列一致(对每个文件比较 `Program.dump()` 的规范化文本)。 + +**验证点**: + +- 合法 DSL 的 IR 输出逐字节不变; +- 现有 `tests/test_dsl_extended.py::test_invalid_if_missing_parens` 仍以 `except DSLParseError` 捕获成功(验证异常继承); +- `make test` 全量通过。 + +--- + +## 四、修改模块与实现步骤 + +### 4.1 涉及文件 + +| 文件 | 角色 | 改动量级 | +|------|------|----------| +| `scratchv/frontend/dsl_errors.py` | 异常基类、错误码、格式化、建议、收集器 | 中 | +| `scratchv/frontend/dsl_parser.py` | 语句级错误分支、行号/列号、for 栈位置 | 中 | +| `scratchv/frontend/dsl_extended.py` | 块解析重构、E202/E203/E204、恢复 | 大 | +| `scratchv/compiler.py` | 不再用 fallback 吞掉定位错误;解析错误带格式输出 | 小 | +| `scratchv/frontend/__init__.py` | 导出 `DSLParseError`/`ErrorCode`/`make_error` | 极小 | +| `tests/test_dsl_errors.py` | 补格式化/收集器新行为单测 | 小 | +| `tests/test_dsl_errors_integration.py` | 新增集成测试(本课题核心验收) | 新文件 | + +(注:实际文件路径可能不同;以仓库当前快照为准,锚点见开发文档。) + +### 4.2 `dsl_errors.py` 改造 + +1. **迁移异常基类**:在 `dsl_errors.py` 定义 `class DSLParseError(Exception)`;`dsl_parser.py` 删除本地定义并改为导入 + 重导出(消除循环依赖:`dsl_errors` 不依赖任何 frontend 模块)。 +2. **错误码常量**:新增 `class ErrorCode`,定义 `E101/E201/E202/E203/E204/E205/E301/E302`。 +3. **`DSLSyntaxError`**:继承 `DSLParseError`;增加 `suggestion` 读写别名属性;构造器支持 `suggestion=` 关键字(等价 `fix_hint=`)。 +4. **建议引擎**:重写 `_compute_suggestion` 为"指定列 token 优先 + 全行扫描";新增 `suggest_spelling`、`suggest_op`;`_SUGGESTIONS` 删除不可达的带括号键,新增 `_ARITY_HINTS`。 +5. **`format_error` 修复**:gutter 对齐公式(2.1)、插入符 clamp、`source` 参数支持真实上下文、`` 兜底、token 长度含 `_`。 +6. **`ErrorCollector`**:`source` 参数、去重、`suppressed_count`、溢出 note 替换旧"伪错误"哨兵。 + +### 4.3 `dsl_parser.py` 改造 + +1. `__init__` 增加 `self._raw_lines`、`self._filename`、`self._collector`、`self._for_positions`。 +2. `parse(text, filename=None, collector=None)`:改用 `raw_lines = text.split("\n")` 保留物理行号;逐行带行号调用 `_parse_line`;EOF 时对 `_loop_stack` 逐条报 E203。 +3. `_parse_line(line, line_no=0)` 各错误分支改造(详见开发文档"错误分支清单"):内联注释剥离 → 锚定赋值正则 → 空实参/嵌套调用/括号不平衡 → 未知算子 → 参数个数 → 兜底 E101/E201。 +4. 新增 `_report_error` / `_col_of` 公共辅助(放基类供扩展解析器复用)。 + +### 4.4 `dsl_extended.py` 改造 + +1. `parse(text, filename=None, collector=None)`:设置行列上下文;顶层分发用 `^if\b` / `^while\b`;EOF 检查 `_loop_stack`/`_while_stack`。 +2. 新增 `_parse_block(lines, start_idx, terminators, opener_kind, opener_line, opener_col)`:统一 then/else/while-body 三处重复扫描逻辑;只返回遇到的终结符(**不消费**);`else` 在非法位置报 E204。 +3. `_parse_if_block` / `_parse_while_block` 按 2.5 规则处理 E202/E203/E204,并在错误路径下仍补齐块标签。 +4. `_parse_line` 覆写:`endif`/`endwhile`/`else`/`else:` 在块解析器未消费而落到此处时 → E204(不再静默 return)。 +5. 新增 `_recover_after_bad_header`:E202 后跳过整个块到匹配终结符或 EOF。 + +### 4.5 `compiler.py` 集成 + +1. `_parse`:`except DSLSyntaxError: raise`(**不得**再被 `DSLParser` fallback 吞掉);`except DSLParseError:` 保留旧 fallback;向 parser 透传 `filename`。 +2. `compile`:`except DSLSyntaxError as e: return CompileResult(success=False, errors=[str(e)])`,使 CLI 输出的错误即为 gcc 风格多行文本(`str(DSLSyntaxError)` 内部走 `format_error(use_color=False)`)。 +3. 不改 CLI 参数与退出码语义(失败仍返回 1)。 + +### 4.6 集成与回归测试 + +- 新增集成测试(第三部分用例 1–4); +- `python3 -m pytest tests/test_dsl_errors.py tests/test_dsl_errors_integration.py -q`; +- `make test`(全量 348+ 用例); +- `python .claude/harness/verify/run.py --level L2`; +- 手工 CLI 冒烟:对含 2 个错误的 DSL 验证退出码 1 与 stderr 输出格式。 + +--- + +## 五、附录 + +### 5.1 完整错误输出示例 + +**示例 A:多个错误的完整报告(对应测试用例 3)** + +``` +--- 4 error(s) found --- +multi_error.dsl:1:5: error[E302]: add() expects 2 arguments, got 1 + 1 | a = add(1) + | ^~~ +note: add() requires exactly 2 arguments +multi_error.dsl:2:5: error[E301]: unknown operation 'retrun' + 2 | b = retrun(a, 2) + | ^~~~~~ +note: did you mean 'return'? +multi_error.dsl:3:1: error[E202]: invalid condition in 'if'; expected 'if () ():' + 3 | if a > 0: + | ^~ +note: expected one of ==, !=, <, >, <=, >= and parentheses around each operand +multi_error.dsl:5:1: error[E204]: 'endwhile' without matching 'while' + 5 | endwhile + | ^~~~~~~~ +note: remove this line or add a matching 'while' +``` + +**示例 B:异常对象的 `str()`** + +```python +from scratchv.frontend.dsl_extended import ExtendedDSLParser + +try: + ExtendedDSLParser().parse(source, filename="bad.dsl") +except Exception as e: + print(e) # 等价 format_error(e, use_color=False),多行 gcc 风格 +``` + +**示例 C:收集模式调用模板** + +```python +from scratchv.frontend.dsl_errors import ErrorCollector +from scratchv.frontend.dsl_extended import ExtendedDSLParser + +collector = ErrorCollector(filename=path, use_color=False) +program = ExtendedDSLParser().parse(source, filename=path, collector=collector) +if collector.has_errors: + print(collector.report()) # 全部错误一次性输出 + # 注意:此时 program 为部分结果,不可用于代码生成 +``` + +### 5.2 错误消息模板速查 + +| 码 | 消息模板 | 显式 hint 模板 | +|----|----------|----------------| +| E101 | `unexpected character '{ch}'` | `remove or replace '{ch}'` | +| E201 | `cannot parse statement; expected 'name = op(args)'` | 括号不平衡时 `missing closing ')'` / `missing opening '('` | +| E202 | `invalid condition in '{kw}'; expected '{kw} () ():'` | `expected one of ==, !=, <, >, <=, >= and parentheses around each operand` | +| E203 | `missing '{end}' for '{kw}' opened here` | `add '{end}' to close this block` | +| E204 | `'{tok}' without matching '{opener}'` | `remove this line or add a matching '{opener}'` | +| E205 | `nested function call is not supported` | `assign the inner call to a temporary variable first` | +| E301 | `unknown operation '{op}'` | 拼写库 / `did you mean '{cand}'?` | +| E302 | `{op}() expects {n} argument(s), got {m}` | `{op}() requires exactly {n} arguments` | +| E304 | `invalid keyword argument '{k}'` / `'{k}' requires a numeric value` | `'{k}' is not accepted by {op}()` / `pass a number for '{k}', got '{v}'` | + +### 5.3 如何扩展新错误类型 + +1. 在 `ErrorCode` 增加常量(编码规则:词法 `E1xx`、语法 `E2xx`、语义 `E3xx`,不复用旧值)。 +2. 在解析器对应分支调用 `_report_error(line_no, col, message, ErrorCode.XXX, fix_hint=...)`,禁止手工构造异常。 +3. 若需要自动建议:静态映射放 `_COMMON_FIXES`(按错误码优先于关键词),算子相关放 `_ARITY_HINTS`,拼写相关放 `_SUGGESTIONS`。 +4. 在 `tests/test_dsl_errors_integration.py` 增加"输入 / 精确 header / 列号 / 验证点"三件套用例。 +5. 更新本文档 2.2 错误码表状态列。 + +### 5.4 参考资料 + +- 课题文档:`docs/topics/09-DSL错误提示美化器.md` +- GCC 诊断格式: +- Rust Compiler Error Index: +- 相邻课题:课题 15(表达式/语法增强,本课题明确不涉及);课题 07(编译器日志增强器) diff --git a/scratchv/compiler.py b/scratchv/compiler.py index fa5459e..3c1759d 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -23,6 +23,7 @@ from dataclasses import dataclass, field from typing import Any, Optional +from scratchv.frontend.dsl_errors import DSLParseError, DSLSyntaxError from scratchv.pass_interface import CompilerPass, PassResult @@ -271,15 +272,12 @@ def compile(self, input_path: str, output_path: str | None = None, # --- 1. Parse --- try: program = self._parse(input_path, dsl_source) + except DSLSyntaxError as e: + return CompileResult( + success=False, errors=[str(e)], diagnostics=[e], + ) except Exception as e: if use_dsl: - from scratchv.frontend.dsl_errors import DSLSyntaxError - if isinstance(e, DSLSyntaxError): - return CompileResult( - success=False, - errors=[str(e)], - diagnostics=[e], - ) raise return CompileResult( success=False, errors=[f"Parse error: {e}"], @@ -368,13 +366,21 @@ def _parse(self, input_path: str, dsl_source: str | None = None): if use_dsl: source = dsl_source + filename: str | None = None if source is None and input_path: with open(input_path) as f: source = f.read() - from scratchv.frontend.dsl_extended import ExtendedDSLParser - return ExtendedDSLParser().parse( - source or "", filename=input_path or "", - ) + filename = input_path + # Try extended DSL first + try: + from scratchv.frontend.dsl_extended import ExtendedDSLParser + return ExtendedDSLParser().parse(source, filename=filename) + except DSLSyntaxError: + # Precise, positioned error: never swallow it with fallback + raise + except DSLParseError: + from scratchv.frontend.dsl_parser import DSLParser + return DSLParser().parse(source, filename=filename) else: from scratchv.frontend.onnx_parser import ONNXParser return ONNXParser().parse(input_path) diff --git a/scratchv/frontend/__init__.py b/scratchv/frontend/__init__.py index d5e9f8a..109441e 100644 --- a/scratchv/frontend/__init__.py +++ b/scratchv/frontend/__init__.py @@ -4,9 +4,13 @@ from .dsl_errors import ( DSLParseError, DSLSyntaxError, + ErrorCode, ErrorCollector, format_error, + make_error, render_error, + suggest_op, + suggest_spelling, ) from .dsl_validator import DSLValidator, OP_SIGNATURES, SourceBuffer @@ -14,11 +18,15 @@ "ONNXParser", "DSLParser", "ExtendedDSLParser", - "DSLSyntaxError", "DSLParseError", + "DSLSyntaxError", + "ErrorCode", + "ErrorCollector", "format_error", + "make_error", "render_error", - "ErrorCollector", + "suggest_op", + "suggest_spelling", "DSLValidator", "OP_SIGNATURES", "SourceBuffer", diff --git a/scratchv/frontend/dsl_errors.py b/scratchv/frontend/dsl_errors.py index acb4c53..147d6d7 100644 --- a/scratchv/frontend/dsl_errors.py +++ b/scratchv/frontend/dsl_errors.py @@ -1,26 +1,31 @@ """DSL error beautifier with gcc/clang-style error messages. Provides: +- DSLParseError: backward-compatible base class for all DSL parse errors - DSLSyntaxError: enriched exception with line, column, message, source_line +- ErrorCode: stable error-code constants (E1xx lexical / E2xx syntax / E3xx semantic) - format_error(): produces gcc/clang-style formatted error output +- render_error(): renders an error with color chosen from the target stream - ErrorCollector: collects multiple errors before reporting - ANSI color support for enhanced readability Example output:: - test.dsl:5:12: error: unexpected token 'retrun' + test.dsl:5:12: error[E301]: unexpected token 'retrun' 5 | result = retrun(x) - | ^~~~~~~ + | ^~~~~~ note: did you mean 'return'? """ from __future__ import annotations +import difflib import enum import os +import re import sys from dataclasses import dataclass -from typing import Optional, TextIO +from typing import Iterable, Optional, TextIO # --------------------------------------------------------------------------- @@ -46,6 +51,30 @@ def _color(text: str, color: Color) -> str: return f"{color.value}{text}{Color.RESET.value}" +# --------------------------------------------------------------------------- +# Error-code constants (stable; only append, never reuse) +# --------------------------------------------------------------------------- + +class ErrorCode: + """Stable DSL diagnostic error codes. + + Encoding: ``E1xx`` lexical, ``E2xx`` syntax, ``E3xx`` semantic. + """ + + # Lexical + LEX_ILLEGAL_CHAR = "E101" + # Syntax + SYN_INVALID_STATEMENT = "E201" + SYN_INVALID_CONDITION = "E202" + SYN_MISSING_TERMINATOR = "E203" + SYN_STRAY_TERMINATOR = "E204" + SYN_NESTED_CALL = "E205" + # Semantic + SEM_UNKNOWN_OP = "E301" + SEM_ARITY = "E302" + SEM_UNKNOWN_KWARG = "E304" + + # --------------------------------------------------------------------------- # Fix suggestion database # --------------------------------------------------------------------------- @@ -61,14 +90,22 @@ def _color(text: str, color: Color) -> str: "maxpol": "did you mean 'maxpool'?", "enfor": "did you mean 'endfor'?", "ednfor": "did you mean 'endfor'?", - "add(": "add() requires exactly 2 arguments", - "mul(": "mul() requires exactly 2 arguments", - "sub(": "sub() requires exactly 2 arguments", - "div(": "div() requires exactly 2 arguments", - "matmul(": ( - "matmul() requires rows:, cols:, inner: kwargs " - "(e.g., m:2, n:2, k:2)" - ), +} + +# Arity hints keyed by bare operator name (served by E302). +_ARITY_HINTS: dict[str, str] = { + "add": "add() requires exactly 2 arguments", + "sub": "sub() requires exactly 2 arguments", + "mul": "mul() requires exactly 2 arguments", + "div": "div() requires exactly 2 arguments", + "neg": "neg() requires exactly 1 argument", + "exp": "exp() requires exactly 1 argument", + "relu": "relu() requires exactly 1 argument", + "gelu": "gelu() requires exactly 1 argument", + "dot": "dot() requires exactly 2 arguments", + "matmul": "matmul() requires exactly 2 arguments", + "softmax": "softmax() requires exactly 1 argument", + "maxpool": "maxpool() requires exactly 1 argument", } _COMMON_FIXES: dict[str, str] = { @@ -88,14 +125,14 @@ def _color(text: str, color: Color) -> str: # --------------------------------------------------------------------------- -# DSLSyntaxError +# Exception hierarchy # --------------------------------------------------------------------------- class DSLParseError(Exception): - """Base exception retained for compatibility with existing callers.""" + """Base class for DSL parse errors (backward-compatible catch-all).""" -@dataclass +@dataclass(init=False) class DSLSyntaxError(DSLParseError): """Enriched syntax error with precise location information. @@ -107,6 +144,8 @@ class DSLSyntaxError(DSLParseError): filename: Optional source filename for display. fix_hint: Optional suggestion for fixing the error. error_code: Optional error code string for categorization. + end_col: Optional 1-based column just past the erroneous span. + suggestion: Read/write alias of ``fix_hint``. """ line: int @@ -118,33 +157,130 @@ class DSLSyntaxError(DSLParseError): error_code: Optional[str] = None end_col: Optional[int] = None + def __init__( + self, + line: int, + col: int, + message: str, + source_line: str = "", + filename: Optional[str] = None, + fix_hint: Optional[str] = None, + error_code: Optional[str] = None, + *, + suggestion: Optional[str] = None, + end_col: Optional[int] = None, + ) -> None: + if fix_hint is None: + fix_hint = suggestion + self.line = line + self.col = col + self.message = message + self.source_line = source_line + self.filename = filename + self.fix_hint = fix_hint + self.error_code = error_code + self.end_col = end_col + Exception.__init__(self, message) + + @property + def suggestion(self) -> Optional[str]: + """Read/write alias for ``fix_hint``.""" + return self.fix_hint + + @suggestion.setter + def suggestion(self, value: Optional[str]) -> None: + self.fix_hint = value + def __str__(self) -> str: return format_error(self, use_color=False) # --------------------------------------------------------------------------- -# Error formatting functions +# Suggestion helpers # --------------------------------------------------------------------------- -def _compute_suggestion(message: str, source_line: str) -> Optional[str]: - """Heuristically compute a fix suggestion based on the error message - and source line content. +_IDENT_RE = re.compile(r"[A-Za-z_]\w*") - Args: - message: The error message text. - source_line: The full source line content. + +def _identifier_at(source_line: str, col: int) -> Optional[str]: + """Return the identifier containing the 1-based column ``col``.""" + if not source_line or col <= 0: + return None + idx = col - 1 + for m in _IDENT_RE.finditer(source_line): + if m.start() <= idx < m.end(): + return m.group(0) + return None + + +def suggest_spelling( + source_line: str, + col: int = 0, +) -> Optional[str]: + """Suggest a spelling fix from ``_SUGGESTIONS``. + + The identifier covering ``col`` (1-based) is checked first, then the + whole line is scanned. Matching is case-insensitive and exact. Returns: - A human-readable suggestion string, or None. + A suggestion such as ``"did you mean 'return'?"`` or ``None``. + """ + if not source_line: + return None + if col > 0: + token = _identifier_at(source_line, col) + if token is not None: + hint = _SUGGESTIONS.get(token.lower()) + if hint: + return hint + for token in _IDENT_RE.findall(source_line): + hint = _SUGGESTIONS.get(token.lower()) + if hint: + return hint + return None + + +def suggest_op( + op: str, + candidates: Iterable[str], + cutoff: float = 0.6, +) -> Optional[str]: + """Suggest the closest known operator via ``difflib``. + + Returns: + A suggestion such as ``"did you mean 'mul'?"`` or ``None``. + """ + matches = difflib.get_close_matches(op, list(candidates), n=1, cutoff=cutoff) + if matches: + return f"did you mean '{matches[0]}'?" + return None + + +def _compute_suggestion( + message: str, + source_line: str, + error_code: Optional[str] = None, +) -> Optional[str]: + """Heuristically compute a fix suggestion. + + Priority: error-code specific hints, spelling library, then keyword-based + common fixes. Returns ``None`` when nothing applies. """ - # Check for known misspellings in source line - words = source_line.strip().split() - for word in words: - clean = word.strip("(){},:=* ") - if clean.lower() in _SUGGESTIONS: - return _SUGGESTIONS[clean.lower()] - - # Check common patterns in message + if error_code == ErrorCode.SEM_ARITY: + m = re.match(r"(\w+)\(\)\s+expects\s+", message) + if m: + op = m.group(1) + hint = _ARITY_HINTS.get(op) + if hint: + return hint + n = re.search(r"expects\s+(\d+)", message) + if n: + return f"{op}() requires exactly {n.group(1)} arguments" + + hint = suggest_spelling(source_line) + if hint: + return hint + msg_lower = message.lower() if "unterminated" in msg_lower or "missing end" in msg_lower: return _COMMON_FIXES["unterminated_block"] @@ -161,66 +297,80 @@ def _compute_suggestion(message: str, source_line: str) -> Optional[str]: return None +# --------------------------------------------------------------------------- +# Error formatting functions +# --------------------------------------------------------------------------- + def format_error( err: DSLSyntaxError, use_color: bool = True, context_lines: int = 0, show_column_marker: bool = True, + source: Optional[str] = None, ) -> str: """Format a DSLSyntaxError as a gcc/clang-style error message. Output format:: - filename:line:col: error: message + filename:line:col: error[code]: message line | source_line - | ^ marker + | ^ marker note: fix suggestion Args: err: The DSLSyntaxError to format. use_color: Whether to use ANSI color codes. - context_lines: Number of context lines to show before the error line. - show_column_marker: Whether to show the caret/carrot marker. + context_lines: Number of context lines shown before the error line + (only rendered when ``source`` is provided). + show_column_marker: Whether to show the caret marker. + source: Full source text used to render real context lines. Returns: - A formatted error string. + A formatted error string (no trailing newline). """ parts: list[str] = [] - # Build location prefix + # Header: location + error label + message location = f"{err.filename or ''}:{err.line}:{err.col}: " - - # Error header - error_label = ( - f"error[{err.error_code}]" if err.error_code else "error" - ) + error_label = f"error[{err.error_code}]" if err.error_code else "error" if use_color: - location = _color(location, Color.BOLD) - error_tag = _color(error_label, Color.RED) - parts.append(f"{location}{error_tag}: {err.message}") + parts.append( + f"{_color(location, Color.BOLD)}" + f"{_color(error_label, Color.RED)}: {err.message}" + ) else: parts.append(f"{location}{error_label}: {err.message}") - # Source line display - if err.source_line: - # Optionally show context lines before - if context_lines > 0: - for ctx_off in range(-context_lines, 0): - ctx_line_num = err.line + ctx_off - if ctx_line_num > 0: - ctx_indicator = ( - " |" if context_lines > 1 else " " + line_str = str(err.line) + gutter_src = f" {line_str} | " + gutter_mark = " " * (3 + len(line_str)) + "| " + assert len(gutter_src) == len(gutter_mark) + + # Optional context lines (only with real source text) + if context_lines > 0 and source: + src_lines = source.split("\n") + if 0 < err.line <= len(src_lines): + start = max(1, err.line - context_lines) + for n in range(start, err.line): + ctx_text = src_lines[n - 1].expandtabs(4) + if use_color: + parts.append( + f"{_color(f' {n} |', Color.GRAY)} {ctx_text}" ) - parts.append(f" {ctx_line_num}{ctx_indicator}") + else: + parts.append(f" {n} | {ctx_text}") - # Error line + # Source line display + if err.source_line: + display_source = err.source_line.expandtabs(4) if use_color: - line_prefix = _color(f" {err.line} |", Color.GRAY) - parts.append(f"{line_prefix} {err.source_line.expandtabs(4)}") + parts.append( + f"{_color(f' {line_str} |', Color.GRAY)} {display_source}" + ) else: - parts.append(f" {err.line} | {err.source_line.expandtabs(4)}") + parts.append(f"{gutter_src}{display_source}") - # Column marker + # Column marker, aligned with the expanded source display if show_column_marker: raw_start = max(err.col - 1, 0) display_start = len(err.source_line[:raw_start].expandtabs(4)) @@ -233,23 +383,22 @@ def format_error( ) else: token_len = _estimate_token_length(err.source_line, raw_start) - marker_padding = len(f" {err.line} | ") + display_start - marker = " " * marker_padding + "^" - if use_color: - marker = ( - " " * marker_padding - + _color("^", Color.GREEN) - ) - # Add tildes to indicate token length - marker += "~" * (max(token_len - 1, 1)) + caret = _color("^", Color.GREEN) if use_color else "^" + marker = ( + gutter_mark + + " " * display_start + + caret + + "~" * max(token_len - 1, 1) + ) parts.append(marker) - # Fix suggestion - hint = err.fix_hint or _compute_suggestion(err.message, err.source_line) + # Fix suggestion (explicit hint wins over heuristic) + hint = err.fix_hint or _compute_suggestion( + err.message, err.source_line, err.error_code, + ) if hint: if use_color: - note_tag = _color("note", Color.CYAN) - parts.append(f"{note_tag}: {hint}") + parts.append(f"{_color('note', Color.CYAN)}: {hint}") else: parts.append(f"note: {hint}") @@ -277,13 +426,17 @@ def _estimate_token_length(source_line: str, col_start: int) -> int: col_start: 0-based column index of the start of the token. Returns: - Estimated length of the token in characters. + Estimated length of the token in characters (at least 1). """ - if col_start >= len(source_line): + if col_start < 0 or col_start >= len(source_line): return 1 token_end = col_start - while token_end < len(source_line) and source_line[token_end].isalnum(): - token_end += 1 + while token_end < len(source_line): + ch = source_line[token_end] + if ch.isalnum() or ch == "_": + token_end += 1 + else: + break return max(token_end - col_start, 1) @@ -300,11 +453,9 @@ class ErrorCollector: Usage:: collector = ErrorCollector(filename="test.dsl") - try: - parser.parse(source) - except DSLSyntaxError as e: - collector.add(e) - collector.report() + program = parser.parse(source, filename="test.dsl", collector=collector) + if collector.has_errors: + print(collector.report()) """ def __init__( @@ -312,24 +463,39 @@ def __init__( filename: Optional[str] = None, use_color: bool = True, max_errors: int = 20, + source: Optional[str] = None, + context_lines: int = 0, ): """Initialize the error collector. Args: filename: Source filename for display. use_color: Whether to use ANSI colors in output. - max_errors: Maximum number of errors to collect before giving up. + max_errors: Maximum number of stored errors (must be >= 1). + source: Full source text used for context rendering. + context_lines: Context lines shown before each error line. + + Raises: + ValueError: If ``max_errors`` is less than 1 (a zero limit would + silently report "no errors" while suppressing everything). """ + if max_errors < 1: + raise ValueError( + f"max_errors must be >= 1, got {max_errors}" + ) self.filename = filename self.use_color = use_color self.max_errors = max_errors + self.source = source + self.context_lines = context_lines self._errors: list[DSLSyntaxError] = [] - self.limit_reached = False self._keys: set[tuple[object, ...]] = set() + self.limit_reached = False + self._suppressed: int = 0 @property def errors(self) -> list[DSLSyntaxError]: - """Return the collected errors.""" + """Return the collected errors, de-duplicated and position-sorted.""" return sorted( self._errors, key=lambda err: (err.line, err.col, err.error_code or ""), @@ -342,14 +508,20 @@ def has_errors(self) -> bool: @property def error_count(self) -> int: - """Return the number of collected errors.""" + """Return the number of collected (non-suppressed) errors.""" return len(self._errors) + @property + def suppressed_count(self) -> int: + """Return the number of errors dropped after ``max_errors``.""" + return self._suppressed + def add(self, err: DSLSyntaxError) -> None: """Add an error to the collector. - Args: - err: A DSLSyntaxError instance. + Duplicates by ``(filename, line, col, error_code, message)`` are + ignored. Once ``max_errors`` is reached, further unique errors are + only counted in ``suppressed_count`` and flip ``limit_reached``. """ if err.filename is None and self.filename is not None: err.filename = self.filename @@ -358,10 +530,11 @@ def add(self, err: DSLSyntaxError) -> None: ) if key in self._keys: return + self._keys.add(key) if len(self._errors) >= self.max_errors: self.limit_reached = True + self._suppressed += 1 return - self._keys.add(key) self._errors.append(err) def add_error( @@ -374,16 +547,7 @@ def add_error( error_code: Optional[str] = None, end_col: Optional[int] = None, ) -> None: - """Convenience method to add an error by components. - - Args: - line: 1-based line number. - col: 1-based column number. - message: Error message. - source_line: Source line content. - fix_hint: Optional fix suggestion. - error_code: Optional error code. - """ + """Convenience method to add an error by components.""" self.add(DSLSyntaxError( line=line, col=col, @@ -399,46 +563,47 @@ def report(self) -> str: """Format all collected errors and return as a string. Returns: - Formatted error report string. + Formatted error report (``""`` when there are no errors). """ if not self._errors: return "" - parts: list[str] = [] + header = f"--- {len(self._errors)} error(s) found ---" if self.use_color: - parts.append(_color( - f"--- {len(self._errors)} error(s) found ---", - Color.BOLD, - )) - else: - parts.append(f"--- {len(self._errors)} error(s) found ---") + header = _color(header, Color.BOLD) + parts: list[str] = [header] for err in self.errors: - parts.append(format_error(err, use_color=self.use_color)) + parts.append(format_error( + err, + use_color=self.use_color, + context_lines=self.context_lines, + source=self.source, + )) if self.limit_reached: - parts.append( + note = ( f"note: error limit ({self.max_errors}) reached; " - "further errors suppressed" + f"{self._suppressed} further errors suppressed" ) + if self.use_color: + note = _color(note, Color.CYAN) + parts.append(note) return "\n".join(parts) def report_and_exit(self, exit_code: int = 1) -> None: - """Print errors and exit if any errors were collected. - - Args: - exit_code: Process exit code to use. - """ + """Print errors and exit if any errors were collected.""" if self._errors: print(self.report(), file=sys.stderr) sys.exit(exit_code) def clear(self) -> None: - """Clear all collected errors.""" + """Clear all collected errors and reset limit/dedup state.""" self._errors.clear() self._keys.clear() self.limit_reached = False + self._suppressed = 0 # --------------------------------------------------------------------------- @@ -455,20 +620,7 @@ def make_error( error_code: Optional[str] = None, end_col: Optional[int] = None, ) -> DSLSyntaxError: - """Factory function to create a DSLSyntaxError. - - Args: - line: 1-based line number. - col: 1-based column number. - message: Error description. - source_line: Content of the erroneous line. - filename: Source filename. - fix_hint: Fix suggestion. - error_code: Error code. - - Returns: - A DSLSyntaxError instance. - """ + """Factory function to create a DSLSyntaxError.""" return DSLSyntaxError( line=line, col=col, diff --git a/scratchv/frontend/dsl_extended.py b/scratchv/frontend/dsl_extended.py index e8ab463..018be7c 100644 --- a/scratchv/frontend/dsl_extended.py +++ b/scratchv/frontend/dsl_extended.py @@ -17,6 +17,11 @@ The extended parser follows the same patterns as the base DSLParser: recursive-descent parsing, variable-to-Value tracking, and IR generation via IRBuilder. + +Diagnostics: strict mode (default) fails fast with the first structured +error from the shared pre-validation pass, then raises ``DSLSyntaxError`` +on any remaining rich error; passing an ``ErrorCollector`` records errors +and recovers/skips bad lines or blocks, returning a partial Program. """ from __future__ import annotations @@ -24,13 +29,24 @@ import re from typing import Optional -# moved import above -from scratchv.frontend.dsl_parser import DSLParser, DSLParseError -from scratchv.frontend.dsl_errors import ErrorCollector +from scratchv.frontend.dsl_errors import ( + DSLParseError, + DSLSyntaxError, + ErrorCollector, + ErrorCode, +) +from scratchv.frontend.dsl_parser import DSLParser from scratchv.frontend.dsl_validator import DSLValidator from scratchv.ir.builder import IRBuilder from scratchv.ir.types import OpCode, Program, Value +__all__ = [ + "ExtendedDSLParser", + "CondExpr", + "DSLParseError", + "DSLSyntaxError", +] + # --------------------------------------------------------------------------- # Conditional expression node @@ -84,7 +100,7 @@ def __init__(self): # Label counters for generating unique block names self._label_counter: int = 0 # Stack for tracking nested while-loop labels - self._while_stack: list[dict[str, str]] = [] + self._while_stack: list[dict[str, str | int]] = [] # ----------------------------------------------------------------------- # Label generation @@ -96,11 +112,14 @@ def _fresh_label(self, prefix: str = "L") -> str: return f"{prefix}{self._label_counter}" # ----------------------------------------------------------------------- - # Core parse method (overrides base) + # Validation # ----------------------------------------------------------------------- def validate( - self, text: str, *, filename: str | None = None, + self, + text: str, + *, + filename: Optional[str] = None, max_errors: int = 20, ) -> ErrorCollector: """Validate base and extended DSL syntax without creating IR.""" @@ -108,24 +127,37 @@ def validate( text, filename=filename, max_errors=max_errors, ) - def parse(self, text: str, *, filename: str | None = None) -> Program: + # ----------------------------------------------------------------------- + # Core parse method (overrides base) + # ----------------------------------------------------------------------- + + def parse( + self, + text: str, + filename: Optional[str] = None, + collector: Optional[ErrorCollector] = None, + ) -> Program: """Parse DSL text into IR Program, supporting if/else and while. Args: text: The DSL source code as a string. + filename: Optional source filename for diagnostics. + collector: Optional ErrorCollector; when provided, errors are + collected and parsing recovers instead of raising. Returns: - A Program object containing the generated IR. + A Program object containing the generated IR (partial when the + collector recorded errors). """ - collector = self.validate(text, filename=filename) - if collector.has_errors: - raise collector.errors[0] + if collector is None: + preflight = self.validate(text, filename=filename) + if preflight.has_errors: + raise preflight.errors[0] - # Strip comments before splitting to handle block-level constructs - lines_raw = text.split("\n") + raw_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") lines: list[str] = [] - for line in lines_raw: - line = line.strip() + for raw in raw_lines: + line = raw.strip() if not line or line.startswith("#"): lines.append("") # keep blank for indexing else: @@ -141,8 +173,13 @@ def parse(self, text: str, *, filename: str | None = None) -> Program: lines.append(line) self.builder = IRBuilder() - self._vars: dict[str] = {} - self._loop_stack: list[str] = [] + self._vars = {} + self._loop_stack = [] + self._for_positions = [] + self._raw_lines = raw_lines + self._filename = filename + self._collector = collector + self._line_no = 0 self._label_counter = 0 self._while_stack = [] @@ -156,16 +193,24 @@ def parse(self, text: str, *, filename: str | None = None) -> Program: idx += 1 continue - if line.startswith("if "): + if re.match(r"^if\b", line): idx = self._parse_if_block(lines, idx) - elif line.startswith("while "): + elif re.match(r"^while\b", line): idx = self._parse_while_block(lines, idx) else: - self._parse_line(line) + self._parse_line(line, idx + 1) idx += 1 + unclosed = bool(self._loop_stack or self._while_stack) + self._report_unclosed_for() + self._report_unclosed_while() + # Ensure function ends with a return - if not self._loop_stack and not self._while_stack: + if ( + not self._loop_stack + and not self._while_stack + and not unclosed + ): block = self.builder.current_block if block and block.instructions: has_ret = ( @@ -178,6 +223,124 @@ def parse(self, text: str, *, filename: str | None = None) -> Program: return self.builder.program + # ----------------------------------------------------------------------- + # Block parsing helpers + # ----------------------------------------------------------------------- + + def _report_unclosed_while(self) -> None: + """Report every unclosed ``while`` at EOF (LIFO), clearing the stack.""" + while self._while_stack: + ctx = self._while_stack.pop() + line_no = int(ctx.get("line", self._line_no)) + col = int(ctx.get("col", 1)) + self._report_error( + line_no, col, + "missing 'endwhile' for 'while' opened here", + ErrorCode.SYN_MISSING_TERMINATOR, + fix_hint="add 'endwhile' to close this block", + ) + + @staticmethod + def _condition_hint(line: str) -> str: + """Build a fix hint for E202 based on paren balance.""" + if line.count("(") > line.count(")"): + return "missing closing ')'" + if line.count(")") > line.count("("): + return "missing opening '('" + return ( + "expected one of ==, !=, <, >, <=, >= " + "and parentheses around each operand" + ) + + def _parse_block( + self, + lines: list[str], + start_idx: int, + terminators: tuple[str, ...], + opener_kind: str, + opener_line: int, + opener_col: int, + ) -> tuple[int, Optional[str]]: + """Parse block body until a terminator. + + Returns ``(next_index, terminator)``. Terminators ``endif`` and + ``endwhile`` are never consumed here: they are returned to the caller + so an unmatched one can be handed to the enclosing block (or reported + as stray at the top level). ``endfor`` is delegated to the base + statement parser. + + Args: + lines: Stripped/comment-free source lines (blank = skipped). + start_idx: First index of the body. + terminators: Tokens that end this block (e.g. ``else``, ``endif``). + opener_kind: ``"if"`` or ``"while"`` (diagnostics only). + opener_line: 1-based line of the opening keyword. + opener_col: 1-based column of the opening keyword. + """ + idx = start_idx + while idx < len(lines): + line = lines[idx] + if not line: + idx += 1 + continue + if line in ("endif", "endwhile"): + return idx, line # never consume; caller decides + if line in ("else", "else:"): + if line in terminators: + return idx, line + self._report_error( + idx + 1, + self._col_of( + idx + 1, "else", self._line_indent(idx + 1) + 1, + ), + "'else' without matching 'if'", + ErrorCode.SYN_STRAY_TERMINATOR, + fix_hint="remove this line or add a matching 'if'", + ) + idx += 1 + continue + if line == "endfor": + self._parse_line(line, idx + 1) + idx += 1 + continue + if re.match(r"^if\b", line): + idx = self._parse_if_block(lines, idx) + elif re.match(r"^while\b", line): + idx = self._parse_while_block(lines, idx) + else: + self._parse_line(line, idx + 1) + idx += 1 + return idx, None + + def _recover_after_bad_header( + self, lines: list[str], start_idx: int, opener_kind: str, + ) -> int: + """Skip a block whose header failed to parse (E202 already reported). + + Scans forward respecting nested openers until the matching terminator + (consumed) or a foreign terminator at depth 0 (not consumed) or EOF. + Never reports errors itself, to avoid cascading diagnostics. + """ + end_tok = "endif" if opener_kind == "if" else "endwhile" + idx = start_idx + 1 + depth = 0 + while idx < len(lines): + line = lines[idx] + if not line: + idx += 1 + continue + if re.match(r"^if\b", line) or re.match(r"^while\b", line): + depth += 1 + elif line in ("endif", "endwhile"): + if depth > 0: + depth -= 1 + elif line == end_tok: + return idx + 1 + else: + return idx + idx += 1 + return len(lines) + # ----------------------------------------------------------------------- # if / else / endif parsing # ----------------------------------------------------------------------- @@ -185,12 +348,24 @@ def parse(self, text: str, *, filename: str | None = None) -> Program: def _parse_if_block(self, lines: list[str], start_idx: int) -> int: """Parse an if/else/endif block starting at start_idx. - Returns the index of the next line after 'endif'. + Returns the index of the next line after 'endif' (or after the + last consumed line on error paths). """ line = lines[start_idx] + opener_line = start_idx + 1 + opener_col = self._col_of( + opener_line, "if", self._line_indent(opener_line) + 1, + ) cond = self._parse_condition(line) if cond is None: - raise DSLParseError(f"Invalid if condition: {line}") + self._report_error( + opener_line, opener_col, + "invalid condition in 'if'; " + "expected 'if () ():'", + ErrorCode.SYN_INVALID_CONDITION, + fix_hint=self._condition_hint(line), + ) + return self._recover_after_bad_header(lines, start_idx, "if") then_label = self._fresh_label("if_then") else_label = self._fresh_label("if_else") @@ -207,56 +382,44 @@ def _parse_if_block(self, lines: list[str], start_idx: int) -> int: # Parse then branch self.builder.new_block(then_label) - idx = start_idx + 1 - while idx < len(lines): - inner_line = lines[idx] - if not inner_line: - idx += 1 - continue - if inner_line == "else:" or inner_line == "else": - break - if inner_line == "endif": - break - if inner_line.startswith("if "): - idx = self._parse_if_block(lines, idx) - elif inner_line.startswith("while "): - idx = self._parse_while_block(lines, idx) - else: - self._parse_line(inner_line) - idx += 1 + idx, term = self._parse_block( + lines, start_idx + 1, ("else", "else:", "endif"), + "if", opener_line, opener_col, + ) # Terminate then branch with jump to endif self.builder.br(endif_label) - # Check for else branch - has_else = False - if idx < len(lines) and lines[idx] in ("else:", "else"): - has_else = True + if term in ("else", "else:"): idx += 1 self.builder.new_block(else_label) - while idx < len(lines): - inner_line = lines[idx] - if not inner_line: - idx += 1 - continue - if inner_line == "endif": - break - if inner_line.startswith("if "): - idx = self._parse_if_block(lines, idx) - elif inner_line.startswith("while "): - idx = self._parse_while_block(lines, idx) - else: - self._parse_line(inner_line) - idx += 1 + idx, term2 = self._parse_block( + lines, idx, ("endif",), "if", opener_line, opener_col, + ) self.builder.br(endif_label) - - if not has_else: - # Else block exists but is empty - just jumps to endif + if term2 != "endif": + self._report_error( + opener_line, opener_col, + "missing 'endif' for 'if' opened here", + ErrorCode.SYN_MISSING_TERMINATOR, + fix_hint="add 'endif' to close this block", + ) + else: + idx += 1 + else: + # No else branch: keep the (empty) else block for IR shape self.builder.new_block(else_label) self.builder.br(endif_label) - - if idx < len(lines) and lines[idx] == "endif": - idx += 1 + if term == "endif": + idx += 1 + else: + # EOF or foreign terminator (e.g. 'endwhile'): not consumed + self._report_error( + opener_line, opener_col, + "missing 'endif' for 'if' opened here", + ErrorCode.SYN_MISSING_TERMINATOR, + fix_hint="add 'endif' to close this block", + ) self.builder.new_block(endif_label) return idx @@ -268,12 +431,24 @@ def _parse_if_block(self, lines: list[str], start_idx: int) -> int: def _parse_while_block(self, lines: list[str], start_idx: int) -> int: """Parse a while/endwhile block starting at start_idx. - Returns the index of the next line after 'endwhile'. + Returns the index of the next line after 'endwhile' (or after the + last consumed line on error paths). """ line = lines[start_idx] + opener_line = start_idx + 1 + opener_col = self._col_of( + opener_line, "while", self._line_indent(opener_line) + 1, + ) cond = self._parse_condition(line) if cond is None: - raise DSLParseError(f"Invalid while condition: {line}") + self._report_error( + opener_line, opener_col, + "invalid condition in 'while'; " + "expected 'while () ():'", + ErrorCode.SYN_INVALID_CONDITION, + fix_hint=self._condition_hint(line), + ) + return self._recover_after_bad_header(lines, start_idx, "while") header_label = self._fresh_label("while_hdr") body_label = self._fresh_label("while_body") @@ -284,46 +459,47 @@ def _parse_while_block(self, lines: list[str], start_idx: int) -> int: "header": header_label, "body": body_label, "exit": exit_label, + "line": opener_line, + "col": opener_col, }) - # Header: evaluate condition, branch to body or exit - self.builder.br(header_label) - self.builder.new_block(header_label) - lhs_val, op_str, rhs_val = cond.resolve(self) - self.builder._emit( - OpCode.BR_IF, - operands=[lhs_val, rhs_val], - target=f"{body_label},{exit_label}", - cmp_op=op_str, - ) - - # Body - self.builder.new_block(body_label) - idx = start_idx + 1 - while idx < len(lines): - inner_line = lines[idx] - if not inner_line: + try: + # Header: evaluate condition, branch to body or exit + self.builder.br(header_label) + self.builder.new_block(header_label) + lhs_val, op_str, rhs_val = cond.resolve(self) + self.builder._emit( + OpCode.BR_IF, + operands=[lhs_val, rhs_val], + target=f"{body_label},{exit_label}", + cmp_op=op_str, + ) + + # Body + self.builder.new_block(body_label) + idx, term = self._parse_block( + lines, start_idx + 1, ("endwhile",), + "while", opener_line, opener_col, + ) + + # Jump back to header + self.builder.br(header_label) + + if term == "endwhile": idx += 1 - continue - if inner_line == "endwhile": - break - if inner_line.startswith("if "): - idx = self._parse_if_block(lines, idx) - elif inner_line.startswith("while "): - idx = self._parse_while_block(lines, idx) else: - self._parse_line(inner_line) - idx += 1 - - # Jump back to header - self.builder.br(header_label) + # EOF or foreign terminator (e.g. 'endif'): not consumed + self._report_error( + opener_line, opener_col, + "missing 'endwhile' for 'while' opened here", + ErrorCode.SYN_MISSING_TERMINATOR, + fix_hint="add 'endwhile' to close this block", + ) - if idx < len(lines) and lines[idx] == "endwhile": - idx += 1 - - self.builder.new_block(exit_label) - self._while_stack.pop() - return idx + self.builder.new_block(exit_label) + return idx + finally: + self._while_stack.pop() # ----------------------------------------------------------------------- # Condition parsing @@ -372,14 +548,35 @@ def _emit_cmp(self, lhs: Value, op_str: str, rhs: Value) -> Value: # Override _parse_line to handle extended keywords # ----------------------------------------------------------------------- - def _parse_line(self, line: str) -> None: + def _parse_line(self, line: str, line_no: int = 0) -> None: """Parse a single DSL line, delegating to base for standard ops.""" - # Keywords we handle at the block level - if line in ("endif", "endwhile", "else", "else:"): + # Stray terminators reaching statement level are errors + if line in ("endif", "endwhile"): + opener = "if" if line == "endif" else "while" + self._report_error( + line_no, + self._col_of( + line_no, line, self._line_indent(line_no) + 1, + ), + f"'{line}' without matching '{opener}'", + ErrorCode.SYN_STRAY_TERMINATOR, + fix_hint=f"remove this line or add a matching '{opener}'", + ) + return + if line in ("else", "else:"): + self._report_error( + line_no, + self._col_of( + line_no, "else", self._line_indent(line_no) + 1, + ), + "'else' without matching 'if'", + ErrorCode.SYN_STRAY_TERMINATOR, + fix_hint="remove this line or add a matching 'if'", + ) return - if line.startswith("if ") or line.startswith("while "): + if re.match(r"^if\b", line) or re.match(r"^while\b", line): return - super()._parse_line(line) + super()._parse_line(line, line_no) # ----------------------------------------------------------------------- # Convenience: create a stand-alone label block diff --git a/scratchv/frontend/dsl_parser.py b/scratchv/frontend/dsl_parser.py index 3d57e9c..df02172 100644 --- a/scratchv/frontend/dsl_parser.py +++ b/scratchv/frontend/dsl_parser.py @@ -20,54 +20,193 @@ from __future__ import annotations import re +from typing import Optional + +from scratchv.frontend.dsl_errors import ( + DSLParseError, + DSLSyntaxError, + ErrorCollector, + ErrorCode, + suggest_op, + suggest_spelling, + _ARITY_HINTS, +) +from scratchv.frontend.dsl_validator import DSLValidator, OP_SIGNATURES from scratchv.ir.builder import IRBuilder from scratchv.ir.types import Value, Program -from scratchv.frontend.dsl_errors import DSLParseError, ErrorCollector -from scratchv.frontend.dsl_validator import DSLValidator + +# Re-exported for backward compatibility: +# from scratchv.frontend.dsl_parser import DSLParseError +__all__ = [ + "DSLParser", + "DSLParseError", + "DSLSyntaxError", + "ErrorCode", + "ErrorCollector", +] + +# Expected count of plain (non-kwarg) arguments per operator (E302). +_ARITY: dict[str, int] = { + "add": 2, + "sub": 2, + "mul": 2, + "div": 2, + "neg": 1, + "exp": 1, + "relu": 1, + "gelu": 1, + "dot": 2, + "matmul": 2, + "softmax": 1, + "maxpool": 1, +} + +# Numeric literal accepted for numeric kwargs (mirrors validator `_NUMBER`). +_NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)") class DSLParser: - """Parses a simple DSL text into an IR Program.""" + """Parses a simple DSL text into an IR Program. + + ``parse(text)`` runs in strict mode: a structural pre-validation pass + (``validate``) fails fast with the first structured error, then the + rich parser raises ``DSLSyntaxError`` on the first remaining error. + Passing a non-None ``collector`` switches to collecting mode: errors + are recorded with full recovery/suggestions, returning a *partial* + Program that must not be used for code generation. + """ def __init__(self): self.builder = IRBuilder() self._vars: dict[str, Value] = {} self._loop_stack: list[str] = [] + # Diagnostic context (see dev contract 0.7) + self._raw_lines: list[str] = [] + self._filename: Optional[str] = None + self._collector: Optional[ErrorCollector] = None + self._for_positions: list[tuple[int, int]] = [] + self._line_no: int = 0 + + # -- validation --------------------------------------------------------- def validate( - self, text: str, *, filename: str | None = None, + self, + text: str, + *, + filename: Optional[str] = None, max_errors: int = 20, ) -> ErrorCollector: + """Validate DSL syntax without constructing IR.""" return DSLValidator().validate( text, filename=filename, max_errors=max_errors, ) @staticmethod def supported_operations() -> set[str]: + """Return the operator names this parser can lower.""" return { "add", "sub", "mul", "div", "neg", "exp", "relu", "gelu", "dot", "matmul", "softmax", "maxpool", } - def parse(self, text: str, *, filename: str | None = None) -> Program: - collector = self.validate(text, filename=filename) - if collector.has_errors: - raise collector.errors[0] + # -- diagnostics -------------------------------------------------------- + + def _line_indent(self, line_no: int) -> int: + """Return the leading-whitespace width of a physical line.""" + if 0 < line_no <= len(self._raw_lines): + raw = self._raw_lines[line_no - 1] + return len(raw) - len(raw.lstrip()) + return 0 + + def _col_of(self, line_no: int, needle: str, fallback: int = 1) -> int: + """Return the 1-based column of ``needle`` in a physical line.""" + if 0 < line_no <= len(self._raw_lines): + idx = self._raw_lines[line_no - 1].find(needle) + if idx >= 0: + return idx + 1 + return max(fallback, 1) + + def _report_error( + self, + line_no: int, + col: int, + message: str, + error_code: str, + fix_hint: Optional[str] = None, + ) -> None: + """Build/report a DSLSyntaxError (raise in strict, record otherwise).""" + raw = ( + self._raw_lines[line_no - 1] + if 0 < line_no <= len(self._raw_lines) + else "" + ) + err = DSLSyntaxError( + line=line_no, + col=max(col, 1), + message=message, + source_line=raw, + filename=self._filename, + fix_hint=fix_hint, + error_code=error_code, + ) + if self._collector is not None: + self._collector.add(err) + return + raise err + + def _report_unclosed_for(self) -> None: + """Report every unclosed ``for`` at EOF (LIFO), then clear the stack.""" + while self._loop_stack: + self._loop_stack.pop() + if self._for_positions: + line_no, col = self._for_positions.pop() + else: + line_no, col = self._line_no, 1 + self._report_error( + line_no, col, + "missing 'endfor' for 'for' opened here", + ErrorCode.SYN_MISSING_TERMINATOR, + fix_hint="add 'endfor' to close this block", + ) + + # -- parsing ------------------------------------------------------------ + + def parse( + self, + text: str, + filename: Optional[str] = None, + collector: Optional[ErrorCollector] = None, + ) -> Program: + if collector is None: + preflight = self.validate(text, filename=filename) + if preflight.has_errors: + raise preflight.errors[0] + + raw_lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n") self.builder = IRBuilder() self._vars = {} self._loop_stack = [] - lines = text.strip().split("\n") + self._for_positions = [] + self._raw_lines = raw_lines + self._filename = filename + self._collector = collector + self._line_no = 0 + self.builder.new_function("main") self.builder.new_block("entry") - for line in lines: - line = line.strip() + for i, raw in enumerate(raw_lines): + line = raw.strip() if not line or line.startswith("#"): continue - self._parse_line(line) + self._line_no = i + 1 + self._parse_line(line, i + 1) - if not self._loop_stack: + unclosed_loop = bool(self._loop_stack) + self._report_unclosed_for() + + if not self._loop_stack and not unclosed_loop: block = self.builder.current_block if block and block.instructions: has_ret = block.instructions[-1].opcode.name == "RETURN" @@ -77,44 +216,114 @@ def parse(self, text: str, *, filename: str | None = None) -> Program: self.builder.ret() return self.builder.program - def _parse_line(self, line: str) -> None: + def _parse_line(self, line: str, line_no: int = 0) -> None: + # Strip trailing inline comment (same convention as extended parser) + line = line.split(" #", 1)[0].strip() + if not line: + return + + indent = self._line_indent(line_no) + # for i = start, end - m = re.match(r"for\s+(\w+)\s*=\s*(\d+)\s*,\s*(\d+)", line) + m = re.fullmatch(r"for\s+(\w+)\s*=\s*(\d+)\s*,\s*(\d+)", line) if m: iv = self.builder.for_loop(int(m.group(2)), int(m.group(3))) self._vars[m.group(1)] = iv self._loop_stack.append(m.group(1)) + self._for_positions.append((line_no, indent + 1)) return if line == "endfor": if not self._loop_stack: - raise DSLParseError("endfor without matching for") + self._report_error( + line_no, self._col_of(line_no, "endfor", indent + 1), + "'endfor' without matching 'for'", + ErrorCode.SYN_STRAY_TERMINATOR, + fix_hint="remove this line or add a matching 'for'", + ) + return self._loop_stack.pop() + if self._for_positions: + self._for_positions.pop() self.builder.endfor() return # return [var] - m = re.match(r"return\s*(\S+)", line) + m = re.fullmatch(r"return\s+(\S+)", line) if m: val = self._resolve(m.group(1)) self.builder.ret(val) return # name = op(args) - m = re.match(r"(\w+)\s*=\s*(\w+)\((.+)\)", line) - if not m: - raise DSLParseError(f"Cannot parse line: {line}") + m = re.match(r"^(\w+)\s*=\s*(\w+)\s*\((.*)\)\s*$", line) + if m: + dest_name = m.group(1) + op_name = m.group(2) + args_text = m.group(3) - dest_name = m.group(1) - op_name = m.group(2) - args_text = m.group(3) - args = [a.strip() for a in args_text.split(",") if a.strip()] + # Nested function call: inner '(' inside the argument list + if "(" in args_text: + col = indent + m.start(3) + args_text.find("(") + 1 + self._report_error( + line_no, col, + "nested function call is not supported", + ErrorCode.SYN_NESTED_CALL, + fix_hint="assign the inner call to a temporary variable first", + ) + return - result = self._dispatch_op(op_name, args) - self._vars[dest_name] = result + # Stray closing paren in the argument list + if ")" in args_text: + col = indent + m.start(3) + args_text.find(")") + 1 + self._report_error( + line_no, col, + "cannot parse statement; expected 'name = op(args)'", + ErrorCode.SYN_INVALID_STATEMENT, + fix_hint="missing opening '('", + ) + return + + args = [a.strip() for a in args_text.split(",") if a.strip()] + op_col = indent + m.start(2) + 1 + result = self._dispatch_op(op_name, args, line_no, op_col) + if result is None: + # Error already reported; do not pollute _vars. + return + self._vars[dest_name] = result + return + + # Fallback: illegal character scan, then generic statement error + illegal = re.search(r"[^A-Za-z0-9_(),:=.+\-*/#%\s]", line) + if illegal: + ch = illegal.group(0) + col = indent + illegal.start() + 1 + self._report_error( + line_no, col, + f"unexpected character '{ch}'", + ErrorCode.LEX_ILLEGAL_CHAR, + fix_hint=f"remove or replace '{ch}'", + ) + return + + hint: Optional[str] = None + if line.count("(") > line.count(")"): + hint = "missing closing ')'" + elif line.count(")") > line.count("("): + hint = "missing opening '('" + self._report_error( + line_no, indent + 1, + "cannot parse statement; expected 'name = op(args)'", + ErrorCode.SYN_INVALID_STATEMENT, + fix_hint=hint, + ) def _resolve(self, name: str) -> Value: - """Resolve a variable name or literal to a Value.""" + """Resolve a variable name or literal to a Value. + + Note: undefined variables are created on first access (E303 is a + reserved error code and intentionally not raised). + """ if name in self._vars: return self._vars[name] try: @@ -128,16 +337,52 @@ def _resolve(self, name: str) -> Value: return v def _parse_kwargs( - self, args: list[str], - ) -> tuple[list[str], dict[str, int | float | str]]: + self, args: list[str], op: str, + line_no: int = 0, col: int = 1, + ) -> Optional[tuple[list[str], dict[str, int | float | str]]]: + """Split ``args`` into plain and keyword arguments. + + Keyword arguments are validated against the operator signature + (``OP_SIGNATURES``): unknown keys and non-numeric values for numeric + kwargs are reported as E304. Returns ``None`` when an error was + reported (strict mode raises before returning). + """ + signature = OP_SIGNATURES.get(op) + allowed = ( + signature.optional_kwargs | signature.required_kwargs + if signature is not None + else frozenset() + ) + numeric = ( + signature.numeric_kwargs if signature is not None else frozenset() + ) kwargs: dict[str, int | float | str] = {} plain: list[str] = [] for a in args: - if ":" in a: - k, v = a.split(":", 1) - kwargs[k.strip()] = self._parse_value(v.strip()) - else: + if ":" not in a: plain.append(a) + continue + k, v = a.split(":", 1) + k = k.strip() + v = v.strip() + err_col = self._col_of(line_no, k, col) + if k not in allowed: + self._report_error( + line_no, err_col, + f"invalid keyword argument '{k}'", + ErrorCode.SEM_UNKNOWN_KWARG, + fix_hint=f"'{k}' is not accepted by {op}()", + ) + return None + if k in numeric and _NUMBER.fullmatch(v) is None: + self._report_error( + line_no, err_col, + f"'{k}' requires a numeric value", + ErrorCode.SEM_UNKNOWN_KWARG, + fix_hint=f"pass a number for '{k}', got '{v}'", + ) + return None + kwargs[k] = self._parse_value(v) return plain, kwargs def _parse_value(self, s: str) -> int | float | str: @@ -150,10 +395,11 @@ def _parse_value(self, s: str) -> int | float | str: except ValueError: return s - def _dispatch_op(self, op: str, args: list[str]) -> Value: - plain, kwargs = self._parse_kwargs(args) - resolved = [self._resolve(a) for a in plain] - + def _dispatch_op( + self, op: str, args: list[str], + line_no: int = 0, col: int = 1, + ) -> Optional[Value]: + resolved: list[Value] = [] handlers = { "add": lambda: self.builder.add(resolved[0], resolved[1]), "sub": lambda: self.builder.sub(resolved[0], resolved[1]), @@ -183,7 +429,43 @@ def _dispatch_op(self, op: str, args: list[str]) -> Value: ), } assert set(handlers) == self.supported_operations() + + # Unknown operator: report before resolving args (no side effects) + if op not in handlers: + raw = ( + self._raw_lines[line_no - 1] + if 0 < line_no <= len(self._raw_lines) + else "" + ) + hint = suggest_spelling(raw, col) or suggest_op(op, handlers) + self._report_error( + line_no, col, + f"unknown operation '{op}'", + ErrorCode.SEM_UNKNOWN_OP, + fix_hint=hint, + ) + return None + + parsed = self._parse_kwargs(args, op, line_no, col) + if parsed is None: + return None + plain, kwargs = parsed + expected = _ARITY.get(op) + if expected is not None and len(plain) != expected: + hint = _ARITY_HINTS.get(op) + if hint is None: + hint = f"{op}() requires exactly {expected} arguments" + arg_word = "argument" if expected == 1 else "arguments" + self._report_error( + line_no, col, + f"{op}() expects {expected} {arg_word}, got {len(plain)}", + ErrorCode.SEM_ARITY, + fix_hint=hint, + ) + return None + + resolved = [self._resolve(a) for a in plain] handler = handlers.get(op) if handler is None: - raise DSLParseError(f"Unsupported op: {op}") + return None return handler() diff --git a/scratchv/frontend/dsl_validator.py b/scratchv/frontend/dsl_validator.py index b0ddee4..748ac35 100644 --- a/scratchv/frontend/dsl_validator.py +++ b/scratchv/frontend/dsl_validator.py @@ -101,8 +101,6 @@ def validate( stack: list[BlockFrame] = [] for line_no, raw_line in enumerate(source.lines, start=1): - if collector.limit_reached: - break statement = self._statement(raw_line) if not statement: continue @@ -147,8 +145,6 @@ def validate( self._validate_assignment(statement, line_no, col, raw_line, collector) for frame in stack: - if collector.limit_reached: - break expected = {"if": "endif", "while": "endwhile", "for": "endfor"}[frame.kind] self._add( collector, frame.line, frame.col, frame.source_line, "E111", diff --git a/tests/data/dsl_golden_ir.json b/tests/data/dsl_golden_ir.json new file mode 100644 index 0000000..6c5dc5e --- /dev/null +++ b/tests/data/dsl_golden_ir.json @@ -0,0 +1,130 @@ +{ + "benchmarks/cases/001_simple_add.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = add $a $b\n return $v_1\n", + "parser": "extended" + }, + "benchmarks/cases/002_simple_mul.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = mul $a $b\n return $v_1\n", + "parser": "extended" + }, + "benchmarks/cases/003_sub_div.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = sub $a $b\n $v_2 = div $v_1 $a\n return $v_2\n", + "parser": "extended" + }, + "benchmarks/cases/004_relu.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = relu $x\n return $v_1\n", + "parser": "extended" + }, + "benchmarks/cases/005_gelu.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = gelu $x\n return $v_1\n", + "parser": "extended" + }, + "benchmarks/cases/006_softmax.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = softmax $x [axis=-1]\n return $v_1\n", + "parser": "extended" + }, + "benchmarks/cases/007_matmul.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = matmul $A $B [m=2] [n=2] [k=2]\n return $v_1\n", + "parser": "extended" + }, + "benchmarks/cases/008_dot.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = dot $a $b [length=4]\n return $v_1\n", + "parser": "extended" + }, + "benchmarks/cases/009_maxpool.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = maxpool $x [kernel=2] [stride=2]\n return $v_1\n", + "parser": "extended" + }, + "benchmarks/cases/010_exp_neg.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = exp $x\n $v_2 = neg $v_1\n return $v_2\n", + "parser": "extended" + }, + "benchmarks/cases/011_multi_op_chain.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = add $a $b\n $v_2 = sub $a $b\n $v_3 = mul $v_1 $v_2\n return $v_3\n", + "parser": "extended" + }, + "benchmarks/cases/012_nn_pipeline.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = matmul $x $W [m=1] [n=4] [k=4]\n $v_2 = add $v_1 $b\n $v_3 = relu $v_2\n return $v_3\n", + "parser": "extended" + }, + "benchmarks/cases/013_for_sum.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = for [start=0] [end=4] [step=1]\n $v_2 = add $acc $x\n endfor\n return $v_2\n", + "parser": "extended" + }, + "benchmarks/cases/014_for_dot.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = for [start=0] [end=4] [step=1]\n $v_2 = mul $a $b\n $v_3 = add $acc $v_2\n endfor\n return $v_3\n", + "parser": "extended" + }, + "benchmarks/cases/015_for_relu.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = for [start=0] [end=4] [step=1]\n $v_2 = relu $x\n $v_3 = add $y $v_2\n endfor\n return $v_3\n", + "parser": "extended" + }, + "benchmarks/cases/016_if_simple.dsl": { + "ir": "fun $main(\n .entry:\n br_if $a $b -> if_then1,if_else2 [cmp_op=>]\n .if_then1:\n $v_1 = add $a $b\n br -> if_end3\n .if_else2:\n $v_2 = mul $a $b\n br -> if_end3\n .if_end3:\n return $v_2\n", + "parser": "extended" + }, + "benchmarks/cases/017_while_sum.dsl": { + "ir": "fun $main(\n .entry:\n br -> while_hdr1\n .while_hdr1:\n $v_1 = load_const [value=10.0]\n br_if $i $v_1 -> while_body2,while_exit3 [cmp_op=<]\n .while_body2:\n $v_2 = add $acc $x\n br -> while_hdr1\n .while_exit3:\n return $v_2\n", + "parser": "extended" + }, + "benchmarks/cases/018_nested_if.dsl": { + "ir": "fun $main(\n .entry:\n br_if $a $b -> if_then1,if_else2 [cmp_op=>]\n .if_then1:\n $v_1 = load_const [value=0.0]\n br_if $a $v_1 -> if_then4,if_else5 [cmp_op=>]\n .if_then4:\n $v_2 = add $a $b\n br -> if_end6\n .if_else5:\n $v_3 = mul $a $b\n br -> if_end6\n .if_end6:\n br -> if_end3\n .if_else2:\n $v_4 = sub $a $b\n br -> if_end3\n .if_end3:\n return $v_4\n", + "parser": "extended" + }, + "benchmarks/cases/019_nested_loop.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = for [start=0] [end=4] [step=1]\n $v_2 = for [start=0] [end=2] [step=1]\n $v_3 = mul $x $y\n $v_4 = add $acc $v_3\n endfor\n endfor\n return $v_4\n", + "parser": "extended" + }, + "benchmarks/cases/020_constant_propagation.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = load_const [value=2.0]\n $v_2 = load_const [value=3.0]\n $v_3 = add $v_1 $v_2\n $v_4 = load_const [value=4.0]\n $v_5 = mul $v_3 $v_4\n return $v_5\n", + "parser": "extended" + }, + "benchmarks/cases/021_dsl_if_else.dsl": { + "ir": "fun $main(\n .entry:\n br_if $a $b -> if_then1,if_else2 [cmp_op=>]\n .if_then1:\n $v_1 = add $a $b\n $v_2 = load_const [value=2.0]\n $v_3 = mul $v_1 $v_2\n $v_4 = relu $v_3\n br -> if_end3\n .if_else2:\n $v_5 = sub $a $b\n $v_6 = relu $v_5\n br -> if_end3\n .if_end3:\n return $v_6\n", + "parser": "extended" + }, + "benchmarks/cases/022_dsl_while_sum.dsl": { + "ir": "fun $main(\n .entry:\n br -> while_hdr1\n .while_hdr1:\n $v_1 = load_const [value=5.0]\n br_if $i $v_1 -> while_body2,while_exit3 [cmp_op=<]\n .while_body2:\n $v_2 = mul $x $y\n $v_3 = add $acc $v_2\n br -> while_hdr1\n .while_exit3:\n return $v_3\n", + "parser": "extended" + }, + "benchmarks/cases/023_large_chain.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = add $a $b\n $v_2 = sub $a $b\n $v_3 = relu $v_2\n $v_4 = mul $v_1 $v_3\n $v_5 = gelu $x\n $v_6 = div $v_4 $v_5\n return $v_6\n", + "parser": "extended" + }, + "examples/cfg/if_else.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = load_const [value=0.0]\n br_if $x $v_1 -> if_then1,if_else2 [cmp_op=>]\n .if_then1:\n $v_2 = load_const [value=1.0]\n $v_3 = add $x $v_2\n br -> if_end3\n .if_else2:\n $v_4 = load_const [value=1.0]\n $v_5 = sub $x $v_4\n br -> if_end3\n .if_end3:\n return $v_5\n", + "parser": "extended" + }, + "examples/cfg/nested_loop.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = for [start=0] [end=3] [step=1]\n $v_2 = for [start=0] [end=2] [step=1]\n $v_3 = add $sum $a\n endfor\n endfor\n return $v_3\n", + "parser": "extended" + }, + "examples/cfg/unreachable.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = add $a $b\n return $v_1\n $v_2 = mul $a $b\n return $v_2\n", + "parser": "extended" + }, + "examples/cfg/while_loop.dsl": { + "ir": "fun $main(\n .entry:\n br -> while_hdr1\n .while_hdr1:\n $v_1 = load_const [value=10.0]\n br_if $i $v_1 -> while_body2,while_exit3 [cmp_op=<]\n .while_body2:\n $v_2 = add $acc $x\n $v_3 = load_const [value=1.0]\n $v_4 = add $i $v_3\n br -> while_hdr1\n .while_exit3:\n return $v_2\n", + "parser": "extended" + }, + "examples/dot_product.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = dot $a $b [length=4]\n return $v_1\n", + "parser": "extended" + }, + "examples/loop_add.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = add $a $b\n return $v_1\n", + "parser": "extended" + }, + "examples/matmul_test.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = matmul $A $B [m=2] [n=2] [k=2]\n return $v_1\n", + "parser": "extended" + }, + "examples/relu_test.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = add $input $bias\n $v_2 = relu $v_1\n return $v_2\n", + "parser": "extended" + }, + "examples/simple_add.dsl": { + "ir": "fun $main(\n .entry:\n $v_1 = add $a $b\n return $v_1\n", + "parser": "extended" + } +} diff --git a/tests/data/topic09_dsl_errors_feature.dsl b/tests/data/topic09_dsl_errors_feature.dsl new file mode 100644 index 0000000..ce5864e --- /dev/null +++ b/tests/data/topic09_dsl_errors_feature.dsl @@ -0,0 +1,10 @@ +# Topic 09 DSL-error feature case (intentionally invalid). +# Used by benchmarks/run_topic09_errors_case.py; CI asserts the exact +# diagnostics below, so do not "fix" this file. +a = add(1, 2) +b = retrun(a, 1) +c = add(a) +if (a > b): + d = mul(a, b) +endwhile +return d diff --git a/tests/test_dsl_errors.py b/tests/test_dsl_errors.py index 2ed883c..763440f 100644 --- a/tests/test_dsl_errors.py +++ b/tests/test_dsl_errors.py @@ -7,9 +7,12 @@ DSLSyntaxError, format_error, ErrorCollector, + ErrorCode, make_error, Color, render_error, + _compute_suggestion, + _estimate_token_length, ) @@ -286,6 +289,12 @@ def test_max_errors_limit(self): assert collector.limit_reached assert "further errors suppressed" in collector.report() + @pytest.mark.parametrize("max_errors", [0, -1, -20]) + def test_non_positive_max_errors_rejected(self, max_errors): + # A zero/negative limit silently suppressed every error; reject loud. + with pytest.raises(ValueError, match="max_errors"): + ErrorCollector(max_errors=max_errors) + def test_deduplicates_and_sorts_errors(self): collector = ErrorCollector(use_color=False) second = DSLSyntaxError(2, 3, "second", error_code="E200") @@ -384,3 +393,189 @@ def test_color_values_not_empty(self): def test_color_reset(self): assert Color.RESET.value == "\033[0m" + + +class TestErrorHierarchy: + """Tests for the DSLParseError / DSLSyntaxError relationship.""" + + def test_syntax_error_is_parse_error(self): + assert issubclass(DSLSyntaxError, DSLParseError) + with pytest.raises(DSLParseError): + raise DSLSyntaxError(1, 1, "boom") + + def test_suggestion_alias(self): + err = DSLSyntaxError( + line=1, col=2, message="msg", suggestion="try x", + ) + assert err.suggestion == "try x" + assert err.fix_hint == "try x" + err.suggestion = "try y" + assert err.fix_hint == "try y" + + def test_fix_hint_wins_over_suggestion(self): + err = DSLSyntaxError( + line=1, col=2, message="msg", + fix_hint="primary", suggestion="secondary", + ) + assert err.fix_hint == "primary" + assert err.suggestion == "primary" + + def test_positional_construction_compat(self): + err = DSLSyntaxError(3, 4, "m", "src", "f.dsl", "hint", "E201") + assert (err.line, err.col, err.message) == (3, 4, "m") + assert (err.source_line, err.filename) == ("src", "f.dsl") + assert (err.fix_hint, err.error_code) == ("hint", "E201") + assert err.args == ("m",) + + def test_error_code_constants(self): + assert ErrorCode.LEX_ILLEGAL_CHAR == "E101" + assert ErrorCode.SYN_INVALID_STATEMENT == "E201" + assert ErrorCode.SYN_INVALID_CONDITION == "E202" + assert ErrorCode.SYN_MISSING_TERMINATOR == "E203" + assert ErrorCode.SYN_STRAY_TERMINATOR == "E204" + assert ErrorCode.SYN_NESTED_CALL == "E205" + assert ErrorCode.SEM_UNKNOWN_OP == "E301" + assert ErrorCode.SEM_ARITY == "E302" + assert ErrorCode.SEM_UNKNOWN_KWARG == "E304" + + +class TestMarkerAlignment: + """Tests for the gcc-style caret/gutter alignment.""" + + def test_marker_alignment_single_digit(self): + err = DSLSyntaxError( + line=2, col=5, + message="unknown operation 'retrun'", + source_line="b = retrun(a, 1)", + error_code="E301", + ) + lines = format_error(err, use_color=False).splitlines() + assert lines[2] == " | ^~~~~~" + + def test_marker_alignment_double_digit(self): + err = DSLSyntaxError( + line=10, col=5, + message="unknown operation 'retrun'", + source_line="b = retrun(a, 1)", + error_code="E301", + ) + lines = format_error(err, use_color=False).splitlines() + assert lines[2] == " | ^~~~~~" + + def test_token_length_includes_underscore(self): + assert _estimate_token_length("foo_bar(x)", 0) == 7 + assert _estimate_token_length("a_b", 0) == 3 + assert _estimate_token_length("", 0) == 1 + assert _estimate_token_length("abc", 99) == 1 + + def test_no_filename_uses_placeholder(self): + err = DSLSyntaxError(line=1, col=1, message="boom") + output = format_error(err, use_color=False) + assert output.startswith(":1:1: error: boom") + + def test_col_clamped_to_line_end(self): + err = DSLSyntaxError( + line=1, col=99, message="boom", source_line="abc", + ) + output = format_error(err, use_color=False) + # Header keeps the original column + assert ":1:99: error: boom" in output + # Marker is clamped to the end of the line and still emitted + source_display = output.splitlines()[1] + marker = output.splitlines()[2] + assert source_display == " 1 | abc" + assert marker.index("^") == len(source_display) + + def test_context_with_source(self): + source = "a = add(1)\nb = retrun(a, 2)\n" + err = DSLSyntaxError( + line=2, col=5, + message="unknown operation 'retrun'", + source_line="b = retrun(a, 2)", + ) + output = format_error( + err, use_color=False, context_lines=1, source=source, + ) + assert " 1 | a = add(1)" in output + assert "\n\n" not in output + + def test_format_context_without_source_ignored(self): + err = DSLSyntaxError( + line=2, col=1, message="boom", source_line="abc", + ) + output = format_error(err, use_color=False, context_lines=2) + assert "\n\n" not in output + assert output.splitlines()[1] == " 2 | abc" + + def test_arity_hint_reachable(self): + hint = _compute_suggestion( + "add() expects 2 arguments, got 1", "a = add(1)", "E302", + ) + assert hint == "add() requires exactly 2 arguments" + + def test_spelling_hint_beats_difflib(self): + hint = _compute_suggestion( + "unknown operation 'retrun'", "b = retrun(a, 1)", "E301", + ) + assert hint == "did you mean 'return'?" + + +class TestCollectorExtensions: + """Tests for collector dedup, suppression and source context.""" + + def test_collector_dedup(self): + collector = ErrorCollector() + collector.add(DSLSyntaxError( + 3, 5, "dup", source_line="x", error_code="E301", + )) + collector.add(DSLSyntaxError( + 3, 5, "dup", source_line="x", error_code="E301", + )) + assert collector.error_count == 1 + + def test_collector_suppressed_count(self): + collector = ErrorCollector(max_errors=3) + for i in range(10): + collector.add(DSLSyntaxError(i + 1, 1, f"error {i}")) + assert collector.error_count == 3 + assert collector.suppressed_count == 7 + assert len(collector.errors) == 3 + assert "7 further errors suppressed" in collector.report() + + def test_suppressed_duplicates_counted_once(self): + collector = ErrorCollector(max_errors=1) + collector.add(DSLSyntaxError(1, 1, "stored")) + duplicate = DSLSyntaxError(2, 2, "suppressed") + collector.add(duplicate) + collector.add(duplicate) + collector.add(duplicate) + # Repeated suppressed duplicates must not inflate the count. + assert collector.error_count == 1 + assert collector.suppressed_count == 1 + collector.add(DSLSyntaxError(3, 3, "another")) + assert collector.suppressed_count == 2 + assert "2 further errors suppressed" in collector.report() + + def test_collector_source_context(self): + source = "a = add(1)\nb = retrun(a, 2)\n" + err = DSLSyntaxError( + line=2, col=5, + message="unknown operation 'retrun'", + source_line="b = retrun(a, 2)", + ) + collector = ErrorCollector( + use_color=False, source=source, context_lines=1, + ) + collector.add(err) + report = collector.report() + assert " 1 | a = add(1)" in report + assert "\n\n" not in report + + def test_clear_resets_suppressed(self): + collector = ErrorCollector(max_errors=1) + collector.add(DSLSyntaxError(1, 1, "one")) + collector.add(DSLSyntaxError(2, 1, "two")) + assert collector.suppressed_count == 1 + collector.clear() + assert not collector.has_errors + assert collector.suppressed_count == 0 diff --git a/tests/test_dsl_errors_integration.py b/tests/test_dsl_errors_integration.py new file mode 100644 index 0000000..3745788 --- /dev/null +++ b/tests/test_dsl_errors_integration.py @@ -0,0 +1,703 @@ +"""Integration tests: parser error branches, recovery, and golden IR. + +Covers the topic-09 acceptance criteria: + +* Strict ``parse()`` runs the shared structural pre-validation pass first + (structured ``E1xx``/``E2xx`` codes, validator locations) and still raises + rich ``DSLSyntaxError`` objects when the rich parser finds something the + pre-validation missed. +* ``collector=`` mode uses the rich parser for recovery, collecting + ``E3xx``/``E2xx`` diagnostics with suggestions and spans. +* Legal DSL (``examples/**/*.dsl``, ``benchmarks/cases/*.dsl``) produces zero + diagnostics and unchanged IR (golden snapshot in ``tests/data/``). +* The compiler driver surfaces validator diagnostics by default and keeps a + propagation path for rich ``DSLSyntaxError`` objects. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.frontend.dsl_errors import ( + DSLParseError, + DSLSyntaxError, + ErrorCode, + ErrorCollector, + format_error, +) +from scratchv.frontend.dsl_extended import ExtendedDSLParser +from scratchv.frontend.dsl_parser import DSLParser +from scratchv.ir.printer import IRPrinter + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +GOLDEN_PATH = Path(__file__).resolve().parent / "data" / "dsl_golden_ir.json" + + +def rich_errors(parser, source: str, filename: str = "rich.dsl") -> ErrorCollector: + """Parse with a collector to exercise the rich recovery diagnostics.""" + collector = ErrorCollector( + filename=filename, use_color=False, source=source, + ) + parser.parse(source, filename=filename, collector=collector) + return collector + + +# --------------------------------------------------------------------------- +# Individual diagnostics +# --------------------------------------------------------------------------- + +class TestErrorLocations: + def test_unknown_op_preflight_location(self): + source = "a = add(x, y)\nb = retrun(a, 1)\n" + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(source, filename="unknown_op.dsl") + e = excinfo.value + + assert e.error_code == "E200" + assert (e.line, e.col) == (2, 5) + assert e.filename == "unknown_op.dsl" + assert e.source_line == "b = retrun(a, 1)" + assert e.message == "unsupported operation 'retrun'" + assert isinstance(e, DSLParseError) + + def test_unknown_op_rich_suggestion_in_collector_mode(self): + source = "a = add(x, y)\nb = retrun(a, 1)\n" + collector = rich_errors( + ExtendedDSLParser(), source, "unknown_op.dsl", + ) + assert collector.error_count == 1 + e = collector.errors[0] + + assert e.error_code == ErrorCode.SEM_UNKNOWN_OP == "E301" + assert (e.line, e.col) == (2, 5) + assert e.filename == "unknown_op.dsl" + assert e.source_line == "b = retrun(a, 1)" + assert e.fix_hint == "did you mean 'return'?" + + output = format_error(e, use_color=False) + assert output == ( + "unknown_op.dsl:2:5: error[E301]: unknown operation 'retrun'\n" + " 2 | b = retrun(a, 1)\n" + " | ^~~~~~\n" + "note: did you mean 'return'?" + ) + assert output.splitlines()[2] == " | ^~~~~~" + assert str(e) == output + + def test_double_digit_line_marker_alignment(self): + source = "a = add(x, y)\n" * 8 + "b = retrun(a, 1)\n" + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(source, filename="test.dsl") + e = excinfo.value + assert (e.line, e.col) == (9, 5) + assert e.error_code == "E200" + marker = format_error(e, use_color=False).splitlines()[2] + assert marker == " | ^~~~~~" + + # Directly exercise a two-digit line number + e2 = DSLSyntaxError( + line=10, col=5, + message="unknown operation 'retrun'", + source_line="d = retrun(c)", + error_code="E301", + ) + assert format_error(e2, use_color=False).splitlines()[2] == ( + " | ^~~~~~" + ) + + def test_indented_statement_column(self): + source = "if (a > b):\n d = retrun(c)\nendif\n" + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(source, filename="indent.dsl") + e = excinfo.value + assert (e.line, e.col) == (2, 9) + assert e.error_code == "E200" + + collector = rich_errors( + ExtendedDSLParser(), source, "indent.dsl", + ) + rich = collector.errors[0] + assert rich.error_code == "E301" + assert (rich.line, rich.col) == (2, 9) + lines = format_error(rich, use_color=False).splitlines() + assert lines[1] == " 2 | d = retrun(c)" + assert lines[2] == " | ^~~~~~" + + def test_illegal_character(self): + source = "a = add(b, c) $\n" + with pytest.raises(DSLSyntaxError) as excinfo: + DSLParser().parse(source, filename="lex.dsl") + e = excinfo.value + assert e.error_code == "E100" + assert (e.line, e.col) == (1, 1) + assert e.message == "cannot parse statement" + + collector = rich_errors(DSLParser(), source, "lex.dsl") + rich = collector.errors[0] + assert rich.error_code == ErrorCode.LEX_ILLEGAL_CHAR == "E101" + assert rich.message == "unexpected character '$'" + # '$' is the 15th character of the physical line + assert (rich.line, rich.col) == (1, 15) + assert rich.fix_hint == "remove or replace '$'" + + def test_arity_and_nested_call(self): + with pytest.raises(DSLSyntaxError) as excinfo: + DSLParser().parse("a = add(1)\n", filename="arity.dsl") + e = excinfo.value + assert e.error_code == "E201" + assert (e.line, e.col) == (1, 9) + assert e.message == ( + "operation 'add' expects 2 positional argument(s), got 1" + ) + + collector = rich_errors(DSLParser(), "a = add(1)\n", "arity.dsl") + rich = collector.errors[0] + assert rich.error_code == ErrorCode.SEM_ARITY == "E302" + assert (rich.line, rich.col) == (1, 5) + assert rich.message == "add() expects 2 arguments, got 1" + assert rich.fix_hint == "add() requires exactly 2 arguments" + + with pytest.raises(DSLSyntaxError) as excinfo: + DSLParser().parse("a = add()\n", filename="arity.dsl") + assert excinfo.value.error_code == "E201" + + collector = rich_errors(DSLParser(), "a = add()\n", "arity.dsl") + assert collector.errors[0].message == ( + "add() expects 2 arguments, got 0" + ) + + with pytest.raises(DSLSyntaxError) as excinfo: + DSLParser().parse( + "c = add(mul(a, b), d)\n", filename="nested.dsl", + ) + assert excinfo.value.error_code == "E201" + + collector = rich_errors( + DSLParser(), "c = add(mul(a, b), d)\n", "nested.dsl", + ) + rich = collector.errors[0] + assert rich.error_code == ErrorCode.SYN_NESTED_CALL == "E205" + assert (rich.line, rich.col) == (1, 12) + assert rich.message == "nested function call is not supported" + + def test_invalid_condition_is_e202_and_parse_error(self): + source = "if a > b:\n c = add(a, b)\nendif\nreturn c\n" + with pytest.raises(DSLParseError) as excinfo: + ExtendedDSLParser().parse(source, filename="cond.dsl") + e = excinfo.value + assert isinstance(e, DSLSyntaxError) + assert e.error_code == "E101" + assert (e.line, e.col) == (1, 1) + assert e.message == "invalid if condition" + assert e.fix_hint == "add matching parentheses around the condition" + + collector = rich_errors( + ExtendedDSLParser(), source, "cond.dsl", + ) + rich = collector.errors[0] + assert rich.error_code == "E202" + assert (rich.line, rich.col) == (1, 1) + assert rich.message.startswith("invalid condition in 'if'") + assert format_error(rich, use_color=False).splitlines()[-1] == ( + "note: expected one of ==, !=, <, >, <=, >= " + "and parentheses around each operand" + ) + + @pytest.mark.parametrize("line,opener,code", [ + ("endif", "if", "E110"), + ("endwhile", "while", "E110"), + ("endfor", "for", "E110"), + ("else", "if", "E112"), + ("else:", "if", "E112"), + ]) + def test_stray_terminators(self, line, opener, code): + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(line + "\n", filename="stray.dsl") + e = excinfo.value + assert e.error_code == code + assert (e.line, e.col) == (1, 1) + + collector = rich_errors( + ExtendedDSLParser(), line + "\n", "stray.dsl", + ) + rich = collector.errors[0] + assert rich.error_code == ErrorCode.SYN_STRAY_TERMINATOR == "E204" + assert (rich.line, rich.col) == (1, 1) + assert rich.message == ( + f"'{line.rstrip(':')}' without matching '{opener}'" + ) + + +class TestBlockTerminators: + def test_missing_endif_reported_at_opener(self): + source = ( + "i = add(x, 1)\n" + "if (i > 0):\n" + " y = mul(i, 2)\n" + "return y\n" + ) + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(source, filename="missing_endif.dsl") + e = excinfo.value + assert e.error_code == "E111" + assert (e.line, e.col) == (2, 1) + assert e.message == "unterminated if block" + assert e.fix_hint == "add missing 'endif'" + + collector = rich_errors( + ExtendedDSLParser(), source, "missing_endif.dsl", + ) + rich = collector.errors[0] + assert rich.error_code == ErrorCode.SYN_MISSING_TERMINATOR == "E203" + assert (rich.line, rich.col) == (2, 1) + assert rich.message == "missing 'endif' for 'if' opened here" + assert format_error(rich, use_color=False) == ( + "missing_endif.dsl:2:1: error[E203]: " + "missing 'endif' for 'if' opened here\n" + " 2 | if (i > 0):\n" + " | ^~\n" + "note: add 'endif' to close this block" + ) + + collector = ErrorCollector( + filename="missing_endif.dsl", use_color=False, source=source, + ) + program = ExtendedDSLParser().parse( + source, filename="missing_endif.dsl", collector=collector, + ) + assert collector.error_count == 1 + assert program.functions # partial Program is still constructible + + def test_missing_endwhile_reported_at_opener(self): + source = "while (i < 9):\n a = add(a, b)\n" + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(source, filename="w.dsl") + e = excinfo.value + assert e.error_code == "E111" + assert (e.line, e.col) == (1, 1) + assert e.message == "unterminated while block" + + collector = rich_errors(ExtendedDSLParser(), source, "w.dsl") + rich = collector.errors[0] + assert rich.error_code == "E203" + assert rich.message == "missing 'endwhile' for 'while' opened here" + + def test_missing_endfor_reported_at_opener(self): + source = "for i = 0, 4\n acc = add(acc, i)\nreturn acc\n" + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(source, filename="f.dsl") + e = excinfo.value + assert e.error_code == "E111" + assert (e.line, e.col) == (1, 1) + assert e.message == "unterminated for block" + + collector = rich_errors(ExtendedDSLParser(), source, "f.dsl") + rich = collector.errors[0] + assert rich.error_code == "E203" + assert rich.message == "missing 'endfor' for 'for' opened here" + + collector = ErrorCollector(filename="f.dsl", use_color=False) + ExtendedDSLParser().parse( + source, filename="f.dsl", collector=collector, + ) + + def test_missing_endif_when_foreign_terminator(self): + # 'while' never closed, then 'endif' from an outer if + source = ( + "if (a > b):\n" + " while (i < 9):\n" + " c = add(a, b)\n" + "endif\n" + ) + collector = ErrorCollector( + filename="mixed.dsl", use_color=False, source=source, + ) + ExtendedDSLParser().parse( + source, filename="mixed.dsl", collector=collector, + ) + codes = [e.error_code for e in collector.errors] + assert "E203" in codes + + +# --------------------------------------------------------------------------- +# Multi-error collection / recovery +# --------------------------------------------------------------------------- + +class TestCollectorRecovery: + def test_multi_error_collection_and_recovery(self): + source = ( + "a = add(1)\n" + "b = retrun(a, 2)\n" + "if a > 0:\n" + " c = mul(a, 2)\n" + "endwhile\n" + ) + collector = ErrorCollector( + filename="multi_error.dsl", use_color=False, source=source, + ) + program = ExtendedDSLParser().parse( + source, filename="multi_error.dsl", collector=collector, + ) + + assert collector.error_count == 4 + assert collector.suppressed_count == 0 + assert collector.has_errors + assert program.functions + assert [e.error_code for e in collector.errors] == [ + "E302", "E301", "E202", "E204", + ] + assert [e.line for e in collector.errors] == [1, 2, 3, 5] + + assert collector.report() == ( + "--- 4 error(s) found ---\n" + "multi_error.dsl:1:5: error[E302]: add() expects 2 arguments, " + "got 1\n" + " 1 | a = add(1)\n" + " | ^~~\n" + "note: add() requires exactly 2 arguments\n" + "multi_error.dsl:2:5: error[E301]: unknown operation 'retrun'\n" + " 2 | b = retrun(a, 2)\n" + " | ^~~~~~\n" + "note: did you mean 'return'?\n" + "multi_error.dsl:3:1: error[E202]: invalid condition in 'if'; " + "expected 'if () ():'\n" + " 3 | if a > 0:\n" + " | ^~\n" + "note: expected one of ==, !=, <, >, <=, >= " + "and parentheses around each operand\n" + "multi_error.dsl:5:1: error[E204]: 'endwhile' without matching " + "'while'\n" + " 5 | endwhile\n" + " | ^~~~~~~~\n" + "note: remove this line or add a matching 'while'" + ) + + # Strict mode fails fast through the shared pre-validation pass + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(source, filename="multi_error.dsl") + e = excinfo.value + assert (e.line, e.col, e.error_code) == (1, 9, "E201") + + def test_collector_continues_after_unknown_op(self): + source = "a = retrun(b, 1)\nc = add(a, b)\nreturn c\n" + collector = ErrorCollector( + filename="recover.dsl", use_color=False, source=source, + ) + program = ExtendedDSLParser().parse( + source, filename="recover.dsl", collector=collector, + ) + assert collector.error_count == 1 + # The failed assignment must not be registered under its dest name + assert program is not None + + def test_no_derived_errors_inside_bad_block(self): + source = ( + "if bad condition\n" + " x = retrun(a, 1)\n" + " y = add(1)\n" + "endif\n" + ) + collector = ErrorCollector( + filename="recover_block.dsl", use_color=False, source=source, + ) + ExtendedDSLParser().parse( + source, filename="recover_block.dsl", collector=collector, + ) + assert collector.error_count == 1 + assert collector.errors[0].error_code == "E202" + + +# --------------------------------------------------------------------------- +# F1 regression: malformed statements must not be silently accepted +# --------------------------------------------------------------------------- + +class TestCollectorMalformedStatements: + @pytest.mark.parametrize( + ("source", "strict_code", "rich_code"), + [ + ("return x junk\n", "E100", "E201"), + ( + "for i = 0, 4 junk\nacc = add(acc, i)\nendfor\n", + "E100", + "E201", + ), + ("a = add(x, y, foo:1)\n", "E202", "E304"), + ("m = matmul(a, b, rows:abc)\n", "E203", "E304"), + ], + ) + def test_malformed_statements_diagnosed_in_both_modes( + self, source, strict_code, rich_code, + ): + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(source, filename="malformed.dsl") + assert excinfo.value.error_code == strict_code + + collector = rich_errors( + ExtendedDSLParser(), source, "malformed.dsl", + ) + assert collector.error_count >= 1 + assert collector.suppressed_count == 0 + assert rich_code in [e.error_code for e in collector.errors] + + def test_invalid_kwarg_location_and_message(self): + collector = rich_errors( + ExtendedDSLParser(), "a = add(x, y, foo:1)\n", "kw.dsl", + ) + error = collector.errors[0] + assert error.error_code == ErrorCode.SEM_UNKNOWN_KWARG == "E304" + assert (error.line, error.col) == (1, 15) + assert error.message == "invalid keyword argument 'foo'" + assert "'foo' is not accepted by add()" in ( + format_error(error, use_color=False) + ) + + collector = rich_errors( + ExtendedDSLParser(), "m = matmul(a, b, rows:abc)\n", "kw.dsl", + ) + error = collector.errors[0] + assert error.error_code == "E304" + assert (error.line, error.col) == (1, 18) + assert error.message == "'rows' requires a numeric value" + + @pytest.mark.parametrize( + ("source", "opcode"), + [ + ("d = dot(a, b, len:4)\n", "DOT"), + ("m = matmul(a, b, m:2, n:2, k:2)\n", "MATMUL"), + ("s = softmax(x, axis:-1)\n", "SOFTMAX"), + ("p = maxpool(x, kernel:2, stride:2)\n", "MAXPOOL"), + ], + ) + def test_registered_kwargs_stay_accepted(self, source, opcode): + collector = ErrorCollector( + filename="kwargs_ok.dsl", use_color=False, source=source, + ) + program = ExtendedDSLParser().parse( + source, filename="kwargs_ok.dsl", collector=collector, + ) + assert collector.error_count == 0, collector.report() + opcodes = [ + instr.opcode.name + for instr in program.functions[0].blocks[0].instructions + ] + assert opcode in opcodes + + def test_return_prefix_identifier_is_not_swallowed(self): + # E2 root cause: 'returnx = add(a,b)' used to be read as 'return x'. + source = "returnx = add(a, b)\n" + collector = ErrorCollector( + filename="prefix.dsl", use_color=False, source=source, + ) + program = ExtendedDSLParser().parse( + source, filename="prefix.dsl", collector=collector, + ) + assert collector.error_count == 0 + opcodes = [ + instr.opcode.name + for instr in program.functions[0].blocks[0].instructions + ] + assert "ADD" in opcodes + + +# --------------------------------------------------------------------------- +# F2 regression: validator limit accounting +# --------------------------------------------------------------------------- + +class TestValidatorLimitSemantics: + def test_suppressed_counts_all_unreported_line_errors(self): + source = "\n".join(f"retrun x{i}" for i in range(50)) + "\n" + collector = ExtendedDSLParser().validate(source, max_errors=3) + assert collector.error_count == 3 + assert collector.limit_reached + assert collector.suppressed_count == 47 + assert collector.report().splitlines()[-1] == ( + "note: error limit (3) reached; 47 further errors suppressed" + ) + + def test_suppressed_counts_trailing_block_errors(self): + # Unterminated blocks are reported after the line loop; they must + # still be accounted for once the limit was already reached. + source = "".join(f"if (a > {i}):\n" for i in range(5)) + collector = ExtendedDSLParser().validate(source, max_errors=2) + assert collector.error_count == 2 + assert collector.limit_reached + assert collector.suppressed_count == 3 + + +# --------------------------------------------------------------------------- +# F7 regression: CRLF source-line consistency +# --------------------------------------------------------------------------- + +class TestCrlfSourceLineConsistency: + def test_strict_and_collector_agree_on_crlf_source_line(self): + source = "a = retrun(b, 1)\r\n" + with pytest.raises(DSLSyntaxError) as excinfo: + DSLParser().parse(source, filename="crlf.dsl") + strict_line = excinfo.value.source_line + assert strict_line == "a = retrun(b, 1)" + + collector = rich_errors(DSLParser(), source, "crlf.dsl") + assert collector.errors[0].source_line == strict_line + + def test_extended_parser_normalizes_crlf(self): + source = "if (a > b):\r\n c = retrun(a)\r\nendif\r\n" + collector = rich_errors( + ExtendedDSLParser(), source, "crlf_ext.dsl", + ) + assert collector.error_count == 1 + assert collector.errors[0].source_line == " c = retrun(a)" + assert all( + "\r" not in error.source_line for error in collector.errors + ) + + def test_crlf_valid_input_parses_cleanly(self): + source = "c = add(a, b)\r\nreturn c\r\n" + collector = rich_errors(DSLParser(), source, "crlf_ok.dsl") + assert collector.error_count == 0 + + +# --------------------------------------------------------------------------- +# Regression: legal DSL produces zero diagnostics and unchanged IR +# --------------------------------------------------------------------------- + +class TestLegalDslGolden: + def test_legal_dsl_zero_diagnostics(self): + golden = json.loads(GOLDEN_PATH.read_text()) + assert len(golden) >= 30 + + for path_str, expect in sorted(golden.items()): + path = PROJECT_ROOT / path_str + source = path.read_text() + collector = ErrorCollector( + filename=path_str, use_color=False, source=source, + ) + parser = ( + ExtendedDSLParser() if expect["parser"] == "extended" + else DSLParser() + ) + program = parser.parse(source, filename=path_str, + collector=collector) + assert not collector.has_errors, ( + f"{path_str}:\n{collector.report()}" + ) + assert IRPrinter(program).dump() == expect["ir"], path_str + + def test_unparseable_baseline_file_stays_unparseable(self): + # examples/cnn_model.dsl was already unparsable before this change + # (unsupported ops / ';' comments); it must not be silently accepted. + source = (PROJECT_ROOT / "examples/cnn_model.dsl").read_text() + with pytest.raises(DSLParseError): + ExtendedDSLParser().parse(source, filename="cnn_model.dsl") + + def test_parse_default_signature_compat(self): + src = "c = add(a, b)\nreturn c\n" + assert DSLParser().parse(src) + assert ExtendedDSLParser().parse(src) + + +# --------------------------------------------------------------------------- +# Compiler driver integration +# --------------------------------------------------------------------------- + +class TestCompilerIntegration: + def test_compiler_reports_structured_error_for_bad_dsl(self, tmp_path): + bad = tmp_path / "bad.dsl" + bad.write_text("a = add(1)\n") + out = tmp_path / "out.s" + driver = CompilerDriver(CompilerConfig()) + result = driver.compile(str(bad), str(out)) + + assert result.success is False + assert len(result.errors) == 1 + assert "bad.dsl:1:9: error[E201]" in result.errors[0] + assert "operation 'add' expects 2 positional argument(s), got 1" in ( + result.errors[0] + ) + assert not out.exists() + + def test_compiler_propagates_rich_syntax_error( + self, tmp_path, monkeypatch, + ): + import scratchv.frontend.dsl_extended as ext_mod + + rich = DSLSyntaxError( + line=7, col=3, message="unknown operation 'retrun'", + source_line="b = retrun(a, 1)", filename="rich.dsl", + fix_hint="did you mean 'return'?", error_code="E301", + ) + + class RichFailingExtended: + def validate(self, text, *, filename=None, max_errors=20): + return ErrorCollector(filename=filename, use_color=False) + + def parse(self, text, filename=None, collector=None): + raise rich + + monkeypatch.setattr(ext_mod, "ExtendedDSLParser", RichFailingExtended) + src = tmp_path / "rich.dsl" + src.write_text("b = retrun(a, 1)\n") + out = tmp_path / "out.s" + result = CompilerDriver(CompilerConfig()).compile(str(src), str(out)) + + assert result.success is False + assert result.errors == [str(rich)] + assert result.diagnostics == [rich] + assert not out.exists() + + def test_compiler_surfaces_rich_error_when_validator_misses( + self, tmp_path, + ): + # 'add(mul(b, c))' passes the validator (inner comma splits into two + # positional args), so the rich parser must report E205 for real. + src = tmp_path / "nested.dsl" + src.write_text("a = add(mul(b, c))\n") + out = tmp_path / "out.s" + result = CompilerDriver(CompilerConfig()).compile(str(src), str(out)) + + assert result.success is False + assert len(result.errors) == 1 + assert "error[E205]" in result.errors[0] + assert not out.exists() + + def test_compiler_compiles_for_loop(self, tmp_path): + src = tmp_path / "loop.dsl" + src.write_text("for i = 0, 4\n acc = add(acc, i)\nendfor\nreturn acc\n") + out = tmp_path / "out.s" + driver = CompilerDriver(CompilerConfig()) + result = driver.compile(str(src), str(out)) + assert result.success is True + assert out.exists() + + def test_compiler_falls_back_for_base_only_extended_failure( + self, tmp_path, monkeypatch, + ): + import scratchv.frontend.dsl_extended as ext_mod + + class FailingExtended: + def validate(self, text, *, filename=None, max_errors=20): + return ErrorCollector(filename=filename, use_color=False) + + def parse(self, text, filename=None, collector=None): + raise DSLParseError("extended parser not applicable") + + monkeypatch.setattr(ext_mod, "ExtendedDSLParser", FailingExtended) + src = tmp_path / "base.dsl" + src.write_text("c = add(a, b)\nreturn c\n") + out = tmp_path / "out.s" + driver = CompilerDriver(CompilerConfig()) + result = driver.compile(str(src), str(out)) + assert result.success is True + assert out.exists() + + def test_compiler_inline_dsl_source(self, tmp_path): + out = tmp_path / "inline.s" + driver = CompilerDriver(CompilerConfig()) + result = driver.compile( + input_path="", output_path=str(out), + dsl_source="c = add(a, b)\nreturn c\n", + ) + assert result.success is True diff --git a/tests/test_topic09_errors_case_report.py b/tests/test_topic09_errors_case_report.py new file mode 100644 index 0000000..5fceb83 --- /dev/null +++ b/tests/test_topic09_errors_case_report.py @@ -0,0 +1,181 @@ +"""Tests for the Topic 09 DSL-error feature case report. + +The report is the CI artifact that proves the configured compiler pipeline +rejects an intentionally invalid DSL case with positioned, categorized and +rendered diagnostics, while collector capacity and strict fail-fast +semantics stay correct. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from benchmarks.run_topic09_errors_case import ( + EXPECTED_ARITY_SUGGESTION, + EXPECTED_COLUMN_MARKER, + EXPECTED_COMPILER_DIAGNOSTICS, + EXPECTED_RICH_DIAGNOSTICS, + EXPECTED_SPELLING_SUGGESTION, + LIMIT_CASE_LINES, + LIMIT_CASE_MAX_ERRORS, + SCHEMA_VERSION, + check_collector_limit, + check_strict_mode, + collect_rich, + compile_case, + evaluate, + main, + render_via_api, +) +from scratchv.frontend.dsl_errors import DSLSyntaxError, ErrorCode +from scratchv.frontend.dsl_extended import ExtendedDSLParser + +CASE = ( + Path(__file__).resolve().parents[1] + / "tests" / "data" / "topic09_dsl_errors_feature.dsl" +) + + +def test_case_compilation_fails_with_at_least_three_diagnostics(): + compiler = compile_case(CASE, CASE.read_text()) + assert compiler["success"] is False + assert compiler["output_written"] is False + assert compiler["diagnostics"] + assert len(compiler["diagnostics"]) == 3 + assert len({d["error_code"] for d in compiler["diagnostics"]}) == 3 + + +def test_compiler_diagnostic_positions_match_case(): + source = CASE.read_text() + lines = source.split("\n") + compiler = compile_case(CASE, source) + assert tuple( + (d["line"], d["col"], d["error_code"]) + for d in compiler["diagnostics"] + ) == EXPECTED_COMPILER_DIAGNOSTICS + for diag in compiler["diagnostics"]: + assert diag["line"] >= 1 and diag["col"] >= 1 + assert diag["source_line"] == lines[diag["line"] - 1] + assert diag["source_line_matches_case"] is True + + +def test_rich_collector_suggestions_and_render_alignment(): + source = CASE.read_text() + collector = collect_rich(source, str(CASE)) + assert collector.error_count == 4 + assert tuple( + (e.line, e.col, e.error_code) for e in collector.errors + ) == EXPECTED_RICH_DIAGNOSTICS + + spelling = collector.errors[0] + assert spelling.error_code == ErrorCode.SEM_UNKNOWN_OP + assert spelling.fix_hint == EXPECTED_SPELLING_SUGGESTION + arity = next( + e for e in collector.errors if e.error_code == ErrorCode.SEM_ARITY + ) + assert arity.fix_hint == EXPECTED_ARITY_SUGGESTION + + render = render_via_api(spelling) + assert render["api"].endswith("render_error") + assert render["stream_isatty"] is False + assert render["contains_ansi"] is False + assert EXPECTED_COLUMN_MARKER in render["text"] + assert render["caret_aligned_with_token"] is True + assert render["caret_col"] == render["token_col"] + assert render["note_line"] == f"note: {EXPECTED_SPELLING_SUGGESTION}" + + +def test_collector_suppression_accounting(): + limit = check_collector_limit() + assert limit["error_count"] == LIMIT_CASE_MAX_ERRORS + assert limit["suppressed_count"] == ( + LIMIT_CASE_LINES - LIMIT_CASE_MAX_ERRORS + ) + assert limit["suppressed_count"] == 7 + assert limit["limit_reached"] is True + assert f"error limit ({LIMIT_CASE_MAX_ERRORS})" in limit["report_note"] + assert "7 further errors suppressed" in limit["report_note"] + + +def test_strict_mode_raises_first_error(): + source = CASE.read_text() + strict = check_strict_mode(source, str(CASE)) + assert strict["raised"] is True + assert strict["is_dsl_syntax_error"] is True + assert strict["exception_type"] == "DSLSyntaxError" + expected_line, expected_col, expected_code = ( + EXPECTED_COMPILER_DIAGNOSTICS[0] + ) + assert ( + strict["error_code"], strict["line"], strict["col"], + ) == (expected_code, expected_line, expected_col) + + with pytest.raises(DSLSyntaxError) as excinfo: + ExtendedDSLParser().parse(source, filename=str(CASE)) + assert excinfo.value.error_code == expected_code + + +def test_evaluate_passes_all_hard_checks(): + report = evaluate(CASE) + assert report["schema_version"] == SCHEMA_VERSION + assert report["hard_failures"] == [] + assert all(report["hard_checks"].values()) + assert len(report["hard_checks"]) == 15 + assert report["compiler"]["success"] is False + assert report["collector_limit"]["suppressed_count"] == 7 + assert report["honesty"] + + +def test_hard_check_gate_is_not_vacuous(monkeypatch): + """A pipeline that accepts the invalid case must fail the gate.""" + + def fake_compile(_case_path, _source): + return { + "success": True, + "errors": [], + "diagnostics": [], + "diagnostic_limit_reached": False, + "diagnostic_limit": 20, + "output_written": True, + } + + monkeypatch.setattr( + "benchmarks.run_topic09_errors_case.compile_case", fake_compile) + report = evaluate(CASE) + assert "compile_fails" in report["hard_failures"] + assert "compile_writes_no_output" in report["hard_failures"] + assert "at_least_three_diagnostics" in report["hard_failures"] + assert "compiler_positions_match_case" in report["hard_failures"] + # Unrelated invariants must still hold: the gate is targeted, not global. + assert "strict_mode_raises_first_error" not in report["hard_failures"] + assert "collector_limit_accounting" not in report["hard_failures"] + + +def test_main_writes_reports_and_rejects_missing_case(tmp_path, capsys): + json_path = tmp_path / "dsl_error_report.json" + md_path = tmp_path / "dsl_error_report.md" + exit_code = main([ + "--case", str(CASE), + "--json", str(json_path), + "--markdown", str(md_path), + ]) + assert exit_code == 0 + data = json.loads(json_path.read_text()) + assert data["hard_failures"] == [] + assert data["topic"] == "topic09-dsl-errors" + assert data["schema_version"] == SCHEMA_VERSION + assert data["compiler"]["success"] is False + markdown = md_path.read_text() + assert "Topic 09 DSL-Error Feature Case" in markdown + assert "Render sample" in markdown + assert EXPECTED_COLUMN_MARKER in markdown + assert "## Hard checks" in markdown + assert "## Honesty" in markdown + assert capsys.readouterr().out + + with pytest.raises(SystemExit) as exc: + main(["--case", str(tmp_path / "missing.dsl")]) + assert exc.value.code == 2