diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c39ff2..7b8df36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,13 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -68,7 +68,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" @@ -94,7 +94,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml new file mode 100644 index 0000000..df519af --- /dev/null +++ b/.github/workflows/metrics.yml @@ -0,0 +1,83 @@ +name: metrics + +on: + workflow_dispatch: + inputs: + sample: + description: "Number of AFP files to sample" + default: "500" + schedule: + - cron: '0 4 * * 1' # 04:00 UTC every Monday + +# Allow the job to commit the refreshed README back to the repo. +permissions: + contents: write + +concurrency: + group: metrics + cancel-in-progress: false + +jobs: + metrics: + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + version: latest + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install dependencies + run: poetry install --all-extras --no-interaction + + - name: Compute cache week + id: week + run: echo "week=$(date -u +%G-%V)" >> "$GITHUB_OUTPUT" + + - name: Cache AFP corpus + uses: actions/cache@v4 + with: + path: corpus + # Weekly key: refreshes the corpus each ISO week, reused within the + # week (e.g. for manual re-runs). No restore-keys, so a new week is a + # genuine miss and the fetch script downloads the latest release. + key: afp-corpus-${{ steps.week.outputs.week }} + + - name: Fetch AFP corpus + run: bash metrics/fetch_corpus.sh corpus + + - name: Measure coverage and performance + env: + METRICS_SAMPLE: ${{ github.event.inputs.sample || '500' }} + run: poetry run python metrics/measure.py + + - name: Update README + run: poetry run python metrics/update_readme.py + + - name: Commit refreshed metrics + run: | + git config user.name "metrics-bot" + git config user.email "metrics-bot@users.noreply.github.com" + git add README.rst metrics/metrics.json + # Pushing with the default GITHUB_TOKEN does not retrigger workflows; + # [skip ci] is belt-and-suspenders. No-op stays green. + git commit -m "chore: update AFP metrics [skip ci]" || { echo "no changes"; exit 0; } + # master may have advanced during the (multi-minute) run, which would + # reject the push; rebase onto the latest and retry a few times. + for attempt in 1 2 3 4 5; do + if git push; then exit 0; fi + echo "push rejected (attempt $attempt) - rebasing on origin/master ..." + git pull --rebase --autostash origin master + done + echo "failed to push metrics after retries" >&2 + exit 1 diff --git a/.gitignore b/.gitignore index a928965..4c06dae 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ pip-wheel-metadata/ pytestdebug.log scripts/ src/_pytest/_version.py +corpus/ diff --git a/README.rst b/README.rst index e69de29..e8468f7 100644 --- a/README.rst +++ b/README.rst @@ -0,0 +1,162 @@ +================ +isabelle_parser +================ + +.. image:: https://github.com/christiankissig/python-isabelle-parser/actions/workflows/ci.yml/badge.svg?branch=master + :target: https://github.com/christiankissig/python-isabelle-parser/actions/workflows/ci.yml + :alt: CI/CD status + +.. image:: https://img.shields.io/badge/python-3.10%2B-blue.svg + :target: https://www.python.org/downloads/ + :alt: Python 3.10+ + +.. image:: https://img.shields.io/badge/license-MIT-green.svg + :target: https://github.com/christiankissig/python-isabelle-parser/blob/master/LICENSE + :alt: License: MIT + +A parser for `Isabelle/Isar `_ theory (``.thy``) +artifacts, implemented in pure Python on top of `Lark `_ +using an Earley grammar. + +It turns the outer syntax of a theory file — the theory header, document +markup, specifications (``definition``, ``datatype``, ``fun``, ``locale``, +``class``, …), Isar proofs and FFI/ML commands — into a Lark parse tree that you +can walk programmatically. + +Status +====== + +Work in progress. The grammar covers a large and growing share of the outer +syntax found in the `Archive of Formal Proofs `_, but +it is not yet complete: inner-syntax terms with exotic notation, some proof +methods, and a few rarely-used commands are still rejected. Treat a successful +parse as authoritative and a failure as "not yet supported" rather than "invalid +Isabelle". + +Metrics +======= + +Parser coverage and performance against the latest `Archive of Formal Proofs +`_ release, refreshed weekly by the ``metrics`` +workflow (and on demand via *Run workflow*): + +.. METRICS:START + +.. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Metric + - Value + * - AFP snapshot + - afp-2026-06-01 + * - Files sampled + - 500 + * - Parse coverage + - 33.6% (168 parsed) + * - Timeouts (> 15s) + - 21.2% (106) + * - Throughput + - 0.81 files/s · 0.024 MB/s (×4 workers) + * - Median parse time (parsed files) + - 2.785 s + * - Measured + - 2026-06-03 09:58 UTC + +*Coverage is the share of a seeded random sample of AFP theory files that parse within the timeout; a whole file counts as failed if any statement fails. Updated weekly by the metrics workflow.* + +.. METRICS:END + +Requirements +============ + +* Python >= 3.10 +* `lark `_ + +Installation +============ + +From a clone of the repository: + +.. code-block:: bash + + pip install . + +For a development checkout (tests, linters): + +.. code-block:: bash + + pip install -e ".[dev]" + +Command-line usage +================== + +The CLI parses a file or a literal string and prints the resulting tree: + +.. code-block:: bash + + # parse a theory file + python -m isabelle_parser.cli -f path/to/Theory.thy + + # parse a literal string + python -m isabelle_parser.cli "theory T imports Main begin end" + +Options: + +``-f``, ``--file`` + Interpret the input argument as a filename rather than a literal string. + +``-t``, ``--timeout SECONDS`` + Abort the parse after ``SECONDS`` and report a failure instead of hanging. + The Earley chart can grow super-linearly on large or highly ambiguous files, + so this is a useful guard when parsing a whole corpus. + +The command exits non-zero if parsing fails. + +Library usage +============= + +.. code-block:: python + + from isabelle_parser import parse, load_parser, ParsingError + + source = "theory T imports Main begin\nlemma \"x = x\" by simp\nend" + + try: + tree = parse(source) + print(tree.pretty()) + except ParsingError as error: + print(f"parsing failed: {error}") + +``parse(input_text, parser=None, timeout=None)`` + Parse ``input_text`` and return a Lark ``Tree``. If ``parser`` is omitted a + default parser is built for the ``start`` rule. ``timeout`` (seconds) bounds + the parse and raises ``ParsingError`` if exceeded; it relies on ``SIGALRM`` + and therefore applies only on the main thread of a Unix process (it is + silently skipped elsewhere). + +``load_parser(start="start")`` + Build and return the underlying Lark parser, optionally starting from a + specific grammar rule. Reuse a single instance when parsing many files — + building the parser is the expensive part. + +``ParsingError`` + Raised for invalid input and on timeout. Lark may also raise its own + ``lark.exceptions.UnexpectedInput`` subclasses for structurally invalid input. + +Development +=========== + +.. code-block:: bash + + pytest # run the test suite + ruff check . # lint + ruff format . # format + isort . # import ordering + +The grammar lives in ``isabelle_parser/thy_grammar.lark``. + +License +======= + +MIT. See the ``LICENSE`` file. diff --git a/isabelle_parser/cli.py b/isabelle_parser/cli.py index 9fa8889..917f458 100644 --- a/isabelle_parser/cli.py +++ b/isabelle_parser/cli.py @@ -14,6 +14,14 @@ def main() -> None: arg_parser.add_argument( "-f", "--file", action="store_true", help="Interpret input as a filename" ) + arg_parser.add_argument( + "-t", + "--timeout", + type=float, + default=None, + help="Abort the parse after this many seconds (guards against " + "pathological inputs that grow the Earley chart super-linearly)", + ) # Parse arguments args = arg_parser.parse_args() @@ -31,7 +39,7 @@ def main() -> None: # Lex and parse try: - result = parse(data) + result = parse(data, timeout=args.timeout) except ParsingError as e: print("Parsing failed due to errors.") print(f"Error: {e.with_source_code(data)}") diff --git a/isabelle_parser/error.py b/isabelle_parser/error.py index 9b78bf1..91efd90 100644 --- a/isabelle_parser/error.py +++ b/isabelle_parser/error.py @@ -26,7 +26,7 @@ def _source_hint(self) -> str: print(line) underline = " " * (self.column - 1) + "^" * len(self.token) return f"\n\n{line}\n{underline}\n" - return "source_hint" + return "" def __str__(self) -> str: location_info = "" diff --git a/isabelle_parser/thy_grammar.lark b/isabelle_parser/thy_grammar.lark index 6b5da08..9fac9d6 100644 --- a/isabelle_parser/thy_grammar.lark +++ b/isabelle_parser/thy_grammar.lark @@ -7,6 +7,9 @@ QUOTED_STRING: "\"" /[\s\S]*?/ "\"" | "`" /[\s\S]*?/ "`" +// Legacy (pre-2016) verbatim document/source text: {* ... *} +VERBATIM_TEXT: /\{\*[\s\S]*?\*\}/ + // Tokens for cartouche CARTOUCHE_OPEN: "\\" CARTOUCHE_TEXT: /[^\\]+/ @@ -1515,6 +1518,7 @@ COMMENT_CARTOUCHE: "\\" MARKER: "\\<^marker>" // Greek symbols +SYMBOL: /\\<(?!open>)(?!close>)(?!comment>)(?!\^)[A-Za-z][A-Za-z0-9]*>(\\<\^sub>[A-Za-z0-9_\x27]*)?/ GREEK: "\\" | "\\" | "\\" @@ -1573,7 +1577,7 @@ TERM_VAR: /\?[a-zA-Z](_?\d*[a-zA-Z]*)*\.?\d*/ TYPE_VAR: /'[a-zA-Z](_?\d*[a-zA-Z]*)*\.?\d*/ // Other predefined tokens -TYPE_IDENT: /\'[a-zA-Z](_?\d*[a-zA-Z_\']*)*/ +TYPE_IDENT: /\'(\\<[A-Za-z]+>|[a-zA-Z])(\\<\^sub>[a-zA-Z0-9_\']*|_?\d*[a-zA-Z_\']*)*/ SUBSCRIPT: "\\<^sub>" @@ -1593,9 +1597,8 @@ context: ( print_locale | print_locales | print_state )* -theory: ( goal proof_prove - | context - | statement +theory: ( ("private" | "qualified")? goal proof_prove + | ("private" | "qualified")? statement | class_instance proof_prove | derive )* @@ -1611,6 +1614,7 @@ statement: abbreviation | declaration | declare | definition + | code_printing | doc_block | experiment | export_code @@ -1621,6 +1625,7 @@ statement: abbreviation | hide_declarations | inductive | inductive_cases + | inductive_simps | instantiation | instantiation | interpretation_block @@ -1630,8 +1635,12 @@ statement: abbreviation | lifting_update | locale_block | locale_theory_context + | attribute_setup | marker | method_block + | ml_translation + | oracle + | simproc_setup | method_setup | ml | named_theorems @@ -1665,7 +1674,7 @@ statement: abbreviation | value | values -method_block: "method" name "=" instruction +method_block: "method" name for_fixes? "=" instruction instruction: single_instruction | single_instruction instruction_modifier @@ -1704,11 +1713,12 @@ subgoal_params: "for" "..."? ("_" | name)+ # name: QUOTED_STRING + | SYMBOL | /[*]+/ | /[+]+/ | SYM_IDENT // | (ID | GREEK | SUBSCRIPT | "." | "_" | NAT | "'" | letter)+ - | /(([a-zA-Z_][a-zA-Z_0-9\']*(\\<\^sub>[a-zA-Z0-9_]*)?)|(\\<((alpha)|(beta)|(gamma)|(delta)|(epsilon)|(zeta)|(eta)|(theta)|(iota)|(kappa)|(mu)|(nu)|(xi)|(pi)|(rho)|(sigma)|(tau)|(upsilon)|(phi)|(chi)|(psi)|(omega)|(Gamma)|(Delta)|(Theta)|(Lambda)|(Xi)|(Pi)|(Sigma)|(Upsilon)|(Phi)|(Psi)|(Omega))>)|(\\<\\^sub>)|([._'])|([0-9])|([a-zA-Z]+)|(\\?<[a-zA-Z]+>))+/ + | /(([a-zA-Z_][a-zA-Z_0-9\']*(\\<\^sub>[a-zA-Z0-9_]*)*)|(\\<((alpha)|(beta)|(gamma)|(delta)|(epsilon)|(zeta)|(eta)|(theta)|(iota)|(kappa)|(mu)|(nu)|(xi)|(pi)|(rho)|(sigma)|(tau)|(upsilon)|(phi)|(chi)|(psi)|(omega)|(Gamma)|(Delta)|(Theta)|(Lambda)|(Xi)|(Pi)|(Sigma)|(Upsilon)|(Phi)|(Psi)|(Omega))>)|(\\<\^sub>)|([._'])|([0-9])|([a-zA-Z]+)|(\\?<[a-zA-Z]+>))+/ | "\\" par_name: "(" name ")" @@ -1732,6 +1742,7 @@ embedded: QUOTED_STRING | SYM_IDENT | TERM_VAR | TYPE_IDENT + | SYMBOL | cartouche # @@ -1747,7 +1758,8 @@ text: embedded type: embedded term: embedded - | "?"? (GREEK | ID)+ (SUBSCRIPT (FLOAT | ID))* "'"? + | SYMBOL + | "?"? (GREEK | ID | SYMBOL)+ (SUBSCRIPT (FLOAT | ID))* "'"? | "\\" | "\\" | "\\" @@ -1787,7 +1799,7 @@ typespec_sorts: typeargs_sorts? name # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 3.3.6 # -sort: ID +sort: ID | LONG_IDENT | QUOTED_STRING sort_list_comma_sep: sort ("," sort)* @@ -1802,7 +1814,7 @@ vars: var ("and" var)* var: name+ ("::" type)? comment_block? | name ("::" type)? mixfix comment_block? -props: comment_block* thmdecl? comment_block* (prop prop_pat? is_syntax?)+ comment_block* +props: comment_block* thmdecl? marker? comment_block* (prop prop_pat? is_syntax?)+ comment_block* prop_list_with_pat: prop prop_pat? is_syntax? ("and"? prop prop_pat? is_syntax?)* @@ -1870,6 +1882,7 @@ attribute: "OF" thms args: arg* arg: ID + | LONG_IDENT | cartouche | "False" | "for" @@ -1936,7 +1949,7 @@ doc_block: ( "chapter" | "text" | "text_raw" | "txt" - | COMMENT_CARTOUCHE) ("(" "in" name ")")? (cartouche | QUOTED_STRING | ID) + | COMMENT_CARTOUCHE) ("(" "in" name ")")? (cartouche | QUOTED_STRING | VERBATIM_TEXT | ID) comment_block: COMMENT_CARTOUCHE cartouche @@ -2021,7 +2034,7 @@ thy_bounds: name | ("(" name ("|" name)* ")") locale_theory_context: "context" comment_block? name opening? "begin" local_theory "end" | "context" comment_block? includes? context_elem* "begin" local_theory "end" -local_theory_target: "(" "in" name ")" +local_theory_target: "(" "in" (name | "-") ")" # # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 5.3 @@ -2039,7 +2052,7 @@ includes: "includes" name* opening: "opening" name* -unbundle: "unbundle" name* +unbundle: "unbundle" ("(" "in" name ")")? name* # # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 5.4 @@ -2047,7 +2060,7 @@ unbundle: "unbundle" name* decl: name ("::" (ID | QUOTED_STRING | cartouche))? comment_block? mixfix? comment_block? "where" comment_block? -definition: "definition" local_theory_target? decl? thmdecl? prop spec_prems? for_fixes? +definition: "definition" marker? local_theory_target? decl? thmdecl? marker? prop spec_prems? for_fixes? abbreviation: "abbreviation" local_theory_target? mode? decl? prop for_fixes? @@ -2139,10 +2152,9 @@ definitions: "defines" definitions_item ("and" definitions_item)* # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 5.8 # -class: "class" class_spec "begin" local_theory "end" +class: "class" class_spec ("begin" local_theory "end")? -class_spec: name "=" ( (name opening? "+" context_elem+) - | (name opening?) +class_spec: name "=" ( (name ("+" name)* opening? ("+" context_elem+)?) | (opening? "+" context_elem+) | context_elem+ ) instantiation? @@ -2176,7 +2188,8 @@ spec: name ("\\" | "==") term ("(" "unchecked" ")")? # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 5.10 # -ml : ("ml" | "setup") (name | cartouche) +ml : ( "ML" | "ml" | "ML_file" | "ML_val" | "ML_prf" | "ML_command" + | "SML_file" | "setup" | "local_setup" ) (name | cartouche | VERBATIM_TEXT | QUOTED_STRING) # # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 5.12.2 @@ -2184,7 +2197,7 @@ ml : ("ml" | "setup") (name | cartouche) typedecl : "typedecl" typespec mixfix? -type_synonym : "type_synonym" ("(" "in" name ")")? typespec "=" (ID | QUOTED_STRING) mixfix? +type_synonym : "type_synonym" ("(" "in" name ")")? typespec "=" (ID | LONG_IDENT | TYPE_IDENT | QUOTED_STRING | cartouche) mixfix? # # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 5.13 @@ -2331,8 +2344,7 @@ proof_prove: ( show | with proof_chain )* ( terminal_proof_steps | qed | "oops" - | "done" - | by)? doc_block? + | "done")? doc_block? # QUOTED_STRING only found in AFP, not in Isabelle/Isar grammar conclusion: "shows" stmt @@ -2359,7 +2371,7 @@ assms : "assms" # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 6.2.4 # -goal: ("lemma" | "theorem" | "corollary" | "proposition" | "schematic_goal") ("(" "in" name ")")? "%invisible"? (long_statement | short_statement) +goal: ("lemma" | "theorem" | "corollary" | "proposition" | "schematic_goal") ("(" "in" name ")")? "%invisible"? marker? (long_statement | short_statement) have: "have" stmt cond_stmt? for_fixes? @@ -2375,7 +2387,7 @@ cond_stmt: ("if" | "when") stmt short_statement: stmt ("if" stmt)? for_fixes? -long_statement: thmdecl? comment_block? statement_context conclusion +long_statement: thmdecl? marker? comment_block? statement_context conclusion statement_context: includes? context_elem* @@ -2445,6 +2457,16 @@ terminal_proof_steps: "." | ".." | "sorry" method_setup: "method_setup" name "=" text text? +attribute_setup: "attribute_setup" name "=" text text? + +simproc_setup: "simproc_setup" name "(" term ("|" term)* ")" "=" text + +ml_translation: ( "parse_translation" | "print_translation" + | "typed_print_translation" | "parse_ast_translation" + | "print_ast_translation" ) text + +oracle: "oracle" name "=" text + # # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 6.5.1 # @@ -2533,7 +2555,9 @@ notation: name mixfix # TODO mode: ID | "(" "input" ")" + | "(" "output" ")" | "(" "ASCII" ")" + | "(" name ")" # # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 8.5.2 @@ -2573,9 +2597,9 @@ inductive: ("inductive" | "inductive_set" | "coinductive" | "coinductive_set") ( # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 11.2 # -primrec: "primrec" ("(" "transfer" ")")? specification +primrec: "primrec" spec_opts? specification -primcorec: "primcorec" ("(" "transfer" ")")? specification +primcorec: "primcorec" spec_opts? specification fun_block: "fun" opts? specification | ("function" | "nominal_function") opts? specification proof_prove @@ -2586,14 +2610,27 @@ termination: "termination" term? proof_prove | "nominal_termination" ("(" name ")")? term? proof_prove # TODO generated from examples -datatype: "datatype" generic_type "=" constructors +datatype: ("datatype" | "nominal_datatype") spec_opts? dt_typespec "=" constructors + +# Type specification for datatype left-hand sides. Beyond plain and +# sort-annotated type variables it allows the BNF annotations `dead 'a` and +# selector-style `name: 'a`. +dt_typespec: dt_typeargs? name +dt_typeargs: dt_typearg + | "(" dt_typearg ("," dt_typearg)* ")" +dt_typearg: "dead"? (name ":")? TYPE_IDENT ("::" sort)? + +# Option block for spec commands, e.g. (plugins del: size), (nonexhaustive), +# (transfer). The excluded apostrophe keeps this from matching a leading type +# argument list such as ('a, 'b), which would otherwise be ambiguous. +spec_opts: "(" /[^()']+/ ")" generic_type : (type | ("(" type ("," type)*")")) name? constructors : constructor ("|" constructor)* -constructor : comment_block? ID TYPE_IDENT mixfix? comment_block? - | comment_block? ( name +constructor : comment_block? (name ":")? ID TYPE_IDENT mixfix? comment_block? + | comment_block? (name ":")? ( name | cartouche | ("(" (cartouche | (name ":" name) | (name ":" QUOTED_STRING)) ")") )+ mixfix? comment_block? @@ -2703,10 +2740,35 @@ param_arg_value: embedded inductive_cases: "inductive_cases" (thmdecl? prop+ ("and" thmdecl? prop+)*) +inductive_simps: "inductive_simps" (thmdecl? prop+ ("and" thmdecl? prop+)*) + # # https://isabelle.in.tum.de/doc/isar-ref.pdf Section 13 # +code_printing: "code_printing" code_print_decl ("|" code_print_decl)* + +code_print_decl: code_symbol (code_arrow code_printings?)? + +code_arrow: "\" | "\" | "=>" + +code_symbol: "constant" (name | QUOTED_STRING) + | "type_constructor" (name | QUOTED_STRING) + | "type_class" (name | QUOTED_STRING) + | "class_relation" (name | QUOTED_STRING) + | "class_instance" (name | QUOTED_STRING) "::" (name | QUOTED_STRING) + | "code_module" (name | QUOTED_STRING) + +code_printings: code_target_print ("and" code_target_print)* + +code_target_print: "(" name ")" code_template? + +# A printing is a template string/cartouche, an optional infix declaration, or +# "-" to erase the printing for that target. +code_template: ("infix" | "infixl" | "infixr") NAT (QUOTED_STRING | cartouche) + | (QUOTED_STRING | cartouche) + | "-" + export_code : "open" const_expr_list export_target_list | const_expr_list export_target_list diff --git a/isabelle_parser/thy_parser.py b/isabelle_parser/thy_parser.py index c23a94b..8f6feb3 100644 --- a/isabelle_parser/thy_parser.py +++ b/isabelle_parser/thy_parser.py @@ -1,13 +1,52 @@ import logging import os -from typing import Any, List +import signal +import threading +from contextlib import contextmanager +from typing import Any, Iterator, List from lark import Lark, Transformer, Tree from lark.tree import Meta +from .error import ParsingError + logger = logging.getLogger(__name__) +class _ParseTimeout(Exception): + """Internal signal that a parse exceeded its time budget.""" + + +@contextmanager +def _time_limit(seconds: float | None) -> Iterator[None]: + """Abort the wrapped block after ``seconds`` using SIGALRM. + + The Earley parser can grow its chart super-linearly with input size, so a + large/ambiguous theory file can take minutes. SIGALRM interrupts the + pure-Python parse loop cleanly. It is only available on the main thread of a + Unix process; in any other context (worker threads, platforms without + SIGALRM) the limit is silently skipped and parsing proceeds without a bound. + """ + if ( + not seconds + or not hasattr(signal, "SIGALRM") + or threading.current_thread() is not threading.main_thread() + ): + yield + return + + def _handler(signum: int, frame: Any) -> None: + raise _ParseTimeout() + + previous = signal.signal(signal.SIGALRM, _handler) + signal.setitimer(signal.ITIMER_REAL, seconds) + try: + yield + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + signal.signal(signal.SIGALRM, previous) + + def load_parser(start: str = "start") -> Lark: # Construct the path to the .lark file grammar_path = os.path.join(os.path.dirname(__file__), "thy_grammar.lark") @@ -21,10 +60,22 @@ def load_parser(start: str = "start") -> Lark: return parser -def parse(input_text: str, parser: Lark | None = None) -> Any | None: +def parse( + input_text: str, parser: Lark | None = None, timeout: float | None = None +) -> Any | None: + """Parse ``input_text`` into a tree. + + ``timeout`` (seconds) bounds the parse: if it is exceeded a + :class:`ParsingError` is raised instead of letting a pathological file hang + the caller. See :func:`_time_limit` for its limitations. + """ if parser is None: parser = load_parser() - tree = parser.parse(input_text) + try: + with _time_limit(timeout): + tree = parser.parse(input_text) + except _ParseTimeout: + raise ParsingError(f"parsing timed out after {timeout}s") transformer = PositionPrinter() return transformer.transform(tree) diff --git a/metrics/fetch_corpus.sh b/metrics/fetch_corpus.sh new file mode 100755 index 0000000..d6195d5 --- /dev/null +++ b/metrics/fetch_corpus.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Download and extract the latest AFP release into the corpus directory. +# No-op if the corpus is already present (e.g. restored from cache). +set -euo pipefail + +DEST="${1:-corpus}" +URL="${AFP_URL:-https://isa-afp.org/release/afp-current.tar.gz}" + +mkdir -p "$DEST" +if find "$DEST" -name '*.thy' -print -quit 2>/dev/null | grep -q .; then + echo "Corpus already present in '$DEST' ($(find "$DEST" -name '*.thy' | wc -l) .thy files) - skipping download." + exit 0 +fi + +echo "Downloading $URL ..." +tmp="$(mktemp --suffix=.tar.gz)" +curl -fsSL "$URL" -o "$tmp" + +echo "Extracting ..." +tar -xzf "$tmp" -C "$DEST" +rm -f "$tmp" + +# Record the release name: the extracted top-level directory (e.g. afp-2026-06-01). +version="$(find "$DEST" -maxdepth 1 -mindepth 1 -type d -name 'afp-*' -printf '%f\n' | head -1 || true)" +[ -n "$version" ] && echo "$version" > "$DEST/AFP_VERSION" + +echo "Extracted $(find "$DEST" -name '*.thy' | wc -l) .thy files (${version:-unknown})." diff --git a/metrics/measure.py b/metrics/measure.py new file mode 100644 index 0000000..d9d43dd --- /dev/null +++ b/metrics/measure.py @@ -0,0 +1,135 @@ +"""Measure parser coverage and performance against an AFP corpus. + +Parses a fixed (seeded) random sample of theory files from the corpus, recording +how many parse successfully and how fast, and writes the figures to +``metrics/metrics.json``. A per-file timeout bounds the run; timeouts count as +"not parsed" (the Earley chart can grow super-linearly on large files). + +Configuration via environment variables: + CORPUS_DIR corpus location (default: corpus) + METRICS_SAMPLE number of files to sample (default: 500; 0 = all) + METRICS_TIMEOUT per-file timeout in seconds (default: 15) + METRICS_SEED RNG seed for the sample (default: 42) + METRICS_OUT output path (default: metrics/metrics.json) +""" + +import datetime +import glob +import json +import os +import random +import signal +import statistics +import sys +import time +from concurrent.futures import ProcessPoolExecutor, as_completed +from typing import Any + +# Make isabelle_parser importable in pool workers regardless of start method +# (spawn/forkserver re-import this module, re-running this line) or whether the +# package is installed. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +CORPUS = os.environ.get("CORPUS_DIR", "corpus") +SAMPLE = int(os.environ.get("METRICS_SAMPLE", "500")) +TIMEOUT = int(os.environ.get("METRICS_TIMEOUT", "15")) +SEED = int(os.environ.get("METRICS_SEED", "42")) +OUT = os.environ.get("METRICS_OUT", "metrics/metrics.json") + +_parser = None + + +def _get_parser() -> Any: + global _parser + if _parser is None: + from isabelle_parser import load_parser + + _parser = load_parser() + return _parser + + +def _parse_one(path: str) -> tuple[str, int, float]: + try: + data = open(path, errors="replace").read() + except Exception: + return ("error", 0, 0.0) + size = len(data.encode("utf-8", "replace")) + + def _alarm(signum: int, frame: Any) -> None: + raise TimeoutError() + + signal.signal(signal.SIGALRM, _alarm) + signal.setitimer(signal.ITIMER_REAL, TIMEOUT) + start = time.time() + try: + _get_parser().parse(data) + status = "ok" + except TimeoutError: + status = "timeout" + except Exception: + status = "fail" + finally: + signal.setitimer(signal.ITIMER_REAL, 0) + return (status, size, time.time() - start) + + +def main() -> None: + files = sorted(glob.glob(os.path.join(CORPUS, "**", "*.thy"), recursive=True)) + if not files: + raise SystemExit(f"No .thy files found under '{CORPUS}'.") + random.seed(SEED) + random.shuffle(files) + if SAMPLE: + files = files[:SAMPLE] + + version = "unknown" + vfile = os.path.join(CORPUS, "AFP_VERSION") + if os.path.exists(vfile): + version = open(vfile).read().strip() + + workers = os.cpu_count() or 2 + counts = {"ok": 0, "fail": 0, "timeout": 0, "error": 0} + ok_times = [] + total_bytes = 0 + wall_start = time.time() + with ProcessPoolExecutor(max_workers=workers) as ex: + for fut in as_completed([ex.submit(_parse_one, f) for f in files]): + status, size, dt = fut.result() + counts[status] += 1 + total_bytes += size + if status == "ok": + ok_times.append(dt) + wall = time.time() - wall_start + + attempted = len(files) + metrics = { + "afp_version": version, + "sample_size": attempted, + "workers": workers, + "timeout_budget_s": TIMEOUT, + "ok": counts["ok"], + "fail": counts["fail"], + "timeout": counts["timeout"], + "error": counts["error"], + "coverage_pct": round(100 * counts["ok"] / attempted, 1), + "timeout_pct": round(100 * counts["timeout"] / attempted, 1), + "wall_seconds": round(wall, 1), + "files_per_sec": round(attempted / wall, 2) if wall else None, + "mb_per_sec": round(total_bytes / 1e6 / wall, 3) if wall else None, + "median_ok_parse_s": round(statistics.median(ok_times), 3) + if ok_times + else None, + "measured_at": datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y-%m-%d %H:%M UTC" + ), + } + + os.makedirs(os.path.dirname(OUT), exist_ok=True) + with open(OUT, "w") as fh: + json.dump(metrics, fh, indent=2) + fh.write("\n") + print(json.dumps(metrics, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/metrics/metrics.json b/metrics/metrics.json new file mode 100644 index 0000000..ab27fc8 --- /dev/null +++ b/metrics/metrics.json @@ -0,0 +1,17 @@ +{ + "afp_version": "afp-2026-06-01", + "sample_size": 500, + "workers": 4, + "timeout_budget_s": 15, + "ok": 168, + "fail": 226, + "timeout": 106, + "error": 0, + "coverage_pct": 33.6, + "timeout_pct": 21.2, + "wall_seconds": 614.6, + "files_per_sec": 0.81, + "mb_per_sec": 0.024, + "median_ok_parse_s": 2.785, + "measured_at": "2026-06-03 09:58 UTC" +} diff --git a/metrics/update_readme.py b/metrics/update_readme.py new file mode 100644 index 0000000..7b0bfed --- /dev/null +++ b/metrics/update_readme.py @@ -0,0 +1,67 @@ +"""Inject the latest metrics into README.rst between the METRICS markers. + +Reads metrics/metrics.json and rewrites the block delimited by +``.. METRICS:START`` and ``.. METRICS:END`` with an RST table. +""" + +import json +import os +from typing import Any + +README = os.environ.get("README", "README.rst") +METRICS = os.environ.get("METRICS_OUT", "metrics/metrics.json") + + +def build_table(m: dict[str, Any]) -> str: + def row(metric: str, value: Any) -> str: + return f" * - {metric}\n - {value}\n" + + fps = m.get("files_per_sec") + mbs = m.get("mb_per_sec") + throughput = ( + f"{fps} files/s · {mbs} MB/s (×{m.get('workers', '?')} workers)" + if fps is not None + else "n/a" + ) + median = m.get("median_ok_parse_s") + median_s = f"{median} s" if median is not None else "n/a" + + table = ".. list-table::\n :header-rows: 1\n :widths: 45 55\n\n" + table += row("Metric", "Value") + table += row("AFP snapshot", m.get("afp_version", "unknown")) + table += row("Files sampled", m.get("sample_size", "?")) + table += row("Parse coverage", f"{m.get('coverage_pct')}% ({m.get('ok')} parsed)") + table += row( + f"Timeouts (> {m.get('timeout_budget_s')}s)", + f"{m.get('timeout_pct')}% ({m.get('timeout')})", + ) + table += row("Throughput", throughput) + table += row("Median parse time (parsed files)", median_s) + table += row("Measured", m.get("measured_at", "?")) + table += ( + "\n*Coverage is the share of a seeded random sample of AFP theory files " + "that parse within the timeout; a whole file counts as failed if any " + "statement fails. Updated weekly by the metrics workflow.*\n" + ) + return table + + +def main() -> None: + m = json.load(open(METRICS)) + text = open(README).read() + block = build_table(m) + start, end = ".. METRICS:START", ".. METRICS:END" + i = text.find(start) + j = text.find(end) + if i == -1 or j == -1 or j < i: + raise SystemExit( + "METRICS markers not found in README; expected " + "'.. METRICS:START' and '.. METRICS:END'." + ) + new = text[: i + len(start)] + "\n\n" + block + "\n" + text[j:] + open(README, "w").write(new) + print(f"Updated {README}.") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 183751c..f2c8574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,11 @@ dependencies = [ ] classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent" ] diff --git a/tests/test_parser.py b/tests/test_parser.py index 4ea2206..6043582 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -264,6 +264,40 @@ "theory T imports Main begin\ntype_synonym 'a mylist = \"'a list\"\nend", True, ), + ( + "type_synonym_cartouche", + "theory T imports Main begin\n" + "type_synonym int_poly = \\int mpoly\\\nend", + True, + ), + ( + "type_synonym_subscripted_type_vars", + "theory T imports Main begin\n" + "type_synonym ('a\\<^sub>h, 'b\\<^sub>h) sum\\<^sub>h = " + "\"'a\\<^sub>h + 'b\\<^sub>h\"\nend", + True, + ), + ( + "type_synonym_type_var_rhs", + "theory T imports Main begin\ntype_synonym 'a blindable = 'a\nend", + True, + ), + ( + "type_synonym_qualified_rhs", + "theory T imports Main begin\ntype_synonym error = String.literal\nend", + True, + ), + ( + "qualified_toplevel_definition", + "theory T imports Main begin\n" + 'qualified definition bar :: nat where "bar = 0"\nend', + True, + ), + ( + "private_toplevel_lemma", + 'theory T imports Main begin\nprivate lemma l: "x = x" by simp\nend', + True, + ), # ----------------------------------------------------------------------- # Constant declarations # ----------------------------------------------------------------------- @@ -548,6 +582,461 @@ "end", True, ), + # ----------------------------------------------------------------------- + # ML / FFI commands (ML body delimited by a cartouche) + # ----------------------------------------------------------------------- + ( + "ml_block", + "theory T imports Main begin\nML \\val x = 1\\\nend", + True, + ), + ( + "ml_val_block", + 'theory T imports Main begin\nML_val \\writeln "hi"\\\nend', + True, + ), + ( + "ml_file_cartouche", + "theory T imports Main begin\nML_file \\foo.ML\\\nend", + True, + ), + ( + "ml_file_quoted", + 'theory T imports Main begin\nML_file "foo.ML"\nend', + True, + ), + ( + "setup_block", + "theory T imports Main begin\nsetup \\Foo.bar\\\nend", + True, + ), + # ----------------------------------------------------------------------- + # class definitions without a begin..end body + # ----------------------------------------------------------------------- + ( + "class_no_body", + "theory T imports Main begin\n" + 'class c = ord + assumes le: "x \\ x"\n' + "end", + True, + ), + ( + "class_with_body", + "theory T imports Main begin\nclass c = ord\nbegin\nend\nend", + True, + ), + # ----------------------------------------------------------------------- + # quoted sort/class names in arities + # ----------------------------------------------------------------------- + ( + "instantiation_quoted_sort", + "theory T imports Main begin\n" + 'instantiation vec :: ("show") "show"\nbegin\ninstance sorry\nend\n' + "end", + True, + ), + ( + "instantiation_quoted_multi_sort", + "theory T imports Main begin\n" + 'instantiation sq_matrix :: ("semiring_0", finite) semiring_0\n' + "begin\ninstance sorry\nend\n" + "end", + True, + ), + ( + "instance_qualified_class_name", + "theory T imports Main begin\ninstance unit :: heap.rep ..\nend", + True, + ), + ( + "definition_global_target_dash", + "theory T imports Main begin\n" + 'definition (in -) foo :: nat where "foo = 0"\nend', + True, + ), + ( + "inductive_cases_qualified_attribute_arg", + "theory T imports Main begin\n" + 'inductive_cases fooCases[simplified bar.inject]: "P x"\nend', + True, + ), + # ----------------------------------------------------------------------- + # abbreviation with an (output) print mode + # ----------------------------------------------------------------------- + ( + "abbreviation_output_mode", + "theory T imports Main begin\n" + 'abbreviation (output) foo where "foo = x"\n' + "end", + True, + ), + ( + "abbreviation_custom_print_mode", + "theory T imports Main begin\n" + 'abbreviation (latex) foo where "foo = x"\n' + "end", + True, + ), + ( + "legacy_verbatim_doc_markup", + "theory T imports Main begin\n" + "subsubsection {* Monotonicity *}\n" + "text {* some legacy text *}\nend", + True, + ), + ( + "legacy_verbatim_ml_block", + "theory T imports Main begin\nML {* val x = 1; fun f y = y + x *}\nend", + True, + ), + # ----------------------------------------------------------------------- + # inductive_simps command + # ----------------------------------------------------------------------- + ( + "inductive_simps", + 'theory T imports Main begin\ninductive_simps foo: "P x"\nend', + True, + ), + ( + "inductive_simps_and", + "theory T imports Main begin\n" + 'inductive_simps bar: "P x" and baz: "Q y"\n' + "end", + True, + ), + # ----------------------------------------------------------------------- + # datatype / primrec option blocks + # ----------------------------------------------------------------------- + ( + "datatype_plugins_option", + "theory T imports Main begin\n" + "datatype (plugins del: size) 'a t = A 'a\n" + "end", + True, + ), + ( + "datatype_type_args_not_options", + "theory T imports Main begin\ndatatype ('a, 'b) pair = Pair 'a 'b\nend", + True, + ), + ( + "primrec_nonexhaustive_option", + "theory T imports Main begin\n" + "datatype t = A | B\n" + 'primrec (nonexhaustive) f where "f A = A"\n' + "end", + True, + ), + # ----------------------------------------------------------------------- + # attribute_setup (ML body delimited by a cartouche) + # ----------------------------------------------------------------------- + ( + "attribute_setup", + "theory T imports Main begin\n" + "attribute_setup foo = \\Attrib.thms\\\n" + "end", + True, + ), + ( + "attribute_setup_with_description", + "theory T imports Main begin\n" + 'attribute_setup foo = \\Attrib.thms\\ "a description"\n' + "end", + True, + ), + # ----------------------------------------------------------------------- + # simproc_setup (pattern list + ML body) + # ----------------------------------------------------------------------- + ( + "simproc_setup", + "theory T imports Main begin\n" + 'simproc_setup foo ("x + y") = \\K (K (K NONE))\\\n' + "end", + True, + ), + ( + "simproc_setup_multi_pattern", + "theory T imports Main begin\n" + 'simproc_setup foo ("x + y" | "x * y") = \\K (K (K NONE))\\\n' + "end", + True, + ), + # ----------------------------------------------------------------------- + # syntax/AST translation commands (ML body) + # ----------------------------------------------------------------------- + ( + "parse_translation", + "theory T imports Main begin\nparse_translation \\[]\\\nend", + True, + ), + ( + "print_translation", + "theory T imports Main begin\nprint_translation \\[]\\\nend", + True, + ), + ( + "typed_print_translation", + "theory T imports Main begin\n" + "typed_print_translation \\[]\\\n" + "end", + True, + ), + # ----------------------------------------------------------------------- + # oracle (ML body) + # ----------------------------------------------------------------------- + ( + "oracle", + "theory T imports Main begin\n" + "oracle myoracle = \\fn _ => @{cterm True}\\\n" + "end", + True, + ), + # ----------------------------------------------------------------------- + # \<^marker> attached to goal commands + # ----------------------------------------------------------------------- + ( + "marker_after_thmdecl", + "theory T imports Main begin\n" + "lemma foo: \\<^marker>\\contributor \\A Name\\\\\n" + ' "x = x" by simp\n' + "end", + True, + ), + ( + "marker_after_thmdecl_with_attrs", + "theory T imports Main begin\n" + "lemma foo[simp]: \\<^marker>\\tag value\\ " + '"x = x" by simp\n' + "end", + True, + ), + ( + "marker_after_keyword", + "theory T imports Main begin\n" + 'lemma \\<^marker>\\tag value\\ foo: "x = x" by simp\n' + "end", + True, + ), + ( + "marker_on_definition_after_keyword", + "theory T imports Main begin\n" + "definition\\<^marker>\\tag value\\ foo " + 'where "foo = (0::nat)"\n' + "end", + True, + ), + ( + "marker_on_definition_after_where", + "theory T imports Main begin\n" + 'definition foo where \\<^marker>\\tag value\\ "foo = (0::nat)"\n' + "end", + True, + ), + # ----------------------------------------------------------------------- + # code_printing + # ----------------------------------------------------------------------- + ( + "code_printing_constant", + "theory T imports Main begin\n" + 'code_printing constant the \\ (Haskell) "MaybeExt.fromJust"\n' + "end", + True, + ), + ( + "code_printing_multi_entry", + "theory T imports Main begin\n" + "code_printing\n" + ' type_constructor bool \\ (Go) "bool"\n' + '| constant "True::bool" \\ (Go) "true"\n' + "end", + True, + ), + ( + "code_printing_infixl", + "theory T imports Main begin\n" + 'code_printing constant HOL.conj \\ (Go) infixl 1 "&&"\n' + "end", + True, + ), + ( + "code_printing_ascii_arrow_and_erase", + "theory T imports Main begin\n" + 'code_printing class_instance bit :: "HOL.equal" => (Haskell) -\n' + "end", + True, + ), + ( + "code_printing_module_cartouche", + "theory T imports Main begin\n" + "code_printing code_module MaybeExt \\ (Haskell)\n" + " \\module MaybeExt(fromJust) where\n\n" + " import Data.Maybe(fromJust);\\\n" + "end", + True, + ), + ( + "code_printing_multi_target", + "theory T imports Main begin\n" + 'code_printing constant c \\ (Haskell) "a" and (OCaml) "b"\n' + "end", + True, + ), + # ----------------------------------------------------------------------- + # Greek type variables (e.g. '\) + # ----------------------------------------------------------------------- + ( + "type_synonym_greek_tvars", + "theory T imports Main begin\n" + "type_synonym ('\\, '\\) psubst = " + "\"'\\ \\ '\\\"\n" + "end", + True, + ), + ( + "datatype_greek_tvar", + "theory T imports Main begin\n" + "datatype '\\ wrap = Wrap '\\\n" + "end", + True, + ), + # ----------------------------------------------------------------------- + # identifiers with repeated / symbol subscripts in name position + # ----------------------------------------------------------------------- + ( + "definition_repeated_subscript_name", + "theory T imports Main begin\n" + "definition lang\\<^sub>M\\<^sub>2\\<^sub>L :: nat where " + '"lang\\<^sub>M\\<^sub>2\\<^sub>L = 0"\nend', + True, + ), + ( + "lemmas_greek_symbol_subscript_name", + "theory T imports Main begin\n" + "lemmas \\\\<^sub>\\_cong = foo\nend", + True, + ), + # ----------------------------------------------------------------------- + # class with multiple superclasses + # ----------------------------------------------------------------------- + ( + "class_multiple_superclasses", + "theory T imports Main begin\n" + "class c = ordered_ab_semigroup_add + comm_monoid_add + linorder\n" + "end", + True, + ), + ( + "class_superclasses_then_assumes", + "theory T imports Main begin\n" + 'class c = foo + bar + assumes le: "x \\ x"\n' + "end", + True, + ), + # ----------------------------------------------------------------------- + # datatype with sort-annotated type variables + # ----------------------------------------------------------------------- + ( + "datatype_sort_annotated_tvar", + "theory T imports Main begin\ndatatype ('a::type) box = Box 'a\nend", + True, + ), + ( + "datatype_constructor_discriminators", + "theory T imports Main begin\n" + "datatype pr_op = is_PUSH: PUSH | is_RELABEL: RELABEL\nend", + True, + ), + ( + "datatype_multi_sort_annotated_tvars", + "theory T imports Main begin\n" + "datatype ('a::type, 'b::finite) pair = Pair 'a 'b\n" + "end", + True, + ), + ( + "datatype_subscripted_type_var_and_constructor", + "theory T imports Main begin\n" + "datatype 'a\\<^sub>h blindable\\<^sub>h = Content 'a\\<^sub>h\n" + "end", + True, + ), + # ----------------------------------------------------------------------- + # datatype BNF type-argument annotations (dead / selectors) + # ----------------------------------------------------------------------- + ( + "datatype_dead_tvars", + "theory T imports Main begin\n" + "datatype ('s, dead 'p, dead 'f) t = A 's\n" + "end", + True, + ), + ( + "datatype_selector_tvars", + "theory T imports Main begin\n" + "datatype (dverts: 'a, darcs: 'b) graph = G\n" + "end", + True, + ), + # ----------------------------------------------------------------------- + # nominal_datatype (same shape as datatype) + # ----------------------------------------------------------------------- + ( + "nominal_datatype", + "theory T imports Main begin\n" + "nominal_datatype pi = PiNil | Tau pi | Sum pi pi\n" + "end", + True, + ), + # ----------------------------------------------------------------------- + # method definition with `for` parameters + # ----------------------------------------------------------------------- + ( + "method_with_for_param", + "theory T imports Main begin\n" + 'method interval_split for x :: "nat" = (rule x)\n' + "end", + True, + ), + ( + "method_with_for_and", + "theory T imports Main begin\n" + 'method m for a :: "\'a" and b :: "\'b" = (simp)\n' + "end", + True, + ), + # ----------------------------------------------------------------------- + # unbundle with an (in target) qualifier + # ----------------------------------------------------------------------- + ( + "unbundle_in_target", + "theory T imports Main begin\nunbundle (in foo) no funcset_syntax\nend", + True, + ), + # ----------------------------------------------------------------------- + # Isabelle symbol identifiers (\) in term/name positions + # ----------------------------------------------------------------------- + ( + "symbol_term", + 'theory T imports Main begin\ndefinition "u = \\"\nend', + True, + ), + ( + "symbol_interpretation_args", + "theory T imports Main begin\n" + "interpretation g: foo \\ leq \\ .\n" + "end", + True, + ), + ( + "symbol_case_vars", + "theory T imports Main begin\n" + 'lemma "x = x"\n' + "proof (induct x)\n" + " case (step \\

