plain-parser 0.1.2.dev3__tar.gz → 0.1.2.dev5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/PKG-INFO +1 -1
  2. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/src/plain_parser/concept_utils.py +2 -2
  3. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/src/plain_parser/loaders.py +33 -4
  4. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/src/plain_parser/plain_file.py +28 -17
  5. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/src/plain_parser/plain_spec.py +1 -1
  6. plain_parser-0.1.2.dev5/tests/data/plainfile/plain_source_with_missing_link.plain +9 -0
  7. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/task_manager_with_reference_links.plain +2 -2
  8. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_noconcepts.plain +0 -1
  9. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_redefinition.plain +0 -1
  10. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/required_concepts_example.plain +1 -1
  11. plain_parser-0.1.2.dev5/tests/data/templates/malformed_tag.plain +4 -0
  12. plain_parser-0.1.2.dev5/tests/data/templates/unterminated_output.plain +3 -0
  13. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/test_cli.py +44 -40
  14. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/test_loaders.py +87 -1
  15. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/test_plainfileparser.py +54 -7
  16. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/.flake8 +0 -0
  17. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/.github/workflows/lint-and-test.yml +0 -0
  18. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/.github/workflows/publish-dev-to-pypi.yml +0 -0
  19. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/.github/workflows/publish-to-pypi.yml +0 -0
  20. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/.gitignore +0 -0
  21. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/LICENSE +0 -0
  22. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/README.md +0 -0
  23. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/pyproject.toml +0 -0
  24. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/src/plain_parser/__init__.py +0 -0
  25. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/src/plain_parser/cli.py +0 -0
  26. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/src/plain_parser/exceptions.py +0 -0
  27. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/src/plain_parser/liquid_nodes.py +0 -0
  28. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/__init__.py +0 -0
  29. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/conftest.py +0 -0
  30. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/acceptance_test_binary_resource.plain +0 -0
  31. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/acceptance_test_resource.plain +0 -0
  32. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/base64_resource.plain +0 -0
  33. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/binary.bin +0 -0
  34. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/binary_resource.plain +0 -0
  35. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/code_variable_conflict.plain +0 -0
  36. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/code_variable_template.plain +0 -0
  37. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/missing_resource.plain +0 -0
  38. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/not_plain.md +0 -0
  39. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/notes.md +0 -0
  40. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/requires_binary_resource_base.plain +0 -0
  41. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/requires_binary_resource_top.plain +0 -0
  42. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/subdir/nested.plain +0 -0
  43. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/undefined_concept.plain +0 -0
  44. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/cli/valid.plain +0 -0
  45. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/circular_imports_1.plain +0 -0
  46. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/circular_imports_2.plain +0 -0
  47. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/circular_imports_main.plain +0 -0
  48. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/diamond_import_1.plain +0 -0
  49. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/diamond_import_2.plain +0 -0
  50. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/diamond_import_common.plain +0 -0
  51. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/diamond_imports_main.plain +0 -0
  52. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/import_with_functionalities.plain +0 -0
  53. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/imports_module_with_functionalities.plain +0 -0
  54. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/imports/non_existent_import.plain +0 -0
  55. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/add_new_task_modal_specification.yaml +0 -0
  56. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/duplicate_specification_heading.plain +0 -0
  57. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/empty_functional_specs_section.plain +0 -0
  58. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/invalid_specification_order.plain +0 -0
  59. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/missing_non_functional_requirements.plain +0 -0
  60. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/no_functional_specs_section.plain +0 -0
  61. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/plain_source_with_absolute_link.plain +0 -0
  62. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/plain_source_with_url_link.plain +0 -0
  63. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/task_list_ui_specification.yaml +0 -0
  64. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfile/without_non_functional_requirement.plain +0 -0
  65. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_acceptance_tests.plain +0 -0
  66. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain +0 -0
  67. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_defined_nondefined.plain +0 -0
  68. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain +0 -0
  69. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_definition.plain +0 -0
  70. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_nonconcept.plain +0 -0
  71. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_nondefined.plain +0 -0
  72. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_several_concepts.plain +0 -0
  73. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/concept_validation_valid.plain +0 -0
  74. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/cyclic_definitions.plain +0 -0
  75. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts.plain +0 -0
  76. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_base.plain +0 -0
  77. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_defaults_exported.plain +0 -0
  78. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_example.plain +0 -0
  79. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_imported_exported.plain +0 -0
  80. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain +0 -0
  81. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_malformed.plain +0 -0
  82. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_missing_definition.plain +0 -0
  83. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain +0 -0
  84. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain +0 -0
  85. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain +0 -0
  86. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_nested.plain +0 -0
  87. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_nested_example.plain +0 -0
  88. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_no_definitions.plain +0 -0
  89. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_not_declared.plain +0 -0
  90. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_not_declared_example.plain +0 -0
  91. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_transitive_example.plain +0 -0
  92. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_transitive_l1.plain +0 -0
  93. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/exported_concepts_transitive_l2.plain +0 -0
  94. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/plain_file_parser_with_comments.plain +0 -0
  95. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/plain_file_with_comments_indented.plain +0 -0
  96. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/regular_plain_source.plain +0 -0
  97. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/required_concepts.plain +0 -0
  98. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/required_concepts_defs.plain +0 -0
  99. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/required_concepts_l1.plain +0 -0
  100. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/required_concepts_l2.plain +0 -0
  101. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/required_concepts_missing.plain +0 -0
  102. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/required_concepts_module.plain +0 -0
  103. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/required_concepts_partial.plain +0 -0
  104. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/required_concepts_partial_duplicate.plain +0 -0
  105. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/requires_without_definitions_example.plain +0 -0
  106. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/topological_sort.plain +0 -0
  107. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/plainfileparser/topological_sort_not_referenced.plain +0 -0
  108. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/chain_base.plain +0 -0
  109. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/chain_fork_top.plain +0 -0
  110. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/chain_middle.plain +0 -0
  111. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/chain_top.plain +0 -0
  112. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/circular_requires_main.plain +0 -0
  113. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/circular_requires_sub.plain +0 -0
  114. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/diamond_requires_1.plain +0 -0
  115. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/diamond_requires_2.plain +0 -0
  116. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/diamond_requires_common.plain +0 -0
  117. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/diamond_requires_main.plain +0 -0
  118. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/independent_requires_1.plain +0 -0
  119. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/independent_requires_2.plain +0 -0
  120. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/independent_requires_main.plain +0 -0
  121. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/invalid_ancestor_base.plain +0 -0
  122. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/invalid_ancestor_top.plain +0 -0
  123. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/non_existent_require.plain +0 -0
  124. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/normal_requires_1.plain +0 -0
  125. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/normal_requires_2.plain +0 -0
  126. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/normal_requires_common.plain +0 -0
  127. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/requires/normal_requires_main.plain +0 -0
  128. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/sample_base64_image.txt +0 -0
  129. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/simple.plain +0 -0
  130. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/templates/block_level_include.plain +0 -0
  131. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/templates/code_variables.plain +0 -0
  132. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/templates/header.plain +0 -0
  133. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/templates/implement.plain +0 -0
  134. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/templates/implement_2.plain +0 -0
  135. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/templates/template_include.plain +0 -0
  136. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/data/templates/test_hardest_problem.plain +0 -0
  137. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/test_exceptions.py +0 -0
  138. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/test_imports.py +0 -0
  139. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/test_plainfile.py +0 -0
  140. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/test_plainspec.py +0 -0
  141. {plain_parser-0.1.2.dev3 → plain_parser-0.1.2.dev5}/tests/test_requires.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: plain-parser
