plain-parser 0.1.2.dev4__tar.gz → 0.1.2.dev6__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 (147) hide show
  1. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/PKG-INFO +1 -2
  2. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/pyproject.toml +0 -1
  3. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/src/plain_parser/concept_utils.py +49 -20
  4. plain_parser-0.1.2.dev6/src/plain_parser/graph.py +164 -0
  5. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/src/plain_parser/loaders.py +19 -4
  6. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/src/plain_parser/plain_file.py +8 -6
  7. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/src/plain_parser/plain_spec.py +1 -1
  8. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_noconcepts.plain +0 -1
  9. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_redefinition.plain +0 -1
  10. plain_parser-0.1.2.dev6/tests/data/plainfileparser/cyclic_definitions_across_import.plain +13 -0
  11. plain_parser-0.1.2.dev6/tests/data/plainfileparser/cyclic_definitions_long.plain +11 -0
  12. plain_parser-0.1.2.dev6/tests/data/plainfileparser/cyclic_definitions_sub_bullet.plain +11 -0
  13. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_example.plain +1 -1
  14. plain_parser-0.1.2.dev6/tests/data/templates/malformed_tag.plain +4 -0
  15. plain_parser-0.1.2.dev6/tests/data/templates/unterminated_output.plain +3 -0
  16. plain_parser-0.1.2.dev6/tests/test_concept_utils.py +179 -0
  17. plain_parser-0.1.2.dev6/tests/test_graph.py +130 -0
  18. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/test_loaders.py +45 -0
  19. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/test_plainfileparser.py +52 -4
  20. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/.flake8 +0 -0
  21. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/.github/workflows/lint-and-test.yml +0 -0
  22. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/.github/workflows/publish-dev-to-pypi.yml +0 -0
  23. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/.github/workflows/publish-to-pypi.yml +0 -0
  24. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/.gitignore +0 -0
  25. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/LICENSE +0 -0
  26. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/README.md +0 -0
  27. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/src/plain_parser/__init__.py +0 -0
  28. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/src/plain_parser/cli.py +0 -0
  29. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/src/plain_parser/exceptions.py +0 -0
  30. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/src/plain_parser/liquid_nodes.py +0 -0
  31. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/__init__.py +0 -0
  32. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/conftest.py +0 -0
  33. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/acceptance_test_binary_resource.plain +0 -0
  34. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/acceptance_test_resource.plain +0 -0
  35. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/base64_resource.plain +0 -0
  36. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/binary.bin +0 -0
  37. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/binary_resource.plain +0 -0
  38. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/code_variable_conflict.plain +0 -0
  39. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/code_variable_template.plain +0 -0
  40. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/missing_resource.plain +0 -0
  41. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/not_plain.md +0 -0
  42. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/notes.md +0 -0
  43. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/requires_binary_resource_base.plain +0 -0
  44. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/requires_binary_resource_top.plain +0 -0
  45. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/subdir/nested.plain +0 -0
  46. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/undefined_concept.plain +0 -0
  47. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/cli/valid.plain +0 -0
  48. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/circular_imports_1.plain +0 -0
  49. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/circular_imports_2.plain +0 -0
  50. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/circular_imports_main.plain +0 -0
  51. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/diamond_import_1.plain +0 -0
  52. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/diamond_import_2.plain +0 -0
  53. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/diamond_import_common.plain +0 -0
  54. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/diamond_imports_main.plain +0 -0
  55. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/import_with_functionalities.plain +0 -0
  56. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/imports_module_with_functionalities.plain +0 -0
  57. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/imports/non_existent_import.plain +0 -0
  58. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/add_new_task_modal_specification.yaml +0 -0
  59. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/duplicate_specification_heading.plain +0 -0
  60. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/empty_functional_specs_section.plain +0 -0
  61. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/invalid_specification_order.plain +0 -0
  62. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/missing_non_functional_requirements.plain +0 -0
  63. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/no_functional_specs_section.plain +0 -0
  64. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/plain_source_with_absolute_link.plain +0 -0
  65. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/plain_source_with_missing_link.plain +0 -0
  66. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/plain_source_with_url_link.plain +0 -0
  67. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/task_list_ui_specification.yaml +0 -0
  68. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/task_manager_with_reference_links.plain +0 -0
  69. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfile/without_non_functional_requirement.plain +0 -0
  70. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_acceptance_tests.plain +0 -0
  71. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_acceptance_tests_nondefined.plain +0 -0
  72. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_defined_nondefined.plain +0 -0
  73. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_defined_nondefined_2.plain +0 -0
  74. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_definition.plain +0 -0
  75. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_nonconcept.plain +0 -0
  76. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_nondefined.plain +0 -0
  77. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_several_concepts.plain +0 -0
  78. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/concept_validation_valid.plain +0 -0
  79. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/cyclic_definitions.plain +0 -0
  80. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts.plain +0 -0
  81. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_base.plain +0 -0
  82. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_defaults_exported.plain +0 -0
  83. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_example.plain +0 -0
  84. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_imported_exported.plain +0 -0
  85. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_inherited_reexported.plain +0 -0
  86. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_malformed.plain +0 -0
  87. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_missing_definition.plain +0 -0
  88. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_missing_definition_example.plain +0 -0
  89. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet.plain +0 -0
  90. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_multiple_per_bullet_undefined.plain +0 -0
  91. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_nested.plain +0 -0
  92. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_nested_example.plain +0 -0
  93. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_no_definitions.plain +0 -0
  94. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_not_declared.plain +0 -0
  95. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_not_declared_example.plain +0 -0
  96. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_transitive_example.plain +0 -0
  97. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_transitive_l1.plain +0 -0
  98. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/exported_concepts_transitive_l2.plain +0 -0
  99. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/plain_file_parser_with_comments.plain +0 -0
  100. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/plain_file_with_comments_indented.plain +0 -0
  101. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/regular_plain_source.plain +0 -0
  102. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts.plain +0 -0
  103. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_defs.plain +0 -0
  104. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_l1.plain +0 -0
  105. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_l2.plain +0 -0
  106. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_missing.plain +0 -0
  107. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_module.plain +0 -0
  108. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_partial.plain +0 -0
  109. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/required_concepts_partial_duplicate.plain +0 -0
  110. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/requires_without_definitions_example.plain +0 -0
  111. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/topological_sort.plain +0 -0
  112. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/plainfileparser/topological_sort_not_referenced.plain +0 -0
  113. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/chain_base.plain +0 -0
  114. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/chain_fork_top.plain +0 -0
  115. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/chain_middle.plain +0 -0
  116. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/chain_top.plain +0 -0
  117. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/circular_requires_main.plain +0 -0
  118. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/circular_requires_sub.plain +0 -0
  119. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/diamond_requires_1.plain +0 -0
  120. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/diamond_requires_2.plain +0 -0
  121. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/diamond_requires_common.plain +0 -0
  122. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/diamond_requires_main.plain +0 -0
  123. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/independent_requires_1.plain +0 -0
  124. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/independent_requires_2.plain +0 -0
  125. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/independent_requires_main.plain +0 -0
  126. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/invalid_ancestor_base.plain +0 -0
  127. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/invalid_ancestor_top.plain +0 -0
  128. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/non_existent_require.plain +0 -0
  129. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/normal_requires_1.plain +0 -0
  130. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/normal_requires_2.plain +0 -0
  131. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/normal_requires_common.plain +0 -0
  132. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/requires/normal_requires_main.plain +0 -0
  133. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/sample_base64_image.txt +0 -0
  134. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/simple.plain +0 -0
  135. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/templates/block_level_include.plain +0 -0
  136. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/templates/code_variables.plain +0 -0
  137. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/templates/header.plain +0 -0
  138. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/templates/implement.plain +0 -0
  139. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/templates/implement_2.plain +0 -0
  140. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/templates/template_include.plain +0 -0
  141. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/data/templates/test_hardest_problem.plain +0 -0
  142. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/test_cli.py +0 -0
  143. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/test_exceptions.py +0 -0
  144. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/test_imports.py +0 -0
  145. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/test_plainfile.py +0 -0
  146. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/tests/test_plainspec.py +0 -0
  147. {plain_parser-0.1.2.dev4 → plain_parser-0.1.2.dev6}/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.dev4