\\) then show ?case by simp\n" + "qed\n" + "end", + True, + ), ], ) def test_parse(name, test_input, expected): diff --git a/tests/test_thy_parser.py b/tests/test_thy_parser.py index 0b66493..e99b435 100644 --- a/tests/test_thy_parser.py +++ b/tests/test_thy_parser.py @@ -14,10 +14,14 @@ test_thy_grammer.py. """ +import signal +import threading +import time + import pytest from lark import Lark, Tree -from isabelle_parser import load_parser +from isabelle_parser import ParsingError, load_parser from isabelle_parser.thy_parser import parse as _raw_parse @@ -64,3 +68,57 @@ def test_invalid_input_raises(src): """Invalid theory inputs must raise rather than return a result.""" with pytest.raises(Exception): _raw_parse(src, None) + + +class _SlowParser: + """Stand-in Lark parser whose parse() sleeps, to test the timeout path + deterministically without depending on a real pathological input.""" + + def __init__(self, seconds: float) -> None: + self.seconds = seconds + + def parse(self, _text: str): + time.sleep(self.seconds) + return Tree("start", []) + + +class TestParseTimeout: + def test_timeout_not_hit_returns_tree(self): + result = _raw_parse( + "theory T imports Main begin end", load_parser(), timeout=30 + ) + assert isinstance(result, Tree) + + def test_timeout_none_is_default(self): + result = _raw_parse("theory T imports Main begin end", load_parser()) + assert isinstance(result, Tree) + + def test_timeout_exceeded_raises_parsing_error(self): + with pytest.raises(ParsingError): + _raw_parse("ignored", _SlowParser(5), timeout=0.2) + + def test_timeout_exceeded_is_fast(self): + start = time.time() + with pytest.raises(ParsingError): + _raw_parse("ignored", _SlowParser(10), timeout=0.2) + assert time.time() - start < 2 # aborted promptly, not after 10s + + def test_sigalrm_handler_restored_after_parse(self): + sentinel = signal.getsignal(signal.SIGALRM) + _raw_parse("theory T imports Main begin end", load_parser(), timeout=30) + assert signal.getsignal(signal.SIGALRM) is sentinel + + def test_timeout_ignored_off_main_thread(self): + # SIGALRM is main-thread only; off-thread the limit is skipped and a + # normal (fast) parse still succeeds rather than erroring. + results = {} + + def run(): + results["tree"] = _raw_parse( + "theory T imports Main begin end", load_parser(), timeout=30 + ) + + t = threading.Thread(target=run) + t.start() + t.join() + assert isinstance(results["tree"], Tree)