3
- Version: 0.1.2.dev3
3
+ Version: 0.1.2.dev5
4
4
  Summary: Parser for ***plain specification files
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -200,14 +200,14 @@ def sort_definitions(definitions: list[dict]) -> list[dict]:
200
200
  concept_graph.add_node(node)
201
201
 
202
202
  if not nx.is_directed_acyclic_graph(concept_graph):
203
- msg = "Found cycles in the concept graph. Cycles are not allowed."
203
+ msg = "Found cycles in the concept graph. Cycles are not allowed. "
204
204
  all_cycles = list(nx.simple_cycles(concept_graph))
205
205
 
206
206
  for cycle in all_cycles:
207
207
  cyclic_definitions = []
208
208
  cyclic_definitions.append(concept_definitions[cycle[0]]["markdown"])
209
209
  cyclic_definitions.append(concept_definitions[cycle[1]]["markdown"])
210
- msg += "Cyclic definitons:\n"
210
+ msg += "Cyclic definitions:\n"
211
211
  msg += "\n".join(cyclic_definitions)
212
212
  msg += "\n"
213
213
 
@@ -3,10 +3,10 @@ import re
3
3
  from typing import Optional
4
4
 
5
5
  from liquid2 import Environment, FileSystemLoader, StrictUndefined
6
- from liquid2.exceptions import UndefinedError
6
+ from liquid2.exceptions import LiquidSyntaxError, UndefinedError
7
7
 
8
8
  from plain_parser import plain_spec
9
- from plain_parser.exceptions import UnsupportedBase64Content, UnsupportedResourceType
9
+ from plain_parser.exceptions import PlainSyntaxError, UnsupportedBase64Content, UnsupportedResourceType
10
10
  from plain_parser.liquid_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin
11
11
 
12
12
  MAX_BASE64_BLOB_LENGTH = 8192
@@ -36,6 +36,20 @@ def open_from(dirs, file_name):
36
36
  return None
37
37
 
38
38
 