3
+ Version: 0.1.2.dev6
4
4
  Summary: Parser for ***plain specification files
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -10,7 +10,6 @@ Classifier: Programming Language :: Python :: 3
10
10
  Classifier: Topic :: Software Development :: Compilers
11
11
  Requires-Python: >=3.11
12
12
  Requires-Dist: mistletoe<2,>=1.6.0
13
- Requires-Dist: networkx<4,>=3.6.1
14
13
  Requires-Dist: python-frontmatter<2,>=1.3.0
15
14
  Requires-Dist: python-liquid2<0.5,>=0.4.0
16
15
  Requires-Dist: pyyaml<7,>=6
@@ -20,7 +20,6 @@ dependencies = [
20
20
  "python-liquid2>=0.4.0,<0.5",
21
21
  "mistletoe>=1.6.0,<2",
22
22
  "python-frontmatter>=1.3.0,<2",
23
- "networkx>=3.6.1,<4",
24
23
  "pyyaml>=6,<7",
25
24
  ]
26
25
 
@@ -1,11 +1,11 @@
1
1
  import re
2
2
  from copy import deepcopy
3
+ from graphlib import CycleError, TopologicalSorter
3
4
  from typing import Optional
4
5
 
5
6
  import mistletoe.block_token
6
- import networkx as nx
7
7
 
