Skip to content

Commit 8feefa4

Browse files
committed
fix: report a chain's errors top-down instead of deepest first
Check the top module's structure before the requires walk, record required modules during the walk, then validate them top-down after the top module. This is the order codeplain reports errors in, so a broken chain now gives the same first error from `plain-parser check` and from codeplain's `--dry-run`. Each module is still read once.
1 parent 722de69 commit 8feefa4

2 files changed

Lines changed: 125 additions & 20 deletions

File tree

‎src/plain_parser/plain_file.py‎

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -709,19 +709,31 @@ def parse_plain_file(
709709
)
710710

711711

712+
@dataclass
713+
class RequiredModule:
714+
"""A required module as parsed by the requires walk; validated once the top module is complete."""
715+
716+
parse_result: PlainFileParseResult
717+
# Definitions exported to this module by the modules it requires.
718+
ancestor_exported_definitions: list[mistletoe.block_token.token]
719+
code_variables: dict
720+
# Definitions this module exports to the modules that require it.
721+
exported_definitions: list[mistletoe.block_token.token]
722+
723+
712724
def process_required_modules(
713725
required_modules: list[str],
714726
template_dirs: list[str],
715727
all_required_modules: list[str],
716728
modules_trace: list[str],
717-
chain: list[tuple[str, dict]],
718-
module_exports: dict[str, list[mistletoe.block_token.token]],
729+
walk_order: list[str],
730+
required: dict[str, RequiredModule],
719731
) -> list[mistletoe.block_token.token]:
720-
"""Parse and fully validate every required module, deepest first.
732+
"""Parse every required module, deepest first, without validating it.
721733
722-
Appends ``(module name, marshalled plain source tree)`` to ``chain`` for each module not already in it,
723-
records each module's exported definitions in ``module_exports``, and returns the exported definitions
724-
of the directly required modules.
734+
Appends each module name to ``walk_order`` the first time it is reached (deepest ancestors first),
735+
records its parse result in ``required``, and returns the exported definitions of the directly
736+
required modules.
725737
"""
726738
exported_definitions = list[mistletoe.block_token.token]()
727739
for module_name in required_modules:
@@ -730,7 +742,7 @@ def process_required_modules(
730742

731743
if len(all_required_modules) > 0 and module_name == all_required_modules[-1]:
732744
# Already processed as an earlier entry; its exports are still visible to this module.
733-
exported_definitions.extend(module_exports[module_name])
745+
exported_definitions.extend(required[module_name].exported_definitions)
734746
continue
735747

736748
code_variables: dict = {}
@@ -753,8 +765,8 @@ def process_required_modules(
753765
template_dirs,
754766
all_required_modules,
755767
modules_trace + [module_name],
756-
chain,
757-
module_exports,
768+
walk_order,
769+
required,
758770
)
759771

760772
own_exported_definitions = list[mistletoe.block_token.token]()
@@ -770,20 +782,37 @@ def process_required_modules(
770782
exported_concept, plain_file_parse_result.plain_source, renderer
771783
)
772784
)
773-
module_exports[module_name] = own_exported_definitions
785+
required[module_name] = RequiredModule(
786+
plain_file_parse_result, ancestor_exported_definitions, code_variables, own_exported_definitions
787+
)
774788
exported_definitions.extend(own_exported_definitions)
775789

776-
marshalled_plain_source = validate_and_marshall_module(
777-
plain_file_parse_result, module_name, ancestor_exported_definitions, code_variables, template_dirs
778-
)
779-
if module_name not in (chain_module_name for chain_module_name, _ in chain):
780-
chain.append((module_name, marshalled_plain_source))
790+
if module_name not in walk_order:
791+
walk_order.append(module_name)
781792

782793
all_required_modules.append(module_name)
783794

784795
return exported_definitions
785796

786797

798+
def validate_required_modules(
799+
required_modules: list[str],
800+
required: dict[str, RequiredModule],
801+
template_dirs: list[str],
802+
trees: dict[str, dict],
803+
) -> None:
804+
"""Validate recorded required modules as codeplain does: top-down, in listed order, each module once."""
805+
for module_name in required_modules:
806+
if module_name in trees:
807+
continue
808+
809+
module = required[module_name]
810+
trees[module_name] = validate_and_marshall_module(
811+
module.parse_result, module_name, module.ancestor_exported_definitions, module.code_variables, template_dirs
812+
)
813+
validate_required_modules(module.parse_result.required_modules, required, template_dirs, trees)
814+
815+
787816
def process_exported_definitions(plain_source: dict, exported_definitions: list[mistletoe.block_token.token]) -> None:
788817
if len(exported_definitions) == 0:
789818
return
@@ -908,20 +937,29 @@ def _parse_module(
908937
modules_trace=[],
909938
)
910939

911-
chain: list[tuple[str, dict]] = []
940+
# The order below is codeplain's: the top module's structure, the requires walk, the top module's
941+
# remaining checks, then every required module top-down.
942+
validate_module_structure(plain_file_parse_result, module_name)
943+
944+
walk_order: list[str] = []
945+
required: dict[str, RequiredModule] = {}
912946
exported_definitions = process_required_modules(
913947
plain_file_parse_result.required_modules,
914948
template_dirs=template_dirs,
915949
all_required_modules=[],
916950
modules_trace=[],
917-
chain=chain,
918-
module_exports={},
951+
walk_order=walk_order,
952+
required=required,
919953
)
920954

921-
marshalled_plain_source = validate_and_marshall_module(
955+
marshalled_plain_source = validate_and_build_module_tree(
922956
plain_file_parse_result, module_name, exported_definitions, code_variables, template_dirs
923957
)
924958

959+
trees: dict[str, dict] = {}
960+
validate_required_modules(plain_file_parse_result.required_modules, required, template_dirs, trees)
961+
chain = [(chain_module_name, trees[chain_module_name]) for chain_module_name in walk_order]
962+
925963
return module_name, marshalled_plain_source, plain_file_parse_result.required_modules, chain
926964

927965

‎tests/test_requires.py‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pytest
22

33
from plain_parser import plain_file
4-
from plain_parser.exceptions import ModuleDoesNotExistError, PlainSyntaxError
4+
from plain_parser.exceptions import MissingFunctionalitiesError, ModuleDoesNotExistError, PlainSyntaxError
55

66

77
def test_non_existent_require(get_test_data_path):
@@ -154,6 +154,14 @@ def write_modules_using_required_exports(directory, requires_by_module):
154154
marks=pytest.mark.xfail(strict=True, reason="an ancestor must be listed before its dependent"),
155155
),
156156
# Top
157+
# || Top lists Base twice
158+
# Base
159+
pytest.param(
160+
{"Top": ["Base", "Base"], "Base": []},
161+
["Base", "Top"],
162+
id="top-lists-base-twice",
163+
),
164+
# Top
157165
# |
158166
# Upper every module also requires every module below it
159167
# |
@@ -267,3 +275,62 @@ def test_requires_independent_branches_are_rejected(tmp_path, requires_by_module
267275

268276
with pytest.raises(PlainSyntaxError, match="There must be a fixed order how required modules are dependent"):
269277
plain_file.parse_module_chain("Top.plain", [str(tmp_path)])
278+
279+
280+
# Two broken modules in one chain: the first error reported is the one codeplain reports.
281+
def test_top_module_shape_is_checked_before_its_requires_are_read(tmp_path):
282+
(tmp_path / "Top.plain").write_text("---\nrequires:\n - Missing\n---\n\n***implementation reqs***\n\n- Req.\n")
283+
284+
with pytest.raises(MissingFunctionalitiesError):
285+
plain_file.plain_file_parser("Top.plain", [str(tmp_path)])
286+
287+
288+
def test_top_module_shape_is_checked_before_required_modules_are_validated(tmp_path):
289+
write_module(tmp_path, "Base", [], [], [], "Base uses :BaseMissing:.")
290+
(tmp_path / "Top.plain").write_text("---\nrequires:\n - Base\n---\n\n***implementation reqs***\n\n- Req.\n")
291+
292+
with pytest.raises(MissingFunctionalitiesError):
293+
plain_file.plain_file_parser("Top.plain", [str(tmp_path)])
294+
295+
296+
def test_parse_errors_of_required_modules_come_before_top_module_concept_errors(tmp_path):
297+
(tmp_path / "Base.plain").write_text(
298+
"***implementation reqs***\n\n- {% if %}\n\n***functional specs***\n\n- Base works.\n"
299+
)
300+
write_module(tmp_path, "Top", [], ["Base"], [], "Top uses :TopMissing:.")
301+
302+
with pytest.raises(PlainSyntaxError, match="Invalid Liquid"):
303+
plain_file.plain_file_parser("Top.plain", [str(tmp_path)])
304+
305+
306+
def test_top_module_concept_errors_come_before_required_module_concept_errors(tmp_path):
307+
write_module(tmp_path, "Base", [], [], [], "Base uses :BaseMissing:.")
308+
write_module(tmp_path, "Top", [], ["Base"], [], "Top uses :TopMissing:.")
309+
310+
with pytest.raises(PlainSyntaxError, match="Concept :TopMissing: is not defined"):
311+
plain_file.plain_file_parser("Top.plain", [str(tmp_path)])
312+
313+
314+
def test_required_modules_are_validated_top_down(tmp_path):
315+
write_module(tmp_path, "Base", [], [], [], "Base uses :BaseMissing:.")
316+
write_module(tmp_path, "Middle", [], ["Base"], [], "Middle uses :MiddleMissing:.")
317+
write_module(tmp_path, "Top", [], ["Middle"], [], "Top works.")
318+
319+
with pytest.raises(PlainSyntaxError, match="Concept :MiddleMissing: is not defined"):
320+
plain_file.plain_file_parser("Top.plain", [str(tmp_path)])
321+
322+
323+
def test_module_listed_twice_around_its_dependent_is_rejected(tmp_path):
324+
write_modules_using_required_exports(tmp_path, {"Top": ["Base", "Middle", "Base"], "Middle": ["Base"], "Base": []})
325+
326+
with pytest.raises(PlainSyntaxError, match="There must be a fixed order how required modules are dependent"):
327+
plain_file.parse_module_chain("Top.plain", [str(tmp_path)])
328+
329+
330+
def test_chain_has_a_tree_for_every_module_reached_by_the_walk(tmp_path):
331+
write_modules_using_required_exports(tmp_path, {"Top": ["Base", "Middle"], "Middle": ["Base"], "Base": []})
332+
333+
chain = plain_file.parse_module_chain("Top.plain", [str(tmp_path)])
334+
335+
assert [module_name for module_name, _ in chain] == ["Base", "Middle", "Top"]
336+
assert all("functional specs" in tree for _, tree in chain)

0 commit comments

Comments
 (0)