39
+ def resolve_linked_resource(dirs, file_name):
40
+ """Return the first existing path for file_name across dirs, or None if not found.
41
+
42
+ Mirrors the search order used by open_from so that validating a linked resource and
43
+ loading it can never disagree.
44
+ """
45
+ for dir in dirs:
46
+ full_file_name = os.path.join(dir, file_name)
47
+ if os.path.exists(full_file_name):
48
+ return full_file_name
49
+
50
+ return None
51
+
52
+
39
53
  def load_linked_resources(template_dirs: list[str], resources_list, module_name: str):
40
54
  linked_resources = {}
41
55
 
@@ -81,7 +95,20 @@ class TrackingFileSystemLoader(Plain2CodeLoaderMixin, FileSystemLoader):
81
95
  return source
82
96
 
83
97
 
84
- def get_loaded_templates(source_path, plain_source):
98
+ def liquid_error_message(error: LiquidSyntaxError) -> str:
99
+ # Built from attributes: str() of some liquid2 syntax errors (e.g. an unterminated `{{ x`) raises ValueError.
100
+ template_name = error.template_name or None
101
+ token = error.token
102
+ if token is None or token.start < 0:
103
+ where = f" in {template_name}" if template_name else ""
104
+ else:
105
+ line = token.source[: token.start].count("\n") + 1
106
+ column = token.start - (token.source.rfind("\n", 0, token.start) + 1)
107
+ where = f" at {template_name}:{line}:{column}" if template_name else f" at {line}:{column}"
108
+ return f"Plain syntax error: Invalid Liquid{where}: {error.message}"
109
+
110
+
111
+ def get_loaded_templates(source_path, plain_source, template_name=None):
85
112
  # Render the plain source with Liquid templating engine
86
113
  # to identify the templates that are being loaded
87
114
 
@@ -92,9 +119,11 @@ def get_loaded_templates(source_path, plain_source):
92
119
  liquid_env.filters["code_variable"] = plain_spec.code_variable_liquid_filter
93
120
  liquid_env.filters["prohibited_chars"] = plain_spec.prohibited_chars_liquid_filter
94
121
 
95
- plain_source_template = liquid_env.from_string(plain_source)
96
122
  try:
123
+ plain_source_template = liquid_env.from_string(plain_source, name=template_name or "")
97
124
  plain_source = plain_source_template.render()
125
+ except LiquidSyntaxError as e:
126
+ raise PlainSyntaxError(liquid_error_message(e))
98
127
  except UndefinedError as e:
99
128
  raise Exception(f"Undefined liquid variable: {str(e)}")
100
129
 
@@ -88,7 +88,7 @@ def remove_quotes(token):
88
88
  token.children = tuple(new_children)
89
89
 
90
90
 
91
- def check_section_for_linked_resources(section):
91
+ def check_section_for_linked_resources(section, template_dirs):
92
92
  linked_resources = []
93
93
  for link in traverse(section, klass=Link):
94
94
  parsed_url = urlparse(link.node.target)
@@ -97,11 +97,19 @@ def check_section_for_linked_resources(section):
97
97
  f"Plain syntax error: Only relative links are allowed (text: {link.node.children[0].content}, target: {link.node.target})."
98
98
  )
99
99
 
100
- if not os.path.exists(link.node.target):
101
- raise PlainSyntaxError(f"Plain syntax error: Link {link.node.target} does not exist.")
100
+ resolved_target = loaders.resolve_linked_resource(template_dirs, link.node.target)
101
+ if resolved_target is None:
102
+ searched_dirs = "\n".join(f" {position}. {dir}" for position, dir in enumerate(template_dirs, start=1))
103
+ raise PlainSyntaxError(
104
+ f"Plain syntax error: Link {link.node.target} does not exist. "
105
+ f"Linked resources are looked up relative to the following directories "
106
+ f"(highest to lowest precedence):\n{searched_dirs}"
107
+ )
102
108
 
103
- if not os.path.isfile(link.node.target):
104
- raise PlainSyntaxError(f"Plain syntax error: Link {link.node.target} must be a file.")
109
+ if not os.path.isfile(resolved_target):
110
+ raise PlainSyntaxError(
111
+ f"Plain syntax error: Link {link.node.target} must be a file (resolved to {resolved_target})."
112
+ )
105
113
 
106
114
  if len(link.node.children) != 1:
107
115
  raise PlainSyntaxError(f"Plain syntax error: Link must have text specified (link: {link.node.target}).")
@@ -120,15 +128,15 @@ def check_section_for_linked_resources(section):
120
128
  section.linked_resources = linked_resources
121
129
 
122
130
 
123
- def check_for_linked_resources(plain_source):
131
+ def check_for_linked_resources(plain_source, template_dirs):
124
132
  for specification_heading in plain_spec.ALLOWED_SPECIFICATION_HEADINGS:
125
133
  if specification_heading in plain_source and hasattr(plain_source[specification_heading], "children"):
126
134
  for requirement in plain_source[specification_heading].children:
127
- check_section_for_linked_resources(requirement)
135
+ check_section_for_linked_resources(requirement, template_dirs)
128
136
 
129
137
  if hasattr(requirement, plain_spec.ACCEPTANCE_TESTS):
130
138
  for acceptance_test in requirement.acceptance_tests:
131
- check_section_for_linked_resources(acceptance_test)
139
+ check_section_for_linked_resources(acceptance_test, template_dirs)
132
140
 
133
141
 
134
142
  def process_section_code_variables(section, code_variables):
@@ -232,7 +240,7 @@ def _is_acceptance_test_heading(token) -> tuple[bool, str | None]:
232
240
  content = strong_children[0].content.strip()
233
241
  if content == plain_spec.ACCEPTANCE_TEST_HEADING:
234
242
  return True, None
235
- problem = f"Syntax error at line {token.line_number}: Invalid acceptance test heading (`{content}`). Expected: `{plain_spec.ACCEPTANCE_TEST_HEADING}.`"
243
+ problem = f"Syntax error at line {token.line_number}: Invalid acceptance test heading (`{content}`). Expected: `{plain_spec.ACCEPTANCE_TEST_HEADING}`."
236
244
  return False, problem
237
245
 
238
246
 
@@ -500,6 +508,7 @@ def parse_plain_source( # noqa: C901
500
508
  template_dirs: list[str],
501
509
  imported_modules: list[str],
502
510
  modules_trace: list[str],
511
+ module_name: str | None = None,
503
512
  ) -> PlainFileParseResult:
504
513
  plain_source_text = normalize_line_endings(plain_source_text)
505
514
 
@@ -519,7 +528,7 @@ def parse_plain_source( # noqa: C901
519
528
  else:
520
529
  required_concepts = list[str]()
521
530
 
522
- [_, loaded_templates] = loaders.get_loaded_templates(template_dirs, plain_source_text)
531
+ [_, loaded_templates] = loaders.get_loaded_templates(template_dirs, plain_source_text, module_name)
523
532
 
524
533
  plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content)
525
534
 
@@ -577,7 +586,7 @@ def parse_plain_source( # noqa: C901
577
586
 
578
587
  if specification_heading == plain_spec.DEFINITIONS and current_specification_heading is not None:
579
588
  raise PlainSyntaxError(
580
- f"Plain syntax error: Syntax error at line {token.line_number}: Definitions specification must be the first specification in the section (`{token_text}`)"
589
+ f"Plain syntax error: Syntax error at line {token.line_number}: Definitions specification must be the first specification in the section (`{token_text}`)"
581
590
  )
582
591
 
583
592
  current_specification_heading = specification_heading
@@ -594,7 +603,7 @@ def parse_plain_source( # noqa: C901
594
603
 
595
604
  else:
596
605
  raise PlainSyntaxError(
597
- f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)"
606
+ f"Plain syntax error: Syntax error at line {token.line_number}: Invalid source structure (`{token_text}`)"
598
607
  )
599
608
 
600
609
  # Acceptance tests are only allowed nested under functional specs. Reject them when
@@ -673,8 +682,8 @@ def read_module_plain_source(module_name: str, template_dirs: list[str]) -> str:
673
682
  if blob is not None:
674
683
  raise UnsupportedBase64Content(
675
684
  f"Module '{module_name}' contains a base64-encoded blob ({len(blob)} characters) "
676
- "inlined in the specification. This is not supported."
677
- "Remove the base64 data from the .plain file or if necessary,"
685
+ "inlined in the specification. This is not supported. "
686
+ "Remove the base64 data from the .plain file or if necessary, "
678
687
  "include the binary file path in the specification."
679
688
  )
680
689
 
@@ -696,6 +705,7 @@ def parse_plain_file(
696
705
  template_dirs,
697
706
  imported_modules,
698
707
  modules_trace + [module_name],
708
+ module_name=module_name,
699
709
  )
700
710
 
701
711
 
@@ -756,7 +766,7 @@ def process_required_modules(
756
766
  )
757
767
 
758
768
  marshalled_plain_source = validate_and_marshall_module(
759
- plain_file_parse_result, module_name, ancestor_exported_definitions, code_variables
769
+ plain_file_parse_result, module_name, ancestor_exported_definitions, code_variables, template_dirs
760
770
  )
761
771
  if module_name not in (chain_module_name for chain_module_name, _ in chain):
762
772
  chain.append((module_name, marshalled_plain_source))
@@ -793,6 +803,7 @@ def validate_and_marshall_module(
793
803
  module_name: str,
794
804
  exported_definitions: list[mistletoe.block_token.token],
795
805
  code_variables: dict,
806
+ template_dirs: list[str],
796
807
  ) -> dict:
797
808
  """Every check a module must pass after parsing, and its marshalled plain source tree."""
798
809
  if len(plain_file_parse_result.required_concepts) > 0:
@@ -826,7 +837,7 @@ def validate_and_marshall_module(
826
837
 
827
838
  process_acceptance_tests(plain_file_parse_result.plain_source)
828
839
 
829
- check_for_linked_resources(plain_file_parse_result.plain_source)
840
+ check_for_linked_resources(plain_file_parse_result.plain_source, template_dirs)
830
841
 
831
842
  marshalled_plain_source = marshall_plain_source(plain_file_parse_result.plain_source)
832
843
 
@@ -881,7 +892,7 @@ def _parse_module(
881
892
  )
882
893
 
883
894
  marshalled_plain_source = validate_and_marshall_module(
884
- plain_file_parse_result, module_name, exported_definitions, code_variables
895
+ plain_file_parse_result, module_name, exported_definitions, code_variables, template_dirs
885
896
  )
886
897
 
887
898
  return module_name, marshalled_plain_source, plain_file_parse_result.required_modules, chain
@@ -126,7 +126,7 @@ def collect_linked_resources(
126
126
  ):
127
127
 
128
128
  if not isinstance(plain_source_tree, dict):
129
- raise ValueError("[plain_source_tree must be a dictionary.")
129
+ raise ValueError("plain_source_tree must be a dictionary.")
130
130
 
131
131
  if frid is not None:
132
132
  functional_requirements = get_frids(plain_source_tree)
@@ -0,0 +1,9 @@
1
+ ***definitions***
2
+
3
+ ***implementation reqs***
4
+
5
+ - Implementation should be written in Python.
6
+
7
+ ***functional specs***
8
+
9
+ - Links must point at an existing file [missing resource](resources/missing_resource.yaml).
@@ -29,9 +29,9 @@
29
29
 
30
30
  - Implement the entry point for :App:.
31
31
 
32
- - Show :TaskList:. The details of the user interface are provided in the file [task_list_ui_specification.yaml](tests/data/plainfile/task_list_ui_specification.yaml).
32
+ - Show :TaskList:. The details of the user interface are provided in the file [task_list_ui_specification.yaml](task_list_ui_specification.yaml).
33
33
 
34
- - :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](tests/data/plainfile/add_new_task_modal_specification.yaml).
34
+ - :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).
35
35
 