8
- from plain_parser import plain_spec
8
+ from plain_parser import graph, plain_spec
9
9
  from plain_parser.exceptions import PlainSyntaxError
10
10
 
11
11
  # These are reserved concept names that are pre-defined and must not be redefined in the definitions section.
@@ -186,34 +186,63 @@ def build_adjacency_list(definitions: Optional[list[dict]]) -> tuple[dict, dict]
186
186
  return adjacency_list, concept_definitions
187
187
 
188
188
 
189
+ MAX_REPORTED_CYCLES = 10
190
+
191
+
192
+ def format_cycles_error(adjacency_list: dict[str, list[str]], concept_definitions: dict[str, dict]) -> str:
193
+ """
194
+ List the elementary cycles in the graph, each as a "Cycle N: :A: -> :B: -> :A:" line
195
+ followed by the definition of every concept in the cycle. Arrows point from a definition
196
+ to a concept it mentions, each cycle starts at its earliest-defined concept, and cycles
197
+ are ordered by definition position, so the report reads top to bottom like the file.
198
+ At most MAX_REPORTED_CYCLES are listed; the search stops once one more than that is found.
199
+ """
200
+ position = {concept: index for index, concept in enumerate(concept_definitions)}
201
+ cycles = []
202
+ # key the graph in definition order so a search cut short by the limit has found the earliest cycles
203
+ by_position = {concept: adjacency_list[concept] for concept in [*concept_definitions, *adjacency_list]}
204
+ for cycle in graph.simple_cycles(by_position, limit=MAX_REPORTED_CYCLES + 1):
205
+ # reverse since simple_cycles edges point from a used concept to the one defined with it
206
+ cycle = cycle[::-1]
207
+ first = min(range(len(cycle)), key=lambda i: position[cycle[i]])
208
+ cycles.append(cycle[first:] + cycle[:first])
209
+ cycles.sort(key=lambda cycle: (position[cycle[0]], len(cycle), [position[c] for c in cycle]))
210
+
211
+ if len(cycles) > MAX_REPORTED_CYCLES:
212
+ cycles = cycles[:MAX_REPORTED_CYCLES]
213
+ lines = [
214
+ f"Plain syntax error: Found more than {MAX_REPORTED_CYCLES} cycles in concept definitions. Cycles are not allowed.",
215
+ f"Showing the first {MAX_REPORTED_CYCLES}.",
216
+ ]
217
+ else:
218
+ lines = [f"Plain syntax error: Found {len(cycles)} cycle(s) in concept definitions. Cycles are not allowed."]
219
+ for number, cycle in enumerate(cycles, start=1):
220
+ lines.append("")
221
+ lines.append(f"Cycle {number}: " + " -> ".join(cycle + [cycle[0]]))
222
+ # concepts defined on one line can share a cycle; print that line once
223
+ lines.extend(dict.fromkeys(concept_definitions[concept]["markdown"] for concept in cycle))
224
+ return "\n".join(lines)
225
+
226
+
189
227
  def sort_definitions(definitions: list[dict]) -> list[dict]:
190
228
  if len(definitions) <= 1:
191
229
  return
192
230
 
193
231
  adjacency_list, concept_definitions = build_adjacency_list(definitions)
194
232
 
195
- concept_graph = nx.DiGraph()
233
+ # adjacency_list maps a used concept to the concepts defined in terms of it,
234
+ # so the used concept is a predecessor of each of them
235
+ sorter: TopologicalSorter[str] = TopologicalSorter()
196
236
  for node, neighbors in adjacency_list.items():
237
+ sorter.add(node)
197
238
  for neighbour in neighbors:
198
- concept_graph.add_edge(node, neighbour)
199
- if not neighbors:
200
- concept_graph.add_node(node)
201
-
202
- if not nx.is_directed_acyclic_graph(concept_graph):
203
- msg = "Found cycles in the concept graph. Cycles are not allowed."
204
- all_cycles = list(nx.simple_cycles(concept_graph))
205
-
206
- for cycle in all_cycles:
207
- cyclic_definitions = []
208
- cyclic_definitions.append(concept_definitions[cycle[0]]["markdown"])
209
- cyclic_definitions.append(concept_definitions[cycle[1]]["markdown"])
210
- msg += "Cyclic definitons:\n"
211
- msg += "\n".join(cyclic_definitions)
212
- msg += "\n"
239
+ sorter.add(neighbour, node)
213
240
 
