diff --git a/src/plain_parser/plain_file.py b/src/plain_parser/plain_file.py index c4ce1c0..ff15f77 100644 --- a/src/plain_parser/plain_file.py +++ b/src/plain_parser/plain_file.py @@ -448,10 +448,12 @@ def process_imports( if REQUIRED_CONCEPTS_DIRECTIVE in plain_file_parse_result.plain_source_obj.metadata: for item in plain_file_parse_result.plain_source_obj.metadata[REQUIRED_CONCEPTS_DIRECTIVE]: - assert isinstance( - item, str - ), f"Syntax error: Invalid {REQUIRED_CONCEPTS_DIRECTIVE} metadata. Expected a string." - new_concepts, _ = concept_utils.extract_concepts_from_definition(item) + new_concepts, errors = concept_utils.extract_concepts_from_definition(item) + if errors: + raise PlainSyntaxError( + f"Plain syntax error: Module '{module_name}' requires an invalid concept (`{item}`). " + f"Expected a concept token, e.g. :ConceptName:." + ) required_concepts.extend(new_concepts) required_concepts.extend(plain_file_parse_result.required_concepts) @@ -480,11 +482,17 @@ def read_plain_source_metadata(plain_source_text): except Exception as e: raise PlainSyntaxError(f"Plain syntax error: Invalid frontmatter: {e}") + for directive in [IMPORT_DIRECTIVE, REQUIRES_DIRECTIVE, EXPORTED_CONCEPTS_DIRECTIVE, REQUIRED_CONCEPTS_DIRECTIVE]: + if directive in plain_source_obj.metadata and not isinstance(plain_source_obj.metadata[directive], list): + plain_source_obj.metadata[directive] = [plain_source_obj.metadata[directive]] + + for directive in [IMPORT_DIRECTIVE, REQUIRES_DIRECTIVE]: + for module_name in plain_source_obj.metadata.get(directive, []): + if not isinstance(module_name, str): + raise PlainSyntaxError(f"Plain syntax error: Invalid {directive} metadata. Expected a module name.") + for directive in [EXPORTED_CONCEPTS_DIRECTIVE, REQUIRED_CONCEPTS_DIRECTIVE]: if directive in plain_source_obj.metadata: - assert isinstance( - plain_source_obj.metadata[directive], list - ), f"Syntax error: Invalid {directive} metadata. Expected a list." prepared_metadata = [] for item in plain_source_obj.metadata[directive]: if isinstance(item, dict): diff --git a/tests/data/imports/scalar_import/lib.plain b/tests/data/imports/scalar_import/lib.plain new file mode 100644 index 0000000..2edf6f5 --- /dev/null +++ b/tests/data/imports/scalar_import/lib.plain @@ -0,0 +1,3 @@ +***implementation reqs*** + +- The main executable file should be called "hello_world.py". diff --git a/tests/data/imports/scalar_import/main.plain b/tests/data/imports/scalar_import/main.plain new file mode 100644 index 0000000..357257d --- /dev/null +++ b/tests/data/imports/scalar_import/main.plain @@ -0,0 +1,11 @@ +--- +import: lib +--- + +***implementation reqs*** + +- The main executable file should be called "hello_world.py". + +***functional specs*** + +- Display "hello, world" diff --git a/tests/data/imports/scalar_import_single_char/l.plain b/tests/data/imports/scalar_import_single_char/l.plain new file mode 100644 index 0000000..2edf6f5 --- /dev/null +++ b/tests/data/imports/scalar_import_single_char/l.plain @@ -0,0 +1,3 @@ +***implementation reqs*** + +- The main executable file should be called "hello_world.py". diff --git a/tests/data/imports/scalar_import_single_char/main.plain b/tests/data/imports/scalar_import_single_char/main.plain new file mode 100644 index 0000000..3429bea --- /dev/null +++ b/tests/data/imports/scalar_import_single_char/main.plain @@ -0,0 +1,11 @@ +--- +import: l +--- + +***implementation reqs*** + +- The main executable file should be called "hello_world.py". + +***functional specs*** + +- Display "hello, world" diff --git a/tests/data/requires/scalar_requires_main.plain b/tests/data/requires/scalar_requires_main.plain new file mode 100644 index 0000000..dd0d24d --- /dev/null +++ b/tests/data/requires/scalar_requires_main.plain @@ -0,0 +1,11 @@ +--- +requires: chain_base +--- + +***implementation reqs*** + +- Scalar requires implementation requirement. + +***functional specs*** + +- A scalar requires functionality. diff --git a/tests/test_imports.py b/tests/test_imports.py index 83816da..f350848 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -1,7 +1,7 @@ import pytest -from plain_parser import plain_file -from plain_parser.exceptions import ImportedModuleWithFunctionalitiesError +from plain_parser import concept_utils, plain_file +from plain_parser.exceptions import ImportedModuleWithFunctionalitiesError, PlainSyntaxError def test_imported_module_with_functionalities(load_test_data, get_test_data_path): @@ -51,3 +51,55 @@ def test_diamond_imports(load_test_data, get_test_data_path): ], "functional specs": [{"markdown": '- Display "hello, world"'}], } + + +@pytest.mark.parametrize("fixture", ["scalar_import", "scalar_import_single_char"]) +def test_scalar_import_is_a_list_of_one(load_test_data, get_test_data_path, fixture): + plain_source_text = load_test_data(f"data/imports/{fixture}/main.plain") + template_dirs = [get_test_data_path(f"data/imports/{fixture}")] + plain_file_parse_result = plain_file.parse_plain_source(plain_source_text, {}, template_dirs, [], []) + + assert plain_file.marshall_plain_source(plain_file_parse_result.plain_source)["implementation reqs"] == [ + {"markdown": '- The main executable file should be called "hello_world.py".'}, + {"markdown": '- The main executable file should be called "hello_world.py".'}, + ] + + +@pytest.mark.parametrize("directive", ["import", "requires"]) +def test_non_string_module_entry_is_a_syntax_error(directive): + plain_source_text = f"---\n{directive}: [1]\n---\n\n***functional specs***\n\n- Display text\n" + with pytest.raises(PlainSyntaxError, match=f"Invalid {directive} metadata"): + plain_file.parse_plain_source(plain_source_text, {}, [], [], []) + + +@pytest.mark.parametrize("directive", ["exported_concepts", "required_concepts"]) +def test_scalar_concept_directive_is_a_list_of_one(directive): + plain_source_obj = plain_file.read_plain_source_metadata(f'---\n{directive}: ":A:"\n---\n') + + assert plain_source_obj.metadata[directive] == ["- :A:"] + + +@pytest.mark.parametrize("directive", ["exported_concepts", "required_concepts"]) +def test_non_string_concept_entry_is_a_syntax_error(directive): + with pytest.raises(PlainSyntaxError, match=f"Invalid {directive} metadata"): + plain_file.read_plain_source_metadata(f"---\n{directive}: [1]\n---\n") + + +@pytest.mark.parametrize("value", ["foo", "[foo]"]) +def test_imported_required_concept_without_concept_token_is_a_syntax_error(tmp_path, value): + (tmp_path / "lib.plain").write_text(f"---\nrequired_concepts: {value}\n---\n\n***implementation reqs***\n\n- x\n") + plain_source_text = ( + "---\nimport: [lib]\n---\n\n***implementation reqs***\n\n- y\n\n***functional specs***\n\n- Display text\n" + ) + with pytest.raises(PlainSyntaxError, match="requires an invalid concept"): + plain_file.parse_plain_source(plain_source_text, {}, [str(tmp_path)], [], []) + + +@pytest.mark.parametrize("directive", ["exported_concepts", "required_concepts"]) +@pytest.mark.parametrize("entry, concepts", [(":A:", [":A:"]), (":A:, :B:", [":A:", ":B:"])]) +def test_unquoted_concept_in_block_list(directive, entry, concepts): + # YAML reads an unquoted `- :A:` as a mapping; real modules rely on it still naming :A:. + plain_source_obj = plain_file.read_plain_source_metadata(f"---\n{directive}:\n - {entry}\n---\n") + + [item] = plain_source_obj.metadata[directive] + assert concept_utils.extract_concepts_from_definition(item) == (concepts, []) diff --git a/tests/test_requires.py b/tests/test_requires.py index 0166b01..e434b73 100644 --- a/tests/test_requires.py +++ b/tests/test_requires.py @@ -37,6 +37,12 @@ def test_parse_module_chain_orders_required_modules_first(get_test_data_path): assert "functional specs" in plain_source_tree +def test_scalar_requires_is_a_list_of_one(get_test_data_path): + chain = plain_file.parse_module_chain("scalar_requires_main.plain", [get_test_data_path("data/requires")]) + + assert [module_name for module_name, _ in chain] == ["chain_base", "scalar_requires_main"] + + def test_parse_module_chain_single_module(get_test_data_path): chain = plain_file.parse_module_chain("chain_base.plain", [get_test_data_path("data/requires")])