From 37e0b358a36107205ea8a2964669974526732700 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:11:08 +0200 Subject: [PATCH 1/6] feat: parse functional specs as func-id headings A functionality is a `# ` heading followed by its body: a prose statement line and the `- ` bullets indented under it. The parse result entry carries the id, the body markdown, its linked resources and empty conformance and acceptance test lists. A flush-left `- ` list in functional specs, and an acceptance tests or conformance tests section in a core spec, are the old syntax; the error names the migrate-plain-syntax skill. The nested acceptance test code is removed and every fixture is converted. --- src/plain_parser/concept_utils.py | 9 - src/plain_parser/plain_file.py | 448 ++++++++---------- src/plain_parser/plain_spec.py | 15 +- .../cli/acceptance_test_binary_resource.plain | 10 - tests/data/cli/acceptance_test_resource.plain | 10 - tests/data/cli/base64_resource.plain | 3 +- tests/data/cli/binary_resource.plain | 3 +- tests/data/cli/code_variable_conflict.plain | 6 +- tests/data/cli/missing_resource.plain | 3 +- tests/data/cli/parent_link/a/spec/top.plain | 3 +- .../cli/requires_binary_resource_base.plain | 3 +- .../cli/requires_binary_resource_top.plain | 3 +- .../cli/resource_before_frid_walk/base.plain | 3 +- .../cli/resource_before_frid_walk/top.plain | 6 +- tests/data/cli/shared_link_helper.plain | 3 +- tests/data/cli/shared_link_top.plain | 3 +- tests/data/cli/subdir/nested.plain | 3 +- tests/data/cli/template_link/spec/top.plain | 3 +- tests/data/cli/undefined_concept.plain | 6 +- tests/data/cli/valid.plain | 3 +- .../data/imports/circular_imports_main.plain | 3 +- tests/data/imports/diamond_imports_main.plain | 3 +- .../imports/import_with_functionalities.plain | 3 +- .../imports_module_with_functionalities.plain | 3 +- tests/data/imports/non_existent_import.plain | 3 +- .../missing_non_functional_requirements.plain | 3 +- .../plain_source_with_absolute_link.plain | 3 +- .../plain_source_with_missing_link.plain | 3 +- .../plain_source_with_url_link.plain | 3 +- .../task_manager_with_reference_links.plain | 18 +- .../without_non_functional_requirement.plain | 3 +- .../concept_validation_acceptance_tests.plain | 20 - ...lidation_acceptance_tests_nondefined.plain | 20 - ...oncept_validation_defined_nondefined.plain | 3 +- ...cept_validation_defined_nondefined_2.plain | 3 +- .../concept_validation_definition.plain | 3 +- .../concept_validation_noconcepts.plain | 3 +- .../concept_validation_nonconcept.plain | 3 +- .../concept_validation_nondefined.plain | 3 +- .../concept_validation_redefinition.plain | 3 +- .../concept_validation_several_concepts.plain | 9 +- .../concept_validation_valid.plain | 3 +- .../plainfileparser/cyclic_definitions.plain | 3 +- .../cyclic_definitions_across_import.plain | 3 +- .../cyclic_definitions_long.plain | 3 +- .../cyclic_definitions_sub_bullet.plain | 3 +- .../plainfileparser/exported_concepts.plain | 3 +- .../exported_concepts_defaults_exported.plain | 3 +- .../exported_concepts_example.plain | 3 +- .../exported_concepts_imported_exported.plain | 3 +- ...ported_concepts_inherited_reexported.plain | 3 +- .../exported_concepts_malformed.plain | 3 +- ...exported_concepts_missing_definition.plain | 3 +- ..._concepts_missing_definition_example.plain | 3 +- ...xported_concepts_multiple_per_bullet.plain | 3 +- ...ncepts_multiple_per_bullet_undefined.plain | 3 +- .../exported_concepts_nested.plain | 3 +- .../exported_concepts_nested_example.plain | 3 +- .../exported_concepts_no_definitions.plain | 3 +- .../exported_concepts_not_declared.plain | 3 +- ...ported_concepts_not_declared_example.plain | 3 +- ...exported_concepts_transitive_example.plain | 3 +- .../exported_concepts_transitive_l1.plain | 3 +- .../exported_concepts_transitive_l2.plain | 3 +- .../old_syntax_functional_specs.plain | 11 + .../plain_file_parser_with_comments.plain | 3 +- .../plain_file_with_comments_indented.plain | 3 +- .../regular_plain_source.plain | 3 +- .../required_concepts_example.plain | 3 +- .../required_concepts_missing.plain | 3 +- .../required_concepts_module.plain | 3 +- .../required_concepts_partial.plain | 3 +- .../required_concepts_partial_duplicate.plain | 3 +- ...requires_without_definitions_example.plain | 3 +- .../plainfileparser/topological_sort.plain | 9 +- .../topological_sort_not_referenced.plain | 12 +- tests/data/requires/chain_base.plain | 3 +- tests/data/requires/chain_fork_top.plain | 3 +- tests/data/requires/chain_middle.plain | 3 +- tests/data/requires/chain_top.plain | 3 +- .../requires/circular_requires_main.plain | 3 +- .../data/requires/circular_requires_sub.plain | 3 +- tests/data/requires/diamond_requires_1.plain | 3 +- tests/data/requires/diamond_requires_2.plain | 3 +- .../requires/diamond_requires_common.plain | 3 +- .../data/requires/diamond_requires_main.plain | 3 +- .../requires/independent_requires_1.plain | 3 +- .../requires/independent_requires_2.plain | 3 +- .../requires/independent_requires_main.plain | 3 +- .../data/requires/invalid_ancestor_base.plain | 3 +- .../data/requires/invalid_ancestor_top.plain | 3 +- .../data/requires/non_existent_require.plain | 3 +- tests/data/requires/normal_requires_1.plain | 3 +- tests/data/requires/normal_requires_2.plain | 3 +- .../requires/normal_requires_common.plain | 3 +- .../data/requires/normal_requires_main.plain | 3 +- tests/data/requires/shared_export_above.plain | 3 +- tests/data/requires/shared_export_base.plain | 3 +- .../data/requires/shared_export_middle.plain | 3 +- tests/data/requires/shared_export_top.plain | 3 +- tests/data/simple.plain | 3 +- .../data/templates/block_level_include.plain | 5 +- tests/data/templates/code_variables.plain | 3 +- tests/data/templates/implement.plain | 4 +- tests/data/templates/implement_2.plain | 4 +- tests/data/templates/malformed_tag.plain | 3 +- tests/data/templates/template_include.plain | 3 +- .../data/templates/unterminated_output.plain | 3 +- tests/test_cli.py | 31 +- tests/test_imports.py | 10 +- tests/test_loaders.py | 2 +- tests/test_plainfile.py | 270 ----------- tests/test_plainfileparser.py | 253 ++++++---- tests/test_plainspec.py | 2 +- tests/test_requires.py | 4 +- 115 files changed, 628 insertions(+), 836 deletions(-) delete mode 100644 tests/data/cli/acceptance_test_binary_resource.plain delete mode 100644 tests/data/cli/acceptance_test_resource.plain delete mode 100644 tests/data/plainfileparser/concept_validation_acceptance_tests.plain delete mode 100644 tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain create mode 100644 tests/data/plainfileparser/old_syntax_functional_specs.plain delete mode 100644 tests/test_plainfile.py diff --git a/src/plain_parser/concept_utils.py b/src/plain_parser/concept_utils.py index c08cebf..7cdf659 100644 --- a/src/plain_parser/concept_utils.py +++ b/src/plain_parser/concept_utils.py @@ -117,15 +117,6 @@ def validate_concepts(marshalled_plain_source) -> list[str]: ]: errors.extend(validate_concepts_in_spec_group(spec_group, marshalled_plain_source, tmp_tree_based_concepts)) - if plain_spec.FUNCTIONAL_REQUIREMENTS in marshalled_plain_source: - for func_spec in marshalled_plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS]: - if "acceptance_tests" not in func_spec: - continue - - acceptance_tests = func_spec["acceptance_tests"] - for spec in acceptance_tests: - errors.extend(validate_concepts_in_spec(spec, tmp_tree_based_concepts, plain_spec.ACCEPTANCE_TESTS)) - return errors diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index c4ce1c0..88acc62 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -1,6 +1,6 @@ import io import os -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Sequence, cast from urllib.parse import urlparse @@ -9,7 +9,7 @@ import mistletoe import mistletoe.block_token from liquid2 import DictLoader, Environment -from mistletoe.block_token import List, Paragraph, Quote +from mistletoe.block_token import Heading, List, Paragraph, Quote from mistletoe.markdown_renderer import Fragment, MarkdownRenderer from mistletoe.span_token import Emphasis, Link, RawText, SpanToken, Strong from mistletoe.token import Token @@ -30,6 +30,7 @@ IMPORT_DIRECTIVE = "import" REQUIRES_DIRECTIVE = "requires" +MIGRATION_HINT = "Run the migrate-plain-syntax skill to convert the project." REQUIRED_CONCEPTS_DIRECTIVE = "required_concepts" EXPORTED_CONCEPTS_DIRECTIVE = "exported_concepts" @@ -42,6 +43,17 @@ } +@dataclass +class Functionality: + """A functionality of ***functional specs***: its func-id and the block tokens of its body.""" + + id: str + line_number: int + # The statement paragraph and the lists under it, in source order. + body: list = field(default_factory=list) + linked_resources: list[dict] = field(default_factory=list) + + @dataclass class PlainFileParseResult: plain_source: dict @@ -88,9 +100,10 @@ def remove_quotes(token): token.children = tuple(new_children) -def check_section_for_linked_resources(section, template_dirs): +def find_linked_resources(tokens, template_dirs) -> list[dict]: + """The links in the tokens, each checked to be a relative path to an existing file that is not a .plain file.""" linked_resources = [] - for link in traverse(section, klass=Link): + for link in (link for token in tokens for link in traverse(token, klass=Link)): parsed_url = urlparse(link.node.target) if parsed_url.scheme != "" or os.path.isabs(link.node.target): raise PlainSyntaxError( @@ -124,19 +137,23 @@ def check_section_for_linked_resources(section, template_dirs): linked_resources.append({"text": link.node.children[0].content, "target": link.node.target}) + return linked_resources + + +def check_section_for_linked_resources(section, template_dirs): + linked_resources = find_linked_resources([section], template_dirs) if linked_resources: section.linked_resources = linked_resources def check_for_linked_resources(plain_source, template_dirs): - for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS: + for specification_heading in plain_spec.LIST_SECTION_HEADINGS: if specification_heading in plain_source and hasattr(plain_source[specification_heading], "children"): for requirement in plain_source[specification_heading].children: check_section_for_linked_resources(requirement, template_dirs) - if hasattr(requirement, plain_spec.ACCEPTANCE_TESTS): - for acceptance_test in requirement.acceptance_tests: - check_section_for_linked_resources(acceptance_test, template_dirs) + for functionality in plain_source.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or []: + functionality.linked_resources = find_linked_resources(functionality.body, template_dirs) def process_section_code_variables(section, code_variables): @@ -171,8 +188,8 @@ def has_functional_specs_section(plain_source) -> bool: def count_functionalities(plain_source) -> int: """Number of functionalities under the ***functional specs*** section (0 if absent or empty).""" - section = plain_source.get(plain_spec.FUNCTIONAL_REQUIREMENTS) - return len(section.children) if section is not None else 0 + functionalities = plain_source.get(plain_spec.FUNCTIONAL_REQUIREMENTS) + return len(functionalities) if functionalities is not None else 0 def validate_functionalities_have_implementation_reqs(plain_source, module_name, has_requires=False) -> None: @@ -201,148 +218,6 @@ def validate_functionalities_have_implementation_reqs(plain_source, module_name, raise PlainSyntaxError(message) -def _is_acceptance_test_heading(token) -> tuple[bool, str | None]: - """ - Check if token is an 'Acceptance test:' heading. - - This method is going to evaluate to True when: - - - The token is a Paragraph - - The paragraph has 1 child - - The child is an Emphasis (*** ... *** marker) - - The Emphasis has 1 child (the raw text content) - - The child of the Emphasis is a Strong - - The Strong has 1 child - - The child of the Strong is a RawText - - The RawText content is equal to "Acceptance test:" - - This is expected structure for the acceptance test heading. - """ - if not isinstance(token, Paragraph): - return False, None - - # Check for the specific structure, descending one level at a time. mistletoe types - # ``children`` as ``Optional[Iterable[Token]]``, so cast each level to a concrete - # sequence before indexing/len. - children = cast("Sequence[Token]", token.children) - if len(children) != 1 or not isinstance(children[0], Emphasis): - return False, None - - emphasis_children = cast("Sequence[Token]", children[0].children) - if len(emphasis_children) != 1 or not isinstance(emphasis_children[0], Strong): - return False, None - - strong_children = cast("Sequence[Token]", emphasis_children[0].children) - if len(strong_children) != 1 or not isinstance(strong_children[0], RawText): - return False, None - - # Check the actual text content - content = strong_children[0].content.strip() - if content == plain_spec.ACCEPTANCE_TEST_HEADING: - return True, None - problem = f"Syntax error at line {token.line_number}: Invalid acceptance test heading (`{content}`). Expected: `{plain_spec.ACCEPTANCE_TEST_HEADING}`." - return False, problem - - -def _find_acceptance_test_heading(token): - """ - Recursively search a token tree for an `acceptance tests` heading. - - Returns the heading token if one is found anywhere in the subtree, otherwise None. - Used to detect acceptance tests nested under sections other than functional specs. - """ - is_acceptance_test_heading, _ = _is_acceptance_test_heading(token) - if is_acceptance_test_heading: - return token - - if getattr(token, "children", None): - for child in token.children: - found = _find_acceptance_test_heading(child) - if found is not None: - return found - - return None - - -def _process_single_acceptance_test_requirement(functional_requirement: mistletoe.block_token.ListItem): - """ - Process a single functionality to extract acceptance tests. - - Expected functional_requirement properties: - - Is a list item - - If acceptance tests are specified, it has 3 children: - - List item element with functionality instructions/text - - Paragraph with `***Acceptance test:***` heading - - List of acceptance tests - - If acceptance tests are not specified, it has 1 child: - - List item element with functionality instructions/text - """ - new_children: list = [] - functional_requirement_children = iter(functional_requirement.children or ()) - acceptance_tests_found_already = False - - for functional_requirement_child in functional_requirement_children: - is_acceptance_test_heading, acceptance_test_heading_problem = _is_acceptance_test_heading( - functional_requirement_child - ) - if acceptance_test_heading_problem: - # Handle the case when the heading is not valid. This case includes cases such as: - # - Writing `acceptance test` instead of `acceptance tests` (or any other syntax diffs). - # - Instead of specifying `acceptance tests` below the functionality, creator of the plain file - # might have specified some other building block (e.g. `implementation reqs`) - raise PlainSyntaxError(f"Plain syntax error: {acceptance_test_heading_problem}") - - if is_acceptance_test_heading: - if acceptance_tests_found_already: - # Handle edge case of duplicated ***acceptance tests*** heading - raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {functional_requirement_child.line_number}: Duplicate 'acceptance tests' heading found within the same functionality. Only one block of acceptance tests is allowed per functionality." - ) - - try: - # If there is an acceptance test heading, the next token should be a list, with children being list items - next_token = next(functional_requirement_children) - if isinstance(next_token, List): - # Found valid acceptance test list -> Assign it property acceptance_tests and append all list items to it - if not hasattr(functional_requirement, plain_spec.ACCEPTANCE_TESTS): - functional_requirement.acceptance_tests = [] - for list_item in next_token.children: - functional_requirement.acceptance_tests.append(list_item) - acceptance_tests_found_already = True - else: - # Not followed by a list, keep both tokens - new_children.append(functional_requirement_child) - new_children.append(next_token) - except StopIteration: - # No next token, keep this one - new_children.append(functional_requirement_child) - else: - # Regular token, keep it - new_children.append(functional_requirement_child) - - # Assign the children property to all the children of the functionality from previous, with exception - # of those we parsed as acceptance tests - container_type = cast(type, type(functional_requirement.children)) - functional_requirement.children = container_type(new_children) - - -def process_acceptance_tests(plain_source): - # Early returns for cases without functionalities - if plain_spec.FUNCTIONAL_REQUIREMENTS not in plain_source: - return - frs = plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS] - if not hasattr(frs, "children"): - return - - # Process each functionality - for functional_requirement in frs.children: - if not hasattr(functional_requirement, "children"): - continue - - # Process each requirement to extract acceptance tests - _process_single_acceptance_test_requirement(functional_requirement) - - def get_raw_text(token): if isinstance(token, RawText): yield token.content @@ -357,16 +232,27 @@ def get_raw_text(token): raise Exception(f"Unknown token type: {type(token)}") +def render_tokens(renderer: MarkdownRenderer, tokens) -> str: + """The block tokens rendered as one markdown text, without leading and trailing blank lines.""" + document = mistletoe.Document("") + document.children = list(tokens) + return renderer.render(document).strip("\n") + + +def marshall_functionality(renderer: MarkdownRenderer, functionality: Functionality) -> dict: + return { + "id": functionality.id, + "markdown": render_tokens(renderer, functionality.body), + "linked_resources": functionality.linked_resources, + plain_spec.CONFORMANCE_TESTS: [], + plain_spec.ACCEPTANCE_TESTS: [], + } + + def marshall_plain_source(input_plain_source): plain_source = {} with PlainRenderer() as renderer: - if "ID" in input_plain_source: - plain_source["ID"] = input_plain_source["ID"] - - if "Heading" in input_plain_source: - plain_source["Heading"] = input_plain_source["Heading"] - - for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS: + for specification_heading in plain_spec.LIST_SECTION_HEADINGS: if specification_heading in input_plain_source and hasattr( input_plain_source[specification_heading], "children" ): @@ -376,18 +262,16 @@ def marshall_plain_source(input_plain_source): if hasattr(requirement, "linked_resources"): requirement_section["linked_resources"] = requirement.linked_resources - if hasattr(requirement, plain_spec.ACCEPTANCE_TESTS): - requirement_section[plain_spec.ACCEPTANCE_TESTS] = [] - for acceptance_test in requirement.acceptance_tests: - acceptance_test_section = {"markdown": renderer.render(acceptance_test).strip()} - if hasattr(acceptance_test, "linked_resources"): - acceptance_test_section["linked_resources"] = acceptance_test.linked_resources - requirement_section[plain_spec.ACCEPTANCE_TESTS].append(acceptance_test_section) - list_of_requirements.append(requirement_section) plain_source[specification_heading] = list_of_requirements + if plain_spec.FUNCTIONAL_REQUIREMENTS in input_plain_source: + plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS] = [ + marshall_functionality(renderer, functionality) + for functionality in input_plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS] or [] + ] + return plain_source @@ -474,6 +358,16 @@ def restore_stripped_lines(plain_source_text: str, content: str) -> str: return "\n" * stripped_line_count + content +def syntax_error_at(line_number: int | None, module_name: str | None) -> str: + """`Syntax error at line N in module 'm'`, without the parts that are not known.""" + where = "Syntax error" + if line_number is not None: + where += f" at line {line_number}" + if module_name: + where += f" in module '{module_name}'" + return where + + def read_plain_source_metadata(plain_source_text): try: plain_source_obj = frontmatter.loads(plain_source_text) @@ -502,125 +396,173 @@ def read_plain_source_metadata(plain_source_text): return plain_source_obj -def parse_plain_source( # noqa: C901 - plain_source_text: str, - code_variables: dict, - template_dirs: list[str], - imported_modules: list[str], - modules_trace: list[str], - module_name: str | None = None, -) -> PlainFileParseResult: - plain_source_text = normalize_line_endings(plain_source_text) +TEST_PLAIN_FILE_SECTION_HEADINGS = (plain_spec.CONFORMANCE_TEST_HEADING, plain_spec.ACCEPTANCE_TEST_HEADING) - plain_source_obj = read_plain_source_metadata(plain_source_text) - - plain_source = PLAIN_SOURCE_TEMPLATE.copy() - - if IMPORT_DIRECTIVE in plain_source_obj.metadata: - required_concepts = process_imports( - plain_source, - plain_source_obj.metadata[IMPORT_DIRECTIVE], - code_variables, - template_dirs, - imported_modules, - modules_trace, - ) - else: - required_concepts = list[str]() - [_, loaded_templates] = loaders.get_loaded_templates(template_dirs, plain_source_text, module_name) +def check_no_test_sections(plain_source_full_text: str, module_name: str | None) -> None: + """Raise when a `***conformance tests***` or `***acceptance tests***` marker is anywhere in a core spec.""" + for line_number, line in enumerate(plain_source_full_text.split("\n"), start=1): + for heading in TEST_PLAIN_FILE_SECTION_HEADINGS: + if line.strip() == f"***{heading}***": + raise PlainSyntaxError( + f"Plain syntax error: {syntax_error_at(line_number, module_name)}: The `{heading}` section is not " + f"allowed in a core spec; test scenarios live in test plain files. {MIGRATION_HINT}" + ) - plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content) - plain_source_full_text = render_plain_source(plain_source_content, loaded_templates, code_variables) +def section_marker(token: Paragraph) -> str | None: + """The name in a `***name***` paragraph, or None when the paragraph is not a section marker.""" + paragraph_children = cast("Sequence[Token]", token.children) + if len(paragraph_children) != 1 or not isinstance(paragraph_children[0], Emphasis): + return None + emphasis_children = cast("Sequence[Token]", paragraph_children[0].children) + if len(emphasis_children) != 1 or not isinstance(emphasis_children[0], Strong): + return None + strong_children = cast("Sequence[Token]", emphasis_children[0].children) + return cast(RawText, strong_children[0]).content - plain_file = mistletoe.Document(io.StringIO(plain_source_full_text)) - remove_quotes(plain_file) +def read_sections( # noqa: C901 + plain_source: dict, document: mistletoe.Document, source_lines: list[str], module_name: str | None +) -> None: + """Add the sections of the rendered core spec to ``plain_source``. + A list section holds a mistletoe Document whose children are its list items; ``functional specs`` holds a list + of Functionality items, one per `# ` heading, each with the block tokens up to the next heading or + section marker. Raise on the first structural error. + """ current_specification_heading = None + current_functionality: Functionality | None = None processed_specification_headings = set[str]() - for token in plain_file.children: - token_text = "".join(get_raw_text(token)).strip() - if isinstance(token, Paragraph): - paragraph_children = cast("Sequence[Token]", token.children) - emphasis_children = ( - cast("Sequence[Token]", paragraph_children[0].children) - if paragraph_children and isinstance(paragraph_children[0], Emphasis) - else () + + def close_functionality(): + if current_functionality is not None and not current_functionality.body: + raise PlainSyntaxError( + f"Plain syntax error: {syntax_error_at(current_functionality.line_number, module_name)}: " + f"Functionality `{current_functionality.id}` has no body." ) - if not ( - len(paragraph_children) == 1 - and isinstance(paragraph_children[0], Emphasis) - and len(emphasis_children) == 1 - and isinstance(emphasis_children[0], Strong) - ): + for token in document.children: + token_text = "".join(get_raw_text(token)).strip() + where = syntax_error_at(token.line_number, module_name) + in_functional_specs = current_specification_heading == plain_spec.FUNCTIONAL_REQUIREMENTS + if isinstance(token, Heading): + if not in_functional_specs: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification (`{token_text}`)" + f"Plain syntax error: {where}: A `# ` heading is allowed only in the " + f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`)." ) - - strong_children = cast("Sequence[Token]", emphasis_children[0].children) - specification_heading = cast(RawText, strong_children[0]).content - - if specification_heading == plain_spec.ACCEPTANCE_TEST_HEADING: + if token.level != 1: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec." + f"Plain syntax error: {where}: Invalid heading level {token.level} (`{token_text}`). " + f"A functionality heading is `# `." ) + close_functionality() + current_functionality = Functionality(token_text, token.line_number) + plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS].append(current_functionality) + elif isinstance(token, Paragraph): + specification_heading = section_marker(token) + if specification_heading is None: + if not in_functional_specs: + raise PlainSyntaxError(f"Plain syntax error: {where}: Invalid specification (`{token_text}`)") + if current_functionality is None: + raise PlainSyntaxError( + f"Plain syntax error: {where}: Content before the first `# ` heading in the " + f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`)." + ) + current_functionality.body.append(token) + continue if specification_heading not in plain_spec.ALLOWED_SPECIFICATION_HEADINGS: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Invalid specification heading (`{specification_heading}`). Allowed headings: {', '.join(plain_spec.ALLOWED_SPECIFICATION_HEADINGS)}" + f"Plain syntax error: {where}: Invalid specification heading (`{specification_heading}`). " + f"Allowed headings: {', '.join(plain_spec.ALLOWED_SPECIFICATION_HEADINGS)}" ) - if ( - specification_heading == plain_spec.FUNCTIONAL_REQUIREMENTS - and specification_heading not in plain_source - ): - plain_source[specification_heading] = None - if specification_heading in processed_specification_headings: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Duplicate specification heading (`{specification_heading}`)" + f"Plain syntax error: {where}: Duplicate specification heading (`{specification_heading}`)" ) if specification_heading == plain_spec.DEFINITIONS and current_specification_heading is not None: raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Definitions specification must be the first specification in the section (`{token_text}`)" + f"Plain syntax error: {where}: Definitions specification must be the first specification in " + f"the section (`{token_text}`)" ) + close_functionality() + current_functionality = None current_specification_heading = specification_heading - if plain_source[current_specification_heading] is None: - plain_source[current_specification_heading] = mistletoe.Document("") + if specification_heading == plain_spec.FUNCTIONAL_REQUIREMENTS: + plain_source[specification_heading] = [] + elif plain_source[specification_heading] is None: + plain_source[specification_heading] = mistletoe.Document("") - processed_specification_headings.add(current_specification_heading) + processed_specification_headings.add(specification_heading) elif isinstance(token, List): if current_specification_heading is None: + raise PlainSyntaxError(f"Plain syntax error: {where}: Missing specification heading (`{token_text}`)") + if not in_functional_specs: + plain_source[current_specification_heading].children.extend(token.children) + continue + + if not source_lines[token.line_number - 1][:1].isspace(): raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Missing specification heading (`{token_text}`)" + f"Plain syntax error: {where}: A flush-left `- ` list in the {plain_spec.FUNCTIONAL_REQUIREMENTS} " + f"section is the old syntax; a functionality is a `# ` heading followed by its body. " + f"{MIGRATION_HINT}" ) - plain_source[current_specification_heading].children.extend(token.children) - + if current_functionality is None: + raise PlainSyntaxError( + f"Plain syntax error: {where}: Content before the first `# ` heading in the " + f"{plain_spec.FUNCTIONAL_REQUIREMENTS} section (`{token_text}`)." + ) + current_functionality.body.append(token) else: - raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)" - ) + raise PlainSyntaxError(f"Plain syntax error: {where}: Invalid source structure (`{token_text}`)") - # Acceptance tests are only allowed nested under functional specs. Reject them when - # they are nested under any other section (e.g. definitions, implementation reqs, test reqs). - for non_functional_heading in ( - plain_spec.DEFINITIONS, - plain_spec.NON_FUNCTIONAL_REQUIREMENTS, - plain_spec.TEST_REQUIREMENTS, - ): - section = plain_source.get(non_functional_heading) - if section is None: - continue - nested_acceptance_test_heading = _find_acceptance_test_heading(section) - if nested_acceptance_test_heading is not None: - raise PlainSyntaxError( - f"Plain syntax error: Syntax error at line {nested_acceptance_test_heading.line_number}: {plain_spec.ACCEPTANCE_TEST_HEADING} heading should be nested under specific functional spec." - ) + close_functionality() + + +def parse_plain_source( # noqa: C901 + plain_source_text: str, + code_variables: dict, + template_dirs: list[str], + imported_modules: list[str], + modules_trace: list[str], + module_name: str | None = None, +) -> PlainFileParseResult: + plain_source_text = normalize_line_endings(plain_source_text) + + plain_source_obj = read_plain_source_metadata(plain_source_text) + + plain_source = PLAIN_SOURCE_TEMPLATE.copy() + + if IMPORT_DIRECTIVE in plain_source_obj.metadata: + required_concepts = process_imports( + plain_source, + plain_source_obj.metadata[IMPORT_DIRECTIVE], + code_variables, + template_dirs, + imported_modules, + modules_trace, + ) + else: + required_concepts = list[str]() + + [_, loaded_templates] = loaders.get_loaded_templates(template_dirs, plain_source_text, module_name) + + plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content) + + plain_source_full_text = render_plain_source(plain_source_content, loaded_templates, code_variables) + + check_no_test_sections(plain_source_full_text, module_name) + + plain_file = mistletoe.Document(io.StringIO(plain_source_full_text)) + + remove_quotes(plain_file) + + read_sections(plain_source, plain_file, plain_source_full_text.split("\n"), module_name) defined_concepts = set[str]() if plain_source[plain_spec.DEFINITIONS] is not None: @@ -866,12 +808,10 @@ def validate_and_build_module_tree( code_variables: dict, template_dirs: list[str], ) -> dict: - """Merge exported definitions, validate acceptance tests, linked resources and concepts, and return the - marshalled plain source tree.""" + """Merge exported definitions, validate linked resources and concepts, and return the marshalled plain source + tree.""" process_exported_definitions(plain_file_parse_result.plain_source, exported_definitions) - process_acceptance_tests(plain_file_parse_result.plain_source) - check_for_linked_resources(plain_file_parse_result.plain_source, template_dirs) marshalled_plain_source = marshall_plain_source(plain_file_parse_result.plain_source) diff --git a/src/plain_parser/plain_spec.py b/src/plain_parser/plain_spec.py index 3c224e5..c6bcbd1 100644 --- a/src/plain_parser/plain_spec.py +++ b/src/plain_parser/plain_spec.py @@ -11,18 +11,17 @@ NON_FUNCTIONAL_REQUIREMENTS = "implementation reqs" TEST_REQUIREMENTS = "test reqs" FUNCTIONAL_REQUIREMENTS = "functional specs" +CONFORMANCE_TESTS = "conformance_tests" ACCEPTANCE_TESTS = "acceptance_tests" +CONFORMANCE_TEST_HEADING = "conformance tests" ACCEPTANCE_TEST_HEADING = "acceptance tests" -ALLOWED_SPECIFICATION_HEADINGS = [ - DEFINITIONS, - NON_FUNCTIONAL_REQUIREMENTS, - TEST_REQUIREMENTS, - FUNCTIONAL_REQUIREMENTS, - ACCEPTANCE_TEST_HEADING, -] +# The sections whose content is a list of `- ` items. +LIST_SECTION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS] -ALLOWED_IMPORT_SPECIFICATION_HEADINGS = [DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS] +ALLOWED_SPECIFICATION_HEADINGS = [*LIST_SECTION_HEADINGS, FUNCTIONAL_REQUIREMENTS] + +ALLOWED_IMPORT_SPECIFICATION_HEADINGS = LIST_SECTION_HEADINGS def has_acceptance_tests(plain_source_tree) -> bool: diff --git a/tests/data/cli/acceptance_test_binary_resource.plain b/tests/data/cli/acceptance_test_binary_resource.plain deleted file mode 100644 index 8b7aaa2..0000000 --- a/tests/data/cli/acceptance_test_binary_resource.plain +++ /dev/null @@ -1,10 +0,0 @@ -***implementation reqs*** - -- An implementation requirement. - -***functional specs*** - -- A functionality. - - ***acceptance tests*** - - Behaves as described in [the binary](binary.bin). diff --git a/tests/data/cli/acceptance_test_resource.plain b/tests/data/cli/acceptance_test_resource.plain deleted file mode 100644 index 017bbae..0000000 --- a/tests/data/cli/acceptance_test_resource.plain +++ /dev/null @@ -1,10 +0,0 @@ -***implementation reqs*** - -- An implementation requirement. - -***functional specs*** - -- A functionality. - - ***acceptance tests*** - - Behaves as described in [the notes](notes.md). diff --git a/tests/data/cli/base64_resource.plain b/tests/data/cli/base64_resource.plain index a367653..2334ce7 100644 --- a/tests/data/cli/base64_resource.plain +++ b/tests/data/cli/base64_resource.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. +# a-functionality +A functionality. diff --git a/tests/data/cli/binary_resource.plain b/tests/data/cli/binary_resource.plain index 0bca357..a07c31c 100644 --- a/tests/data/cli/binary_resource.plain +++ b/tests/data/cli/binary_resource.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. +# a-functionality +A functionality. diff --git a/tests/data/cli/code_variable_conflict.plain b/tests/data/cli/code_variable_conflict.plain index eb087c5..356bcec 100644 --- a/tests/data/cli/code_variable_conflict.plain +++ b/tests/data/cli/code_variable_conflict.plain @@ -4,6 +4,8 @@ ***functional specs*** -- Implement {% include "code_variable_template.plain", variable_name: "first" %} +# implement-include-code-variable-template +Implement {% include "code_variable_template.plain", variable_name: "first" %} -- Implement {% include "code_variable_template.plain", variable_name: "second" %} +# implement-include-code-variable-template-2 +Implement {% include "code_variable_template.plain", variable_name: "second" %} diff --git a/tests/data/cli/missing_resource.plain b/tests/data/cli/missing_resource.plain index e0e687c..0916c7f 100644 --- a/tests/data/cli/missing_resource.plain +++ b/tests/data/cli/missing_resource.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. +# a-functionality +A functionality. diff --git a/tests/data/cli/parent_link/a/spec/top.plain b/tests/data/cli/parent_link/a/spec/top.plain index 7065c83..87bb201 100644 --- a/tests/data/cli/parent_link/a/spec/top.plain +++ b/tests/data/cli/parent_link/a/spec/top.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. See [the notes](../shared/notes.md). +# a-functionality-see-the-notes +A functionality. See [the notes](../shared/notes.md). diff --git a/tests/data/cli/requires_binary_resource_base.plain b/tests/data/cli/requires_binary_resource_base.plain index 253e5a1..ad78fda 100644 --- a/tests/data/cli/requires_binary_resource_base.plain +++ b/tests/data/cli/requires_binary_resource_base.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality. +# a-base-functionality +A base functionality. diff --git a/tests/data/cli/requires_binary_resource_top.plain b/tests/data/cli/requires_binary_resource_top.plain index 33ea1ac..3f6fb79 100644 --- a/tests/data/cli/requires_binary_resource_top.plain +++ b/tests/data/cli/requires_binary_resource_top.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A top functionality. +# a-top-functionality +A top functionality. diff --git a/tests/data/cli/resource_before_frid_walk/base.plain b/tests/data/cli/resource_before_frid_walk/base.plain index 97044b1..7afab60 100644 --- a/tests/data/cli/resource_before_frid_walk/base.plain +++ b/tests/data/cli/resource_before_frid_walk/base.plain @@ -5,4 +5,5 @@ ***functional specs*** -- :Implementation: has an entry point. +# implementation-has-an-entry-point +:Implementation: has an entry point. diff --git a/tests/data/cli/resource_before_frid_walk/top.plain b/tests/data/cli/resource_before_frid_walk/top.plain index 4ab7c68..9414b9e 100644 --- a/tests/data/cli/resource_before_frid_walk/top.plain +++ b/tests/data/cli/resource_before_frid_walk/top.plain @@ -9,6 +9,8 @@ requires: ***functional specs*** -- :Implementation: names {% include "snippet.plain", name: "first" %}. +# implementation-names-include-snippet-plain +:Implementation: names {% include "snippet.plain", name: "first" %}. -- :Implementation: names {% include "snippet.plain", name: "second" %}. +# implementation-names-include-snippet-plain-2 +:Implementation: names {% include "snippet.plain", name: "second" %}. diff --git a/tests/data/cli/shared_link_helper.plain b/tests/data/cli/shared_link_helper.plain index 61f18e7..1c0660c 100644 --- a/tests/data/cli/shared_link_helper.plain +++ b/tests/data/cli/shared_link_helper.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. See [the notes](notes.md). +# a-functionality-see-the-notes +A functionality. See [the notes](notes.md). diff --git a/tests/data/cli/shared_link_top.plain b/tests/data/cli/shared_link_top.plain index 19b565d..e7d2c93 100644 --- a/tests/data/cli/shared_link_top.plain +++ b/tests/data/cli/shared_link_top.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A functionality. Also [the notes](notes.md). +# a-functionality-also-the-notes +A functionality. Also [the notes](notes.md). diff --git a/tests/data/cli/subdir/nested.plain b/tests/data/cli/subdir/nested.plain index 72f8371..f17036e 100644 --- a/tests/data/cli/subdir/nested.plain +++ b/tests/data/cli/subdir/nested.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A nested functionality. +# a-nested-functionality +A nested functionality. diff --git a/tests/data/cli/template_link/spec/top.plain b/tests/data/cli/template_link/spec/top.plain index 61f18e7..1c0660c 100644 --- a/tests/data/cli/template_link/spec/top.plain +++ b/tests/data/cli/template_link/spec/top.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A functionality. See [the notes](notes.md). +# a-functionality-see-the-notes +A functionality. See [the notes](notes.md). diff --git a/tests/data/cli/undefined_concept.plain b/tests/data/cli/undefined_concept.plain index adaa93a..53c8b1b 100644 --- a/tests/data/cli/undefined_concept.plain +++ b/tests/data/cli/undefined_concept.plain @@ -8,6 +8,8 @@ ***functional specs*** -- :App: greets the :User:. +# app-greets-the-user +:App: greets the :User:. -- :App: says goodbye to the :Visitor:. +# app-says-goodbye-to-the +:App: says goodbye to the :Visitor:. diff --git a/tests/data/cli/valid.plain b/tests/data/cli/valid.plain index 349be5a..f6dd1d5 100644 --- a/tests/data/cli/valid.plain +++ b/tests/data/cli/valid.plain @@ -8,4 +8,5 @@ ***functional specs*** -- :App: prints "hello, world". +# app-prints-hello-world +:App: prints "hello, world". diff --git a/tests/data/imports/circular_imports_main.plain b/tests/data/imports/circular_imports_main.plain index 828c0b6..b673d41 100644 --- a/tests/data/imports/circular_imports_main.plain +++ b/tests/data/imports/circular_imports_main.plain @@ -9,4 +9,5 @@ import: ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/imports/diamond_imports_main.plain b/tests/data/imports/diamond_imports_main.plain index 9d06ea2..05f5820 100644 --- a/tests/data/imports/diamond_imports_main.plain +++ b/tests/data/imports/diamond_imports_main.plain @@ -10,4 +10,5 @@ import: ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/imports/import_with_functionalities.plain b/tests/data/imports/import_with_functionalities.plain index 577041d..e4c01da 100644 --- a/tests/data/imports/import_with_functionalities.plain +++ b/tests/data/imports/import_with_functionalities.plain @@ -4,4 +4,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/imports/imports_module_with_functionalities.plain b/tests/data/imports/imports_module_with_functionalities.plain index f2ff517..d497923 100644 --- a/tests/data/imports/imports_module_with_functionalities.plain +++ b/tests/data/imports/imports_module_with_functionalities.plain @@ -9,4 +9,5 @@ import: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/imports/non_existent_import.plain b/tests/data/imports/non_existent_import.plain index e3c902d..035b6d7 100644 --- a/tests/data/imports/non_existent_import.plain +++ b/tests/data/imports/non_existent_import.plain @@ -9,4 +9,5 @@ import: ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/plainfile/missing_non_functional_requirements.plain b/tests/data/plainfile/missing_non_functional_requirements.plain index 8c4aa76..10049d6 100644 --- a/tests/data/plainfile/missing_non_functional_requirements.plain +++ b/tests/data/plainfile/missing_non_functional_requirements.plain @@ -2,4 +2,5 @@ - Test definition ***functional specs*** -- Test functionality +# test-functionality +Test functionality diff --git a/tests/data/plainfile/plain_source_with_absolute_link.plain b/tests/data/plainfile/plain_source_with_absolute_link.plain index 627d089..504afe2 100644 --- a/tests/data/plainfile/plain_source_with_absolute_link.plain +++ b/tests/data/plainfile/plain_source_with_absolute_link.plain @@ -6,4 +6,5 @@ ***functional specs*** -- Absolute links are not allowed for now [url_link](/link/to/absolute.file). +# absolute-links-are-not-allowed +Absolute links are not allowed for now [url_link](/link/to/absolute.file). diff --git a/tests/data/plainfile/plain_source_with_missing_link.plain b/tests/data/plainfile/plain_source_with_missing_link.plain index 4f06694..f6fb406 100644 --- a/tests/data/plainfile/plain_source_with_missing_link.plain +++ b/tests/data/plainfile/plain_source_with_missing_link.plain @@ -6,4 +6,5 @@ ***functional specs*** -- Links must point at an existing file [missing resource](resources/missing_resource.yaml). +# links-must-point-at-an +Links must point at an existing file [missing resource](resources/missing_resource.yaml). diff --git a/tests/data/plainfile/plain_source_with_url_link.plain b/tests/data/plainfile/plain_source_with_url_link.plain index 47efdd1..c569e65 100644 --- a/tests/data/plainfile/plain_source_with_url_link.plain +++ b/tests/data/plainfile/plain_source_with_url_link.plain @@ -6,4 +6,5 @@ ***functional specs*** -- URL links are not allowed for now [url_link](http://www.example.com). +# url-links-are-not-allowed +URL links are not allowed for now [url_link](http://www.example.com). diff --git a/tests/data/plainfile/task_manager_with_reference_links.plain b/tests/data/plainfile/task_manager_with_reference_links.plain index bbddc31..98d3423 100644 --- a/tests/data/plainfile/task_manager_with_reference_links.plain +++ b/tests/data/plainfile/task_manager_with_reference_links.plain @@ -27,14 +27,20 @@ ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. -- Show :TaskList:. The details of the user interface are provided in the file [task_list_ui_specification.yaml](task_list_ui_specification.yaml). +# show-tasklist-the-details-of +Show :TaskList:. The details of the user interface are provided in the file [task_list_ui_specification.yaml](task_list_ui_specification.yaml). -- :User: should be able to add :Task:. Only valid :Task: items can be added. The details of the user interface are provided in the file [add_new_task_modal_specification.yaml](add_new_task_modal_specification.yaml). +# user-should-be-able-to +:User: should be able to add :Task:. Only valid :Task: items can be added. The details of the user interface are provided in the file [add_new_task_modal_specification.yaml](add_new_task_modal_specification.yaml). -- :User: should be able to delete :Task:. +# user-should-be-able-to-2 +:User: should be able to delete :Task:. -- :User: should be able to edit :Task:. +# user-should-be-able-to-3 +:User: should be able to edit :Task:. -- :User: should be able to mark :Task: as completed. +# user-should-be-able-to-4 +:User: should be able to mark :Task: as completed. diff --git a/tests/data/plainfile/without_non_functional_requirement.plain b/tests/data/plainfile/without_non_functional_requirement.plain index 1aca180..d9a44de 100644 --- a/tests/data/plainfile/without_non_functional_requirement.plain +++ b/tests/data/plainfile/without_non_functional_requirement.plain @@ -2,4 +2,5 @@ ***functional specs*** -- Simple functionality +# simple-functionality +Simple functionality diff --git a/tests/data/plainfileparser/concept_validation_acceptance_tests.plain b/tests/data/plainfileparser/concept_validation_acceptance_tests.plain deleted file mode 100644 index f9b5495..0000000 --- a/tests/data/plainfileparser/concept_validation_acceptance_tests.plain +++ /dev/null @@ -1,20 +0,0 @@ -***definitions*** - -- :concept: is a concept. - -***test reqs*** - -- You can test the :concept:. - -***implementation reqs*** - -- You cannot solve the hardest problem. -- If you do, let me know. - -***functional specs*** - -- Display "hello, world" - - ***acceptance tests*** - - - Test the :concept:. diff --git a/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain b/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain deleted file mode 100644 index f492c45..0000000 --- a/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain +++ /dev/null @@ -1,20 +0,0 @@ -***definitions*** - -- :concept: is a concept. - -***test reqs*** - -- You can test the :concept:. - -***implementation reqs*** - -- You cannot solve the hardest problem. -- If you do, let me know. - -***functional specs*** - -- Display "hello, world" - - ***acceptance tests*** - - - Test the :nonDefinedConcept:. diff --git a/tests/data/plainfileparser/concept_validation_defined_nondefined.plain b/tests/data/plainfileparser/concept_validation_defined_nondefined.plain index 0a31671..7f51fef 100644 --- a/tests/data/plainfileparser/concept_validation_defined_nondefined.plain +++ b/tests/data/plainfileparser/concept_validation_defined_nondefined.plain @@ -15,4 +15,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain b/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain index b3fcb6a..9a318b7 100644 --- a/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain +++ b/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain @@ -15,4 +15,5 @@ ***functional specs*** -- Implement the :nonDefinedConcept:. +# implement-the-nondefinedconcept +Implement the :nonDefinedConcept:. diff --git a/tests/data/plainfileparser/concept_validation_definition.plain b/tests/data/plainfileparser/concept_validation_definition.plain index 1885e67..a207b4d 100644 --- a/tests/data/plainfileparser/concept_validation_definition.plain +++ b/tests/data/plainfileparser/concept_validation_definition.plain @@ -13,4 +13,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_noconcepts.plain b/tests/data/plainfileparser/concept_validation_noconcepts.plain index e47b786..c8c9e31 100644 --- a/tests/data/plainfileparser/concept_validation_noconcepts.plain +++ b/tests/data/plainfileparser/concept_validation_noconcepts.plain @@ -14,4 +14,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_nonconcept.plain b/tests/data/plainfileparser/concept_validation_nonconcept.plain index c42c5e3..e4255a1 100644 --- a/tests/data/plainfileparser/concept_validation_nonconcept.plain +++ b/tests/data/plainfileparser/concept_validation_nonconcept.plain @@ -12,4 +12,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_nondefined.plain b/tests/data/plainfileparser/concept_validation_nondefined.plain index b8cc98c..6f7fb05 100644 --- a/tests/data/plainfileparser/concept_validation_nondefined.plain +++ b/tests/data/plainfileparser/concept_validation_nondefined.plain @@ -15,4 +15,5 @@ ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/plainfileparser/concept_validation_redefinition.plain b/tests/data/plainfileparser/concept_validation_redefinition.plain index 2f1793a..9e38375 100644 --- a/tests/data/plainfileparser/concept_validation_redefinition.plain +++ b/tests/data/plainfileparser/concept_validation_redefinition.plain @@ -15,4 +15,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/concept_validation_several_concepts.plain b/tests/data/plainfileparser/concept_validation_several_concepts.plain index 741576b..cf5a77d 100644 --- a/tests/data/plainfileparser/concept_validation_several_concepts.plain +++ b/tests/data/plainfileparser/concept_validation_several_concepts.plain @@ -8,6 +8,9 @@ ***functional specs*** -- Display :concept1:. -- Display :concept2:. -- Display :concept3:. +# display-concept1 +Display :concept1:. +# display-concept2 +Display :concept2:. +# display-concept3 +Display :concept3:. diff --git a/tests/data/plainfileparser/concept_validation_valid.plain b/tests/data/plainfileparser/concept_validation_valid.plain index 0878022..f153142 100644 --- a/tests/data/plainfileparser/concept_validation_valid.plain +++ b/tests/data/plainfileparser/concept_validation_valid.plain @@ -13,4 +13,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/cyclic_definitions.plain b/tests/data/plainfileparser/cyclic_definitions.plain index 154492d..7d0db92 100644 --- a/tests/data/plainfileparser/cyclic_definitions.plain +++ b/tests/data/plainfileparser/cyclic_definitions.plain @@ -6,4 +6,5 @@ - The :A: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :A: concept. +# implement-a-wrapper-for-the +Implement a wrapper for the :A: concept. diff --git a/tests/data/plainfileparser/cyclic_definitions_across_import.plain b/tests/data/plainfileparser/cyclic_definitions_across_import.plain index 8c26f13..11a1732 100644 --- a/tests/data/plainfileparser/cyclic_definitions_across_import.plain +++ b/tests/data/plainfileparser/cyclic_definitions_across_import.plain @@ -10,4 +10,5 @@ import: - The :Concept: concept must be implemented. ***functional specs*** -- Implement the :Concept: concept. +# implement-the-concept-concept +Implement the :Concept: concept. diff --git a/tests/data/plainfileparser/cyclic_definitions_long.plain b/tests/data/plainfileparser/cyclic_definitions_long.plain index 9f4c071..31b8ea3 100644 --- a/tests/data/plainfileparser/cyclic_definitions_long.plain +++ b/tests/data/plainfileparser/cyclic_definitions_long.plain @@ -8,4 +8,5 @@ - The :D: concept must be implemented. ***functional specs*** -- Implement the :D: concept. +# implement-the-d-concept +Implement the :D: concept. diff --git a/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain b/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain index 08006e9..b5c6e53 100644 --- a/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain +++ b/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain @@ -8,4 +8,5 @@ - The :Receipt: concept must be implemented. ***functional specs*** -- Implement the :Receipt: concept. +# implement-the-receipt-concept +Implement the :Receipt: concept. diff --git a/tests/data/plainfileparser/exported_concepts.plain b/tests/data/plainfileparser/exported_concepts.plain index a8425c9..08ea994 100644 --- a/tests/data/plainfileparser/exported_concepts.plain +++ b/tests/data/plainfileparser/exported_concepts.plain @@ -10,4 +10,5 @@ exported_concepts: - The :Concept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :Concept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_defaults_exported.plain b/tests/data/plainfileparser/exported_concepts_defaults_exported.plain index 014a009..7f986de 100644 --- a/tests/data/plainfileparser/exported_concepts_defaults_exported.plain +++ b/tests/data/plainfileparser/exported_concepts_defaults_exported.plain @@ -10,4 +10,5 @@ exported_concepts: - The :NewConcept: must be implemented. ***functional specs*** -- Implement a wrapper for the :NewConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :NewConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_example.plain b/tests/data/plainfileparser/exported_concepts_example.plain index 4747839..fa62d9a 100644 --- a/tests/data/plainfileparser/exported_concepts_example.plain +++ b/tests/data/plainfileparser/exported_concepts_example.plain @@ -10,4 +10,5 @@ requires: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_imported_exported.plain b/tests/data/plainfileparser/exported_concepts_imported_exported.plain index 8c3e983..ce5f206 100644 --- a/tests/data/plainfileparser/exported_concepts_imported_exported.plain +++ b/tests/data/plainfileparser/exported_concepts_imported_exported.plain @@ -9,4 +9,5 @@ exported_concepts: - The :Concept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :Concept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain b/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain index 660943c..9935ce9 100644 --- a/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain +++ b/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain @@ -12,4 +12,5 @@ exported_concepts: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. diff --git a/tests/data/plainfileparser/exported_concepts_malformed.plain b/tests/data/plainfileparser/exported_concepts_malformed.plain index c5528ff..4cdb2bb 100644 --- a/tests/data/plainfileparser/exported_concepts_malformed.plain +++ b/tests/data/plainfileparser/exported_concepts_malformed.plain @@ -10,4 +10,5 @@ exported_concepts: - The :A: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :A: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :A: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_missing_definition.plain b/tests/data/plainfileparser/exported_concepts_missing_definition.plain index 6c0ae87..0e5d781 100644 --- a/tests/data/plainfileparser/exported_concepts_missing_definition.plain +++ b/tests/data/plainfileparser/exported_concepts_missing_definition.plain @@ -11,4 +11,5 @@ exported_concepts: - The :NewConcept: must be implemented. ***functional specs*** -- Implement a wrapper for the :NewConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :NewConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain b/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain index 7ad273d..7403ecc 100644 --- a/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain +++ b/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain @@ -10,4 +10,5 @@ requires: - :AnotherConcept: must be implemented. ***functional specs*** -- Implement a wrapper for the :AnotherConcept: concept. +# implement-a-wrapper-for-the +Implement a wrapper for the :AnotherConcept: concept. diff --git a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain b/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain index fc05887..3698535 100644 --- a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain +++ b/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain @@ -11,4 +11,5 @@ exported_concepts: - The :NewConcept: and :OtherConcept: concepts must be implemented. ***functional specs*** -- Implement a wrapper for the :NewConcept: and :OtherConcept: concepts. +# implement-a-wrapper-for-the +Implement a wrapper for the :NewConcept: and :OtherConcept: concepts. diff --git a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain b/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain index 064ee9c..c72ecd3 100644 --- a/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain +++ b/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain @@ -10,4 +10,5 @@ exported_concepts: - The :NewConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :NewConcept: concept. +# implement-a-wrapper-for-the +Implement a wrapper for the :NewConcept: concept. diff --git a/tests/data/plainfileparser/exported_concepts_nested.plain b/tests/data/plainfileparser/exported_concepts_nested.plain index 618ad06..a479aef 100644 --- a/tests/data/plainfileparser/exported_concepts_nested.plain +++ b/tests/data/plainfileparser/exported_concepts_nested.plain @@ -12,4 +12,5 @@ exported_concepts: - The :NestedConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :NestedConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :NestedConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_nested_example.plain b/tests/data/plainfileparser/exported_concepts_nested_example.plain index bb99c9d..b3479c7 100644 --- a/tests/data/plainfileparser/exported_concepts_nested_example.plain +++ b/tests/data/plainfileparser/exported_concepts_nested_example.plain @@ -10,4 +10,5 @@ requires: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_no_definitions.plain b/tests/data/plainfileparser/exported_concepts_no_definitions.plain index 5e8ec49..1985bd2 100644 --- a/tests/data/plainfileparser/exported_concepts_no_definitions.plain +++ b/tests/data/plainfileparser/exported_concepts_no_definitions.plain @@ -7,4 +7,5 @@ exported_concepts: - :Implementation: is a simple wrapper. ***functional specs*** -- :Implementation: has an entry point. \ No newline at end of file +# implementation-has-an-entry-point +:Implementation: has an entry point. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_not_declared.plain b/tests/data/plainfileparser/exported_concepts_not_declared.plain index a9e0ba5..1ebf1db 100644 --- a/tests/data/plainfileparser/exported_concepts_not_declared.plain +++ b/tests/data/plainfileparser/exported_concepts_not_declared.plain @@ -5,4 +5,5 @@ - The :Concept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :Concept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_not_declared_example.plain b/tests/data/plainfileparser/exported_concepts_not_declared_example.plain index c3b77d2..30412f8 100644 --- a/tests/data/plainfileparser/exported_concepts_not_declared_example.plain +++ b/tests/data/plainfileparser/exported_concepts_not_declared_example.plain @@ -10,4 +10,5 @@ requires: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_transitive_example.plain b/tests/data/plainfileparser/exported_concepts_transitive_example.plain index 781c383..c59cb60 100644 --- a/tests/data/plainfileparser/exported_concepts_transitive_example.plain +++ b/tests/data/plainfileparser/exported_concepts_transitive_example.plain @@ -10,4 +10,5 @@ requires: - The :SomeOtherDependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :SomeOtherDependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :SomeOtherDependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_transitive_l1.plain b/tests/data/plainfileparser/exported_concepts_transitive_l1.plain index 45e698d..d260db1 100644 --- a/tests/data/plainfileparser/exported_concepts_transitive_l1.plain +++ b/tests/data/plainfileparser/exported_concepts_transitive_l1.plain @@ -10,4 +10,5 @@ requires: - The :DependantConcept: concept must be implemented. ***functional specs*** -- Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file +# implement-a-wrapper-for-the +Implement a wrapper for the :DependantConcept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/exported_concepts_transitive_l2.plain b/tests/data/plainfileparser/exported_concepts_transitive_l2.plain index 35bfa92..046411f 100644 --- a/tests/data/plainfileparser/exported_concepts_transitive_l2.plain +++ b/tests/data/plainfileparser/exported_concepts_transitive_l2.plain @@ -10,4 +10,5 @@ exported_concepts: - The :Concept: concept must be implemented. ***functional specs*** -- Implement the :Concept: concept. \ No newline at end of file +# implement-the-concept-concept +Implement the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/old_syntax_functional_specs.plain b/tests/data/plainfileparser/old_syntax_functional_specs.plain new file mode 100644 index 0000000..71f673c --- /dev/null +++ b/tests/data/plainfileparser/old_syntax_functional_specs.plain @@ -0,0 +1,11 @@ +***definitions*** + +***implementation reqs*** + +- First implementation requirement. + +- Second implementation requirement. + + +***functional specs*** +- Display "hello, world" diff --git a/tests/data/plainfileparser/plain_file_parser_with_comments.plain b/tests/data/plainfileparser/plain_file_parser_with_comments.plain index e1320b2..7e26161 100644 --- a/tests/data/plainfileparser/plain_file_parser_with_comments.plain +++ b/tests/data/plainfileparser/plain_file_parser_with_comments.plain @@ -5,4 +5,5 @@ - Second implementation requirement. ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/plain_file_with_comments_indented.plain b/tests/data/plainfileparser/plain_file_with_comments_indented.plain index 58faf5b..4876bbb 100644 --- a/tests/data/plainfileparser/plain_file_with_comments_indented.plain +++ b/tests/data/plainfileparser/plain_file_with_comments_indented.plain @@ -7,4 +7,5 @@ > This is a comment ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/plainfileparser/regular_plain_source.plain b/tests/data/plainfileparser/regular_plain_source.plain index 71f673c..ade2231 100644 --- a/tests/data/plainfileparser/regular_plain_source.plain +++ b/tests/data/plainfileparser/regular_plain_source.plain @@ -8,4 +8,5 @@ ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/plainfileparser/required_concepts_example.plain b/tests/data/plainfileparser/required_concepts_example.plain index 1bda063..1cf4e8c 100644 --- a/tests/data/plainfileparser/required_concepts_example.plain +++ b/tests/data/plainfileparser/required_concepts_example.plain @@ -10,4 +10,5 @@ import: - The :Concept: concept must be implemented. ***functional specs*** -- Implement the :Concept: concept. \ No newline at end of file +# implement-the-concept-concept +Implement the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_missing.plain b/tests/data/plainfileparser/required_concepts_missing.plain index 6ef43a7..c3b4a75 100644 --- a/tests/data/plainfileparser/required_concepts_missing.plain +++ b/tests/data/plainfileparser/required_concepts_missing.plain @@ -7,4 +7,5 @@ import: - The stuff must be implemented. ***functional specs*** -- Implement stuff. \ No newline at end of file +# implement-stuff +Implement stuff. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_module.plain b/tests/data/plainfileparser/required_concepts_module.plain index 1bbd136..a16cd48 100644 --- a/tests/data/plainfileparser/required_concepts_module.plain +++ b/tests/data/plainfileparser/required_concepts_module.plain @@ -8,4 +8,5 @@ import: - The :Concept: concept must be implemented. ***functional specs*** -- Implement the :Concept: concept. \ No newline at end of file +# implement-the-concept-concept +Implement the :Concept: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_partial.plain b/tests/data/plainfileparser/required_concepts_partial.plain index 938568a..9aef1a1 100644 --- a/tests/data/plainfileparser/required_concepts_partial.plain +++ b/tests/data/plainfileparser/required_concepts_partial.plain @@ -10,4 +10,5 @@ import: - These are nested required concepts. ***functional specs*** -- Implement the concepts. \ No newline at end of file +# implement-the-concepts +Implement the concepts. \ No newline at end of file diff --git a/tests/data/plainfileparser/required_concepts_partial_duplicate.plain b/tests/data/plainfileparser/required_concepts_partial_duplicate.plain index 5122c34..2a1b4d0 100644 --- a/tests/data/plainfileparser/required_concepts_partial_duplicate.plain +++ b/tests/data/plainfileparser/required_concepts_partial_duplicate.plain @@ -11,4 +11,5 @@ import: - These are nested required concepts. ***functional specs*** -- Implement the concepts. \ No newline at end of file +# implement-the-concepts +Implement the concepts. \ No newline at end of file diff --git a/tests/data/plainfileparser/requires_without_definitions_example.plain b/tests/data/plainfileparser/requires_without_definitions_example.plain index c63b375..1ad3b80 100644 --- a/tests/data/plainfileparser/requires_without_definitions_example.plain +++ b/tests/data/plainfileparser/requires_without_definitions_example.plain @@ -7,4 +7,5 @@ requires: - The :Concept: concept must be reused. ***functional specs*** -- Implement a second wrapper for the :Concept: concept. +# implement-a-second-wrapper-for +Implement a second wrapper for the :Concept: concept. diff --git a/tests/data/plainfileparser/topological_sort.plain b/tests/data/plainfileparser/topological_sort.plain index 5fb5972..554f140 100644 --- a/tests/data/plainfileparser/topological_sort.plain +++ b/tests/data/plainfileparser/topological_sort.plain @@ -9,6 +9,9 @@ - The :Concept3: concept must be implemented. ***functional specs*** -- Implement the :Concept1: concept. -- Implement the :Concept2: concept. -- Implement the :Concept3: concept. \ No newline at end of file +# implement-the-concept1-concept +Implement the :Concept1: concept. +# implement-the-concept2-concept +Implement the :Concept2: concept. +# implement-the-concept3-concept +Implement the :Concept3: concept. \ No newline at end of file diff --git a/tests/data/plainfileparser/topological_sort_not_referenced.plain b/tests/data/plainfileparser/topological_sort_not_referenced.plain index 7655522..e1b3ee5 100644 --- a/tests/data/plainfileparser/topological_sort_not_referenced.plain +++ b/tests/data/plainfileparser/topological_sort_not_referenced.plain @@ -16,7 +16,11 @@ - The :Concept3: concept must be implemented. ***functional specs*** -- Implement the :Concept1: concept. -- Implement the :Concept2: concept. -- Implement the :Concept3: concept. -- Implement the :NonReferencedConcept: concept. \ No newline at end of file +# implement-the-concept1-concept +Implement the :Concept1: concept. +# implement-the-concept2-concept +Implement the :Concept2: concept. +# implement-the-concept3-concept +Implement the :Concept3: concept. +# implement-the-nonreferencedconcept-concept +Implement the :NonReferencedConcept: concept. \ No newline at end of file diff --git a/tests/data/requires/chain_base.plain b/tests/data/requires/chain_base.plain index 90ebe54..4c55a85 100644 --- a/tests/data/requires/chain_base.plain +++ b/tests/data/requires/chain_base.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality. +# a-base-functionality +A base functionality. diff --git a/tests/data/requires/chain_fork_top.plain b/tests/data/requires/chain_fork_top.plain index dd14f07..c1f4cb5 100644 --- a/tests/data/requires/chain_fork_top.plain +++ b/tests/data/requires/chain_fork_top.plain @@ -10,4 +10,5 @@ requires: ***functional specs*** -- A fork functionality. +# a-fork-functionality +A fork functionality. diff --git a/tests/data/requires/chain_middle.plain b/tests/data/requires/chain_middle.plain index fbd00fc..518b12d 100644 --- a/tests/data/requires/chain_middle.plain +++ b/tests/data/requires/chain_middle.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A middle functionality. +# a-middle-functionality +A middle functionality. diff --git a/tests/data/requires/chain_top.plain b/tests/data/requires/chain_top.plain index 9bbe7a1..a673d27 100644 --- a/tests/data/requires/chain_top.plain +++ b/tests/data/requires/chain_top.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A top functionality. +# a-top-functionality +A top functionality. diff --git a/tests/data/requires/circular_requires_main.plain b/tests/data/requires/circular_requires_main.plain index d0a04d0..2c88ac4 100644 --- a/tests/data/requires/circular_requires_main.plain +++ b/tests/data/requires/circular_requires_main.plain @@ -15,4 +15,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/requires/circular_requires_sub.plain b/tests/data/requires/circular_requires_sub.plain index ab921a4..a8e3834 100644 --- a/tests/data/requires/circular_requires_sub.plain +++ b/tests/data/requires/circular_requires_sub.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. diff --git a/tests/data/requires/diamond_requires_1.plain b/tests/data/requires/diamond_requires_1.plain index c6b8e11..4776650 100644 --- a/tests/data/requires/diamond_requires_1.plain +++ b/tests/data/requires/diamond_requires_1.plain @@ -13,4 +13,5 @@ requires: ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. diff --git a/tests/data/requires/diamond_requires_2.plain b/tests/data/requires/diamond_requires_2.plain index f6a4773..a5ec254 100644 --- a/tests/data/requires/diamond_requires_2.plain +++ b/tests/data/requires/diamond_requires_2.plain @@ -13,4 +13,5 @@ requires: ***functional specs*** -- :User: should be able to log in using their email and password. +# user-should-be-able-to +:User: should be able to log in using their email and password. diff --git a/tests/data/requires/diamond_requires_common.plain b/tests/data/requires/diamond_requires_common.plain index bd06f03..f971fd5 100644 --- a/tests/data/requires/diamond_requires_common.plain +++ b/tests/data/requires/diamond_requires_common.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality. \ No newline at end of file +# a-base-functionality +A base functionality. \ No newline at end of file diff --git a/tests/data/requires/diamond_requires_main.plain b/tests/data/requires/diamond_requires_main.plain index c117671..351d98c 100644 --- a/tests/data/requires/diamond_requires_main.plain +++ b/tests/data/requires/diamond_requires_main.plain @@ -16,4 +16,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/requires/independent_requires_1.plain b/tests/data/requires/independent_requires_1.plain index ee05e0f..67285a9 100644 --- a/tests/data/requires/independent_requires_1.plain +++ b/tests/data/requires/independent_requires_1.plain @@ -8,4 +8,5 @@ ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. diff --git a/tests/data/requires/independent_requires_2.plain b/tests/data/requires/independent_requires_2.plain index a844d3a..8c24d77 100644 --- a/tests/data/requires/independent_requires_2.plain +++ b/tests/data/requires/independent_requires_2.plain @@ -8,4 +8,5 @@ ***functional specs*** -- :User: should be able to log in using their email and password. +# user-should-be-able-to +:User: should be able to log in using their email and password. diff --git a/tests/data/requires/independent_requires_main.plain b/tests/data/requires/independent_requires_main.plain index a9a0c30..9803028 100644 --- a/tests/data/requires/independent_requires_main.plain +++ b/tests/data/requires/independent_requires_main.plain @@ -16,4 +16,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/requires/invalid_ancestor_base.plain b/tests/data/requires/invalid_ancestor_base.plain index ec5862a..2d2d310 100644 --- a/tests/data/requires/invalid_ancestor_base.plain +++ b/tests/data/requires/invalid_ancestor_base.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality that uses :UndefinedThing:. +# a-base-functionality-that-uses +A base functionality that uses :UndefinedThing:. diff --git a/tests/data/requires/invalid_ancestor_top.plain b/tests/data/requires/invalid_ancestor_top.plain index 844d8d9..aab6e5c 100644 --- a/tests/data/requires/invalid_ancestor_top.plain +++ b/tests/data/requires/invalid_ancestor_top.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A top functionality. +# a-top-functionality +A top functionality. diff --git a/tests/data/requires/non_existent_require.plain b/tests/data/requires/non_existent_require.plain index f3f0bef..0d05795 100644 --- a/tests/data/requires/non_existent_require.plain +++ b/tests/data/requires/non_existent_require.plain @@ -15,4 +15,5 @@ requires: ***functional specs*** -- Display "hello, world" \ No newline at end of file +# display-hello-world +Display "hello, world" \ No newline at end of file diff --git a/tests/data/requires/normal_requires_1.plain b/tests/data/requires/normal_requires_1.plain index 816ec41..80b7608 100644 --- a/tests/data/requires/normal_requires_1.plain +++ b/tests/data/requires/normal_requires_1.plain @@ -13,4 +13,5 @@ requires: ***functional specs*** -- Implement the entry point for :App:. +# implement-the-entry-point-for +Implement the entry point for :App:. diff --git a/tests/data/requires/normal_requires_2.plain b/tests/data/requires/normal_requires_2.plain index 462cb82..c62d823 100644 --- a/tests/data/requires/normal_requires_2.plain +++ b/tests/data/requires/normal_requires_2.plain @@ -13,4 +13,5 @@ requires: ***functional specs*** -- :User: should be able to log in using their email and password. +# user-should-be-able-to +:User: should be able to log in using their email and password. diff --git a/tests/data/requires/normal_requires_common.plain b/tests/data/requires/normal_requires_common.plain index bd06f03..f971fd5 100644 --- a/tests/data/requires/normal_requires_common.plain +++ b/tests/data/requires/normal_requires_common.plain @@ -4,4 +4,5 @@ ***functional specs*** -- A base functionality. \ No newline at end of file +# a-base-functionality +A base functionality. \ No newline at end of file diff --git a/tests/data/requires/normal_requires_main.plain b/tests/data/requires/normal_requires_main.plain index 1a75e2d..3084099 100644 --- a/tests/data/requires/normal_requires_main.plain +++ b/tests/data/requires/normal_requires_main.plain @@ -16,4 +16,5 @@ requires: ***functional specs*** -- Display "hello, world" +# display-hello-world +Display "hello, world" diff --git a/tests/data/requires/shared_export_above.plain b/tests/data/requires/shared_export_above.plain index 7d03428..0ae27bc 100644 --- a/tests/data/requires/shared_export_above.plain +++ b/tests/data/requires/shared_export_above.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- An above functionality. +# an-above-functionality +An above functionality. diff --git a/tests/data/requires/shared_export_base.plain b/tests/data/requires/shared_export_base.plain index f3b7197..3992ed2 100644 --- a/tests/data/requires/shared_export_base.plain +++ b/tests/data/requires/shared_export_base.plain @@ -13,4 +13,5 @@ exported_concepts: ***functional specs*** -- :KeyStore: stores a key. +# keystore-stores-a-key +:KeyStore: stores a key. diff --git a/tests/data/requires/shared_export_middle.plain b/tests/data/requires/shared_export_middle.plain index 60eb832..7d36fcd 100644 --- a/tests/data/requires/shared_export_middle.plain +++ b/tests/data/requires/shared_export_middle.plain @@ -9,4 +9,5 @@ requires: ***functional specs*** -- A middle functionality. +# a-middle-functionality +A middle functionality. diff --git a/tests/data/requires/shared_export_top.plain b/tests/data/requires/shared_export_top.plain index b54fcbe..914ec89 100644 --- a/tests/data/requires/shared_export_top.plain +++ b/tests/data/requires/shared_export_top.plain @@ -10,4 +10,5 @@ requires: ***functional specs*** -- A top functionality. +# a-top-functionality +A top functionality. diff --git a/tests/data/simple.plain b/tests/data/simple.plain index 81f440c..3cc3622 100644 --- a/tests/data/simple.plain +++ b/tests/data/simple.plain @@ -4,4 +4,5 @@ ***functional specs*** -- Simple functionality +# simple-functionality +Simple functionality diff --git a/tests/data/templates/block_level_include.plain b/tests/data/templates/block_level_include.plain index b91fb98..15fab01 100644 --- a/tests/data/templates/block_level_include.plain +++ b/tests/data/templates/block_level_include.plain @@ -5,8 +5,7 @@ ***functional specs*** -- Display "hello, world" - - ***acceptance tests*** +# display-hello-world +Display "hello, world" - Test {% include "test_hardest_problem.plain" %} > - Test the function "cannot_solve_hardest_problem". diff --git a/tests/data/templates/code_variables.plain b/tests/data/templates/code_variables.plain index a290ec2..1cba98f 100644 --- a/tests/data/templates/code_variables.plain +++ b/tests/data/templates/code_variables.plain @@ -7,4 +7,5 @@ - Second implementation requirement. ***functional specs*** -- Implement {% include "implement.plain", variable_name: "nice" %} +# implement +Implement {% include "implement.plain", variable_name: "nice" %} diff --git a/tests/data/templates/implement.plain b/tests/data/templates/implement.plain index b32f8d4..40f1c82 100644 --- a/tests/data/templates/implement.plain +++ b/tests/data/templates/implement.plain @@ -1,3 +1,3 @@ something nice and useful - - the nice thing should be really {{ variable_name | code_variable }} - - the useful thing should be really useful \ No newline at end of file + - the nice thing should be really {{ variable_name | code_variable }} + - the useful thing should be really useful \ No newline at end of file diff --git a/tests/data/templates/implement_2.plain b/tests/data/templates/implement_2.plain index a171ed4..6e62ffd 100644 --- a/tests/data/templates/implement_2.plain +++ b/tests/data/templates/implement_2.plain @@ -1,3 +1,3 @@ something nice and useful - - the nice thing should be really {{ variable_name_1 | code_variable }} - - the useful thing should be really useful \ No newline at end of file + - the nice thing should be really {{ variable_name_1 | code_variable }} + - the useful thing should be really useful \ No newline at end of file diff --git a/tests/data/templates/malformed_tag.plain b/tests/data/templates/malformed_tag.plain index 984ef80..8d65f1b 100644 --- a/tests/data/templates/malformed_tag.plain +++ b/tests/data/templates/malformed_tag.plain @@ -1,4 +1,5 @@ ***functional specs*** {% if %} -- A :User: can add a :Task:. +# a-user-can-add-a +A :User: can add a :Task:. diff --git a/tests/data/templates/template_include.plain b/tests/data/templates/template_include.plain index 84226d6..e6bcb72 100644 --- a/tests/data/templates/template_include.plain +++ b/tests/data/templates/template_include.plain @@ -1,4 +1,5 @@ {% include "header.plain", variable_name_1: "nice_1", variable_name_2: "nice_2" %} ***functional specs*** -- Implement {% include "implement_2.plain", variable_name_1: "nice" %} +# implement +Implement {% include "implement_2.plain", variable_name_1: "nice" %} diff --git a/tests/data/templates/unterminated_output.plain b/tests/data/templates/unterminated_output.plain index 2e4e733..9708642 100644 --- a/tests/data/templates/unterminated_output.plain +++ b/tests/data/templates/unterminated_output.plain @@ -1,3 +1,4 @@ ***functional specs*** -- A :User: can add a {{ task_name +# a-user-can-add-a +A :User: can add a {{ task_name diff --git a/tests/test_cli.py b/tests/test_cli.py index fd76d29..28606c5 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -77,12 +77,15 @@ def test_check_valid_module_in_subdirectory_from_parent(cli_specs_dir, capsys, m _assert_ok((exit_code, out, err)) -def test_check_valid_module_with_linked_resource_in_acceptance_test(cli_specs_dir, capsys, monkeypatch, tmp_path): +def test_check_probe_module_without_config_prints_ok(tmp_path, capsys, monkeypatch): + # The module plain-healthcheck writes to probe whether the installed parser reads the current syntax. + (tmp_path / "probe.plain").write_text( + "***implementation reqs***\n\n- :Implementation: is written in Python 3.12.\n\n" + "***functional specs***\n\n# probe\n:Implementation: starts.\n" + ) monkeypatch.chdir(tmp_path) - exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "acceptance_test_resource.plain")) - - _assert_ok((exit_code, out, err)) + assert _run_check(capsys, "probe.plain") == (0, "probe.plain: OK\n", "") def test_check_nonexistent_template_dir_is_ignored(cli_specs_dir, capsys): @@ -194,12 +197,12 @@ def test_check_wrong_extension(cli_specs_dir, capsys): def test_check_empty_template_dir_is_ignored(tmp_path, capsys, monkeypatch): # An empty --template-dir must not add the working directory to the search path (codeplain ignores it). (tmp_path / "helper.plain").write_text( - "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality.\n" + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# main\nA functionality.\n" ) spec_dir = tmp_path / "spec" spec_dir.mkdir() (spec_dir / "top.plain").write_text( - "---\nrequires:\n - helper\n---\n\n***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality.\n" + "---\nrequires:\n - helper\n---\n\n***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# main\nA functionality.\n" ) monkeypatch.chdir(tmp_path) @@ -210,18 +213,6 @@ def test_check_empty_template_dir_is_ignored(tmp_path, capsys, monkeypatch): assert err == "Error: Module does not exist (helper).\n" -def test_check_binary_linked_resource_in_acceptance_test(cli_specs_dir, capsys, monkeypatch, tmp_path): - monkeypatch.chdir(tmp_path) - - exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "acceptance_test_binary_resource.plain")) - - assert exit_code == 1 - assert out == "" - assert err.startswith( - "Error: Referenced resource 'binary.bin' in module 'acceptance_test_binary_resource' is a binary file." - ) - - @pytest.mark.parametrize("interrupted_function", ["check", "resolve_config_file"]) def test_check_interrupted_exits_130_without_traceback(cli_specs_dir, capsys, monkeypatch, interrupted_function): def interrupt(*_): @@ -237,7 +228,7 @@ def interrupt(*_): assert "Traceback" not in captured.err -_MODULE = "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality.\n" +_MODULE = "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# main\nA functionality.\n" _TOP = "---\nrequires:\n - helper\n---\n\n" + _MODULE @@ -442,7 +433,7 @@ def _write_module(path, body="", requires=None): front = "---\nrequires:\n" + "".join(f" - {name}\n" for name in requires) + "---\n\n" if requires else "" path.parent.mkdir(parents=True, exist_ok=True) path.write_text( - front + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n- A functionality." + body + front + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# main\nA functionality." + body ) diff --git a/tests/test_imports.py b/tests/test_imports.py index 83816da..cf3373d 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -49,5 +49,13 @@ def test_diamond_imports(load_test_data, get_test_data_path): {"markdown": "- :Import1Def: is tested in diamond_import_1."}, {"markdown": "- :Import2Def: is tested in diamond_import_2."}, ], - "functional specs": [{"markdown": '- Display "hello, world"'}], + "functional specs": [ + { + "id": "display-hello-world", + "markdown": 'Display "hello, world"', + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + } + ], } diff --git a/tests/test_loaders.py b/tests/test_loaders.py index 9ebe6eb..2c7a08d 100644 --- a/tests/test_loaders.py +++ b/tests/test_loaders.py @@ -173,7 +173,7 @@ def test_get_loaded_templates_unterminated_output_raises_plain_syntax_error(): with pytest.raises( PlainSyntaxError, - match=re.escape("Plain syntax error: Invalid Liquid at unterminated_output:4:0: "), + match=re.escape("Plain syntax error: Invalid Liquid at unterminated_output:5:0: "), ): get_loaded_templates([directory], source, "unterminated_output") diff --git a/tests/test_plainfile.py b/tests/test_plainfile.py deleted file mode 100644 index 7737a08..0000000 --- a/tests/test_plainfile.py +++ /dev/null @@ -1,270 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from mistletoe.block_token import List, ListItem, Paragraph -from mistletoe.span_token import Emphasis, RawText, Strong - -from plain_parser import plain_file, plain_spec -from plain_parser.exceptions import PlainSyntaxError -from plain_parser.plain_file import process_acceptance_tests - - -class MockParseBuffer(list): - def __init__(self, *args, loose=False): - super().__init__(*args) - self.loose = loose - - -def _create_mock_fr_content(content="Implement the entry point for ...", line_number=32): - """Helper to create a mock functionality content paragraph.""" - mock_fr_content_text = MagicMock(spec=RawText) - mock_fr_content_text.content = content - - mock_fr_content = MagicMock(spec=Paragraph) - mock_fr_content.children = [mock_fr_content_text] - mock_fr_content.line_number = line_number - - return mock_fr_content - - -def _create_mock_at_heading_paragraph(line_number=34): - """Helper to create a mock acceptance test heading paragraph with proper structure.""" - mock_at_heading_raw_text = MagicMock(spec=RawText) - mock_at_heading_raw_text.content = plain_spec.ACCEPTANCE_TEST_HEADING - - mock_at_heading_strong = MagicMock(spec=Strong) - mock_at_heading_strong.children = [mock_at_heading_raw_text] - - mock_at_heading_emphasis = MagicMock(spec=Emphasis) - mock_at_heading_emphasis.children = [mock_at_heading_strong] - - mock_at_heading_paragraph = MagicMock(spec=Paragraph) - mock_at_heading_paragraph.children = [mock_at_heading_emphasis] - mock_at_heading_paragraph.line_number = line_number - - return mock_at_heading_paragraph - - -def _create_mock_at_list(content="The App shouldn't show logging...", line_number=36): - """Helper to create a mock acceptance test list with a single item.""" - mock_at_item_raw_text = MagicMock(spec=RawText) - mock_at_item_raw_text.content = content - - mock_at_item_paragraph = MagicMock(spec=Paragraph) - mock_at_item_paragraph.children = [mock_at_item_raw_text] - mock_at_item_paragraph.line_number = line_number - - mock_at_item = MagicMock(spec=ListItem) - mock_at_item.children = [mock_at_item_paragraph] - mock_at_item.line_number = line_number - - mock_at_list = MagicMock(spec=List) - mock_at_list.children = [mock_at_item] - mock_at_list.line_number = line_number - - return mock_at_list - - -@patch("plain_parser.plain_file._process_single_acceptance_test_requirement") -def test_process_acceptance_tests_no_functional_requirements_key(mock_process_single): - """Tests early return when functionality key is missing.""" - plain_source_tree = {} - - process_acceptance_tests(plain_source_tree) - - mock_process_single.assert_not_called() - - -@patch("plain_parser.plain_file._process_single_acceptance_test_requirement") -def test_process_acceptance_tests_frs_lacks_children_attr(mock_process_single): - """Tests early return when frs object lacks children attribute.""" - plain_source_tree = {plain_spec.FUNCTIONAL_REQUIREMENTS: None} - - process_acceptance_tests(plain_source_tree) - - mock_process_single.assert_not_called() - - -@patch("plain_parser.plain_file._process_single_acceptance_test_requirement") -def test_process_acceptance_tests_no_sections_direct_frs(mock_process_single): - """Tests processing of top-level functionality when no sections exist.""" - mock_fr_item1_with_children = MagicMock(name="FRItem1_WithChildren") - mock_fr_item1_with_children.children = MagicMock(name="ChildrenOfFRItem1") - - mock_fr_item2_no_children = MagicMock(spec=[], name="FRItem2_NoChildren") - - mock_fr_item3_with_children = MagicMock(name="FRItem3_WithChildren") - mock_fr_item3_with_children.children = MagicMock(name="ChildrenOfFRItem3") - - mock_frs_top_level = MagicMock(name="TopLevelFRS") - mock_frs_top_level.children = [ - mock_fr_item1_with_children, - mock_fr_item2_no_children, - mock_fr_item3_with_children, - ] - - plain_source_tree = { - plain_spec.FUNCTIONAL_REQUIREMENTS: mock_frs_top_level, - "definitions": MagicMock(name="TopLevelDefs"), - "implementation reqs": MagicMock(name="TopLevelNFRs"), - } - - process_acceptance_tests(plain_source_tree) - - assert mock_process_single.call_count == 2 - mock_process_single.assert_any_call(mock_fr_item1_with_children) - mock_process_single.assert_any_call(mock_fr_item3_with_children) - - -def test_psart_no_acceptance_tests_present(): - """Tests behavior when no 'Acceptance test:' heading is present.""" - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - mock_paragraph1 = MagicMock(spec=Paragraph) - mock_paragraph1.children = [] - mock_paragraph2 = MagicMock(spec=Paragraph) - mock_paragraph2.children = [] - - original_children = [mock_paragraph1, mock_paragraph2] - functional_requirement_mock.children = list(original_children) - - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - assert not hasattr( - functional_requirement_mock, plain_spec.ACCEPTANCE_TESTS - ), "acceptance_tests attribute should not be added if no ATs are found." - assert list(functional_requirement_mock.children) == original_children, "FR children should remain unchanged." - - -def test_psart_empty_children_list_in_fr(): - """Tests behavior when the functionality itself has no children.""" - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - functional_requirement_mock.children = [] - - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - assert not hasattr(functional_requirement_mock, plain_spec.ACCEPTANCE_TESTS) - assert list(functional_requirement_mock.children) == [] - - -def test_psart_heading_not_followed_by_list(): - """Tests behavior if AT heading is present but not followed by a list token.""" - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - mock_heading_raw_text = MagicMock(spec=RawText) - mock_heading_raw_text.content = plain_spec.ACCEPTANCE_TEST_HEADING - mock_heading_paragraph = MagicMock(spec=Paragraph) - mock_heading_paragraph.children = [mock_heading_raw_text] - mock_heading_paragraph.line_number = 2 - - mock_not_a_list_token = MagicMock(spec=Paragraph) - mock_not_a_list_token.children = [] - - original_children = [mock_heading_paragraph, mock_not_a_list_token] - functional_requirement_mock.children = list(original_children) - - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - assert not hasattr( - functional_requirement_mock, plain_spec.ACCEPTANCE_TESTS - ), "acceptance_tests attribute should not be added." - assert len(functional_requirement_mock.children) == 2, "Children count should remain the same as original." - assert ( - list(functional_requirement_mock.children) == original_children - ), "Children should be unchanged as AT block was malformed." - - -def test_psart_with_valid_acceptance_tests(): - # Main item to test - OuterListItem - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - # Create the three children using helper methods - mock_fr_content = _create_mock_fr_content() - mock_at_heading_paragraph = _create_mock_at_heading_paragraph() - mock_at_list = _create_mock_at_list() - - # Set up children of the functionality - functional_requirement_mock.children = [mock_fr_content, mock_at_heading_paragraph, mock_at_list] - - is_acceptance_test_heading, acceptance_test_heading_problem = plain_file._is_acceptance_test_heading( - mock_at_heading_paragraph - ) - assert is_acceptance_test_heading - assert acceptance_test_heading_problem is None - - # Call the function under test - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - # Verify acceptance_tests were extracted correctly - assert hasattr(functional_requirement_mock, plain_spec.ACCEPTANCE_TESTS) - assert len(functional_requirement_mock.acceptance_tests) == 1 - assert mock_at_list.children[0] in functional_requirement_mock.acceptance_tests - - # Verify only the FR content paragraph remains in children - assert len(functional_requirement_mock.children) == 1 - assert mock_fr_content in functional_requirement_mock.children - - -def test_psart_with_invalid_acceptance_test_heading(): - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - # Create a heading paragraph with wrong content - mock_at_heading_paragraph = _create_mock_at_heading_paragraph() - - # Change the content of the raw text to be invalid - mock_at_heading_paragraph.children[0].children[0].children[0].content = "Acceptance test" # missing colon - - # Set up children of the functionality with just the invalid heading - functional_requirement_mock.children = [mock_at_heading_paragraph] - - # Verify that invalid heading is detected - is_acceptance_test_heading, acceptance_test_heading_problem = plain_file._is_acceptance_test_heading( - mock_at_heading_paragraph - ) - assert not is_acceptance_test_heading - assert acceptance_test_heading_problem is not None - - # Call the function under test and expect a PlainSyntaxError - with pytest.raises(PlainSyntaxError): - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - -def test_psart_with_duplicate_acceptance_test_heading(): - functional_requirement_mock = MagicMock(spec=ListItem) - functional_requirement_mock.line_number = 1 - - # Create content and list mocks - mock_fr_content = _create_mock_fr_content() - mock_at_list1 = _create_mock_at_list("First acceptance test") - mock_at_list2 = _create_mock_at_list("Second acceptance test", line_number=40) - - # Create two heading paragraphs with different line numbers - mock_at_heading_paragraph1 = _create_mock_at_heading_paragraph(line_number=34) - mock_at_heading_paragraph2 = _create_mock_at_heading_paragraph(line_number=38) - - # Set up children of the functionality with duplicate AT headings - functional_requirement_mock.children = [ - mock_fr_content, - mock_at_heading_paragraph1, - mock_at_list1, - mock_at_heading_paragraph2, - mock_at_list2, - ] - - # Call the function under test and expect a PlainSyntaxError about duplicate headings - expected_error_message = f"Plain syntax error: Syntax error at line {mock_at_heading_paragraph2.line_number}: Duplicate 'acceptance tests' heading found within the same functionality. Only one block of acceptance tests is allowed per functionality." - - with pytest.raises(PlainSyntaxError) as exc_info: - plain_file._process_single_acceptance_test_requirement(functional_requirement_mock) - - assert str(exc_info.value) == expected_error_message - - -def test_invalid_plain_file_extension(): - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser("test.txt", []) diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 33174cb..1fd13c6 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -21,7 +21,15 @@ def test_regular_plain_source(get_test_data_path): {"markdown": "- First implementation requirement."}, {"markdown": "- Second implementation requirement."}, ], - "functional specs": [{"markdown": '- Display "hello, world"'}], + "functional specs": [ + { + "id": "display-hello-world", + "markdown": 'Display "hello, world"', + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + } + ], } @@ -34,7 +42,7 @@ def test_unknown_section(): with pytest.raises( Exception, match=re.escape( - "Syntax error at line 4: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs, acceptance tests" + "Syntax error at line 4: Invalid specification heading (`Unknown Section:`). Allowed headings: definitions, implementation reqs, test reqs, functional specs" ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) @@ -112,7 +120,15 @@ def test_plain_file_parser_with_comments(get_test_data_path): assert plain_sections == { "definitions": [], "implementation reqs": [{"markdown": "- Second implementation requirement."}], - "functional specs": [{"markdown": '- Display "hello, world"'}], + "functional specs": [ + { + "id": "display-hello-world", + "markdown": 'Display "hello, world"', + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + } + ], } @@ -127,7 +143,15 @@ def test_plain_file_parser_with_comments_indented(get_test_data_path): {"markdown": "- First implementation requirement."}, {"markdown": "- Second implementation requirement."}, ], - "functional specs": [{"markdown": '- Display "hello, world"'}], + "functional specs": [ + { + "id": "display-hello-world", + "markdown": 'Display "hello, world"', + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + } + ], } @@ -231,7 +255,10 @@ def test_reference_link_parsing_independent_of_working_directory(get_test_data_p def test_invalid_specification_order(get_test_data_path): with pytest.raises( Exception, - match="Syntax error at line 6: Definitions specification must be the first specification in the section.", + match=re.escape( + "Syntax error at line 6 in module 'invalid_specification_order': Definitions specification must be the " + "first specification in the section (`definitions`)" + ), ): plain_file.plain_file_parser("invalid_specification_order.plain", [get_test_data_path("data/plainfile")]) @@ -239,7 +266,10 @@ def test_invalid_specification_order(get_test_data_path): def test_duplicate_specification_heading(get_test_data_path): with pytest.raises( Exception, - match=re.escape("Syntax error at line 6: Duplicate specification heading (`definitions`)"), + match=re.escape( + "Syntax error at line 6 in module 'duplicate_specification_heading': Duplicate specification heading " + "(`definitions`)" + ), ): plain_file.plain_file_parser( "duplicate_specification_heading.plain", @@ -405,9 +435,10 @@ def test_code_variables(load_test_data, get_test_data_path): - Second implementation requirement. ***functional specs*** -- Implement something nice and useful - - the nice thing should be really {keys[0]} - - the useful thing should be really useful +# implement +Implement something nice and useful + - the nice thing should be really {keys[0]} + - the useful thing should be really useful """ assert rendered_plain_source == expected_rendered_plain_source @@ -421,7 +452,11 @@ def test_code_variables(load_test_data, get_test_data_path): ], "functional specs": [ { - "markdown": "- Implement something nice and useful\n - the nice thing should be really {{ variable_name }}\n - the useful thing should be really useful", + "id": "implement", + "markdown": "Implement something nice and useful\n - the nice thing should be really {{ variable_name }}\n - the useful thing should be really useful", + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], "code_variables": [{"name": "variable_name", "value": "nice"}], } ], @@ -447,9 +482,10 @@ def test_code_variables(load_test_data, get_test_data_path): - Second implementation requirement {keys[1]}. ***functional specs*** -- Implement something nice and useful - - the nice thing should be really {keys[2]} - - the useful thing should be really useful +# implement +Implement something nice and useful + - the nice thing should be really {keys[2]} + - the useful thing should be really useful """ assert rendered_plain_source == expected_rendered_plain_source @@ -469,7 +505,11 @@ def test_code_variables(load_test_data, get_test_data_path): ], "functional specs": [ { - "markdown": "- Implement something nice and useful\n - the nice thing should be really {{ variable_name_1 }}\n - the useful thing should be really useful", + "id": "implement", + "markdown": "Implement something nice and useful\n - the nice thing should be really {{ variable_name_1 }}\n - the useful thing should be really useful", + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], "code_variables": [{"name": "variable_name_1", "value": "nice"}], } ], @@ -478,9 +518,9 @@ def test_code_variables(load_test_data, get_test_data_path): assert plain_source == expected_plain_source -def test_acceptance_tests_block_include_with_trailing_newline_keeps_structure_and_ignores_quote(get_test_data_path): +def test_block_include_with_trailing_newline_keeps_structure_and_ignores_quote(get_test_data_path): """ - Ensures that a block-level include inside ***acceptance tests*** whose template ends + Ensures that a block-level include inside a functionality body whose template ends with a trailing newline does not terminate the list, and that a following '> ...' line is treated as a comment and ignored. The parser should not raise a syntax error. It's an error we encountered while implementing custom rendering and should break in case of regression. @@ -488,112 +528,156 @@ def test_acceptance_tests_block_include_with_trailing_newline_keeps_structure_an plain_file.plain_file_parser("block_level_include.plain", [get_test_data_path("data/templates")]) -def test_acceptance_tests_top_level_rejected(): - plain_source = """ -***acceptance tests*** +_SPEC_HEAD = "***definitions***\n\n- :Task: is a thing to do.\n\n***implementation reqs***\n\n- A req.\n\n" -- Test something. -""" - with pytest.raises( - PlainSyntaxError, - match=re.escape( - "Syntax error at line 2: acceptance tests heading should be nested under specific functional spec." - ), - ): - plain_file.parse_plain_source(plain_source, {}, [], [], []) +def _parse(plain_source): + return plain_file.marshall_plain_source(plain_file.parse_plain_source(plain_source, {}, [], [], []).plain_source) -def test_acceptance_tests_top_level_after_other_headings_rejected(): - plain_source = """***definitions*** -- :concept: is a concept. +def test_functionality_is_its_heading_id_and_its_body(): + plain_source = _SPEC_HEAD + ( + "***functional specs***\n\n" + "# add-task\n" + "A :Task: can be added.\n" + "It gets an id.\n" + " - Only a :Task: with a name is added.\n" + " - The name is trimmed.\n" + "\n" + "# list-tasks\n" + "> A comment between the heading and the body.\n" + "\n" + "Every :Task: is listed.\n" + ) -***acceptance tests*** + assert _parse(plain_source)["functional specs"] == [ + { + "id": "add-task", + "markdown": "A :Task: can be added.\nIt gets an id.\n - Only a :Task: with a name is added.\n - The name is trimmed.", + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + }, + { + "id": "list-tasks", + "markdown": "Every :Task: is listed.", + "linked_resources": [], + "conformance_tests": [], + "acceptance_tests": [], + }, + ] -- Test something. -""" - with pytest.raises( - PlainSyntaxError, - match=re.escape( - "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." - ), - ): + +def test_functionality_body_may_start_with_a_list(): + plain_source = _SPEC_HEAD + "***functional specs***\n\n# add-task\n - A :Task: can be added.\n" + + assert _parse(plain_source)["functional specs"][0]["markdown"] == " - A :Task: can be added." + + +def test_flush_left_list_in_functional_specs_is_the_old_syntax(get_test_data_path): + with pytest.raises(PlainSyntaxError) as exc_info: + plain_file.plain_file_parser("old_syntax_functional_specs.plain", [get_test_data_path("data/plainfileparser")]) + + assert str(exc_info.value) == ( + "Plain syntax error: Syntax error at line 11 in module 'old_syntax_functional_specs': A flush-left `- ` list " + "in the functional specs section is the old syntax; a functionality is a `# ` heading followed by " + "its body. Run the migrate-plain-syntax skill to convert the project." + ) + + +def test_flush_left_list_after_a_heading_is_the_old_syntax(): + plain_source = ( + _SPEC_HEAD + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n- A :Task: has a name.\n" + ) + + with pytest.raises(PlainSyntaxError, match=re.escape("Syntax error at line 14: A flush-left `- ` list")): plain_file.parse_plain_source(plain_source, {}, [], [], []) -def test_acceptance_tests_nested_under_definitions_rejected(): - plain_source = """***definitions*** +@pytest.mark.parametrize("heading", ["acceptance tests", "conformance tests"]) +def test_test_section_in_core_spec_is_rejected(heading): + plain_source = f"***definitions***\n\n- :Task: is a thing to do.\n\n***{heading}***\n\n# add\nAdding works.\n" -- :concept: is a concept. + with pytest.raises(PlainSyntaxError) as exc_info: + plain_file.parse_plain_source(plain_source, {}, [], [], []) - ***acceptance tests*** + assert str(exc_info.value) == ( + f"Plain syntax error: Syntax error at line 5: The `{heading}` section is not allowed in a core spec; " + "test scenarios live in test plain files. Run the migrate-plain-syntax skill to convert the project." + ) - - Test the :concept:. -***functional specs*** +def test_nested_acceptance_tests_section_is_rejected(): + plain_source = _SPEC_HEAD + ( + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n" + " ***acceptance tests***\n\n - Adding a :Task: shows it.\n" + ) + + with pytest.raises(PlainSyntaxError, match=re.escape("Syntax error at line 14: The `acceptance tests` section")): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_heading_outside_functional_specs_is_rejected(): + plain_source = "***implementation reqs***\n\n# add-task\n- A req.\n" -- Display "hello, world" -""" with pytest.raises( PlainSyntaxError, match=re.escape( - "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + "Syntax error at line 3: A `# ` heading is allowed only in the functional specs section (`add-task`)." ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) -def test_acceptance_tests_nested_under_implementation_reqs_rejected(): - plain_source = """***implementation reqs*** +def test_heading_of_another_level_is_rejected(): + plain_source = _SPEC_HEAD + "***functional specs***\n\n## add-task\nA :Task: can be added.\n" -- First implementation requirement. - - ***acceptance tests*** - - - Test something. -""" with pytest.raises( PlainSyntaxError, match=re.escape( - "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + "Syntax error at line 11: Invalid heading level 2 (`add-task`). A functionality heading is `# `." ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) -def test_acceptance_tests_nested_under_test_reqs_rejected(): - plain_source = """***test reqs*** - -- First test requirement. - - ***acceptance tests*** +@pytest.mark.parametrize("content", ["A :Task: can be added.", " - A :Task: can be added."]) +def test_content_before_the_first_heading_is_rejected(content): + plain_source = _SPEC_HEAD + f"***functional specs***\n\n{content}\n\n# add-task\nA :Task: can be added.\n" - - Test something. -""" with pytest.raises( PlainSyntaxError, match=re.escape( - "Syntax error at line 5: acceptance tests heading should be nested under specific functional spec." + "Syntax error at line 11: Content before the first `# ` heading in the functional specs section " + "(`A :Task: can be added.`)." ), ): plain_file.parse_plain_source(plain_source, {}, [], [], []) -def test_acceptance_tests_nested_under_functional_spec_allowed(): - plain_source = """***definitions*** +@pytest.mark.parametrize( + "tail", + ["\n# list-tasks\nEvery :Task: is listed.\n", "\n***test reqs***\n\n- A test req.\n", "", "\n> Only a comment.\n"], +) +def test_functionality_without_body_is_rejected(tail): + plain_source = _SPEC_HEAD + "***functional specs***\n\n# add-task\n" + tail -- :concept: is a concept. + with pytest.raises( + PlainSyntaxError, match=re.escape("Syntax error at line 11: Functionality `add-task` has no body.") + ): + plain_file.parse_plain_source(plain_source, {}, [], [], []) -***functional specs*** -- Display "hello, world" +def test_code_block_in_functionality_body_is_rejected(): + plain_source = _SPEC_HEAD + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n```\ntodo add\n```\n" - ***acceptance tests*** + with pytest.raises(PlainSyntaxError, match=re.escape("Syntax error at line 14: Invalid source structure")): + plain_file.parse_plain_source(plain_source, {}, [], [], []) - - Test the :concept:. -""" - result = plain_file.parse_plain_source(plain_source, {}, [], [], []) - assert plain_spec.FUNCTIONAL_REQUIREMENTS in result.plain_source + +def test_invalid_plain_file_extension(): + with pytest.raises(PlainSyntaxError): + plain_file.plain_file_parser("test.txt", []) def test_concept_validation_definitions(get_test_data_path): @@ -656,19 +740,6 @@ def test_concept_validation_several_concepts(get_test_data_path): ) -def test_concept_validation_acceptance_tests(get_test_data_path): - plain_file.plain_file_parser( - "concept_validation_acceptance_tests.plain", - [get_test_data_path("data/plainfileparser")], - ) - - with pytest.raises(PlainSyntaxError): - plain_file.plain_file_parser( - "concept_validation_acceptance_tests_nondefined.plain", - [get_test_data_path("data/plainfileparser")], - ) - - def test_concept_validation_cyclic_definitions(get_test_data_path): with pytest.raises(PlainSyntaxError, match="cycle\\(s\\) in concept definitions"): plain_file.plain_file_parser("cyclic_definitions.plain", [get_test_data_path("data/plainfileparser")]) @@ -816,6 +887,6 @@ def test_topological_sort(get_test_data_path): def test_liquid_syntax_error_names_module(get_test_data_path): with pytest.raises( PlainSyntaxError, - match=re.escape("Plain syntax error: Invalid Liquid at unterminated_output:4:0: "), + match=re.escape("Plain syntax error: Invalid Liquid at unterminated_output:5:0: "), ): plain_file.plain_file_parser("unterminated_output.plain", [get_test_data_path("data/templates")]) diff --git a/tests/test_plainspec.py b/tests/test_plainspec.py index a92a7d0..c94fe03 100644 --- a/tests/test_plainspec.py +++ b/tests/test_plainspec.py @@ -98,7 +98,7 @@ def test_get_specifications_simple(get_test_data_path): "definitions": [], "implementation reqs": ["- Simple implementation requirement"], "test reqs": [], - "functional specs": ["- Simple functionality"], + "functional specs": ["Simple functionality"], } diff --git a/tests/test_requires.py b/tests/test_requires.py index 0166b01..73641c8 100644 --- a/tests/test_requires.py +++ b/tests/test_requires.py @@ -72,7 +72,7 @@ def write_module(directory, module_name, exported_concepts, required_modules, de "***definitions***\n\n" + "".join(f"- {definition}\n\n" for definition in definitions) + "***implementation reqs***\n\n- An implementation requirement.\n" - + (f"\n***functional specs***\n\n- {functional_spec}\n" if functional_spec is not None else "") + + (f"\n***functional specs***\n\n# main\n{functional_spec}\n" if functional_spec is not None else "") ) @@ -296,7 +296,7 @@ def test_top_module_shape_is_checked_before_required_modules_are_validated(tmp_p def test_parse_errors_of_required_modules_come_before_top_module_concept_errors(tmp_path): (tmp_path / "Base.plain").write_text( - "***implementation reqs***\n\n- {% if %}\n\n***functional specs***\n\n- Base works.\n" + "***implementation reqs***\n\n- {% if %}\n\n***functional specs***\n\n# main\nBase works.\n" ) write_module(tmp_path, "Top", [], ["Base"], [], "Top uses :TopMissing:.") From f2cd66ec74afde391a13f2f2a6eb8aebeef50848 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:13:49 +0200 Subject: [PATCH 2/6] feat: check func-id syntax and uniqueness A func-id has only lowercase letters, digits and hyphens and is unique in its module. The errors name the line, the module and, for a duplicate, the line of the first use. --- src/plain_parser/plain_file.py | 12 ++++++++ src/plain_parser/plain_spec.py | 8 ++++++ .../plainfileparser/func_id_duplicate.plain | 14 ++++++++++ .../plainfileparser/func_id_invalid.plain | 11 ++++++++ tests/test_plainfileparser.py | 28 +++++++++++++++++++ 5 files changed, 73 insertions(+) create mode 100644 tests/data/plainfileparser/func_id_duplicate.plain create mode 100644 tests/data/plainfileparser/func_id_invalid.plain diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index 88acc62..b81707f 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -434,6 +434,7 @@ def read_sections( # noqa: C901 current_specification_heading = None current_functionality: Functionality | None = None processed_specification_headings = set[str]() + functionality_lines: dict[str, int] = {} # func-id to the line of its heading def close_functionality(): if current_functionality is not None and not current_functionality.body: @@ -457,7 +458,18 @@ def close_functionality(): f"Plain syntax error: {where}: Invalid heading level {token.level} (`{token_text}`). " f"A functionality heading is `# `." ) + if not plain_spec.is_semantic_id(token_text): + raise PlainSyntaxError( + f"Plain syntax error: {where}: Invalid func-id (`{token_text}`). A func-id has only lowercase " + f"letters, digits and hyphens." + ) + if token_text in functionality_lines: + raise PlainSyntaxError( + f"Plain syntax error: {where}: Duplicate func-id `{token_text}`; it is first used at line " + f"{functionality_lines[token_text]}." + ) close_functionality() + functionality_lines[token_text] = token.line_number current_functionality = Functionality(token_text, token.line_number) plain_source[plain_spec.FUNCTIONAL_REQUIREMENTS].append(current_functionality) elif isinstance(token, Paragraph): diff --git a/src/plain_parser/plain_spec.py b/src/plain_parser/plain_spec.py index c6bcbd1..8262208 100644 --- a/src/plain_parser/plain_spec.py +++ b/src/plain_parser/plain_spec.py @@ -1,5 +1,6 @@ import hashlib import json +import re import uuid from typing import Optional @@ -23,6 +24,13 @@ ALLOWED_IMPORT_SPECIFICATION_HEADINGS = LIST_SECTION_HEADINGS +# A func-id or scenario-id: lowercase letters, digits and hyphens. +SEMANTIC_ID_PATTERN = re.compile(r"[a-z0-9-]+") + + +def is_semantic_id(text: str) -> bool: + return SEMANTIC_ID_PATTERN.fullmatch(text) is not None + def has_acceptance_tests(plain_source_tree) -> bool: """Return True if any functional requirement in the plain source defines acceptance tests.""" diff --git a/tests/data/plainfileparser/func_id_duplicate.plain b/tests/data/plainfileparser/func_id_duplicate.plain new file mode 100644 index 0000000..a58b654 --- /dev/null +++ b/tests/data/plainfileparser/func_id_duplicate.plain @@ -0,0 +1,14 @@ +***implementation reqs*** + +- An implementation requirement. + +***functional specs*** + +# add-task +A task can be added. + +# list-tasks +Every task is listed. + +# add-task +A task can be added again. diff --git a/tests/data/plainfileparser/func_id_invalid.plain b/tests/data/plainfileparser/func_id_invalid.plain new file mode 100644 index 0000000..82dda9c --- /dev/null +++ b/tests/data/plainfileparser/func_id_invalid.plain @@ -0,0 +1,11 @@ +***implementation reqs*** + +- An implementation requirement. + +***functional specs*** + +# add-task +A task can be added. + +# Add Task 2 +A second task can be added. diff --git a/tests/test_plainfileparser.py b/tests/test_plainfileparser.py index 1fd13c6..2ce053c 100644 --- a/tests/test_plainfileparser.py +++ b/tests/test_plainfileparser.py @@ -641,6 +641,34 @@ def test_heading_of_another_level_is_rejected(): plain_file.parse_plain_source(plain_source, {}, [], [], []) +def test_func_id_with_other_characters_is_rejected(get_test_data_path): + with pytest.raises(PlainSyntaxError) as exc_info: + plain_file.plain_file_parser("func_id_invalid.plain", [get_test_data_path("data/plainfileparser")]) + + assert str(exc_info.value) == ( + "Plain syntax error: Syntax error at line 10 in module 'func_id_invalid': Invalid func-id (`Add Task 2`). " + "A func-id has only lowercase letters, digits and hyphens." + ) + + +@pytest.mark.parametrize("heading", ["#", "# ", "# add_task", "# add-task!", "# Ädd"]) +def test_func_id_syntax_is_checked(heading): + plain_source = _SPEC_HEAD + f"***functional specs***\n\n{heading}\nA :Task: can be added.\n" + + with pytest.raises(PlainSyntaxError, match="Syntax error at line 11: Invalid func-id"): + plain_file.parse_plain_source(plain_source, {}, [], [], []) + + +def test_duplicate_func_id_is_rejected(get_test_data_path): + with pytest.raises(PlainSyntaxError) as exc_info: + plain_file.plain_file_parser("func_id_duplicate.plain", [get_test_data_path("data/plainfileparser")]) + + assert str(exc_info.value) == ( + "Plain syntax error: Syntax error at line 13 in module 'func_id_duplicate': Duplicate func-id `add-task`; " + "it is first used at line 7." + ) + + @pytest.mark.parametrize("content", ["A :Task: can be added.", " - A :Task: can be added."]) def test_content_before_the_first_heading_is_rejected(content): plain_source = _SPEC_HEAD + f"***functional specs***\n\n{content}\n\n# add-task\nA :Task: can be added.\n" From 449c9557d521d33d4efef7a270a9ee7ad757a959 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:23:39 +0200 Subject: [PATCH 3/6] feat: walk functionalities by func-id in plain_spec get_frids yields the func-ids in file order, and the helpers that take a frid take a func-id. get_specifications_for_frid returns the conformance and acceptance scenarios of the functionality, and collect_linked_resources covers the links in them. The legacy tree walk over sections, ID and Heading is removed. --- src/plain_parser/plain_spec.py | 265 +++++--------------- tests/data/cli/code_variable_conflict.plain | 4 +- tests/test_plainspec.py | 160 +++++++++--- 3 files changed, 188 insertions(+), 241 deletions(-) diff --git a/src/plain_parser/plain_spec.py b/src/plain_parser/plain_spec.py index 8262208..bccaffc 100644 --- a/src/plain_parser/plain_spec.py +++ b/src/plain_parser/plain_spec.py @@ -2,7 +2,6 @@ import json import re import uuid -from typing import Optional from liquid2.filter import with_context @@ -76,104 +75,59 @@ def collect_specification_linked_resources(specification, specification_heading, ) -def collect_linked_resources_in_section( - section, linked_resources_list, specifications_list, include_acceptance_tests, frid=Optional[str] -): - # When should we collect specification headings in the current section. Either when one of the following holds true: - # - frid wasn't specified - # - section has no ID (and that's exactly when it's the root section) - # - section has ID, frid was specified and the specified frid inside the current section tree - should_collect_specification_headings_in_current_section = ( - frid is None or "ID" not in section or frid.startswith(section["ID"]) - ) - if should_collect_specification_headings_in_current_section: - specifications_to_collect = list(set([DEFINITIONS, NON_FUNCTIONAL_REQUIREMENTS, TEST_REQUIREMENTS])) - if specifications_list: - specifications_to_collect = list(set(specifications_to_collect) & set(specifications_list)) - for specification_heading in specifications_to_collect: - if specification_heading in section: - for requirement in section[specification_heading]: - collect_specification_linked_resources(requirement, specification_heading, linked_resources_list) - - if FUNCTIONAL_REQUIREMENTS in section and ( - not specifications_list or FUNCTIONAL_REQUIREMENTS in specifications_list - ): - functional_requirement_count = 0 - for requirement in section[FUNCTIONAL_REQUIREMENTS]: - collect_specification_linked_resources(requirement, FUNCTIONAL_REQUIREMENTS, linked_resources_list) - - functional_requirement_count += 1 - section_id = section.get("ID", None) - current_frid = get_current_frid(section_id, functional_requirement_count) - - if ACCEPTANCE_TESTS in requirement and include_acceptance_tests and (frid is None or frid == current_frid): - for acceptance_test in requirement[ACCEPTANCE_TESTS]: - collect_specification_linked_resources( - acceptance_test, FUNCTIONAL_REQUIREMENTS, linked_resources_list - ) - - if frid is not None and current_frid == frid: - # here, we rely on the fact that FRIDs are incrementing. And if we have reached the current FRID, we should - # not collect any FRIDs after the current one. - return True - - if "sections" in section: - for subsection in section["sections"]: - if collect_linked_resources_in_section( - subsection, linked_resources_list, specifications_list, include_acceptance_tests, frid - ): - return True - - return False - - -# TODO: check if this function can be refactored to return the list of linked resources instead of modifying it in place def collect_linked_resources( - plain_source_tree, linked_resources_list, specifications_list, include_acceptance_tests, frid=None + plain_source_tree, linked_resources_list, specifications_list, include_test_scenarios, frid=None ): + """Add the linked resources of the tree to ``linked_resources_list`` and sort the list by link text. + ``specifications_list`` limits the sections read; None reads every section. The functional specs are read up to + and including ``frid``, or all of them when ``frid`` is None. With ``include_test_scenarios`` the links of the + conformance and acceptance scenarios are added too, for ``frid`` alone when one is given. Returns True when + ``frid`` was reached. + """ if not isinstance(plain_source_tree, dict): raise ValueError("plain_source_tree must be a dictionary.") - if frid is not None: - functional_requirements = get_frids(plain_source_tree) - if frid not in functional_requirements: - raise ValueError(f"frid {frid} does not exist.") + if frid is not None and frid not in get_frids(plain_source_tree): + raise ValueError(f"frid {frid} does not exist.") + + sections_to_collect = [ + heading + for heading in ALLOWED_SPECIFICATION_HEADINGS + if heading in plain_source_tree and (not specifications_list or heading in specifications_list) + ] + frid_reached = False + for specification_heading in sections_to_collect: + for specification in plain_source_tree[specification_heading]: + collect_specification_linked_resources(specification, specification_heading, linked_resources_list) + + if specification_heading != FUNCTIONAL_REQUIREMENTS: + continue + + if include_test_scenarios and (frid is None or frid == specification["id"]): + scenarios = [*specification.get(CONFORMANCE_TESTS, []), *specification.get(ACCEPTANCE_TESTS, [])] + for scenario in scenarios: + collect_specification_linked_resources(scenario, FUNCTIONAL_REQUIREMENTS, linked_resources_list) - result = collect_linked_resources_in_section( - plain_source_tree, linked_resources_list, specifications_list, include_acceptance_tests, frid - ) + if specification["id"] == frid: + frid_reached = True + break - # Sort linked_resources_list by the "text" field linked_resources_list.sort(key=lambda x: x["text"]) - return result + return frid_reached def get_frids(plain_source_tree): - if FUNCTIONAL_REQUIREMENTS in plain_source_tree: - for functional_requirement_id in range(1, len(plain_source_tree[FUNCTIONAL_REQUIREMENTS]) + 1): - if "ID" in plain_source_tree: - yield plain_source_tree["ID"] + "." + str(functional_requirement_id) - else: - yield str(functional_requirement_id) - - if "sections" in plain_source_tree: - for section in plain_source_tree["sections"]: - yield from get_frids(section) + """The func-ids of the tree's functionalities, in file order.""" + for functionality in plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or []: + yield functionality["id"] def get_first_frid(plain_source_tree): return next(get_frids(plain_source_tree), None) -def get_current_frid(section_id: Optional[str], functional_requirement_count: int) -> str: - if section_id is None: - return str(functional_requirement_count) - else: - return section_id + "." + str(functional_requirement_count) - - def get_next_frid(plain_source_tree, frid): functional_requirements = get_frids(plain_source_tree) for temp_frid in functional_requirements: @@ -195,16 +149,7 @@ def get_previous_frid(plain_source_tree, frid): def get_frids_before(plain_source_tree, target_frid: str) -> list[str]: - """ - Get all FRIDs that appear before the target FRID in the specification. - - Args: - plain_source_tree: The plain source tree - target_frid: The FRID to find predecessors for - - Returns: - List of FRIDs that appear before target_frid, in order - """ + """The func-ids that come before ``target_frid`` in the specification, in order.""" previous_frids = [] for frid in get_frids(plain_source_tree): if frid == target_frid: @@ -214,8 +159,10 @@ def get_frids_before(plain_source_tree, target_frid: str) -> list[str]: return previous_frids -def get_specification_item_markdown(specification_item, code_variables, replace_code_variables): - markdown = specification_item["markdown"] +def get_specification_item_markdown(specification_item, code_variables, replace_code_variables, key="markdown"): + """The text under ``key`` of the item, with its code variables recorded in ``code_variables`` and, when + ``replace_code_variables`` is set, their values put in place of the `{{ name }}` placeholders.""" + markdown = specification_item[key] if "code_variables" in specification_item: for code_variable in specification_item["code_variables"]: if code_variable["name"] in code_variables: @@ -232,120 +179,37 @@ def get_specification_item_markdown(specification_item, code_variables, replace_ return markdown -def get_specifications_from_plain_source_tree( - frid, - plain_source_tree, - definitions, - non_functional_requirements, - test_requirements, - functional_requirements, - acceptance_tests, - code_variables, - replace_code_variables, - section_id=None, -): - return_frid = None - if FUNCTIONAL_REQUIREMENTS in plain_source_tree and len(plain_source_tree[FUNCTIONAL_REQUIREMENTS]) > 0: - functional_requirement_count = 0 - for functional_requirement in plain_source_tree[FUNCTIONAL_REQUIREMENTS]: - functional_requirement_count += 1 - if section_id is None: - current_frid = str(functional_requirement_count) - else: - current_frid = section_id + "." + str(functional_requirement_count) - - functional_requirements.append( - get_specification_item_markdown(functional_requirement, code_variables, replace_code_variables) - ) - - if current_frid == frid: - if ACCEPTANCE_TESTS in functional_requirement: - for acceptance_test in functional_requirement[ACCEPTANCE_TESTS]: - acceptance_tests.append( - get_specification_item_markdown(acceptance_test, code_variables, replace_code_variables) - ) - - return_frid = current_frid - break - - if "sections" in plain_source_tree: - for section in plain_source_tree["sections"]: - sub_frid = get_specifications_from_plain_source_tree( - frid, - section, - definitions, - non_functional_requirements, - test_requirements, - functional_requirements, - acceptance_tests, - code_variables, - replace_code_variables, - section["ID"], - ) - if sub_frid is not None: - return_frid = sub_frid - break - - if return_frid is not None: - if DEFINITIONS in plain_source_tree and plain_source_tree[DEFINITIONS] is not None: - definitions[0:0] = [ - get_specification_item_markdown(specification, code_variables, replace_code_variables) - for specification in plain_source_tree[DEFINITIONS] - ] - if ( - NON_FUNCTIONAL_REQUIREMENTS in plain_source_tree - and plain_source_tree[NON_FUNCTIONAL_REQUIREMENTS] is not None - ): - non_functional_requirements[0:0] = [ - get_specification_item_markdown(specification, code_variables, replace_code_variables) - for specification in plain_source_tree[NON_FUNCTIONAL_REQUIREMENTS] - ] - if TEST_REQUIREMENTS in plain_source_tree and plain_source_tree[TEST_REQUIREMENTS] is not None: - test_requirements[0:0] = [ - get_specification_item_markdown(specification, code_variables, replace_code_variables) - for specification in plain_source_tree[TEST_REQUIREMENTS] - ] - - return return_frid - - def get_specifications_for_frid(plain_source_tree, frid, replace_code_variables=True): - definitions = [] - non_functional_requirements = [] - test_requirements = [] - functional_requirements = [] - acceptance_tests = [] + """The specifications that build ``frid``: every list section, the functional specs up to and including + ``frid``, and the conformance and acceptance scenarios of ``frid`` as `{"id", "text"}` entries. + + Returns the specifications and the code variables they use (None when there are none). Raise when ``frid`` + is not in the tree. + """ + functionalities = plain_source_tree.get(FUNCTIONAL_REQUIREMENTS) or [] + position = next((index for index, item in enumerate(functionalities) if item["id"] == frid), None) + if position is None: + raise Exception(f"Functionality {frid} does not exist.") code_variables = {} - result = get_specifications_from_plain_source_tree( - frid, - plain_source_tree, - definitions, - non_functional_requirements, - test_requirements, - functional_requirements, - acceptance_tests, - code_variables, - replace_code_variables, - ) - if result is None: - raise Exception(f"Functionality {frid} does not exist.") + def texts(items, key="markdown"): + return [get_specification_item_markdown(item, code_variables, replace_code_variables, key) for item in items] + + def scenarios(items): + return [{"id": item["id"], "text": text} for item, text in zip(items, texts(items, "text"))] + functionality = functionalities[position] specifications = { - DEFINITIONS: definitions, - NON_FUNCTIONAL_REQUIREMENTS: non_functional_requirements, - TEST_REQUIREMENTS: test_requirements, - FUNCTIONAL_REQUIREMENTS: functional_requirements, + DEFINITIONS: texts(plain_source_tree.get(DEFINITIONS) or []), + NON_FUNCTIONAL_REQUIREMENTS: texts(plain_source_tree.get(NON_FUNCTIONAL_REQUIREMENTS) or []), + TEST_REQUIREMENTS: texts(plain_source_tree.get(TEST_REQUIREMENTS) or []), + FUNCTIONAL_REQUIREMENTS: texts(functionalities[: position + 1]), + CONFORMANCE_TESTS: scenarios(functionality.get(CONFORMANCE_TESTS, [])), + ACCEPTANCE_TESTS: scenarios(functionality.get(ACCEPTANCE_TESTS, [])), } - if acceptance_tests: - specifications[ACCEPTANCE_TESTS] = acceptance_tests - - if code_variables: - return specifications, code_variables - else: - return specifications, None + return specifications, (code_variables or None) @with_context @@ -397,16 +261,13 @@ def get_hash_value(specifications): def get_frids_range(plain_source, start, end=None): - """Return the FRIDs from ``start`` through ``end`` inclusive, or through the last FRID when ``end`` is None.""" + """The func-ids from ``start`` through ``end`` inclusive, or through the last one when ``end`` is None.""" frids = list(get_frids(plain_source)) - start = str(start) - if start not in frids: raise InvalidFridArgument(f"Invalid start functionality ID: {start}. Valid IDs are: {frids}.") if end is not None: - end = str(end) if end not in frids: raise InvalidFridArgument(f"Invalid end functionality ID: {end}. Valid IDs are: {frids}.") diff --git a/tests/data/cli/code_variable_conflict.plain b/tests/data/cli/code_variable_conflict.plain index 356bcec..c637306 100644 --- a/tests/data/cli/code_variable_conflict.plain +++ b/tests/data/cli/code_variable_conflict.plain @@ -4,8 +4,8 @@ ***functional specs*** -# implement-include-code-variable-template +# implement-first Implement {% include "code_variable_template.plain", variable_name: "first" %} -# implement-include-code-variable-template-2 +# implement-second Implement {% include "code_variable_template.plain", variable_name: "second" %} diff --git a/tests/test_plainspec.py b/tests/test_plainspec.py index c94fe03..57bfb82 100644 --- a/tests/test_plainspec.py +++ b/tests/test_plainspec.py @@ -3,6 +3,24 @@ from plain_parser import plain_file, plain_spec +def _functionality(id_, markdown, conformance_tests=(), acceptance_tests=(), linked_resources=()): + return { + "id": id_, + "markdown": markdown, + "linked_resources": list(linked_resources), + plain_spec.CONFORMANCE_TESTS: list(conformance_tests), + plain_spec.ACCEPTANCE_TESTS: list(acceptance_tests), + } + + +def _scenario(id_, text, linked_resources=()): + return {"id": id_, "text": text, "linked_resources": list(linked_resources)} + + +def _link(target): + return {"text": target, "target": target} + + def test_get_linked_resources_invalid_input(): with pytest.raises(ValueError, match="plain_source_tree must be a dictionary."): plain_spec.collect_linked_resources([], [], None, True) @@ -11,11 +29,8 @@ def test_get_linked_resources_invalid_input(): def test_has_acceptance_tests_true(): plain_source = { plain_spec.FUNCTIONAL_REQUIREMENTS: [ - {"markdown": "- First functionality."}, - { - "markdown": "- Second functionality.", - plain_spec.ACCEPTANCE_TESTS: [{"markdown": "- Test the second functionality."}], - }, + _functionality("first", "First functionality."), + _functionality("second", "Second functionality.", acceptance_tests=[_scenario("works", "It works.")]), ] } assert plain_spec.has_acceptance_tests(plain_source) is True @@ -24,17 +39,8 @@ def test_has_acceptance_tests_true(): def test_has_acceptance_tests_false_when_no_acceptance_tests(): plain_source = { plain_spec.FUNCTIONAL_REQUIREMENTS: [ - {"markdown": "- First functionality."}, - {"markdown": "- Second functionality."}, - ] - } - assert plain_spec.has_acceptance_tests(plain_source) is False - - -def test_has_acceptance_tests_false_when_empty_acceptance_tests(): - plain_source = { - plain_spec.FUNCTIONAL_REQUIREMENTS: [ - {"markdown": "- First functionality.", plain_spec.ACCEPTANCE_TESTS: []}, + _functionality("first", "First functionality."), + _functionality("second", "Second functionality."), ] } assert plain_spec.has_acceptance_tests(plain_source) is False @@ -49,39 +55,39 @@ def test_get_frids_simple(get_test_data_path): frids = list(plain_spec.get_frids(plain_source)) - assert frids == ["1"] + assert frids == ["simple-functionality"] def test_get_first_functional_requirement_simple(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - assert plain_spec.get_first_frid(plain_source) == "1" + assert plain_spec.get_first_frid(plain_source) == "simple-functionality" def test_get_next_frid_not_exists(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - with pytest.raises(Exception, match="Functionality 2 does not exist."): - plain_spec.get_next_frid(plain_source, "2") + with pytest.raises(Exception, match="Functionality missing does not exist."): + plain_spec.get_next_frid(plain_source, "missing") def test_get_next_frid_simple(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - assert plain_spec.get_next_frid(plain_source, "1") is None + assert plain_spec.get_next_frid(plain_source, "simple-functionality") is None def test_get_previous_frid_not_exists(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - with pytest.raises(Exception, match="Functionality 2 does not exist."): - plain_spec.get_previous_frid(plain_source, "2") + with pytest.raises(Exception, match="Functionality missing does not exist."): + plain_spec.get_previous_frid(plain_source, "missing") def test_get_previous_frid_no_previous_frid(get_test_data_path): _, plain_source, _ = plain_file.plain_file_parser("simple.plain", [get_test_data_path("data/")]) - assert plain_spec.get_previous_frid(plain_source, "1") is None + assert plain_spec.get_previous_frid(plain_source, "simple-functionality") is None def test_get_specifications_simple(get_test_data_path): @@ -92,42 +98,122 @@ def test_get_specifications_simple(get_test_data_path): frid = plain_spec.get_first_frid(plain_source) - specifications, _ = plain_spec.get_specifications_for_frid(plain_source, frid) + specifications, code_variables = plain_spec.get_specifications_for_frid(plain_source, frid) assert specifications == { "definitions": [], "implementation reqs": ["- Simple implementation requirement"], "test reqs": [], "functional specs": ["Simple functionality"], + "conformance_tests": [], + "acceptance_tests": [], } + assert code_variables is None def _four_functionalities(): - return {plain_spec.FUNCTIONAL_REQUIREMENTS: [{"markdown": f"- Functionality {i}."} for i in range(1, 5)]} + return { + plain_spec.DEFINITIONS: [{"markdown": "- :Thing: is a thing."}], + plain_spec.FUNCTIONAL_REQUIREMENTS: [ + _functionality( + f"f{i}", + f"Functionality {i}.", + conformance_tests=[_scenario(f"f{i}-works", f"Functionality {i} works.", [_link(f"c{i}.md")])], + acceptance_tests=[_scenario(f"f{i}-flow", f"Functionality {i} flows.", [_link(f"a{i}.md")])], + linked_resources=[_link(f"r{i}.md")], + ) + for i in range(1, 5) + ], + } -def test_get_frids_range_start_and_end_inclusive(): - assert plain_spec.get_frids_range(_four_functionalities(), "2", "3") == ["2", "3"] +def test_get_frids_yields_ids_in_file_order(): + assert list(plain_spec.get_frids(_four_functionalities())) == ["f1", "f2", "f3", "f4"] -def test_get_frids_range_without_end_runs_to_last_frid(): - assert plain_spec.get_frids_range(_four_functionalities(), "3") == ["3", "4"] +def test_get_frids_before(): + assert plain_spec.get_frids_before(_four_functionalities(), "f3") == ["f1", "f2"] -def test_get_frids_range_accepts_integer_frids(): - assert plain_spec.get_frids_range(_four_functionalities(), 1, 1) == ["1"] +def test_get_specifications_for_frid_takes_functional_specs_up_to_the_frid_and_its_scenarios(): + specifications, _ = plain_spec.get_specifications_for_frid(_four_functionalities(), "f2") + + assert specifications == { + "definitions": ["- :Thing: is a thing."], + "implementation reqs": [], + "test reqs": [], + "functional specs": ["Functionality 1.", "Functionality 2."], + "conformance_tests": [{"id": "f2-works", "text": "Functionality 2 works."}], + "acceptance_tests": [{"id": "f2-flow", "text": "Functionality 2 flows."}], + } + + +def test_get_specifications_for_frid_replaces_code_variables_in_scenarios(): + tree = _four_functionalities() + tree[plain_spec.FUNCTIONAL_REQUIREMENTS][0][plain_spec.CONFORMANCE_TESTS] = [ + { + "id": "greets", + "text": "It prints {{ greeting }}.", + "linked_resources": [], + "code_variables": [{"name": "greeting", "value": "hello"}], + } + ] + + specifications, code_variables = plain_spec.get_specifications_for_frid(tree, "f1") + + assert specifications["conformance_tests"] == [{"id": "greets", "text": "It prints hello."}] + assert code_variables == {"greeting": "hello"} + + +def _collected_targets(tree, include_test_scenarios, frid=None): + linked_resources = [] + reached = plain_spec.collect_linked_resources(tree, linked_resources, None, include_test_scenarios, frid) + return reached, [resource["target"] for resource in linked_resources] + + +def test_collect_linked_resources_stops_at_the_frid_and_takes_only_its_scenarios(): + reached, targets = _collected_targets(_four_functionalities(), True, "f2") + + assert reached is True + assert targets == ["a2.md", "c2.md", "r1.md", "r2.md"] + + +def test_collect_linked_resources_without_frid_takes_every_functionality_and_scenario(): + reached, targets = _collected_targets(_four_functionalities(), True) + + assert reached is False + assert targets == sorted(f"{kind}{i}.md" for kind in "acr" for i in range(1, 5)) + + +def test_collect_linked_resources_can_leave_out_the_scenarios(): + _, targets = _collected_targets(_four_functionalities(), False) + + assert targets == ["r1.md", "r2.md", "r3.md", "r4.md"] + + +def test_collect_linked_resources_unknown_frid_raises(): + with pytest.raises(ValueError, match="frid f9 does not exist."): + plain_spec.collect_linked_resources(_four_functionalities(), [], None, True, "f9") + + +def test_get_frids_range_start_and_end_inclusive(): + assert plain_spec.get_frids_range(_four_functionalities(), "f2", "f3") == ["f2", "f3"] + + +def test_get_frids_range_without_end_runs_to_last_frid(): + assert plain_spec.get_frids_range(_four_functionalities(), "f3") == ["f3", "f4"] def test_get_frids_range_unknown_start_raises(): - with pytest.raises(plain_spec.InvalidFridArgument, match="Invalid start functionality ID: 9"): - plain_spec.get_frids_range(_four_functionalities(), "9") + with pytest.raises(plain_spec.InvalidFridArgument, match="Invalid start functionality ID: f9"): + plain_spec.get_frids_range(_four_functionalities(), "f9") def test_get_frids_range_unknown_end_raises(): - with pytest.raises(plain_spec.InvalidFridArgument, match="Invalid end functionality ID: 9"): - plain_spec.get_frids_range(_four_functionalities(), "1", "9") + with pytest.raises(plain_spec.InvalidFridArgument, match="Invalid end functionality ID: f9"): + plain_spec.get_frids_range(_four_functionalities(), "f1", "f9") def test_get_frids_range_start_after_end_raises(): with pytest.raises(plain_spec.InvalidFridArgument, match="must be before"): - plain_spec.get_frids_range(_four_functionalities(), "3", "2") + plain_spec.get_frids_range(_four_functionalities(), "f3", "f2") From 113184860fbc585453e614c1b7c1f8c9e67e4875 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:29:39 +0200 Subject: [PATCH 4/6] feat: parse test plain files A test plain file binds to one functionality with the frontmatter keys module and func_id and holds a conformance tests section followed by an acceptance tests section. A scenario is a `# ` heading and its text, kept verbatim. Liquid and linked resources work as in a core spec; the concepts of the scenarios are checked once the bound module is known. --- src/plain_parser/__init__.py | 3 +- src/plain_parser/plain_file.py | 28 ++- src/plain_parser/test_plain_file.py | 227 ++++++++++++++++++ .../content_before_scenario.plain | 13 + .../content_before_section.plain | 10 + .../testplainfile/duplicate_scenario_id.plain | 14 ++ .../testplainfile/duplicate_section.plain | 13 + tests/data/testplainfile/empty_scenario.plain | 14 ++ tests/data/testplainfile/empty_sections.plain | 8 + .../testplainfile/extra_frontmatter_key.plain | 9 + .../testplainfile/invalid_scenario_id.plain | 11 + .../testplainfile/link_to_plain_file.plain | 11 + .../missing_acceptance_section.plain | 9 + .../data/testplainfile/missing_func_id.plain | 7 + tests/data/testplainfile/missing_link.plain | 11 + .../testplainfile/module_not_a_string.plain | 8 + tests/data/testplainfile/notes.md | 1 + .../scenario_before_section.plain | 11 + .../testplainfile/sections_out_of_order.plain | 11 + .../data/testplainfile/unknown_section.plain | 11 + tests/data/testplainfile/valid.plain | 40 +++ tests/test_test_plain_file.py | 215 +++++++++++++++++ 22 files changed, 673 insertions(+), 12 deletions(-) create mode 100644 src/plain_parser/test_plain_file.py create mode 100644 tests/data/testplainfile/content_before_scenario.plain create mode 100644 tests/data/testplainfile/content_before_section.plain create mode 100644 tests/data/testplainfile/duplicate_scenario_id.plain create mode 100644 tests/data/testplainfile/duplicate_section.plain create mode 100644 tests/data/testplainfile/empty_scenario.plain create mode 100644 tests/data/testplainfile/empty_sections.plain create mode 100644 tests/data/testplainfile/extra_frontmatter_key.plain create mode 100644 tests/data/testplainfile/invalid_scenario_id.plain create mode 100644 tests/data/testplainfile/link_to_plain_file.plain create mode 100644 tests/data/testplainfile/missing_acceptance_section.plain create mode 100644 tests/data/testplainfile/missing_func_id.plain create mode 100644 tests/data/testplainfile/missing_link.plain create mode 100644 tests/data/testplainfile/module_not_a_string.plain create mode 100644 tests/data/testplainfile/notes.md create mode 100644 tests/data/testplainfile/scenario_before_section.plain create mode 100644 tests/data/testplainfile/sections_out_of_order.plain create mode 100644 tests/data/testplainfile/unknown_section.plain create mode 100644 tests/data/testplainfile/valid.plain create mode 100644 tests/test_test_plain_file.py diff --git a/src/plain_parser/__init__.py b/src/plain_parser/__init__.py index c1e8724..b27dbbf 100644 --- a/src/plain_parser/__init__.py +++ b/src/plain_parser/__init__.py @@ -1,6 +1,6 @@ """Parser for ***plain specification files.""" -from plain_parser import concept_utils, exceptions, loaders, plain_file, plain_spec +from plain_parser import concept_utils, exceptions, loaders, plain_file, plain_spec, test_plain_file from plain_parser.plain_file import ( PlainFileParseResult, get_filename_from_module_name, @@ -26,4 +26,5 @@ "plain_file", "plain_file_parser", "plain_spec", + "test_plain_file", ] diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index b81707f..8fdc686 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -100,37 +100,42 @@ def remove_quotes(token): token.children = tuple(new_children) -def find_linked_resources(tokens, template_dirs) -> list[dict]: - """The links in the tokens, each checked to be a relative path to an existing file that is not a .plain file.""" +def find_linked_resources(tokens, template_dirs, where: str = "") -> list[dict]: + """The links in the tokens, each checked to be a relative path to an existing file that is not a .plain file. + + ``where`` names the place of the links in error messages, e.g. " in test plain file 'tests/m/f.plain'". + """ linked_resources = [] for link in (link for token in tokens for link in traverse(token, klass=Link)): parsed_url = urlparse(link.node.target) if parsed_url.scheme != "" or os.path.isabs(link.node.target): raise PlainSyntaxError( - f"Plain syntax error: Only relative links are allowed (text: {link.node.children[0].content}, target: {link.node.target})." + f"Plain syntax error: Only relative links are allowed{where} (text: {link.node.children[0].content}, target: {link.node.target})." ) resolved_target = loaders.resolve_linked_resource(template_dirs, link.node.target) if resolved_target is None: searched_dirs = "\n".join(f" {position}. {dir}" for position, dir in enumerate(template_dirs, start=1)) raise PlainSyntaxError( - f"Plain syntax error: Link {link.node.target} does not exist. " + f"Plain syntax error: Link {link.node.target}{where} does not exist. " f"Linked resources are looked up relative to the following directories " f"(highest to lowest precedence):\n{searched_dirs}" ) if not os.path.isfile(resolved_target): raise PlainSyntaxError( - f"Plain syntax error: Link {link.node.target} must be a file (resolved to {resolved_target})." + f"Plain syntax error: Link {link.node.target}{where} must be a file (resolved to {resolved_target})." ) if len(link.node.children) != 1: - raise PlainSyntaxError(f"Plain syntax error: Link must have text specified (link: {link.node.target}).") + raise PlainSyntaxError( + f"Plain syntax error: Link{where} must have text specified (link: {link.node.target})." + ) linked_resource_file_extension = os.path.splitext(os.path.basename(link.node.target))[1] if linked_resource_file_extension == ".plain": raise PlainSyntaxError( - f"Referenced resource '{link.node.target}' is a .plain file. " + f"Referenced resource '{link.node.target}'{where} is a .plain file. " f"Referencing .plain files through Linked Resources is not supported. " "Please use the import section to include definitions or implementation/test requirements from another module." ) @@ -156,17 +161,18 @@ def check_for_linked_resources(plain_source, template_dirs): functionality.linked_resources = find_linked_resources(functionality.body, template_dirs) -def process_section_code_variables(section, code_variables): - if "markdown" not in section: +def process_section_code_variables(section, code_variables, text_key="markdown"): + """Put `{{ name }}` placeholders back into the section text and record the code variables it uses.""" + if text_key not in section: return code_variables_array = [] for key, value in code_variables.items(): - if key not in section["markdown"]: + if key not in section[text_key]: continue variable_name = next(iter(value)) - section["markdown"] = section["markdown"].replace(key, f"{{{{ {variable_name} }}}}") + section[text_key] = section[text_key].replace(key, f"{{{{ {variable_name} }}}}") code_variables_array.append({"name": variable_name, "value": value[variable_name]}) diff --git a/src/plain_parser/test_plain_file.py b/src/plain_parser/test_plain_file.py new file mode 100644 index 0000000..df334eb --- /dev/null +++ b/src/plain_parser/test_plain_file.py @@ -0,0 +1,227 @@ +"""Test plain files: the conformance and acceptance scenarios of one functionality. + +A test plain file binds to a functionality with the frontmatter keys ``module`` and ``func_id``. It holds a +``***conformance tests***`` section followed by an ``***acceptance tests***`` section; each scenario in them is a +`# ` heading followed by free text, which is kept verbatim. +""" + +import io +from dataclasses import dataclass, field + +import frontmatter +import mistletoe + +from plain_parser import concept_utils, loaders, plain_file, plain_spec +from plain_parser.exceptions import PlainSyntaxError + +MODULE_KEY = "module" +FUNC_ID_KEY = "func_id" +FRONTMATTER_KEYS = (MODULE_KEY, FUNC_ID_KEY) +SECTION_HEADINGS = (plain_spec.CONFORMANCE_TEST_HEADING, plain_spec.ACCEPTANCE_TEST_HEADING) +TEST_SCENARIOS_HINT = "Run the add-test-scenarios skill to update the file." + + +@dataclass +class Scenario: + id: str + line_number: int + # The body as written, without `>` comment lines and without leading and trailing blank lines. + text: str = "" + linked_resources: list[dict] = field(default_factory=list) + code_variables: list[dict] = field(default_factory=list) + + def to_dict(self) -> dict: + entry = {"id": self.id, "text": self.text, "linked_resources": self.linked_resources} + if self.code_variables: + entry["code_variables"] = self.code_variables + return entry + + +@dataclass +class TestPlainFile: + name: str # The path of the file as shown in messages. + module: str + func_id: str + conformance_tests: list[Scenario] + acceptance_tests: list[Scenario] + + @property + def scenarios(self) -> list[Scenario]: + return [*self.conformance_tests, *self.acceptance_tests] + + +def is_test_plain_file(metadata: dict) -> bool: + """Whether a frontmatter binds its file to a functionality, which makes the file a test plain file.""" + return all(key in metadata for key in FRONTMATTER_KEYS) + + +def _error(message: str) -> PlainSyntaxError: + return PlainSyntaxError(f"Plain syntax error: {message}") + + +def _at(line_number: int, name: str) -> str: + return f"Syntax error at line {line_number} in test plain file '{name}'" + + +def read_metadata(plain_source_text: str, name: str) -> frontmatter.Post: + """The frontmatter and content of a test plain file; raise unless the keys are exactly module and func_id.""" + try: + post = frontmatter.loads(plain_source_text) + except Exception as e: + raise _error(f"Test plain file '{name}' has invalid frontmatter: {e}") + + for key in FRONTMATTER_KEYS: + if key not in post.metadata: + raise _error(f"Test plain file '{name}' has no `{key}:` key in its frontmatter. {TEST_SCENARIOS_HINT}") + value = post.metadata[key] + if not isinstance(value, str) or not value.strip(): + raise _error(f"Test plain file '{name}': the frontmatter key `{key}:` must be a non-empty string.") + + for key in post.metadata: + if key not in FRONTMATTER_KEYS: + raise _error( + f"Test plain file '{name}' has the frontmatter key `{key}:`; only `module:` and `func_id:` are allowed." + ) + + return post + + +def _trim_blank_lines(lines: list[str]) -> list[str]: + start, end = 0, len(lines) + while start < end and not lines[start].strip(): + start += 1 + while end > start and not lines[end - 1].strip(): + end -= 1 + return lines[start:end] + + +def read_scenarios(rendered_text: str, name: str) -> dict[str, list[Scenario]]: # noqa: C901 + """The scenarios of the rendered content, keyed by section heading; raise on the first structural error.""" + sections: dict[str, list[Scenario]] = {} + current_section: str | None = None + current: Scenario | None = None + body: list[str] = [] + heading_lines: dict[str, int] = {} # scenario-id to the line of its heading + + def close_scenario() -> None: + if current is None: + return + lines = _trim_blank_lines(body) + if not lines: + raise _error(f"{_at(current.line_number, name)}: Scenario `{current.id}` has no text.") + current.text = "\n".join(lines) + + for line_number, line in enumerate(rendered_text.split("\n"), start=1): + where = _at(line_number, name) + if line.startswith("***"): + heading = line.strip()[3:-3] if line.strip().endswith("***") and len(line.strip()) > 6 else None + if heading not in SECTION_HEADINGS: + raise _error( + f"{where}: Invalid section (`{line.strip()}`). A test plain file has the sections " + f"`***{SECTION_HEADINGS[0]}***` and `***{SECTION_HEADINGS[1]}***`, in this order." + ) + if heading in sections: + raise _error(f"{where}: Duplicate section `***{heading}***`.") + if heading != SECTION_HEADINGS[len(sections)]: + raise _error(f"{where}: The `***{heading}***` section must come after `***{SECTION_HEADINGS[0]}***`.") + close_scenario() + current = None + current_section = heading + sections[heading] = [] + elif line.startswith("# ") or line == "#": + scenario_id = line[1:].strip() + if current_section is None: + raise _error( + f"{where}: Scenario `{scenario_id}` comes before the first section; a test plain file starts " + f"with `***{SECTION_HEADINGS[0]}***`." + ) + if not plain_spec.is_semantic_id(scenario_id): + raise _error( + f"{where}: Invalid scenario-id (`{scenario_id}`). A scenario-id has only lowercase letters, " + f"digits and hyphens." + ) + if scenario_id in heading_lines: + raise _error( + f"{where}: Duplicate scenario-id `{scenario_id}`; it is first used at line " + f"{heading_lines[scenario_id]}." + ) + close_scenario() + current = Scenario(scenario_id, line_number) + body = [] + heading_lines[scenario_id] = line_number + sections[current_section].append(current) + elif line.lstrip().startswith(">"): + continue + elif current is not None: + body.append(line) + elif line.strip(): + if current_section is None: + raise _error( + f"{where}: Content before the first section (`{line.strip()}`); a test plain file starts with " + f"`***{SECTION_HEADINGS[0]}***`." + ) + raise _error( + f"{where}: Content before the first `# ` heading in the `{current_section}` section " + f"(`{line.strip()}`)." + ) + + close_scenario() + + for heading in SECTION_HEADINGS: + if heading not in sections: + raise _error(f"Test plain file '{name}' has no `***{heading}***` section. {TEST_SCENARIOS_HINT}") + + return sections + + +def find_scenario_linked_resources(scenario: Scenario, template_dirs: list[str], name: str) -> list[dict]: + document = mistletoe.Document(io.StringIO(scenario.text)) + where = f" in test plain file '{name}', scenario `{scenario.id}`" + return plain_file.find_linked_resources(document.children, template_dirs, where) + + +def parse_test_plain_file_source( + plain_source_text: str, name: str, template_dirs: list[str], code_variables: dict +) -> TestPlainFile: + """Parse the text of a test plain file. Liquid runs as on a core spec, with the same template directories; + every link in a scenario is resolved against them. Concepts are not checked here, see + validate_scenario_concepts.""" + plain_source_text = plain_file.normalize_line_endings(plain_source_text) + + post = read_metadata(plain_source_text, name) + + _, loaded_templates = loaders.get_loaded_templates(template_dirs, plain_source_text, name) + + content = plain_file.restore_stripped_lines(plain_source_text, post.content) + + rendered_text = plain_file.render_plain_source(content, loaded_templates, code_variables) + + sections = read_scenarios(rendered_text, name) + + for scenario in [*sections[SECTION_HEADINGS[0]], *sections[SECTION_HEADINGS[1]]]: + scenario.linked_resources = find_scenario_linked_resources(scenario, template_dirs, name) + entry = {"text": scenario.text} + plain_file.process_section_code_variables(entry, code_variables, "text") + scenario.text = entry["text"] + scenario.code_variables = entry.get("code_variables", []) + + return TestPlainFile( + name, + str(post.metadata[MODULE_KEY]), + str(post.metadata[FUNC_ID_KEY]), + sections[SECTION_HEADINGS[0]], + sections[SECTION_HEADINGS[1]], + ) + + +def validate_scenario_concepts(test_plain_file: TestPlainFile, concepts: set[str]) -> list[str]: + """One message per concept used in a scenario that is neither in ``concepts`` nor predefined.""" + errors = [] + for scenario in test_plain_file.scenarios: + for concept in dict.fromkeys(concept_utils.extract_concepts_from_spec_text(scenario.text)): + if concept not in concepts and concept not in concept_utils.DEFAULT_CONCEPTS: + errors.append( + f"{_at(scenario.line_number, test_plain_file.name)}: Concept {concept} used in scenario " + f"`{scenario.id}` is not defined in module '{test_plain_file.module}'." + ) + return errors diff --git a/tests/data/testplainfile/content_before_scenario.plain b/tests/data/testplainfile/content_before_scenario.plain new file mode 100644 index 0000000..75bb3f2 --- /dev/null +++ b/tests/data/testplainfile/content_before_scenario.plain @@ -0,0 +1,13 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +It works. + +# works +It works. + +***acceptance tests*** diff --git a/tests/data/testplainfile/content_before_section.plain b/tests/data/testplainfile/content_before_section.plain new file mode 100644 index 0000000..e8b879f --- /dev/null +++ b/tests/data/testplainfile/content_before_section.plain @@ -0,0 +1,10 @@ +--- +module: task-manager +func_id: add-task +--- + +It works. + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/duplicate_scenario_id.plain b/tests/data/testplainfile/duplicate_scenario_id.plain new file mode 100644 index 0000000..7a64a60 --- /dev/null +++ b/tests/data/testplainfile/duplicate_scenario_id.plain @@ -0,0 +1,14 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +It works. + +***acceptance tests*** + +# works +It still works. diff --git a/tests/data/testplainfile/duplicate_section.plain b/tests/data/testplainfile/duplicate_section.plain new file mode 100644 index 0000000..f5877ec --- /dev/null +++ b/tests/data/testplainfile/duplicate_section.plain @@ -0,0 +1,13 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +It works. + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/empty_scenario.plain b/tests/data/testplainfile/empty_scenario.plain new file mode 100644 index 0000000..bf35b8b --- /dev/null +++ b/tests/data/testplainfile/empty_scenario.plain @@ -0,0 +1,14 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +> Only a comment. + +# works-too +It works. + +***acceptance tests*** diff --git a/tests/data/testplainfile/empty_sections.plain b/tests/data/testplainfile/empty_sections.plain new file mode 100644 index 0000000..1799d34 --- /dev/null +++ b/tests/data/testplainfile/empty_sections.plain @@ -0,0 +1,8 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/extra_frontmatter_key.plain b/tests/data/testplainfile/extra_frontmatter_key.plain new file mode 100644 index 0000000..b13a654 --- /dev/null +++ b/tests/data/testplainfile/extra_frontmatter_key.plain @@ -0,0 +1,9 @@ +--- +module: task-manager +func_id: add-task +description: Scenarios of add-task. +--- + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/invalid_scenario_id.plain b/tests/data/testplainfile/invalid_scenario_id.plain new file mode 100644 index 0000000..656234a --- /dev/null +++ b/tests/data/testplainfile/invalid_scenario_id.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# Works Well +It works. + +***acceptance tests*** diff --git a/tests/data/testplainfile/link_to_plain_file.plain b/tests/data/testplainfile/link_to_plain_file.plain new file mode 100644 index 0000000..ec9f92f --- /dev/null +++ b/tests/data/testplainfile/link_to_plain_file.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +See [the spec](valid.plain). + +***acceptance tests*** diff --git a/tests/data/testplainfile/missing_acceptance_section.plain b/tests/data/testplainfile/missing_acceptance_section.plain new file mode 100644 index 0000000..f823f9b --- /dev/null +++ b/tests/data/testplainfile/missing_acceptance_section.plain @@ -0,0 +1,9 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +It works. diff --git a/tests/data/testplainfile/missing_func_id.plain b/tests/data/testplainfile/missing_func_id.plain new file mode 100644 index 0000000..6bcf562 --- /dev/null +++ b/tests/data/testplainfile/missing_func_id.plain @@ -0,0 +1,7 @@ +--- +module: task-manager +--- + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/missing_link.plain b/tests/data/testplainfile/missing_link.plain new file mode 100644 index 0000000..39cf4ee --- /dev/null +++ b/tests/data/testplainfile/missing_link.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +See [the missing notes](missing.md). + +***acceptance tests*** diff --git a/tests/data/testplainfile/module_not_a_string.plain b/tests/data/testplainfile/module_not_a_string.plain new file mode 100644 index 0000000..b3fe4ea --- /dev/null +++ b/tests/data/testplainfile/module_not_a_string.plain @@ -0,0 +1,8 @@ +--- +module: [task-manager] +func_id: add-task +--- + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/notes.md b/tests/data/testplainfile/notes.md new file mode 100644 index 0000000..a6c631c --- /dev/null +++ b/tests/data/testplainfile/notes.md @@ -0,0 +1 @@ +The expected output. diff --git a/tests/data/testplainfile/scenario_before_section.plain b/tests/data/testplainfile/scenario_before_section.plain new file mode 100644 index 0000000..289c79d --- /dev/null +++ b/tests/data/testplainfile/scenario_before_section.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +# works +It works. + +***conformance tests*** + +***acceptance tests*** diff --git a/tests/data/testplainfile/sections_out_of_order.plain b/tests/data/testplainfile/sections_out_of_order.plain new file mode 100644 index 0000000..15df80b --- /dev/null +++ b/tests/data/testplainfile/sections_out_of_order.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***acceptance tests*** + +# works +It works. + +***conformance tests*** diff --git a/tests/data/testplainfile/unknown_section.plain b/tests/data/testplainfile/unknown_section.plain new file mode 100644 index 0000000..f96844b --- /dev/null +++ b/tests/data/testplainfile/unknown_section.plain @@ -0,0 +1,11 @@ +--- +module: task-manager +func_id: add-task +--- + +***conformance tests*** + +# works +It works. + +***definitions*** diff --git a/tests/data/testplainfile/valid.plain b/tests/data/testplainfile/valid.plain new file mode 100644 index 0000000..d793a8f --- /dev/null +++ b/tests/data/testplainfile/valid.plain @@ -0,0 +1,40 @@ +--- +module: task-manager +func_id: add-task +--- + +> A comment before the first section. + +***conformance tests*** + +# add-first-task +Adding a :Task: named "Buy milk" to an empty :TaskList: shows one :Task: named "Buy milk". + - :App: prints `Added task 1: Buy milk`. + +# add-without-name + +given: + - an empty :TaskList: +when: + - :User: runs `todo add` +then: + - stderr is `Error: name is required` +> A comment inside a body is dropped. + - exit status is 1 + + +# gherkin-shape +Scenario: adding a task with a note + Given an empty :TaskList: + When :User: adds "Buy milk" + + Then :TaskList: shows it + +# links-the-notes +The output matches [the notes](notes.md). + +***acceptance tests*** + +# add-then-list +- :User: adds a :Task: named "Buy milk". +- `todo list` shows `1 Buy milk`. diff --git a/tests/test_test_plain_file.py b/tests/test_test_plain_file.py new file mode 100644 index 0000000..ab9c98e --- /dev/null +++ b/tests/test_test_plain_file.py @@ -0,0 +1,215 @@ +import os +import re + +import pytest + +from plain_parser import test_plain_file +from plain_parser.exceptions import PlainSyntaxError + + +@pytest.fixture +def fixtures_dir(get_test_data_path): + return get_test_data_path("data/testplainfile") + + +def _parse(fixtures_dir, file_name, code_variables=None): + with open(os.path.join(fixtures_dir, file_name)) as f: + text = f.read() + return test_plain_file.parse_test_plain_file_source(text, file_name, [fixtures_dir], code_variables or {}) + + +def test_valid_file_binds_to_its_functionality_and_keeps_the_scenarios_verbatim(fixtures_dir): + parsed = _parse(fixtures_dir, "valid.plain") + + assert (parsed.name, parsed.module, parsed.func_id) == ("valid.plain", "task-manager", "add-task") + assert [scenario.to_dict() for scenario in parsed.conformance_tests] == [ + { + "id": "add-first-task", + "text": 'Adding a :Task: named "Buy milk" to an empty :TaskList: shows one :Task: named "Buy milk".\n' + " - :App: prints `Added task 1: Buy milk`.", + "linked_resources": [], + }, + { + "id": "add-without-name", + "text": "given:\n - an empty :TaskList:\nwhen:\n - :User: runs `todo add`\nthen:\n" + " - stderr is `Error: name is required`\n - exit status is 1", + "linked_resources": [], + }, + { + "id": "gherkin-shape", + "text": 'Scenario: adding a task with a note\n Given an empty :TaskList:\n When :User: adds "Buy milk"\n\n' + " Then :TaskList: shows it", + "linked_resources": [], + }, + { + "id": "links-the-notes", + "text": "The output matches [the notes](notes.md).", + "linked_resources": [{"text": "the notes", "target": "notes.md"}], + }, + ] + assert [scenario.to_dict() for scenario in parsed.acceptance_tests] == [ + { + "id": "add-then-list", + "text": '- :User: adds a :Task: named "Buy milk".\n- `todo list` shows `1 Buy milk`.', + "linked_resources": [], + } + ] + assert [scenario.line_number for scenario in parsed.scenarios] == [10, 14, 26, 33, 38] + + +def test_both_sections_may_be_empty(fixtures_dir): + parsed = _parse(fixtures_dir, "empty_sections.plain") + + assert (parsed.conformance_tests, parsed.acceptance_tests) == ([], []) + + +@pytest.mark.parametrize( + ("file_name", "message"), + [ + ( + "missing_acceptance_section.plain", + "Plain syntax error: Test plain file 'missing_acceptance_section.plain' has no `***acceptance tests***` " + "section. Run the add-test-scenarios skill to update the file.", + ), + ( + "sections_out_of_order.plain", + "Plain syntax error: Syntax error at line 6 in test plain file 'sections_out_of_order.plain': The " + "`***acceptance tests***` section must come after `***conformance tests***`.", + ), + ( + "duplicate_section.plain", + "Plain syntax error: Syntax error at line 11 in test plain file 'duplicate_section.plain': Duplicate " + "section `***conformance tests***`.", + ), + ( + "unknown_section.plain", + "Plain syntax error: Syntax error at line 11 in test plain file 'unknown_section.plain': Invalid section " + "(`***definitions***`). A test plain file has the sections `***conformance tests***` and " + "`***acceptance tests***`, in this order.", + ), + ( + "extra_frontmatter_key.plain", + "Plain syntax error: Test plain file 'extra_frontmatter_key.plain' has the frontmatter key " + "`description:`; only `module:` and `func_id:` are allowed.", + ), + ( + "missing_func_id.plain", + "Plain syntax error: Test plain file 'missing_func_id.plain' has no `func_id:` key in its frontmatter. " + "Run the add-test-scenarios skill to update the file.", + ), + ( + "module_not_a_string.plain", + "Plain syntax error: Test plain file 'module_not_a_string.plain': the frontmatter key `module:` must be " + "a non-empty string.", + ), + ( + "duplicate_scenario_id.plain", + "Plain syntax error: Syntax error at line 13 in test plain file 'duplicate_scenario_id.plain': Duplicate " + "scenario-id `works`; it is first used at line 8.", + ), + ( + "invalid_scenario_id.plain", + "Plain syntax error: Syntax error at line 8 in test plain file 'invalid_scenario_id.plain': Invalid " + "scenario-id (`Works Well`). A scenario-id has only lowercase letters, digits and hyphens.", + ), + ( + "content_before_section.plain", + "Plain syntax error: Syntax error at line 6 in test plain file 'content_before_section.plain': Content " + "before the first section (`It works.`); a test plain file starts with `***conformance tests***`.", + ), + ( + "content_before_scenario.plain", + "Plain syntax error: Syntax error at line 8 in test plain file 'content_before_scenario.plain': Content " + "before the first `# ` heading in the `conformance tests` section (`It works.`).", + ), + ( + "scenario_before_section.plain", + "Plain syntax error: Syntax error at line 6 in test plain file 'scenario_before_section.plain': Scenario " + "`works` comes before the first section; a test plain file starts with `***conformance tests***`.", + ), + ( + "empty_scenario.plain", + "Plain syntax error: Syntax error at line 8 in test plain file 'empty_scenario.plain': Scenario `works` " + "has no text.", + ), + ], +) +def test_invalid_file_is_rejected_with_its_place(fixtures_dir, file_name, message): + with pytest.raises(PlainSyntaxError) as exc_info: + _parse(fixtures_dir, file_name) + + assert str(exc_info.value) == message + + +def test_missing_link_names_the_file_and_the_scenario(fixtures_dir): + with pytest.raises(PlainSyntaxError) as exc_info: + _parse(fixtures_dir, "missing_link.plain") + + assert str(exc_info.value).startswith( + "Plain syntax error: Link missing.md in test plain file 'missing_link.plain', scenario `works` does not " + "exist. Linked resources are looked up relative to the following directories" + ) + + +def test_link_to_a_plain_file_is_rejected(fixtures_dir): + with pytest.raises(PlainSyntaxError, match=re.escape("Referenced resource 'valid.plain' in test plain file")): + _parse(fixtures_dir, "link_to_plain_file.plain") + + +def test_invalid_frontmatter_is_rejected(): + with pytest.raises(PlainSyntaxError, match="Test plain file 'broken.plain' has invalid frontmatter"): + test_plain_file.parse_test_plain_file_source("---\nmodule: [\n---\n", "broken.plain", [], {}) + + +def test_liquid_runs_with_the_template_directories(tmp_path): + (tmp_path / "shape.plain").write_text(" - exit status is {{ status | code_variable }}") + text = ( + "---\nmodule: m\nfunc_id: f\n---\n\n***conformance tests***\n\n# works\n" + 'It works.\n{% include "shape.plain", status: "0" %}\n\n***acceptance tests***\n' + ) + code_variables = {} + + parsed = test_plain_file.parse_test_plain_file_source(text, "f.plain", [str(tmp_path)], code_variables) + + assert parsed.conformance_tests[0].to_dict() == { + "id": "works", + "text": "It works.\n - exit status is {{ status }}", + "linked_resources": [], + "code_variables": [{"name": "status", "value": "0"}], + } + + +def test_liquid_error_names_the_file(): + text = "---\nmodule: m\nfunc_id: f\n---\n\n***conformance tests***\n\n# works\nIt {{ works\n" + + with pytest.raises(PlainSyntaxError, match=re.escape("Invalid Liquid at f.plain:")): + test_plain_file.parse_test_plain_file_source(text, "f.plain", [], {}) + + +def test_scenario_concepts_are_checked_against_the_visible_concepts(fixtures_dir): + parsed = _parse(fixtures_dir, "valid.plain") + + assert test_plain_file.validate_scenario_concepts(parsed, {":Task:", ":TaskList:", ":App:", ":User:"}) == [] + assert test_plain_file.validate_scenario_concepts(parsed, {":Task:", ":App:", ":User:"}) == [ + "Syntax error at line 10 in test plain file 'valid.plain': Concept :TaskList: used in scenario " + "`add-first-task` is not defined in module 'task-manager'.", + "Syntax error at line 14 in test plain file 'valid.plain': Concept :TaskList: used in scenario " + "`add-without-name` is not defined in module 'task-manager'.", + "Syntax error at line 26 in test plain file 'valid.plain': Concept :TaskList: used in scenario " + "`gherkin-shape` is not defined in module 'task-manager'.", + ] + + +def test_predefined_concepts_need_no_definition(): + scenario = test_plain_file.Scenario("works", 8, ":ConformanceTests: start :Implementation:.") + parsed = test_plain_file.TestPlainFile("f.plain", "m", "f", [scenario], []) + + assert test_plain_file.validate_scenario_concepts(parsed, set()) == [] + + +@pytest.mark.parametrize( + ("metadata", "expected"), + [({"module": "m", "func_id": "f"}, True), ({"module": "m"}, False), ({}, False), ({"description": "x"}, False)], +) +def test_is_test_plain_file(metadata, expected): + assert test_plain_file.is_test_plain_file(metadata) is expected From 6b4314e84721315319be232b1d37e8fbdf9ddc44 Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:36:15 +0200 Subject: [PATCH 5/6] feat: bind test plain files to functionalities Every .plain file under the test folders is read as a test plain file, bound to a module of the chain and one of its func-ids, checked against the concepts of that module, and merged into the functionality in discovery order. With conformance-tests-script in the config, every functionality needs a conformance scenario; the error names it as : and the add-test-scenarios skill. The config keys test-dir and conformance-tests-script are read like template-dir. --- src/plain_parser/__init__.py | 3 +- src/plain_parser/cli.py | 115 ++++++-- src/plain_parser/exceptions.py | 6 + src/plain_parser/project.py | 154 ++++++++++ tests/data/cli/project/config.yaml | 2 + .../cli/project/tests/todo/add-task.plain | 17 ++ .../cli/project/tests/todo/list-tasks.plain | 11 + tests/data/cli/project/todo.plain | 15 + tests/test_cli.py | 100 +++++++ tests/test_project.py | 268 ++++++++++++++++++ 10 files changed, 660 insertions(+), 31 deletions(-) create mode 100644 src/plain_parser/project.py create mode 100644 tests/data/cli/project/config.yaml create mode 100644 tests/data/cli/project/tests/todo/add-task.plain create mode 100644 tests/data/cli/project/tests/todo/list-tasks.plain create mode 100644 tests/data/cli/project/todo.plain create mode 100644 tests/test_project.py diff --git a/src/plain_parser/__init__.py b/src/plain_parser/__init__.py index b27dbbf..a37fbfe 100644 --- a/src/plain_parser/__init__.py +++ b/src/plain_parser/__init__.py @@ -1,6 +1,6 @@ """Parser for ***plain specification files.""" -from plain_parser import concept_utils, exceptions, loaders, plain_file, plain_spec, test_plain_file +from plain_parser import concept_utils, exceptions, loaders, plain_file, plain_spec, project, test_plain_file from plain_parser.plain_file import ( PlainFileParseResult, get_filename_from_module_name, @@ -26,5 +26,6 @@ "plain_file", "plain_file_parser", "plain_spec", + "project", "test_plain_file", ] diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 24a9a48..990c2a1 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -4,14 +4,17 @@ import json import os import sys -from dataclasses import dataclass +from dataclasses import dataclass, field import yaml -from plain_parser import loaders, plain_file, plain_spec +from plain_parser import loaders, plain_spec, project DEFAULT_CONFIG_NAME = "config.yaml" TEMPLATE_DIR_CONFIG_KEYS = ("template_dir", "template-dir") +TEST_DIR_CONFIG_KEYS = ("test_dir", "test-dir") +CONFORMANCE_TESTS_SCRIPT_CONFIG_KEYS = ("conformance_tests_script", "conformance-tests-script") +DEFAULT_TEST_DIR = "tests" PARSE_FORMAT_VERSION = 1 @@ -71,17 +74,37 @@ def resolve_config_file(config_name: str, plain_file_path: str) -> str | None: return None -def template_dir_from_config(config_file: str) -> str | None: - """The template-dir value of the config file, resolved against the file's directory. Other keys are ignored.""" +@dataclass +class Config: + template_dir: str | None = None + test_dirs: list[str] | None = None # None when the config does not set test-dir. + conformance_tests_enabled: bool = False # Whether the config names a conformance-tests-script. + + +def _config_value(config: dict, keys: tuple[str, ...]): + """The first value set under one of the key spellings, or None.""" + return next((config[key] for key in keys if config.get(key)), None) + + +def read_config(config_file: str) -> Config: + """The values plain-parser reads from the config file, paths resolved against the file's directory. Other keys + are ignored.""" with open(config_file) as f: config = yaml.safe_load(f) or {} - for key in TEMPLATE_DIR_CONFIG_KEYS: - value = config.get(key) - if value: - value = os.path.expanduser(str(value)) - return value if os.path.isabs(value) else os.path.join(os.path.dirname(config_file), value) - return None + def resolve(value) -> str: + value = os.path.expanduser(str(value)) + return value if os.path.isabs(value) else os.path.join(os.path.dirname(config_file), value) + + result = Config() + template_dir = _config_value(config, TEMPLATE_DIR_CONFIG_KEYS) + if template_dir: + result.template_dir = resolve(template_dir) + test_dir = _config_value(config, TEST_DIR_CONFIG_KEYS) + if test_dir: + result.test_dirs = [resolve(value) for value in (test_dir if isinstance(test_dir, list) else [test_dir])] + result.conformance_tests_enabled = bool(_config_value(config, CONFORMANCE_TESTS_SCRIPT_CONFIG_KEYS)) + return result @dataclass @@ -92,20 +115,37 @@ class ParseResult: # Each link target in the chain, as written, to the path it resolves to, relative to the entry module's dir # with `/` as separator (loaders.relative_resource_path). resources: dict[str, str] - - -def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: bool) -> ParseResult: - """Parse the module and its requires chain, resolve its links, and walk the entry's FRIDs; raise on the first error. - - With ``load_resources``, each module's linked files are also read and checked (text only, no large base64 blob) right - after that module's links are collected, so a resource error is reported before a FRID-walk error. + # Each test plain file, by its path relative to the entry module's dir with `/`, to the functionality it binds to. + test_plain_files: dict[str, dict] = field(default_factory=dict) + + +def parse_spec( + plain_file_path: str, + template_dir: str | None, + load_resources: bool, + test_dirs: list[str] | None = None, + require_conformance_scenarios: bool = False, +) -> ParseResult: + """Parse the module, its requires chain and its test plain files, resolve the links, and walk the entry's + FRIDs; raise on the first error. + + ``test_dirs`` defaults to ``tests`` next to the module. With ``load_resources``, each module's linked files are + also read and checked (text only, no large base64 blob) right after that module's links are collected, so a + resource error is reported before a FRID-walk error. """ # Absolute, so the chain parse and the resource paths below search the same directories. - template_dirs = [os.path.dirname(os.path.abspath(plain_file_path))] + entry_dir = os.path.dirname(os.path.abspath(plain_file_path)) + template_dirs = [entry_dir] if template_dir: template_dirs.append(os.path.abspath(template_dir)) + if test_dirs is None: + test_dirs = [os.path.join(entry_dir, DEFAULT_TEST_DIR)] + test_dirs = [os.path.abspath(test_dir) for test_dir in test_dirs] - chain = plain_file.parse_module_chain(os.path.basename(plain_file_path), template_dirs) + parsed = project.parse_project( + os.path.basename(plain_file_path), template_dirs, test_dirs, require_conformance_scenarios + ) + chain = parsed.chain # The target joined as written onto its template dir (`..` kept, symlinks not resolved), made relative to the # entry module's dir so the result does not depend on where the project sits on the machine. @@ -124,12 +164,21 @@ def parse_spec(plain_file_path: str, template_dir: str | None, load_resources: b for frid in plain_spec.get_frids(top_plain_source_tree): plain_spec.get_specifications_for_frid(top_plain_source_tree, frid) - return ParseResult(chain, resources) + test_plain_files = { + bound.name: {"module": bound.parsed.module, "func_id": bound.parsed.func_id} + for bound in parsed.test_plain_files + } + return ParseResult(chain, resources, test_plain_files) -def check(plain_file_path: str, template_dir: str | None) -> None: - """Raise if the module, its requires chain, or its linked resources are invalid.""" - parse_spec(plain_file_path, template_dir, load_resources=True) +def check( + plain_file_path: str, + template_dir: str | None, + test_dirs: list[str] | None = None, + require_conformance_scenarios: bool = False, +) -> None: + """Raise if the module, its requires chain, its test plain files, or its linked resources are invalid.""" + parse_spec(plain_file_path, template_dir, True, test_dirs, require_conformance_scenarios) def _error_output(type_name: str, message: str) -> dict: @@ -146,11 +195,16 @@ def _encode_output(output: dict) -> bytes: return (json.dumps(output, indent=2, ensure_ascii=False) + "\n").encode("utf-8") -def parse(plain_file_path: str, template_dir: str | None) -> int: +def parse( + plain_file_path: str, + template_dir: str | None, + test_dirs: list[str] | None = None, + require_conformance_scenarios: bool = False, +) -> int: """Print the parse result, or the parser error, as one UTF-8 JSON document on stdout; return the exit code.""" output: dict try: - result = parse_spec(plain_file_path, template_dir, load_resources=False) + result = parse_spec(plain_file_path, template_dir, False, test_dirs, require_conformance_scenarios) output = { "format_version": PARSE_FORMAT_VERSION, "modules": [{"module": module_name, "tree": tree} for module_name, tree in result.modules], @@ -209,10 +263,11 @@ def main(argv: list[str] | None = None) -> int: try: # The config file is resolved even when --template-dir is given, so an ambiguous config is always an error. - config_template_dir = _template_dir_from_config(parser, args) + config = _read_config(parser, args) + template_dir = args.template_dir or config.template_dir if args.command == "parse": - return parse(args.plain_file, args.template_dir or config_template_dir) - check(args.plain_file, args.template_dir or config_template_dir) + return parse(args.plain_file, template_dir, config.test_dirs, config.conformance_tests_enabled) + check(args.plain_file, template_dir, config.test_dirs, config.conformance_tests_enabled) except KeyboardInterrupt: return 130 except Exception as e: @@ -223,10 +278,10 @@ def main(argv: list[str] | None = None) -> int: return 0 -def _template_dir_from_config(parser: argparse.ArgumentParser, args: argparse.Namespace) -> str | None: +def _read_config(parser: argparse.ArgumentParser, args: argparse.Namespace) -> Config: try: config_file = resolve_config_file(args.config_name, args.plain_file) - return template_dir_from_config(config_file) if config_file is not None else None + return read_config(config_file) if config_file is not None else Config() except AmbiguousConfigFileError as e: parser.error(str(e)) except Exception as e: diff --git a/src/plain_parser/exceptions.py b/src/plain_parser/exceptions.py index 85b408d..9ef40e7 100644 --- a/src/plain_parser/exceptions.py +++ b/src/plain_parser/exceptions.py @@ -37,3 +37,9 @@ class ImportedModuleWithFunctionalitiesError(Exception): """ pass + + +class MissingConformanceScenariosError(Exception): + """Raised when conformance tests are enabled and a functionality has no conformance scenario.""" + + pass diff --git a/src/plain_parser/project.py b/src/plain_parser/project.py new file mode 100644 index 0000000..6bb4338 --- /dev/null +++ b/src/plain_parser/project.py @@ -0,0 +1,154 @@ +"""A module with its requires chain and the test plain files bound to its functionalities.""" + +import os +from dataclasses import dataclass + +import frontmatter + +from plain_parser import concept_utils, loaders, plain_file, plain_spec, test_plain_file +from plain_parser.exceptions import MissingConformanceScenariosError, PlainSyntaxError, UnsupportedBase64Content + + +@dataclass +class BoundTestPlainFile: + path: str # The file on disk. + name: str # The path relative to the entry module's directory, with `/` as separator. + parsed: test_plain_file.TestPlainFile + + +@dataclass +class ProjectParseResult: + # As parse_module_chain returns it, with the scenarios of the test plain files merged into the functionalities. + chain: list[tuple[str, dict]] + test_plain_files: list[BoundTestPlainFile] + + +def discover_test_plain_files(test_dirs: list[str], entry_dir: str) -> list[tuple[str, str]]: + """(path, name) of every .plain file under the test dirs: test dir by test dir, and within one by path. A + test dir that does not exist is skipped.""" + found = [] + for test_dir in test_dirs: + if not os.path.isdir(test_dir): + continue + for root, dirs, files in os.walk(test_dir): + dirs.sort() + for file_name in sorted(files): + if file_name.endswith(plain_file.PLAIN_SOURCE_FILE_EXTENSION): + found.append( + (os.path.join(root, file_name), loaders.relative_resource_path(entry_dir, root, file_name)) + ) + return found + + +def read_test_plain_file_text(path: str, name: str) -> str: + with open(path, "rb") as f: + raw = f.read() + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + raise PlainSyntaxError(f"Plain syntax error: File '{name}' under a test folder is not a text file.") + + blob = loaders.find_large_base64_blob(text) + if blob is not None: + raise UnsupportedBase64Content( + f"Test plain file '{name}' contains a base64-encoded blob ({len(blob)} characters) inlined in the " + "scenarios. This is not supported. Remove the base64 data from the file." + ) + return text + + +def bind_test_plain_files( + chain: list[tuple[str, dict]], test_dirs: list[str], template_dirs: list[str], entry_dir: str +) -> list[BoundTestPlainFile]: + """Read every test plain file under the test dirs and add its scenarios to the functionality it binds to. + + A `.plain` file under a test dir without the `module:` and `func_id:` keys is an error, as is a file that binds to + a module outside the chain or to a func-id the module does not have. The concepts of the scenarios must be + visible to the bound module. Files bound to one functionality are merged in discovery order; a scenario-id is + unique across that merged set. + """ + trees = dict(chain) + bound = [] + scenario_places: dict[tuple[str, str], dict[str, str]] = {} # (module, func-id) -> scenario-id -> place + for path, name in discover_test_plain_files(test_dirs, entry_dir): + text = read_test_plain_file_text(path, name) + try: + metadata = frontmatter.loads(text).metadata + except Exception as e: + raise PlainSyntaxError( + f"Plain syntax error: File '{name}' under a test folder has invalid frontmatter: {e}" + ) + if not test_plain_file.is_test_plain_file(metadata): + raise PlainSyntaxError( + f"Plain syntax error: File '{name}' is under a test folder but is not a test plain file: its " + f"frontmatter needs the keys `module:` and `func_id:`." + ) + + parsed = test_plain_file.parse_test_plain_file_source(text, name, template_dirs, {}) + if parsed.module not in trees: + raise PlainSyntaxError( + f"Plain syntax error: Test plain file '{name}' binds to module '{parsed.module}', which is not the " + f"entry module or one of its required modules ({', '.join(trees)})." + ) + tree = trees[parsed.module] + functionalities = tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or [] + functionality = next((item for item in functionalities if item["id"] == parsed.func_id), None) + if functionality is None: + raise PlainSyntaxError( + f"Plain syntax error: Test plain file '{name}' binds to functionality " + f"'{parsed.module}:{parsed.func_id}', which module '{parsed.module}' does not have. " + f"{test_plain_file.TEST_SCENARIOS_HINT}" + ) + + concepts, _ = concept_utils.collect_concepts(tree) + errors = test_plain_file.validate_scenario_concepts(parsed, set(concepts)) + if errors: + raise PlainSyntaxError( + f"Plain syntax error: Found {len(errors)} errors in test plain file '{name}':\n" + "\n".join(errors) + ) + + places = scenario_places.setdefault((parsed.module, parsed.func_id), {}) + for key, scenarios in ( + (plain_spec.CONFORMANCE_TESTS, parsed.conformance_tests), + (plain_spec.ACCEPTANCE_TESTS, parsed.acceptance_tests), + ): + for scenario in scenarios: + if scenario.id in places: + raise PlainSyntaxError( + f"Plain syntax error: Syntax error at line {scenario.line_number} in test plain file " + f"'{name}': Duplicate scenario-id `{scenario.id}` for functionality " + f"'{parsed.module}:{parsed.func_id}'; it is first used {places[scenario.id]}." + ) + places[scenario.id] = f"at line {scenario.line_number} in test plain file '{name}'" + functionality[key].append(scenario.to_dict()) + + bound.append(BoundTestPlainFile(path, name, parsed)) + + return bound + + +def check_conformance_scenarios(chain: list[tuple[str, dict]]) -> None: + """Raise for the first functionality of the chain, in render order, that has no conformance scenario.""" + for module_name, tree in chain: + for functionality in tree.get(plain_spec.FUNCTIONAL_REQUIREMENTS) or []: + if not functionality[plain_spec.CONFORMANCE_TESTS]: + func_id = functionality["id"] + raise MissingConformanceScenariosError( + f"Functionality {module_name}:{func_id} has no conformance scenario. Add one in a test plain file " + f"(by convention tests/{module_name}/{func_id}.plain) with the add-test-scenarios skill." + ) + + +def parse_project( + plain_file_name: str, template_dirs: list[str], test_dirs: list[str], require_conformance_scenarios: bool +) -> ProjectParseResult: + """Parse a module with its requires chain and bind the test plain files under ``test_dirs`` to its functionalities. + + ``template_dirs[0]`` is the entry module's directory; test plain files are named relative to it. With + ``require_conformance_scenarios``, every functionality of every module in the chain needs a conformance scenario. + """ + chain = plain_file.parse_module_chain(plain_file_name, template_dirs) + bound = bind_test_plain_files(chain, test_dirs, template_dirs, template_dirs[0]) + if require_conformance_scenarios: + check_conformance_scenarios(chain) + return ProjectParseResult(chain, bound) diff --git a/tests/data/cli/project/config.yaml b/tests/data/cli/project/config.yaml new file mode 100644 index 0000000..909167b --- /dev/null +++ b/tests/data/cli/project/config.yaml @@ -0,0 +1,2 @@ +unittests-script: test_scripts/run_unittests.sh +conformance-tests-script: test_scripts/run_conformance_tests.sh diff --git a/tests/data/cli/project/tests/todo/add-task.plain b/tests/data/cli/project/tests/todo/add-task.plain new file mode 100644 index 0000000..bb85002 --- /dev/null +++ b/tests/data/cli/project/tests/todo/add-task.plain @@ -0,0 +1,17 @@ +--- +module: todo +func_id: add-task +--- + +***conformance tests*** + +# add-first-task +Running `todo add Buy milk` on an empty list prints `Added task 1: Buy milk` and exits with status 0. + +# add-without-name +Running `todo add` prints `Error: name is required` to stderr and exits with status 1. + +***acceptance tests*** + +# add-then-list +Running `todo add Buy milk` and then `todo list` prints `1 Buy milk`. diff --git a/tests/data/cli/project/tests/todo/list-tasks.plain b/tests/data/cli/project/tests/todo/list-tasks.plain new file mode 100644 index 0000000..121a9ef --- /dev/null +++ b/tests/data/cli/project/tests/todo/list-tasks.plain @@ -0,0 +1,11 @@ +--- +module: todo +func_id: list-tasks +--- + +***conformance tests*** + +# list-two-tasks +With two :Task: items added, `todo list` prints two lines, lowest id first, and exits with status 0. + +***acceptance tests*** diff --git a/tests/data/cli/project/todo.plain b/tests/data/cli/project/todo.plain new file mode 100644 index 0000000..8167187 --- /dev/null +++ b/tests/data/cli/project/todo.plain @@ -0,0 +1,15 @@ +***definitions*** + +- :Task: is a thing to do. + +***implementation reqs*** + +- :Implementation: is written in Python 3.12. + +***functional specs*** + +# add-task +A :Task: can be added with `todo add `. + +# list-tasks +Every :Task: is listed by `todo list`, one per line. diff --git a/tests/test_cli.py b/tests/test_cli.py index 28606c5..228f354 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -337,6 +337,106 @@ def test_check_valid_module_prints_ok_line_with_file_as_given(cli_specs_dir, cap assert (exit_code, out, err) == (0, "valid.plain: OK\n", "") +@pytest.fixture +def project_dir(cli_specs_dir): + # todo.plain with two functionalities, a config.yaml naming a conformance-tests-script, and one test plain file + # per functionality under tests/todo/. + return os.path.join(cli_specs_dir, "project") + + +def _copy_project(project_dir, tmp_path): + copy = tmp_path / "project" + shutil.copytree(project_dir, copy) + return copy + + +def test_check_project_with_test_plain_files_prints_ok(project_dir, capsys, monkeypatch): + monkeypatch.chdir(project_dir) + + assert _run_check(capsys, "todo.plain") == (0, "todo.plain: OK\n", "") + + +def test_check_missing_conformance_scenario_names_the_functionality_and_the_skill(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").unlink() + + exit_code, out, err = _run_check(capsys, str(copy / "todo.plain")) + + assert (exit_code, out) == (1, "") + assert err == ( + "Error: Functionality todo:list-tasks has no conformance scenario. Add one in a test plain file " + "(by convention tests/todo/list-tasks.plain) with the add-test-scenarios skill.\n" + ) + + +def test_check_without_conformance_tests_script_needs_no_scenarios(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + shutil.rmtree(copy / "tests") + (copy / "config.yaml").write_text("unittests-script: test_scripts/run_unittests.sh\n") + + _assert_ok(_run_check(capsys, str(copy / "todo.plain"))) + + +def test_check_test_plain_file_error_is_reported_with_its_file(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").write_text( + "---\nmodule: todo\nfunc_id: list-tasks\n---\n\n***conformance tests***\n\n# lists\nIt lists a :Note:.\n\n" + "***acceptance tests***\n" + ) + + exit_code, out, err = _run_check(capsys, str(copy / "todo.plain")) + + assert (exit_code, out) == (1, "") + assert err == ( + "Error: Plain syntax error: Found 1 errors in test plain file 'tests/todo/list-tasks.plain':\n" + "Syntax error at line 8 in test plain file 'tests/todo/list-tasks.plain': Concept :Note: used in scenario " + "`lists` is not defined in module 'todo'.\n" + ) + + +@pytest.mark.parametrize( + "config_text", + [ + "conformance-tests-script: run.sh\ntest-dir: scenarios\n", + "conformance_tests_script: run.sh\ntest_dir: scenarios\n", + "conformance-tests-script: run.sh\ntest-dir:\n - scenarios\n - more\n", + ], +) +def test_check_reads_test_dir_from_config(project_dir, tmp_path, capsys, monkeypatch, config_text): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests").rename(copy / "scenarios") + (copy / "config.yaml").write_text(config_text) + monkeypatch.chdir(tmp_path) # not the project dir: the relative value must resolve against the config file + + _assert_ok(_run_check(capsys, str(copy / "todo.plain"))) + (copy / "scenarios" / "todo" / "add-task.plain").unlink() + assert "todo:add-task has no conformance scenario" in _run_check(capsys, str(copy / "todo.plain"))[2] + + +def test_check_config_test_dir_replaces_the_default(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntest-dir: scenarios\n") + + exit_code, _, err = _run_check(capsys, str(copy / "todo.plain")) + + assert exit_code == 1 + assert "todo:add-task has no conformance scenario" in err + + +def test_check_link_in_scenario_is_read(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").write_text( + "---\nmodule: todo\nfunc_id: list-tasks\n---\n\n***conformance tests***\n\n# lists\n" + "The output is [the listing](listing.bin).\n\n***acceptance tests***\n" + ) + (copy / "listing.bin").write_bytes(b"\x00\xff\xfe") + + exit_code, _, err = _run_check(capsys, str(copy / "todo.plain")) + + assert exit_code == 1 + assert err.startswith("Error: Referenced resource 'listing.bin' in module 'todo' is a binary file.") + + def test_check_reports_ancestor_resource_before_frid_walk_error(cli_specs_dir, capsys): # base.plain links a binary file; top.plain also has a code-variable conflict only the FRID walk finds. spec = os.path.join(cli_specs_dir, "resource_before_frid_walk", "top.plain") diff --git a/tests/test_project.py b/tests/test_project.py new file mode 100644 index 0000000..4f30dd9 --- /dev/null +++ b/tests/test_project.py @@ -0,0 +1,268 @@ +import pytest + +from plain_parser import plain_spec, project +from plain_parser.exceptions import MissingConformanceScenariosError, PlainSyntaxError + +MODULE = ( + "***definitions***\n\n- :Task: is a thing to do.\n\n***implementation reqs***\n\n- A req.\n\n" + "***functional specs***\n\n# add-task\nA :Task: can be added.\n\n# list-tasks\nEvery :Task: is listed.\n" +) + + +def write(path, text): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + + +def scenario_file(module, func_id, conformance="", acceptance=""): + return ( + f"---\nmodule: {module}\nfunc_id: {func_id}\n---\n\n***conformance tests***\n\n{conformance}" + f"\n***acceptance tests***\n\n{acceptance}" + ) + + +def parse(tmp_path, require_conformance_scenarios=False, test_dirs=None): + test_dirs = [str(tmp_path / "tests")] if test_dirs is None else test_dirs + return project.parse_project("todo.plain", [str(tmp_path)], test_dirs, require_conformance_scenarios) + + +def functionalities(result, module="todo"): + return {item["id"]: item for item in dict(result.chain)[module][plain_spec.FUNCTIONAL_REQUIREMENTS]} + + +def scenario_ids(functionality, key): + return [scenario["id"] for scenario in functionality[key]] + + +def test_scenarios_are_merged_into_the_bound_functionality(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write( + tmp_path / "tests" / "todo" / "add-task.plain", + scenario_file( + "todo", "add-task", "# add-one\nAdding one :Task: shows it.\n", "# add-then-list\nAdd, then list.\n" + ), + ) + + result = parse(tmp_path) + + assert [(bound.name, bound.parsed.module, bound.parsed.func_id) for bound in result.test_plain_files] == [ + ("tests/todo/add-task.plain", "todo", "add-task") + ] + assert functionalities(result)["add-task"][plain_spec.CONFORMANCE_TESTS] == [ + {"id": "add-one", "text": "Adding one :Task: shows it.", "linked_resources": []} + ] + assert functionalities(result)["add-task"][plain_spec.ACCEPTANCE_TESTS] == [ + {"id": "add-then-list", "text": "Add, then list.", "linked_resources": []} + ] + assert functionalities(result)["list-tasks"][plain_spec.CONFORMANCE_TESTS] == [] + + +def test_location_and_name_of_a_test_plain_file_are_convention_only(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "anything.plain", scenario_file("todo", "list-tasks", "# lists\nIt lists.\n")) + + result = parse(tmp_path) + + assert scenario_ids(functionalities(result)["list-tasks"], plain_spec.CONFORMANCE_TESTS) == ["lists"] + + +def test_files_bound_to_one_functionality_merge_in_path_order(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "b.plain", scenario_file("todo", "add-task", "# from-b\nFrom b.\n")) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# from-a\nFrom a.\n")) + write(tmp_path / "tests" / "sub" / "c.plain", scenario_file("todo", "add-task", "# from-c\nFrom c.\n")) + + result = parse(tmp_path) + + assert scenario_ids(functionalities(result)["add-task"], plain_spec.CONFORMANCE_TESTS) == [ + "from-a", + "from-b", + "from-c", + ] + assert [bound.name for bound in result.test_plain_files] == [ + "tests/a.plain", + "tests/b.plain", + "tests/sub/c.plain", + ] + + +def test_test_dirs_are_read_in_the_given_order(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "more" / "b.plain", scenario_file("todo", "add-task", "# from-b\nFrom b.\n")) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# from-a\nFrom a.\n")) + + result = parse(tmp_path, test_dirs=[str(tmp_path / "more"), str(tmp_path / "tests")]) + + assert scenario_ids(functionalities(result)["add-task"], plain_spec.CONFORMANCE_TESTS) == ["from-b", "from-a"] + assert [bound.name for bound in result.test_plain_files] == ["more/b.plain", "tests/a.plain"] + + +def test_missing_test_dir_is_not_an_error(tmp_path): + write(tmp_path / "todo.plain", MODULE) + + result = parse(tmp_path) + + assert result.test_plain_files == [] + + +def test_duplicate_scenario_id_across_files_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nIt works.\n")) + write(tmp_path / "tests" / "b.plain", scenario_file("todo", "add-task", "", "# works\nIt works again.\n")) + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: Syntax error at line 11 in test plain file 'tests/b.plain': Duplicate scenario-id " + "`works` for functionality 'todo:add-task'; it is first used at line 8 in test plain file 'tests/a.plain'." + ) + + +def test_same_scenario_id_for_two_functionalities_is_allowed(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nIt works.\n")) + write(tmp_path / "tests" / "b.plain", scenario_file("todo", "list-tasks", "# works\nIt works.\n")) + + parse(tmp_path) + + +def test_unknown_module_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("tasks", "add-task", "# works\nIt works.\n")) + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: Test plain file 'tests/a.plain' binds to module 'tasks', which is not the entry " + "module or one of its required modules (todo)." + ) + + +def test_unknown_func_id_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "delete-task", "# works\nIt works.\n")) + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: Test plain file 'tests/a.plain' binds to functionality 'todo:delete-task', which " + "module 'todo' does not have. Run the add-test-scenarios skill to update the file." + ) + + +def test_plain_file_under_test_dir_without_binding_keys_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "helper.plain", "***definitions***\n\n- :Helper: helps.\n") + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: File 'tests/helper.plain' is under a test folder but is not a test plain file: its " + "frontmatter needs the keys `module:` and `func_id:`." + ) + + +def test_file_under_test_dir_with_invalid_frontmatter_is_rejected(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "broken.plain", "---\nmodule: [\n---\n") + + with pytest.raises(PlainSyntaxError, match="File 'tests/broken.plain' under a test folder has invalid frontmatter"): + parse(tmp_path) + + +def test_test_plain_file_errors_name_their_file(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", "---\nmodule: todo\nfunc_id: add-task\n---\n\n***conformance tests***\n") + + with pytest.raises(PlainSyntaxError, match="Test plain file 'tests/a.plain' has no `\\*\\*\\*acceptance tests"): + parse(tmp_path) + + +def test_scenario_concepts_are_checked_against_the_bound_module(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write( + tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nA :Task: and a :Note: are added.\n") + ) + + with pytest.raises(PlainSyntaxError) as exc_info: + parse(tmp_path) + + assert str(exc_info.value) == ( + "Plain syntax error: Found 1 errors in test plain file 'tests/a.plain':\n" + "Syntax error at line 8 in test plain file 'tests/a.plain': Concept :Note: used in scenario `works` is not " + "defined in module 'todo'." + ) + + +def test_scenarios_see_imported_and_exported_concepts(tmp_path): + write(tmp_path / "shared.plain", "***definitions***\n\n- :Clock: tells the time.\n") + write( + tmp_path / "core.plain", + '---\nexported_concepts: [":Task:"]\n---\n\n***definitions***\n\n- :Task: is a thing to do.\n\n' + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# store-task\nA :Task: is stored.\n", + ) + write( + tmp_path / "todo.plain", + "---\nrequires:\n - core\nimport:\n - shared\n---\n\n***implementation reqs***\n\n- A req.\n\n" + "***functional specs***\n\n# add-task\nA :Task: can be added at a :Clock: time.\n", + ) + write( + tmp_path / "tests" / "todo" / "add-task.plain", + scenario_file("todo", "add-task", "# works\nA :Task: at :Clock:.\n"), + ) + write( + tmp_path / "tests" / "core" / "store-task.plain", + scenario_file("core", "store-task", "# stores\nA :Task: is kept.\n"), + ) + + result = parse(tmp_path) + + assert scenario_ids(functionalities(result, "core")["store-task"], plain_spec.CONFORMANCE_TESTS) == ["stores"] + assert scenario_ids(functionalities(result)["add-task"], plain_spec.CONFORMANCE_TESTS) == ["works"] + + +def test_required_module_scenarios_do_not_see_the_top_modules_concepts(tmp_path): + write( + tmp_path / "core.plain", + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# store-task\nIt stores.\n", + ) + write(tmp_path / "todo.plain", "---\nrequires:\n - core\n---\n\n" + MODULE) + write( + tmp_path / "tests" / "core" / "store-task.plain", + scenario_file("core", "store-task", "# stores\nA :Task: is kept.\n"), + ) + + with pytest.raises( + PlainSyntaxError, match="Concept :Task: used in scenario `stores` is not defined in module 'core'" + ): + parse(tmp_path) + + +def test_every_functionality_needs_a_conformance_scenario_when_required(tmp_path): + write(tmp_path / "todo.plain", MODULE) + write(tmp_path / "tests" / "a.plain", scenario_file("todo", "add-task", "# works\nIt works.\n")) + write(tmp_path / "tests" / "b.plain", scenario_file("todo", "list-tasks", "", "# flows\nIt flows.\n")) + + parse(tmp_path) + with pytest.raises(MissingConformanceScenariosError) as exc_info: + parse(tmp_path, require_conformance_scenarios=True) + + assert str(exc_info.value) == ( + "Functionality todo:list-tasks has no conformance scenario. Add one in a test plain file (by convention " + "tests/todo/list-tasks.plain) with the add-test-scenarios skill." + ) + + +def test_completeness_covers_the_required_modules_first(tmp_path): + write( + tmp_path / "core.plain", + "***implementation reqs***\n\n- A req.\n\n***functional specs***\n\n# store-task\nIt stores.\n", + ) + write(tmp_path / "todo.plain", "---\nrequires:\n - core\n---\n\n" + MODULE) + + with pytest.raises(MissingConformanceScenariosError, match="Functionality core:store-task has no conformance"): + parse(tmp_path, require_conformance_scenarios=True) From 9d2c4b13fe52da6c8bbb6e5101a430f540a5e74a Mon Sep 17 00:00:00 2001 From: Nejc Stebe Date: Fri, 16 Oct 2026 12:38:22 +0200 Subject: [PATCH 6/6] feat: add --test-dir and print test plain files in parse output check and parse take --test-dir, repeatable, which overrides the test-dir config key and the default tests folder. The parse output is format version 2: the functionality trees carry the merged scenarios, resources covers the links in them, and test_plain_files maps each file to the functionality it binds to. The README describes the new syntax. --- README.md | 73 ++++++++++++++++++++++++-------------- src/plain_parser/cli.py | 28 ++++++++++----- tests/test_cli.py | 78 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 140 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 38645e4..6fa0c08 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Python parser for [`***plain`](https://www.plainlang.org/docs/) specification files. -Reads a `.plain` module and its `import` / `requires` chain, resolves Liquid templates, -validates concepts and linked resources, and returns the marshalled specification tree -plus the ordered list of functionalities (FRIDs). +Reads a `.plain` module with its `import` / `requires` chain and its test plain files, resolves Liquid templates, +validates concepts and linked resources, and returns the marshalled specification tree of every module in the +chain, with the test scenarios of each functionality merged in. ## Install @@ -15,24 +15,29 @@ pip install plain-parser ## Command line ``` -plain-parser check [--template-dir DIR] [--config-name NAME] -plain-parser parse [--template-dir DIR] [--config-name NAME] +plain-parser check [--template-dir DIR] [--test-dir DIR]... [--config-name NAME] +plain-parser parse [--template-dir DIR] [--test-dir DIR]... [--config-name NAME] ``` -Both commands parse the module and its `requires` chain, check that every linked resource is an existing -file (not a directory), and walk the module's functionalities. They stop at the first error. +Both commands parse the module and its `requires` chain, bind the test plain files under the test directories to the +functionalities, check that every linked resource is an existing file (not a directory), and walk the module's +functionalities. They stop at the first error. ### `check` Validates the module. In addition to the steps above it reads every linked file and rejects binary files and -files holding a large base64 blob. +files holding a large base64 blob. When the config file names a `conformance-tests-script`, every functionality +of every module in the chain needs at least one conformance scenario. ``` $ plain-parser check my_spec.plain my_spec.plain: OK ``` -Exit `0` when the module is valid. On an error, `Error: …` is written to stderr and the exit code is `1`. +Exit `0` when the module is valid. On an error, `Error: …` is written to stderr and the exit code is `1`. The +message names the file, the line where known, and the skill that fixes the problem: the old syntax (a flush-left +`- ` list in `***functional specs***`, an `***acceptance tests***` section in a core spec) names +`migrate-plain-syntax`, and a functionality without a conformance scenario names `add-test-scenarios`. ### `parse` @@ -41,23 +46,27 @@ Prints the parse result as one UTF-8 JSON document on stdout. Linked files are l ``` $ plain-parser parse my_spec.plain { - "format_version": 1, + "format_version": 2, "modules": [{"module": "...", "tree": {...}}, ...], - "resources": {...} + "resources": {...}, + "test_plain_files": {...} } ``` -- `format_version`: `1`. +- `format_version`: `2`. - `modules`: every module in the `requires` chain, in build order: deepest ancestor first, the module itself last. Each entry has the module name and its specification tree, in the shape described under *Python* below. -- `resources`: every linked-resource target in the chain, as written in the spec, mapped to the path it resolves - to, relative to the entry module's directory, with `/` as separator: `notes.md` when found next to the module, - `../tpl/notes.md` when found in a template directory. The directory part is lexical (symlinks not resolved) and - the target is appended as written (`..` kept), so joining the value onto the entry module's directory names the - file the parser matched. When no relative path exists (another drive on Windows) the value is the absolute path, - still with `/`. Two spellings of one file (`notes.md`, `./notes.md`) are two keys. - -Exit `0` on success. On a parser error the JSON is `{"format_version": 1, "type": "", +- `resources`: every linked-resource target in the chain and its test plain files, as written in the spec, mapped + to the path it resolves to, relative to the entry module's directory, with `/` as separator: `notes.md` when + found next to the module, `../tpl/notes.md` when found in a template directory. The directory part is lexical + (symlinks not resolved) and the target is appended as written (`..` kept), so joining the value onto the entry + module's directory names the file the parser matched. When no relative path exists (another drive on Windows) + the value is the absolute path, still with `/`. Two spellings of one file (`notes.md`, `./notes.md`) are two + keys. +- `test_plain_files`: every test plain file read, by its path relative to the entry module's directory with `/` + as separator, mapped to `{"module": ..., "func_id": ...}`, the functionality it binds to. + +Exit `0` on success. On a parser error the JSON is `{"format_version": 2, "type": "", "message": "…"}` and the exit code is `1`. ### Options and exit codes @@ -66,27 +75,37 @@ Modules and `{% include %}` templates are looked up in the `.plain` file's direc When `--template-dir` is not given, it is read from the `template-dir` key of `config.yaml` (or the file named by `--config-name`), found next to the `.plain` file or in the working directory. Finding it in both is a usage error. The config file is read even when `--template-dir` is given, so a malformed one fails the run either way. -Other config keys are ignored. + +Test plain files are the `.plain` files under the test directories: those given with `--test-dir` (repeat the +option for more than one), else the `test-dir` key of the config file (a string or a list), else `tests` next to +the `.plain` file. A test directory that does not exist is skipped. Linked resources in a scenario are looked up +as in a module. The `conformance-tests-script` key of the config file turns on the check that every +functionality has a conformance scenario. Other config keys are ignored. | Exit code | Meaning | |---|---| | `0` | Success | -| `1` | The module, its chain, or a linked resource is invalid | +| `1` | The module, its chain, a test plain file, or a linked resource is invalid | | `2` | Usage error, including an ambiguous or malformed config file | | `130` | Interrupted | ## Python ```python -from plain_parser import plain_file_parser +from plain_parser import plain_file_parser, project module_name, plain_source_tree, required_modules = plain_file_parser("my_spec.plain", ["."]) + +result = project.parse_project("my_spec.plain", ["."], ["tests"], require_conformance_scenarios=True) +chain = result.chain # (module name, plain source tree) pairs, deepest ancestor first, the module itself last ``` -`plain_source_tree` is a dict keyed by section (`definitions`, `implementation reqs`, -`test reqs`, `functional specs`), each a list of `{"markdown": ..., "linked_resources": [...]}` -entries. `plain_parser.plain_spec` has helpers for walking functionalities -(`get_frids`, `get_specifications_for_frid`, `collect_linked_resources`). +`plain_source_tree` is a dict keyed by section. `definitions`, `implementation reqs` and `test reqs` are lists +of `{"markdown": ..., "linked_resources": [...]}` entries. `functional specs` is a list of +`{"id", "markdown", "linked_resources", "conformance_tests", "acceptance_tests"}` entries, one per `# ` +heading in file order; each test entry is `{"id", "text", "linked_resources"}` with the scenario text as written. +`plain_parser.plain_spec` has helpers for walking functionalities by func-id (`get_frids`, +`get_specifications_for_frid`, `collect_linked_resources`). ## Development diff --git a/src/plain_parser/cli.py b/src/plain_parser/cli.py index 990c2a1..eac0b26 100644 --- a/src/plain_parser/cli.py +++ b/src/plain_parser/cli.py @@ -1,4 +1,5 @@ -"""Command-line entry point: ``plain-parser {check,parse} [--template-dir DIR] [--config-name NAME]``.""" +"""Command-line entry point: ``plain-parser {check,parse} [--template-dir DIR] [--test-dir DIR]... +[--config-name NAME]``.""" import argparse import json @@ -15,7 +16,7 @@ TEST_DIR_CONFIG_KEYS = ("test_dir", "test-dir") CONFORMANCE_TESTS_SCRIPT_CONFIG_KEYS = ("conformance_tests_script", "conformance-tests-script") DEFAULT_TEST_DIR = "tests" -PARSE_FORMAT_VERSION = 1 +PARSE_FORMAT_VERSION = 2 class AmbiguousConfigFileError(Exception): @@ -28,13 +29,13 @@ def build_parser() -> argparse.ArgumentParser: check_parser = subparsers.add_parser( "check", - help="Validate a .plain module and its requires chain, and read every linked file: " + help="Validate a .plain module, its requires chain and its test plain files, and read every linked file: " "binary files and large base64 blobs are rejected.", ) parse_parser = subparsers.add_parser( "parse", - help="Print the parse result of a .plain module and its requires chain as JSON. Linked files are " - "located but never read.", + help="Print the parse result of a .plain module, its requires chain and its test plain files as JSON. " + "Linked files are located but never read.", ) for subparser in (check_parser, parse_parser): subparser.add_argument("plain_file", help="Path to the .plain module.") @@ -43,11 +44,18 @@ def build_parser() -> argparse.ArgumentParser: help="Directory searched for modules and templates after the .plain file's own directory. " "Overrides the template-dir value of the config file.", ) + subparser.add_argument( + "--test-dir", + action="append", + metavar="DIR", + help="Directory scanned for test plain files; repeat the option for more than one. Overrides the " + f"test-dir value of the config file. Defaults to {DEFAULT_TEST_DIR} next to the .plain file.", + ) subparser.add_argument( "--config-name", default=DEFAULT_CONFIG_NAME, - help="Name of the config file to read template-dir from. Looked up in the .plain file's directory " - "and the current working directory. Defaults to %(default)s.", + help="Name of the config file to read template-dir, test-dir and conformance-tests-script from. " + "Looked up in the .plain file's directory and the current working directory. Defaults to %(default)s.", ) return parser @@ -209,6 +217,7 @@ def parse( "format_version": PARSE_FORMAT_VERSION, "modules": [{"module": module_name, "tree": tree} for module_name, tree in result.modules], "resources": result.resources, + "test_plain_files": result.test_plain_files, } exit_code = 0 except Exception as e: @@ -265,9 +274,10 @@ def main(argv: list[str] | None = None) -> int: # The config file is resolved even when --template-dir is given, so an ambiguous config is always an error. config = _read_config(parser, args) template_dir = args.template_dir or config.template_dir + test_dirs = args.test_dir or config.test_dirs if args.command == "parse": - return parse(args.plain_file, template_dir, config.test_dirs, config.conformance_tests_enabled) - check(args.plain_file, template_dir, config.test_dirs, config.conformance_tests_enabled) + return parse(args.plain_file, template_dir, test_dirs, config.conformance_tests_enabled) + check(args.plain_file, template_dir, test_dirs, config.conformance_tests_enabled) except KeyboardInterrupt: return 130 except Exception as e: diff --git a/tests/test_cli.py b/tests/test_cli.py index 228f354..c1e234e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -437,6 +437,29 @@ def test_check_link_in_scenario_is_read(project_dir, tmp_path, capsys): assert err.startswith("Error: Referenced resource 'listing.bin' in module 'todo' is a binary file.") +def test_check_test_dir_option_overrides_the_config_and_the_default(project_dir, tmp_path, capsys, monkeypatch): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests").rename(copy / "scenarios") + (copy / "config.yaml").write_text("conformance-tests-script: run.sh\ntest-dir: does_not_exist\n") + monkeypatch.chdir(tmp_path) # the option is relative to the working directory + + assert "todo:add-task has no conformance scenario" in _run_check(capsys, "project/todo.plain")[2] + _assert_ok(_run_check(capsys, "project/todo.plain", "--test-dir", "project/scenarios")) + + +def test_check_test_dir_option_can_be_repeated(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "more").mkdir() + (copy / "tests" / "todo" / "list-tasks.plain").rename(copy / "more" / "list-tasks.plain") + + assert "todo:list-tasks has no conformance scenario" in _run_check(capsys, str(copy / "todo.plain"))[2] + _assert_ok( + _run_check( + capsys, str(copy / "todo.plain"), "--test-dir", str(copy / "tests"), "--test-dir", str(copy / "more") + ) + ) + + def test_check_reports_ancestor_resource_before_frid_walk_error(cli_specs_dir, capsys): # base.plain links a binary file; top.plain also has a code-variable conflict only the FRID walk finds. spec = os.path.join(cli_specs_dir, "resource_before_frid_walk", "top.plain") @@ -473,9 +496,10 @@ def test_parse_valid_module_prints_success_json(cli_specs_dir, capsys): assert (exit_code, err) == (0, "") result = _parse_output(out) - assert set(result) == {"format_version", "modules", "resources"} - assert result["format_version"] == 1 + assert set(result) == {"format_version", "modules", "resources", "test_plain_files"} + assert result["format_version"] == 2 assert result["resources"] == {} + assert result["test_plain_files"] == {} _, tree = plain_file.parse_module_chain("valid.plain", [cli_specs_dir])[-1] assert result["modules"] == [{"module": "valid", "tree": json.loads(json.dumps(tree))}] @@ -488,7 +512,7 @@ def test_parse_invalid_module_prints_error_json(cli_specs_dir, capsys): assert (exit_code, err) == (1, "") result = _parse_output(out) assert set(result) == {"format_version", "type", "message"} - assert result["format_version"] == 1 + assert result["format_version"] == 2 assert result["type"] == "PlainSyntaxError" _, _, check_err = _run_check(capsys, spec) assert check_err == f"Error: {result['message']}\n" @@ -572,6 +596,54 @@ def test_parse_lists_a_target_linked_from_two_modules_once(cli_specs_dir, capsys assert result["resources"] == {"notes.md": "notes.md"} +def test_parse_lists_the_test_plain_files_and_merges_their_scenarios(project_dir, capsys, monkeypatch): + monkeypatch.chdir(project_dir) + + exit_code, out, err = _run_parse(capsys, "todo.plain") + + assert (exit_code, err) == (0, "") + result = _parse_output(out) + assert result["test_plain_files"] == { + "tests/todo/add-task.plain": {"module": "todo", "func_id": "add-task"}, + "tests/todo/list-tasks.plain": {"module": "todo", "func_id": "list-tasks"}, + } + functionalities = {item["id"]: item for item in result["modules"][0]["tree"]["functional specs"]} + assert [scenario["id"] for scenario in functionalities["add-task"]["conformance_tests"]] == [ + "add-first-task", + "add-without-name", + ] + assert [scenario["id"] for scenario in functionalities["add-task"]["acceptance_tests"]] == ["add-then-list"] + assert functionalities["list-tasks"]["conformance_tests"][0]["text"] == ( + "With two :Task: items added, `todo list` prints two lines, lowest id first, and exits with status 0." + ) + + +def test_parse_resources_include_the_links_of_the_scenarios(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").write_text( + "---\nmodule: todo\nfunc_id: list-tasks\n---\n\n***conformance tests***\n\n# lists\n" + "The output is [the listing](listing.txt).\n\n***acceptance tests***\n" + ) + (copy / "listing.txt").write_text("1 Buy milk\n") + + exit_code, out, _ = _run_parse(capsys, str(copy / "todo.plain")) + + assert exit_code == 0 + assert _parse_output(out)["resources"] == {"listing.txt": "listing.txt"} + + +def test_parse_missing_conformance_scenario_is_a_parser_error(project_dir, tmp_path, capsys): + copy = _copy_project(project_dir, tmp_path) + (copy / "tests" / "todo" / "list-tasks.plain").unlink() + + exit_code, out, _ = _run_parse(capsys, str(copy / "todo.plain")) + + assert exit_code == 1 + result = _parse_output(out) + assert result["type"] == "MissingConformanceScenariosError" + assert "add-test-scenarios" in result["message"] + + def test_parse_without_file_argument_is_usage_error(capsys): with pytest.raises(SystemExit) as exc_info: cli.main(["parse"])