214
- raise PlainSyntaxError(f"Plain syntax error: {msg}")
241
+ try:
242
+ order = list(sorter.static_order())
243
+ except CycleError:
244
+ raise PlainSyntaxError(format_cycles_error(adjacency_list, concept_definitions)) from None
215
245
 
216
- order = list(nx.topological_sort(concept_graph))
217
246
  if len(order) > 0:
218
247
  definition_to_order_idx = {
219
248
  plain_spec.hash_text(str(concept_definitions[concept])): idx for idx, concept in enumerate(order)
@@ -0,0 +1,164 @@
1
+ """Cycle search over a directed graph given as a mapping from node to its successors.
2
+
3
+ Nodes that appear only as successors are treated as having no outgoing edges.
4
+
5
+ The search is Johnson's algorithm, written from the pseudocode in the paper, with the
6
+ strongly connected components found by Tarjan's algorithm as given on Wikipedia.
7
+
8
+ - D. B. Johnson, "Finding all the elementary circuits of a directed graph",
9
+ SIAM J. Comput. 4(1):77-84, 1975, doi:10.1137/0204007
10
+ - R. Tarjan, "Depth-first search and linear graph algorithms",
11
+ SIAM J. Comput. 1(2):146-160, 1972, doi:10.1137/0201010
12
+ - https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
13
+
14
+ Both searches keep their own stack of frames instead of recursing, so the depth of a
15
+ tangle is not limited by Python's recursion limit.
16
+ """
17
+
18
+ import heapq
19
+ from collections import defaultdict
20
+ from collections.abc import Hashable, Iterable, Iterator, Mapping
21
+ from typing import Optional, TypeVar
22
+
23
+ Node = TypeVar("Node", bound=Hashable)
24
+
25
+
26
+ def _successors(graph: Mapping[Node, Iterable[Node]]) -> dict[Node, list[Node]]:
27
+ # a repeated edge would otherwise yield every cycle through it once per repeat
28
+ successors = {node: list(dict.fromkeys(targets)) for node, targets in graph.items()}
29
+ for targets in list(successors.values()):
30
+ for target in targets:
31
+ successors.setdefault(target, [])
32
+ return successors
33
+
34
+
35
+ def strongly_connected_components(graph: Mapping[Node, Iterable[Node]]) -> list[set[Node]]:
36
+ """Tarjan: one depth-first walk, each node stamped with its visit index and the lowest
37
+ index it can reach; a node whose lowest reachable index is its own closes a component."""
38
+ successors = _successors(graph)
39
+ index: dict[Node, int] = {}
40
+ lowlink: dict[Node, int] = {}
41
+ stack: list[Node] = []
42
+ on_stack: set[Node] = set()
43
+ components: list[set[Node]] = []
44
+
45
+ def visit(node: Node) -> tuple[Node, Iterator[Node]]:
46
+ index[node] = lowlink[node] = len(index)
47
+ stack.append(node)
48
+ on_stack.add(node)
49
+ return node, iter(successors[node])
50
+
51
+ for root in successors:
52
+ if root in index:
53
+ continue
54
+ frames = [visit(root)]
55
+ while frames:
56
+ node, targets = frames[-1]
57
+ for target in targets:
58
+ if target not in index:
59
+ frames.append(visit(target))
60
+ break
61
+ if target in on_stack:
62
+ lowlink[node] = min(lowlink[node], index[target])
63
+ else:
64
+ frames.pop()
65
+ if frames:
66
+ parent = frames[-1][0]
67
+ lowlink[parent] = min(lowlink[parent], lowlink[node])
68
+ if lowlink[node] == index[node]:
69
+ component: set[Node] = set()
70
+ while True:
71
+ member = stack.pop()
72
+ on_stack.discard(member)
73
+ component.add(member)
74
+ if member == node:
75
+ break
76
+ components.append(component)
77
+ return components
78
+
79
+
80
+ def _cycles_through(adjacency: dict[Node, list[Node]], s: Node) -> Iterator[list[Node]]:
81
+ """Johnson's CIRCUIT: every elementary cycle through `s` in one strongly connected component.
82
+
83
+ A node is blocked while it is on the path, and stays blocked after backtracking until a
84
+ cycle is found through some node it was waiting on."""
85
+ blocked: set[Node] = set()
86
+ blocked_map: dict[Node, set[Node]] = defaultdict(set)
87
+
88
+ def unblock(node: Node) -> None:
89
+ pending = [node]
90
+ while pending:
91
+ current = pending.pop()
92
+ blocked.discard(current)
93
+ pending.extend(waiting for waiting in blocked_map.pop(current, ()) if waiting in blocked)
94
+
95
+ path = [s]
96
+ blocked.add(s)
97
+ frames = [iter(adjacency[s])]
98
+ found_below = [False]
99
+ while frames:
100
+ node = path[-1]
101
+ for target in frames[-1]:
102
+ if target == s:
103
+ yield list(path)
104
+ found_below[-1] = True
105
+ elif target not in blocked:
106
+ path.append(target)
107
+ blocked.add(target)
108
+ frames.append(iter(adjacency[target]))
109
+ found_below.append(False)
110
+ break
111
+ else:
112
+ frames.pop()
113
+ path.pop()
114
+ if found_below.pop():
115
+ unblock(node)
116
+ if found_below:
117
+ found_below[-1] = True
118
+ else:
119
+ for target in adjacency[node]:
120
+ blocked_map[target].add(node)
121
+
122
+
123
+ def _all_cycles(graph: Mapping[Node, Iterable[Node]]) -> Iterator[list[Node]]:
124
+ """Johnson's main loop: every cycle lies inside one strongly connected component, and
125
+ every cycle inside a component either passes through its least node or survives that
126
+ node's removal. So search each component from its least node, drop the node, and split
127
+ what is left into components again. Components are taken least node first, so a caller
128
+ that stops early has seen the cycles through the earliest nodes."""
129
+ successors = _successors(graph)
130
+ for node, targets in successors.items():
131
+ if node in targets:
132
+ yield [node]
133
+ successors = {node: [t for t in targets if t != node] for node, targets in successors.items()}
134
+ position = {node: i for i, node in enumerate(successors)}
135
+
136
+ # components are disjoint, so no two entries share a least position and the sets are never compared
137
+ components: list[tuple[int, set[Node]]] = []
138
+
139
+ def push_components(adjacency: Mapping[Node, Iterable[Node]]) -> None:
140
+ for component in strongly_connected_components(adjacency):
141
+ if len(component) >= 2:
142
+ heapq.heappush(components, (min(map(position.__getitem__, component)), component))
143
+
144
+ push_components(successors)
145
+ while components:
146
+ _, component = heapq.heappop(components)
147
+ s = min(component, key=position.__getitem__)
148
+ adjacency = {n: [t for t in successors[n] if t in component] for n in component}
149
+ yield from _cycles_through(adjacency, s)
150
+ del adjacency[s]
151
+ for targets in adjacency.values():
152
+ if s in targets:
153
+ targets.remove(s)
154
+ push_components(adjacency)
155
+
156
+
157
+ def simple_cycles(graph: Mapping[Node, Iterable[Node]], limit: Optional[int] = None) -> Iterator[list[Node]]:
158
+ """Yield the elementary cycles of `graph`, each as its nodes in edge order, stopping
159
+ after `limit` cycles when one is given. Self-loops come first; longer cycles follow in
160
+ order of their earliest node in `graph`'s iteration order."""
161
+ for found, cycle in enumerate(_all_cycles(graph), start=1):
162
+ if limit is not None and found > limit:
163
+ return
164
+ yield cycle
@@ -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
@@ -95,7 +95,20 @@ class TrackingFileSystemLoader(Plain2CodeLoaderMixin, FileSystemLoader):
95
95
  return source
96
96
 
97
97
 
98
- 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):
99
112
  # Render the plain source with Liquid templating engine