36
36
  - :User: should be able to delete :Task:.
37
37
 
@@ -1,4 +1,3 @@
1
- # "hello, world" in Plain (Python version)
2
1
 
3
2
  ***definitions***
4
3
 
@@ -1,4 +1,3 @@
1
- # "hello, world" in Plain (Python version)
2
1
 
3
2
  ***definitions***
4
3
 
@@ -1,5 +1,5 @@
1
1
  ---
2
- import
2
+ import:
3
3
  - required_concepts
4
4
  ---
5
5
 
@@ -0,0 +1,4 @@
1
+ ***functional specs***
2
+
3
+ {% if %}
4
+ - A :User: can add a :Task:.
@@ -0,0 +1,3 @@
1
+ ***functional specs***
2
+
3
+ - A :User: can add a {{ task_name
@@ -9,12 +9,12 @@ from plain_parser import cli
9
9
 
10
10
 
11
11
  @pytest.fixture
12
- def cli_data_dir(get_test_data_path):
12
+ def cli_specs_dir(get_test_data_path):
13
13
  return get_test_data_path("data/cli")
14
14
 
15
15
 
16
- def test_check_valid_module_exits_zero(cli_data_dir, capsys):
17
- exit_code = cli.main(["check", os.path.join(cli_data_dir, "valid.plain")])
16
+ def test_check_valid_module_exits_zero(cli_specs_dir, capsys):
17
+ exit_code = cli.main(["check", os.path.join(cli_specs_dir, "valid.plain")])
18
18
 
19
19
  captured = capsys.readouterr()
20
20
  assert exit_code == 0
@@ -36,7 +36,7 @@ def test_without_subcommand_is_usage_error(capsys):
36
36
  assert exc_info.value.code == 2
37
37
 
38
38
 
39
- def test_console_script_is_installed_and_exits_zero(cli_data_dir):
39
+ def test_console_script_is_installed_and_exits_zero(cli_specs_dir):
40
40
  # The venv's script dir may not be on PATH when pytest is run through an absolute interpreter path.
41
41
  search_path = os.pathsep.join([os.path.dirname(sys.executable), os.environ.get("PATH", "")])
42
42
  script = shutil.which("plain-parser", path=search_path)
@@ -46,7 +46,7 @@ def test_console_script_is_installed_and_exits_zero(cli_data_dir):
46
46
  pytest.fail(message) if os.environ.get("CI") else pytest.skip(message)
47
47
 
48
48
  result = subprocess.run(
49
- [script, "check", os.path.join(cli_data_dir, "valid.plain")], capture_output=True, text=True
49
+ [script, "check", os.path.join(cli_specs_dir, "valid.plain")], capture_output=True, text=True
50
50
  )
51
51
 
52
52
  assert result.returncode == 0
@@ -66,32 +66,32 @@ def _assert_ok(result):
66
66
  assert out.endswith(": OK\n"), out
67
67
 
68
68
 
69
- def test_check_valid_module_in_subdirectory_from_parent(cli_data_dir, capsys, monkeypatch):
70
- monkeypatch.chdir(cli_data_dir)
69
+ def test_check_valid_module_in_subdirectory_from_parent(cli_specs_dir, capsys, monkeypatch):
70
+ monkeypatch.chdir(cli_specs_dir)
71
71
 
72
72
  exit_code, out, err = _run_check(capsys, os.path.join("subdir", "nested.plain"))
73
73
 
74
74
  _assert_ok((exit_code, out, err))
75
75
 
76
76
 
77
- def test_check_valid_module_with_linked_resource_in_acceptance_test(cli_data_dir, capsys, monkeypatch):
78
- monkeypatch.chdir(cli_data_dir)
77
+ def test_check_valid_module_with_linked_resource_in_acceptance_test(cli_specs_dir, capsys, monkeypatch, tmp_path):
78
+ monkeypatch.chdir(tmp_path)
79
79
 
80
- exit_code, out, err = _run_check(capsys, "acceptance_test_resource.plain")
80
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "acceptance_test_resource.plain"))
81
81
 