100
113
  # to identify the templates that are being loaded
101
114
 
@@ -106,9 +119,11 @@ def get_loaded_templates(source_path, plain_source):
106
119
  liquid_env.filters["code_variable"] = plain_spec.code_variable_liquid_filter
107
120
  liquid_env.filters["prohibited_chars"] = plain_spec.prohibited_chars_liquid_filter
108
121
 
109
- plain_source_template = liquid_env.from_string(plain_source)
110
122
  try:
123
+ plain_source_template = liquid_env.from_string(plain_source, name=template_name or "")
111
124
  plain_source = plain_source_template.render()
125
+ except LiquidSyntaxError as e:
126
+ raise PlainSyntaxError(liquid_error_message(e))
112
127
  except UndefinedError as e:
113
128
  raise Exception(f"Undefined liquid variable: {str(e)}")
114
129
 
@@ -240,7 +240,7 @@ def _is_acceptance_test_heading(token) -> tuple[bool, str | None]:
240
240
  content = strong_children[0].content.strip()
241
241
  if content == plain_spec.ACCEPTANCE_TEST_HEADING:
242
242
  return True, None
243
- 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}`."
244
244
  return False, problem
245
245
 
246
246
 
@@ -508,6 +508,7 @@ def parse_plain_source( # noqa: C901
508
508
  template_dirs: list[str],
509
509
  imported_modules: list[str],
510
510
  modules_trace: list[str],
511
+ module_name: str | None = None,
511
512
  ) -> PlainFileParseResult:
512
513
  plain_source_text = normalize_line_endings(plain_source_text)
513
514
 
@@ -527,7 +528,7 @@ def parse_plain_source( # noqa: C901
527
528
  else:
528
529
  required_concepts = list[str]()
529
530
 
530
- [_, 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)
531
532
 
532
533
  plain_source_content = restore_stripped_lines(plain_source_text, plain_source_obj.content)
533
534
 
@@ -585,7 +586,7 @@ def parse_plain_source( # noqa: C901
585
586
 
586
587
  if specification_heading == plain_spec.DEFINITIONS and current_specification_heading is not None:
587
588
  raise PlainSyntaxError(
588
- 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}`)"
589
590
  )
590
591
 
591
592
  current_specification_heading = specification_heading
@@ -602,7 +603,7 @@ def parse_plain_source( # noqa: C901
602
603
 
603
604
  else:
604
605
  raise PlainSyntaxError(
605
- 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}`)"
606
607
  )
607
608
 
608
609
  # Acceptance tests are only allowed nested under functional specs. Reject them when
@@ -681,8 +682,8 @@ def read_module_plain_source(module_name: str, template_dirs: list[str]) -> str:
681
682
  if blob is not None:
682
683
  raise UnsupportedBase64Content(
683
684
  f"Module '{module_name}' contains a base64-encoded blob ({len(blob)} characters) "
684
- "inlined in the specification. This is not supported."
685
- "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, "
686
687
  "include the binary file path in the specification."
687
688
  )
688
689
 
@@ -704,6 +705,7 @@ def parse_plain_file(
704
705
  template_dirs,
705
706
  imported_modules,
706
707
  modules_trace + [module_name],
708
+ module_name=module_name,
707
709
  )
708
710
 
709
711
 
@@ -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)
@@ -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
 
@@ -0,0 +1,13 @@
1
+ ---
2
+ import:
3
+ - required_concepts
4
+ ---
5
+
6
+ ***definitions***
7
+ - :Concept: is the concept that :DependantConcept: builds on.
8
+
9
+ ***implementation reqs***
10
+ - The :Concept: concept must be implemented.
11
+
12
+ ***functional specs***
13
+ - Implement the :Concept: concept.
@@ -0,0 +1,11 @@
1
+ ***definitions***
2
+ - :A: uses :B:.
3
+ - :B: uses :C:.
4
+ - :C: uses :A:.
5
+ - :D: is on its own.
6
+
7
+ ***implementation reqs***
8
+ - The :D: concept must be implemented.
9
+
10
+ ***functional specs***
11
+ - Implement the :D: concept.
@@ -0,0 +1,11 @@
1
+ ***definitions***
2
+ - :Order: is a purchase.
3
+ - It is placed by a :Customer:.
4
+ - :Customer: is a buyer with :Order: items.
5
+ - :Receipt: records an :Order:.
6
+
7
+ ***implementation reqs***
8
+ - The :Receipt: concept must be implemented.
9
+
10
+ ***functional specs***
11
+ - Implement the :Receipt: concept.
@@ -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
@@ -0,0 +1,179 @@
1
+ import re
2
+
3
+ import pytest
4
+
5
+ from plain_parser import concept_utils
6
+ from plain_parser.exceptions import PlainSyntaxError
7
+
8
+
9
+ def definitions(*markdown: str) -> list[dict]:
10
+ return [{"markdown": line} for line in markdown]
11
+
12
+
13
+ def defined_concepts(sorted_definitions: list[dict]) -> list[str]:
14
+ return [concept_utils.extract_concepts_from_definition(d["markdown"])[0][0] for d in sorted_definitions]
15
+
16
+
17
+ def test_sort_single_definition_is_unchanged():
18
+ defs = definitions("- :A: is x.")
19
+ concept_utils.sort_definitions(defs)
20
+ assert defined_concepts(defs) == [":A:"]
21
+
22
+
23
+ def test_sort_definitions_without_concept_references_keeps_file_order():
24
+ defs = definitions("- :B: is y.", "- :A: is x.")
25
+ concept_utils.sort_definitions(defs)
26
+ assert defined_concepts(defs) == [":B:", ":A:"]
27
+
28
+
29
+ def test_sort_places_dependency_before_dependant():
30
+ defs = definitions("- :B: uses :A:.", "- :A: is x.")
31
+ concept_utils.sort_definitions(defs)
32
+ assert defined_concepts(defs) == [":A:", ":B:"]
33
+
34
+
35
+ def test_sort_resolves_diamond_dependencies():
36
+ defs = definitions("- :D: uses :B: and :C:.", "- :B: uses :A:.", "- :C: uses :A:.", "- :A: is x.")
37
+ concept_utils.sort_definitions(defs)
38
+ order = defined_concepts(defs)
39
+ assert order.index(":A:") < order.index(":B:") < order.index(":D:")
40
+ assert order.index(":A:") < order.index(":C:") < order.index(":D:")
41
+
42
+
43
+ def test_sort_follows_references_in_sub_bullets():
44
+ defs = definitions("- :B: is y.\n - it uses :A:.", "- :A: is x.")
45
+ concept_utils.sort_definitions(defs)
46
+ assert defined_concepts(defs) == [":A:", ":B:"]
47
+
48
+
49
+ def test_sort_places_multi_concept_definition_before_its_dependants():
50
+ defs = definitions("- :C: uses :A: and :B:.", "- :A:, :B: are twins.")
51
+ concept_utils.sort_definitions(defs)
52
+ assert defined_concepts(defs) == [":A:", ":C:"]
53
+
54
+
55
+ def test_sort_ignores_self_reference():
56
+ defs = definitions("- :B: uses :A:.", "- :A: is like :A:.")
57
+ concept_utils.sort_definitions(defs)
58
+ assert defined_concepts(defs) == [":A:", ":B:"]
59
+
60
+
61
+ def test_sort_ignores_predefined_concepts():
62
+ defs = definitions("- :B: uses :A: and :Implementation:.", "- :A: is x.")
63
+ concept_utils.sort_definitions(defs)
64
+ assert defined_concepts(defs) == [":A:", ":B:"]
65
+
66
+
67
+ def test_sort_rejects_two_node_cycle_and_lists_both_definitions():
68
+ defs = definitions("- :A: uses :B:.", "- :B: uses :A:.")
69
+ with pytest.raises(PlainSyntaxError, match="Cycles are not allowed") as exc_info:
70
+ concept_utils.sort_definitions(defs)
71
+ assert "- :A: uses :B:." in str(exc_info.value)
72
+ assert "- :B: uses :A:." in str(exc_info.value)
73
+
74
+
75
+ def test_sort_rejects_long_cycle_and_lists_every_definition_on_it():
76
+ defs = definitions("- :A: uses :B:.", "- :B: uses :C:.", "- :C: uses :A:.", "- :D: is on its own.")
77
+ with pytest.raises(PlainSyntaxError) as exc_info:
78
+ concept_utils.sort_definitions(defs)
79
+ message = str(exc_info.value)
80
+ for definition in ("- :A: uses :B:.", "- :B: uses :C:.", "- :C: uses :A:."):
81
+ assert definition in message
82
+ assert "- :D: is on its own." not in message
83
+
84
+
85
+ def test_sort_rejects_cycle_through_multi_concept_definition():
86
+ defs = definitions("- :A:, :B: use :C:.", "- :C: uses :B:.")
87
+ with pytest.raises(PlainSyntaxError, match="Cycles are not allowed"):
88
+ concept_utils.sort_definitions(defs)
89
+
90
+
91
+ def test_sort_rejects_graph_with_two_separate_cycles_and_reports_both():
92
+ defs = definitions("- :A: uses :B:.", "- :B: uses :A:.", "- :X: uses :Y:.", "- :Y: uses :X:.")
93
+ with pytest.raises(PlainSyntaxError, match="Found 2 cycle") as exc_info:
94
+ concept_utils.sort_definitions(defs)
95
+ message = str(exc_info.value)
96
+ assert "Cycle 1: :A: -> :B: -> :A:" in message
97
+ assert "Cycle 2: :X: -> :Y: -> :X:" in message
98
+
99
+
100
+ def test_sort_reports_every_elementary_cycle_of_a_tangle():
101
+ defs = definitions(
102
+ "- :Order: is placed by a :Customer: and billed by an :Invoice:.",
103
+ "- :Customer: has :Order: items and :Invoice: items.",
104
+ "- :Invoice: bills an :Order: to a :Customer:.",
105
+ )
106
+ with pytest.raises(PlainSyntaxError, match="Found 5 cycle") as exc_info:
107
+ concept_utils.sort_definitions(defs)
108
+ assert str(exc_info.value).count("Cycle ") == 5
109
+
110
+
111
+ def test_sort_caps_the_number_of_reported_cycles():
112
+ defs = definitions(
113
+ "- :Order: is placed by a :Customer:, billed by an :Invoice:, settled by a :Payment:.",
114
+ "- :Customer: has :Order:, :Invoice: and :Payment: items.",
115
+ "- :Invoice: bills an :Order: to a :Customer: and is settled by a :Payment:.",
116
+ "- :Payment: settles an :Invoice: for an :Order: by a :Customer:.",
117
+ )
118
+ with pytest.raises(PlainSyntaxError, match="Found more than 10 cycles") as exc_info:
119
+ concept_utils.sort_definitions(defs)
120
+ message = str(exc_info.value)
121
+ assert "Showing the first 10." in message
122
+ assert message.count("Cycle ") == 10
123
+
124
+
125
+ def test_sort_reports_cycle_in_reference_order():
126
+ defs = definitions("- :A: uses :B:.", "- :B: uses :C:.", "- :C: uses :A:.")
127
+ with pytest.raises(PlainSyntaxError) as exc_info:
128
+ concept_utils.sort_definitions(defs)
129
+ assert "Cycle 1: :A: -> :B: -> :C: -> :A:\n- :A: uses :B:.\n- :B: uses :C:.\n- :C: uses :A:." in str(exc_info.value)
130
+
131
+
132
+ def test_sort_rejects_cycle_that_also_references_undefined_concept():
133
+ defs = definitions("- :A: uses :B: and :Ghost:.", "- :B: uses :A:.")
134
+ with pytest.raises(PlainSyntaxError, match="Cycles are not allowed"):
135
+ concept_utils.sort_definitions(defs)
136
+
137
+
138
+ def separate_two_cycles(count: int) -> list[str]:
139
+ return [line for i in range(count) for line in (f"- :A{i}: uses :B{i}:.", f"- :B{i}: uses :A{i}:.")]
140
+
141
+
142
+ def cycle_lines(message: str) -> list[str]:
143
+ return [line for line in message.splitlines() if line.startswith("Cycle ")]
144
+
145
+
146
+ def test_sort_reports_exactly_the_maximum_without_truncating():
147
+ with pytest.raises(PlainSyntaxError) as exc_info:
148
+ concept_utils.sort_definitions(definitions(*separate_two_cycles(10)))
149
+ message = str(exc_info.value)
150
+ assert "Found 10 cycle(s)" in message
151
+ assert "Showing" not in message
152
+ assert len(cycle_lines(message)) == 10
153
+
154
+
155
+ def test_sort_truncated_report_shows_the_earliest_defined_cycles():
156
+ with pytest.raises(PlainSyntaxError) as exc_info:
157
+ concept_utils.sort_definitions(definitions(*separate_two_cycles(12)))
158
+ lines = cycle_lines(str(exc_info.value))
159
+ assert lines[0] == "Cycle 1: :A0: -> :B0: -> :A0:"
160
+ assert lines[-1] == "Cycle 10: :A9: -> :B9: -> :A9:"
161
+
162
+
163
+ def test_sort_truncation_follows_definition_order_not_first_mention():
164
+ # :A10: and :A11: are mentioned on the first line but defined last, so their cycles are not among the first 10
165
+ defs = separate_two_cycles(12)
166
+ defs[0] = "- :A0: uses :B0:, :A10: and :A11:."
167
+ with pytest.raises(PlainSyntaxError) as exc_info:
168
+ concept_utils.sort_definitions(definitions(*defs))
169
+ lines = cycle_lines(str(exc_info.value))
170
+ assert lines[-1] == "Cycle 10: :A9: -> :B9: -> :A9:"
171
+ assert not [line for line in lines if re.search(r":A1[01]:", line)]
172
+
173
+
174
+ def test_sort_prints_a_shared_definition_once_per_cycle():
175
+ defs = definitions("- :A:, :B: use :X: and :Y:.", "- :X: uses :B:.", "- :Y: uses :A:.")
176
+ with pytest.raises(PlainSyntaxError) as exc_info:
177
+ concept_utils.sort_definitions(defs)
178
+ blocks = str(exc_info.value).split("\n\n")[1:]
179
+ assert blocks and all(block.count("- :A:, :B: use :X: and :Y:.") == 1 for block in blocks)