82
82
  _assert_ok((exit_code, out, err))
83
83
 
84
84
 
85
- def test_check_nonexistent_template_dir_is_ignored(cli_data_dir, capsys):
85
+ def test_check_nonexistent_template_dir_is_ignored(cli_specs_dir, capsys):
86
86
  exit_code, out, err = _run_check(
87
- capsys, os.path.join(cli_data_dir, "valid.plain"), "--template-dir", os.path.join(cli_data_dir, "nope")
87
+ capsys, os.path.join(cli_specs_dir, "valid.plain"), "--template-dir", os.path.join(cli_specs_dir, "nope")
88
88
  )
89
89
 
90
90
  _assert_ok((exit_code, out, err))
91
91
 
92
92
 
93
- def test_check_undefined_concept(cli_data_dir, capsys):
94
- exit_code, out, err = _run_check(capsys, os.path.join(cli_data_dir, "undefined_concept.plain"))
93
+ def test_check_undefined_concept(cli_specs_dir, capsys):
94
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "undefined_concept.plain"))
95
95
 
96
96
  assert exit_code == 1
97
97
  assert out == ""
@@ -101,30 +101,34 @@ def test_check_undefined_concept(cli_data_dir, capsys):
101
101
  assert "Traceback" not in err
102
102
 
103
103
 
104
- def test_check_missing_linked_resource(cli_data_dir, capsys, monkeypatch):
105
- monkeypatch.chdir(cli_data_dir)
104
+ def test_check_missing_linked_resource(cli_specs_dir, capsys, monkeypatch, tmp_path):
105
+ monkeypatch.chdir(tmp_path)
106
106
 
107
- exit_code, out, err = _run_check(capsys, "missing_resource.plain")
107
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "missing_resource.plain"))
108
108
 
109
109
  assert exit_code == 1
110
110
  assert out == ""
111
- assert err == "Error: Plain syntax error: Link does_not_exist.md does not exist.\n"
111
+ assert err == (
112
+ "Error: Plain syntax error: Link does_not_exist.md does not exist. "
113
+ "Linked resources are looked up relative to the following directories (highest to lowest precedence):\n"
114
+ f" 1. {cli_specs_dir}\n"
115
+ )
112
116
 
113
117
 
114
- def test_check_binary_linked_resource(cli_data_dir, capsys, monkeypatch):
115
- monkeypatch.chdir(cli_data_dir)
118
+ def test_check_binary_linked_resource(cli_specs_dir, capsys, monkeypatch, tmp_path):
119
+ monkeypatch.chdir(tmp_path)
116
120
 
117
- exit_code, out, err = _run_check(capsys, "binary_resource.plain")
121
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "binary_resource.plain"))
118
122
 
119
123
  assert exit_code == 1
120
124
  assert out == ""
121
125
  assert err.startswith("Error: Referenced resource 'binary.bin' in module 'binary_resource' is a binary file.")
122
126
 
123
127
 
124
- def test_check_base64_blob_linked_resource(cli_data_dir, capsys, monkeypatch):
125
- monkeypatch.chdir(cli_data_dir)
128
+ def test_check_base64_blob_linked_resource(cli_specs_dir, capsys, monkeypatch, tmp_path):
129
+ monkeypatch.chdir(tmp_path)
126
130
 
127
- exit_code, out, err = _run_check(capsys, "base64_resource.plain")
131
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "base64_resource.plain"))
128
132
 
129
133
  assert exit_code == 1
130
134
  assert out == ""
@@ -134,10 +138,10 @@ def test_check_base64_blob_linked_resource(cli_data_dir, capsys, monkeypatch):
134
138
  )
135
139
 
136
140
 
137
- def test_check_invalid_linked_resource_in_required_module(cli_data_dir, capsys, monkeypatch):
138
- monkeypatch.chdir(cli_data_dir)
141
+ def test_check_invalid_linked_resource_in_required_module(cli_specs_dir, capsys, monkeypatch, tmp_path):
142
+ monkeypatch.chdir(tmp_path)
139
143
 
140
- exit_code, out, err = _run_check(capsys, "requires_binary_resource_top.plain")
144
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "requires_binary_resource_top.plain"))
141
145
 
142
146
  assert exit_code == 1
143
147
  assert out == ""
@@ -146,24 +150,24 @@ def test_check_invalid_linked_resource_in_required_module(cli_data_dir, capsys,
146
150
  )
147
151
 
148
152
 
149
- def test_check_code_variable_with_two_values(cli_data_dir, capsys):
150
- exit_code, out, err = _run_check(capsys, os.path.join(cli_data_dir, "code_variable_conflict.plain"))
153
+ def test_check_code_variable_with_two_values(cli_specs_dir, capsys):
154
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "code_variable_conflict.plain"))
151
155
 
152
156
  assert exit_code == 1
153
157
  assert out == ""
154
158
  assert err == "Error: Code variable variable_name has multiple values: first and second\n"
155
159
 
156
160
 
157
- def test_check_missing_plain_file(cli_data_dir, capsys):
158
- exit_code, out, err = _run_check(capsys, os.path.join(cli_data_dir, "does_not_exist.plain"))
161
+ def test_check_missing_plain_file(cli_specs_dir, capsys):
162
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "does_not_exist.plain"))
159
163
 
160
164
  assert exit_code == 1
161
165
  assert out == ""
162
166
  assert err == "Error: Module does not exist (does_not_exist).\n"
163
167
 
164
168
 
165
- def test_check_wrong_extension(cli_data_dir, capsys):
166
- exit_code, out, err = _run_check(capsys, os.path.join(cli_data_dir, "not_plain.md"))
169
+ def test_check_wrong_extension(cli_specs_dir, capsys):
170
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "not_plain.md"))
167
171
 
168
172
  assert exit_code == 1
169
173
  assert out == ""
@@ -189,10 +193,10 @@ def test_check_empty_template_dir_is_ignored(tmp_path, capsys, monkeypatch):
189
193
  assert err == "Error: Module does not exist (helper).\n"
190
194
 
191
195
 
192
- def test_check_binary_linked_resource_in_acceptance_test(cli_data_dir, capsys, monkeypatch):
193
- monkeypatch.chdir(cli_data_dir)
196
+ def test_check_binary_linked_resource_in_acceptance_test(cli_specs_dir, capsys, monkeypatch, tmp_path):
197
+ monkeypatch.chdir(tmp_path)
194
198
 
195
- exit_code, out, err = _run_check(capsys, "acceptance_test_binary_resource.plain")
199
+ exit_code, out, err = _run_check(capsys, os.path.join(cli_specs_dir, "acceptance_test_binary_resource.plain"))
196
200
 
197
201
  assert exit_code == 1
198
202
  assert out == ""
@@ -202,13 +206,13 @@ def test_check_binary_linked_resource_in_acceptance_test(cli_data_dir, capsys, m
202
206
 
203
207
 
204
208
  @pytest.mark.parametrize("interrupted_function", ["check", "resolve_config_file"])
205
- def test_check_interrupted_exits_130_without_traceback(cli_data_dir, capsys, monkeypatch, interrupted_function):
209
+ def test_check_interrupted_exits_130_without_traceback(cli_specs_dir, capsys, monkeypatch, interrupted_function):
206
210
  def interrupt(*_):
207
211
  raise KeyboardInterrupt
208
212
 
209
213
  monkeypatch.setattr(cli, interrupted_function, interrupt)
210
214
 
211
- exit_code = cli.main(["check", os.path.join(cli_data_dir, "valid.plain")])
215
+ exit_code = cli.main(["check", os.path.join(cli_specs_dir, "valid.plain")])
212
216
 
213
217
  captured = capsys.readouterr()
214
218
  assert exit_code == 130
@@ -317,8 +321,8 @@ def test_check_unreadable_config_is_usage_error(tmp_path, capsys):
317
321
  assert "Error reading config file" in capsys.readouterr().err
318
322
 
319
323
 
320
- def test_check_valid_module_prints_ok_line_with_file_as_given(cli_data_dir, capsys, monkeypatch):
321
- monkeypatch.chdir(cli_data_dir)
324
+ def test_check_valid_module_prints_ok_line_with_file_as_given(cli_specs_dir, capsys, monkeypatch):
325
+ monkeypatch.chdir(cli_specs_dir)
322
326
 
323
327
  exit_code, out, err = _run_check(capsys, "valid.plain")
324
328
 
@@ -1,10 +1,11 @@
1
1
  import os
2
+ import re
2
3
  import tempfile
3
4
 
4
5
  import pytest
5
6
 
6
7
  from plain_parser.exceptions import UnsupportedBase64Content, UnsupportedResourceType
7
- from plain_parser.loaders import MAX_BASE64_BLOB_LENGTH, load_linked_resources
8
+ from plain_parser.loaders import MAX_BASE64_BLOB_LENGTH, load_linked_resources, resolve_linked_resource
8
9
 
9
10
 
10
11
  @pytest.fixture
@@ -96,3 +97,88 @@ def test_get_loaded_templates_missing_include_names_template(template_dir):
96
97
  message = str(exc_info.value)
97
98
  assert "nope.plain" in message
98
99
  assert "--template-dir" not in message
100
+
101
+
102
+ def test_resolve_linked_resource_returns_none_when_missing(template_dir):
103
+ assert resolve_linked_resource([template_dir], "missing.md") is None
104
+
105
+
106
+ def test_resolve_linked_resource_first_dir_wins():
107
+ with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
108
+ for d in (first, second):
109
+ with open(os.path.join(d, "notes.md"), "w") as f:
110
+ f.write("x")
111
+
112
+ assert resolve_linked_resource([first, second], "notes.md") == os.path.join(first, "notes.md")
113
+
114
+
115
+ def test_resolve_linked_resource_falls_back_to_later_dir():
116
+ with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
117
+ with open(os.path.join(second, "notes.md"), "w") as f:
118
+ f.write("x")
119
+
120
+ assert resolve_linked_resource([first, second], "notes.md") == os.path.join(second, "notes.md")
121
+
122
+
123
+ def test_resolve_linked_resource_parent_relative_path():
124
+ with tempfile.TemporaryDirectory() as root:
125
+ spec_dir = os.path.join(root, "plain")
126
+ os.mkdir(spec_dir)
127
+ with open(os.path.join(root, "shared.md"), "w") as f:
128
+ f.write("x")
129
+
130
+ assert resolve_linked_resource([spec_dir], "../shared.md") == os.path.join(spec_dir, "../shared.md")
131
+
132
+
133
+ def test_resolve_linked_resource_directory_in_first_dir_shadows_file_in_second():
134
+ # Parity with codeplain: existence, not file-ness, decides the match.
135
+ with tempfile.TemporaryDirectory() as first, tempfile.TemporaryDirectory() as second:
136
+ os.mkdir(os.path.join(first, "notes.md"))
137
+ with open(os.path.join(second, "notes.md"), "w") as f:
138
+ f.write("x")
139
+
140
+ assert resolve_linked_resource([first, second], "notes.md") == os.path.join(first, "notes.md")
141
+
142
+
143
+ def _load_template_fixture(name):
144
+ path = os.path.join(os.path.dirname(__file__), "data", "templates", f"{name}.plain")
145
+ with open(path, encoding="utf-8") as f:
146
+ return os.path.dirname(path), f.read()
147
+
148
+
149
+ def test_get_loaded_templates_malformed_tag_raises_plain_syntax_error():
150
+ from plain_parser.exceptions import PlainSyntaxError
151
+ from plain_parser.loaders import get_loaded_templates
152
+
153
+ directory, source = _load_template_fixture("malformed_tag")
154
+
155
+ with pytest.raises(
156
+ PlainSyntaxError,
157
+ match=re.escape("Plain syntax error: Invalid Liquid at malformed_tag:3:0: "),
158
+ ):
159
+ get_loaded_templates([directory], source, "malformed_tag")
160
+
161
+
162
+ def test_get_loaded_templates_unterminated_output_raises_plain_syntax_error():
163
+ from plain_parser.exceptions import PlainSyntaxError
164
+ from plain_parser.loaders import get_loaded_templates
165
+
166
+ directory, source = _load_template_fixture("unterminated_output")
167
+
168
+ with pytest.raises(
169
+ PlainSyntaxError,
170
+ match=re.escape("Plain syntax error: Invalid Liquid at unterminated_output:4:0: "),
171
+ ):
172
+ get_loaded_templates([directory], source, "unterminated_output")
173
+
174
+
175
+ def test_liquid_error_message_without_position():
176
+ from liquid2.exceptions import LiquidSyntaxError
177
+
178
+ from plain_parser.loaders import liquid_error_message
179
+
180
+ error = LiquidSyntaxError("missing expression", token=None, template_name="header")
181
+ assert liquid_error_message(error) == "Plain syntax error: Invalid Liquid in header: missing expression"
182
+
183
+ error = LiquidSyntaxError("missing expression", token=None)
184
+ assert liquid_error_message(error) == "Plain syntax error: Invalid Liquid: missing expression"