periplus-map 0.2.1__py3-none-any.whl

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 (272) hide show
  1. periplus/__init__.py +70 -0
  2. periplus/checkmap.py +80 -0
  3. periplus/cli.py +658 -0
  4. periplus/contract/schema/map.schema.json +138 -0
  5. periplus/contract/schema/navigation.schema.json +200 -0
  6. periplus/contract/schema/pack-file.schema.json +1405 -0
  7. periplus/contract/schema/pack-manifest.schema.json +245 -0
  8. periplus/contract/schema/project-settings.schema.json +85 -0
  9. periplus/contract/spec/engine.md +151 -0
  10. periplus/contract/spec/rules.md +352 -0
  11. periplus/detect.py +228 -0
  12. periplus/engine/__init__.py +5 -0
  13. periplus/engine/check.py +481 -0
  14. periplus/engine/emit.py +368 -0
  15. periplus/engine/packload.py +2321 -0
  16. periplus/engine/pattern.py +27 -0
  17. periplus/engine/rules.py +1946 -0
  18. periplus/engine/select.py +491 -0
  19. periplus/engine/source.py +172 -0
  20. periplus/engine/tree.py +424 -0
  21. periplus/errors.py +332 -0
  22. periplus/init.py +358 -0
  23. periplus/layout.py +28 -0
  24. periplus/manifest.py +498 -0
  25. periplus/map.py +364 -0
  26. periplus/navigate/__init__.py +1 -0
  27. periplus/navigate/find.py +143 -0
  28. periplus/navigate/ids.py +30 -0
  29. periplus/navigate/listing.py +42 -0
  30. periplus/navigate/mapfile.py +189 -0
  31. periplus/navigate/path.py +175 -0
  32. periplus/navigate/show.py +175 -0
  33. periplus/navigate/types.py +83 -0
  34. periplus/navigate/walk.py +138 -0
  35. periplus/packs/advancedqueue_basic@0.0.1/pack.yaml +14 -0
  36. periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type.yaml +9 -0
  37. periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type_annotation.yaml +43 -0
  38. periplus/packs/advancedqueue_basic@0.0.1/queue_job_type/queue_job_type_attribute.yaml +43 -0
  39. periplus/packs/ai_basic@0.0.1/ai_agent/ai_agent.yaml +8 -0
  40. periplus/packs/ai_basic@0.0.1/ai_agent/ai_agent_attribute.yaml +43 -0
  41. periplus/packs/ai_basic@0.0.1/ai_function_call/ai_function_call.yaml +8 -0
  42. periplus/packs/ai_basic@0.0.1/ai_function_call/ai_function_call_attribute.yaml +43 -0
  43. periplus/packs/ai_basic@0.0.1/pack.yaml +15 -0
  44. periplus/packs/config_pages_basic@0.0.1/bundle/config_pages_bundle_config.yaml +54 -0
  45. periplus/packs/config_pages_basic@0.0.1/pack.yaml +11 -0
  46. periplus/packs/crop_basic@0.0.1/bundle/crop_bundle_config.yaml +54 -0
  47. periplus/packs/crop_basic@0.0.1/pack.yaml +11 -0
  48. periplus/packs/drupal@0.1.0/block/block_placement.yaml +25 -0
  49. periplus/packs/drupal@0.1.0/block/block_placement_code.yaml +58 -0
  50. periplus/packs/drupal@0.1.0/block/block_placement_config.yaml +53 -0
  51. periplus/packs/drupal@0.1.0/block/block_plugin.yaml +52 -0
  52. periplus/packs/drupal@0.1.0/block/block_plugin_altered.yaml +61 -0
  53. periplus/packs/drupal@0.1.0/block/block_plugin_annotation.yaml +69 -0
  54. periplus/packs/drupal@0.1.0/block/block_plugin_attribute.yaml +56 -0
  55. periplus/packs/drupal@0.1.0/block/block_plugin_content.yaml +46 -0
  56. periplus/packs/drupal@0.1.0/block/block_plugin_derived.yaml +110 -0
  57. periplus/packs/drupal@0.1.0/block/block_plugin_deriver.yaml +30 -0
  58. periplus/packs/drupal@0.1.0/block/block_type.yaml +14 -0
  59. periplus/packs/drupal@0.1.0/block/block_type_code.yaml +43 -0
  60. periplus/packs/drupal@0.1.0/block/block_type_config.yaml +45 -0
  61. periplus/packs/drupal@0.1.0/cache/cache.yaml +18 -0
  62. periplus/packs/drupal@0.1.0/cache/cache_backend.yaml +31 -0
  63. periplus/packs/drupal@0.1.0/component/sdc.yaml +19 -0
  64. periplus/packs/drupal@0.1.0/component/sdc_definition.yaml +25 -0
  65. periplus/packs/drupal@0.1.0/component/sdc_use.yaml +25 -0
  66. periplus/packs/drupal@0.1.0/config/config_object.yaml +65 -0
  67. periplus/packs/drupal@0.1.0/display/display.yaml +32 -0
  68. periplus/packs/drupal@0.1.0/display/display_code.yaml +53 -0
  69. periplus/packs/drupal@0.1.0/display/display_config.yaml +95 -0
  70. periplus/packs/drupal@0.1.0/entity/entity.yaml +33 -0
  71. periplus/packs/drupal@0.1.0/entity/entity_class.yaml +60 -0
  72. periplus/packs/drupal@0.1.0/entity/entity_save.yaml +27 -0
  73. periplus/packs/drupal@0.1.0/entity/entity_type_annotation.yaml +85 -0
  74. periplus/packs/drupal@0.1.0/entity/entity_type_attribute.yaml +76 -0
  75. periplus/packs/drupal@0.1.0/field/field_instance_code.yaml +40 -0
  76. periplus/packs/drupal@0.1.0/field/field_instance_config.yaml +58 -0
  77. periplus/packs/drupal@0.1.0/field/field_storage.yaml +37 -0
  78. periplus/packs/drupal@0.1.0/field/field_storage_code.yaml +39 -0
  79. periplus/packs/drupal@0.1.0/field/field_storage_config.yaml +24 -0
  80. periplus/packs/drupal@0.1.0/form/form.yaml +48 -0
  81. periplus/packs/drupal@0.1.0/form/form_class.yaml +50 -0
  82. periplus/packs/drupal@0.1.0/form/form_id.yaml +48 -0
  83. periplus/packs/drupal@0.1.0/form/form_methods.yaml +39 -0
  84. periplus/packs/drupal@0.1.0/hook/hook.yaml +8 -0
  85. periplus/packs/drupal@0.1.0/hook/hook_attribute.yaml +28 -0
  86. periplus/packs/drupal@0.1.0/module/module.yaml +8 -0
  87. periplus/packs/drupal@0.1.0/module/module_info.yaml +50 -0
  88. periplus/packs/drupal@0.1.0/pack.yaml +184 -0
  89. periplus/packs/drupal@0.1.0/plugin/plugin.yaml +17 -0
  90. periplus/packs/drupal@0.1.0/plugin/plugin_annotation.yaml +120 -0
  91. periplus/packs/drupal@0.1.0/recommendations/recommendations.yaml +14 -0
  92. periplus/packs/drupal@0.1.0/service/service.yaml +19 -0
  93. periplus/packs/drupal@0.1.0/service/service_autowire.yaml +28 -0
  94. periplus/packs/drupal@0.1.0/service/service_definition.yaml +24 -0
  95. periplus/packs/drupal@0.1.0/service/service_use.yaml +43 -0
  96. periplus/packs/drupal_basic@0.3.0/action/action.yaml +9 -0
  97. periplus/packs/drupal_basic@0.3.0/action/action_annotation.yaml +43 -0
  98. periplus/packs/drupal_basic@0.3.0/action/action_attribute.yaml +43 -0
  99. periplus/packs/drupal_basic@0.3.0/block/block_placement.yaml +16 -0
  100. periplus/packs/drupal_basic@0.3.0/block/block_placement_config.yaml +51 -0
  101. periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin.yaml +14 -0
  102. periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin_annotation.yaml +47 -0
  103. periplus/packs/drupal_basic@0.3.0/block_plugin/block_plugin_attribute.yaml +43 -0
  104. periplus/packs/drupal_basic@0.3.0/block_type/block_type.yaml +8 -0
  105. periplus/packs/drupal_basic@0.3.0/block_type/block_type_config.yaml +54 -0
  106. periplus/packs/drupal_basic@0.3.0/boundary/boundary_types.yaml +19 -0
  107. periplus/packs/drupal_basic@0.3.0/bundle/bundle.yaml +20 -0
  108. periplus/packs/drupal_basic@0.3.0/bundle/bundle_config.yaml +393 -0
  109. periplus/packs/drupal_basic@0.3.0/call/call.yaml +349 -0
  110. periplus/packs/drupal_basic@0.3.0/component/component.yaml +30 -0
  111. periplus/packs/drupal_basic@0.3.0/config/config_object.yaml +119 -0
  112. periplus/packs/drupal_basic@0.3.0/constraint/constraint.yaml +8 -0
  113. periplus/packs/drupal_basic@0.3.0/constraint/constraint_annotation.yaml +43 -0
  114. periplus/packs/drupal_basic@0.3.0/constraint/constraint_attribute.yaml +43 -0
  115. periplus/packs/drupal_basic@0.3.0/display/display.yaml +30 -0
  116. periplus/packs/drupal_basic@0.3.0/display/display_config.yaml +203 -0
  117. periplus/packs/drupal_basic@0.3.0/display/display_mode_config.yaml +60 -0
  118. periplus/packs/drupal_basic@0.3.0/entity_type/config_entity_type_annotation.yaml +46 -0
  119. periplus/packs/drupal_basic@0.3.0/entity_type/config_entity_type_attribute.yaml +43 -0
  120. periplus/packs/drupal_basic@0.3.0/entity_type/content_entity_type_annotation.yaml +46 -0
  121. periplus/packs/drupal_basic@0.3.0/entity_type/content_entity_type_attribute.yaml +43 -0
  122. periplus/packs/drupal_basic@0.3.0/entity_type/entity_type.yaml +16 -0
  123. periplus/packs/drupal_basic@0.3.0/field/field_instance_config.yaml +116 -0
  124. periplus/packs/drupal_basic@0.3.0/field/field_storage.yaml +21 -0
  125. periplus/packs/drupal_basic@0.3.0/field/field_storage_config.yaml +45 -0
  126. periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter.yaml +8 -0
  127. periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter_annotation.yaml +46 -0
  128. periplus/packs/drupal_basic@0.3.0/field_formatter/field_formatter_attribute.yaml +43 -0
  129. periplus/packs/drupal_basic@0.3.0/field_type/field_type_annotation.yaml +46 -0
  130. periplus/packs/drupal_basic@0.3.0/field_type/field_type_attribute.yaml +50 -0
  131. periplus/packs/drupal_basic@0.3.0/field_widget/field_widget_annotation.yaml +46 -0
  132. periplus/packs/drupal_basic@0.3.0/field_widget/field_widget_attribute.yaml +50 -0
  133. periplus/packs/drupal_basic@0.3.0/form/form_id.yaml +80 -0
  134. periplus/packs/drupal_basic@0.3.0/hook/hook.yaml +100 -0
  135. periplus/packs/drupal_basic@0.3.0/hook/hook_attribute.yaml +64 -0
  136. periplus/packs/drupal_basic@0.3.0/hook/hook_theme_include.yaml +34 -0
  137. periplus/packs/drupal_basic@0.3.0/library/library.yaml +12 -0
  138. periplus/packs/drupal_basic@0.3.0/library/library_definition.yaml +58 -0
  139. periplus/packs/drupal_basic@0.3.0/local_action/local_action.yaml +12 -0
  140. periplus/packs/drupal_basic@0.3.0/local_action/local_action_definition.yaml +49 -0
  141. periplus/packs/drupal_basic@0.3.0/mail/mail.yaml +8 -0
  142. periplus/packs/drupal_basic@0.3.0/mail/mail_attribute.yaml +43 -0
  143. periplus/packs/drupal_basic@0.3.0/media_source/media_source.yaml +8 -0
  144. periplus/packs/drupal_basic@0.3.0/media_source/media_source_annotation.yaml +43 -0
  145. periplus/packs/drupal_basic@0.3.0/media_source/media_source_attribute.yaml +79 -0
  146. periplus/packs/drupal_basic@0.3.0/menu_link/menu_link.yaml +18 -0
  147. periplus/packs/drupal_basic@0.3.0/menu_link/menu_link_definition.yaml +48 -0
  148. periplus/packs/drupal_basic@0.3.0/module/module.yaml +7 -0
  149. periplus/packs/drupal_basic@0.3.0/module/module_info.yaml +58 -0
  150. periplus/packs/drupal_basic@0.3.0/pack.yaml +528 -0
  151. periplus/packs/drupal_basic@0.3.0/permission/permission.yaml +7 -0
  152. periplus/packs/drupal_basic@0.3.0/permission/permission_definition.yaml +27 -0
  153. periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker.yaml +9 -0
  154. periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker_annotation.yaml +43 -0
  155. periplus/packs/drupal_basic@0.3.0/queue_worker/queue_worker_attribute.yaml +43 -0
  156. periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource.yaml +8 -0
  157. periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource_annotation.yaml +43 -0
  158. periplus/packs/drupal_basic@0.3.0/rest_resource/rest_resource_attribute.yaml +43 -0
  159. periplus/packs/drupal_basic@0.3.0/role/role.yaml +14 -0
  160. periplus/packs/drupal_basic@0.3.0/role/role_config.yaml +44 -0
  161. periplus/packs/drupal_basic@0.3.0/route/route.yaml +42 -0
  162. periplus/packs/drupal_basic@0.3.0/route/route_definition.yaml +113 -0
  163. periplus/packs/drupal_basic@0.3.0/route/route_options.yaml +55 -0
  164. periplus/packs/drupal_basic@0.3.0/route/route_requirements.yaml +108 -0
  165. periplus/packs/drupal_basic@0.3.0/service/service.yaml +56 -0
  166. periplus/packs/drupal_basic@0.3.0/service/service_definition.yaml +181 -0
  167. periplus/packs/drupal_basic@0.3.0/template/template.yaml +86 -0
  168. periplus/packs/drupal_basic@0.3.0/template/template_route_link.yaml +30 -0
  169. periplus/packs/drupal_basic@0.3.0/theme/theme.yaml +15 -0
  170. periplus/packs/drupal_basic@0.3.0/theme/theme_info.yaml +61 -0
  171. periplus/packs/drupal_basic@0.3.0/theme_hook/template_suggestion.yaml +29 -0
  172. periplus/packs/drupal_basic@0.3.0/theme_hook/theme_hook.yaml +87 -0
  173. periplus/packs/drupal_basic@0.3.0/theme_hook/theme_hook_render.yaml +20 -0
  174. periplus/packs/drupal_basic@0.3.0/url/url.yaml +88 -0
  175. periplus/packs/drupal_basic@0.3.0/views_plugin/views_field_attribute.yaml +80 -0
  176. periplus/packs/drupal_basic@0.3.0/views_plugin/views_filter_attribute.yaml +80 -0
  177. periplus/packs/drupal_basic@0.3.0/views_plugin/views_plugin.yaml +15 -0
  178. periplus/packs/drupal_basic@0.3.0/views_plugin/views_style_attribute.yaml +80 -0
  179. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment.yaml +12 -0
  180. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_php.yaml +15 -0
  181. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_php_array.yaml +52 -0
  182. periplus/packs/drupal_js_basic@0.0.1/attachment/attachment_in_twig.yaml +26 -0
  183. periplus/packs/drupal_js_basic@0.0.1/attachment/php_function_holder.yaml +13 -0
  184. periplus/packs/drupal_js_basic@0.0.1/behavior/behavior.yaml +198 -0
  185. periplus/packs/drupal_js_basic@0.0.1/pack.yaml +153 -0
  186. periplus/packs/drupal_js_basic@0.0.1/setting/setting.yaml +13 -0
  187. periplus/packs/drupal_js_basic@0.0.1/setting/setting_in_js.yaml +15 -0
  188. periplus/packs/drupal_js_basic@0.0.1/setting/setting_in_php.yaml +92 -0
  189. periplus/packs/drush_basic@0.0.1/command/command.yaml +15 -0
  190. periplus/packs/drush_basic@0.0.1/command/command_annotation.yaml +60 -0
  191. periplus/packs/drush_basic@0.0.1/command/command_attribute.yaml +88 -0
  192. periplus/packs/drush_basic@0.0.1/pack.yaml +29 -0
  193. periplus/packs/eck_basic@0.0.1/bundle/eck_bundle_config.yaml +56 -0
  194. periplus/packs/eck_basic@0.0.1/entity_type/eck_entity_type_config.yaml +26 -0
  195. periplus/packs/eck_basic@0.0.1/pack.yaml +28 -0
  196. periplus/packs/go@0.0.2/interfaces/satisfies.yaml +47 -0
  197. periplus/packs/go@0.0.2/pack.yaml +51 -0
  198. periplus/packs/go@0.0.2/types/find_declarations.yaml +137 -0
  199. periplus/packs/go@0.0.2/types/node_types.yaml +36 -0
  200. periplus/packs/go_basic@0.0.3/pack.yaml +104 -0
  201. periplus/packs/go_basic@0.0.3/rules/calls.yaml +25 -0
  202. periplus/packs/go_basic@0.0.3/rules/declarations.yaml +40 -0
  203. periplus/packs/go_basic@0.0.3/rules/imports.yaml +16 -0
  204. periplus/packs/go_basic@0.0.3/types/node_types.yaml +15 -0
  205. periplus/packs/js_basic@0.0.1/function/function.yaml +68 -0
  206. periplus/packs/js_basic@0.0.1/pack.yaml +50 -0
  207. periplus/packs/laravel_basic@0.0.5/pack.yaml +313 -0
  208. periplus/packs/laravel_basic@0.0.5/rules/classes.yaml +42 -0
  209. periplus/packs/laravel_basic@0.0.5/rules/config.yaml +229 -0
  210. periplus/packs/laravel_basic@0.0.5/rules/routes.yaml +210 -0
  211. periplus/packs/laravel_basic@0.0.5/rules/types.yaml +50 -0
  212. periplus/packs/laravel_basic@0.0.5/rules/views.yaml +100 -0
  213. periplus/packs/paragraphs_basic@0.0.1/bundle/paragraph_bundle_config.yaml +54 -0
  214. periplus/packs/paragraphs_basic@0.0.1/pack.yaml +12 -0
  215. periplus/packs/php@0.1.0/edges/edge_kinds.yaml +64 -0
  216. periplus/packs/php@0.1.0/edges/find_calls.yaml +34 -0
  217. periplus/packs/php@0.1.0/edges/find_imports.yaml +18 -0
  218. periplus/packs/php@0.1.0/edges/find_member_access.yaml +22 -0
  219. periplus/packs/php@0.1.0/edges/find_structure.yaml +38 -0
  220. periplus/packs/php@0.1.0/embedded/embedded_regions.yaml +19 -0
  221. periplus/packs/php@0.1.0/imports/imports.yaml +20 -0
  222. periplus/packs/php@0.1.0/pack.yaml +117 -0
  223. periplus/packs/php@0.1.0/resolution/excluded_tokens.yaml +23 -0
  224. periplus/packs/php@0.1.0/resolution/resolution.yaml +35 -0
  225. periplus/packs/php@0.1.0/types/find_declarations.yaml +102 -0
  226. periplus/packs/php@0.1.0/types/node_types.yaml +55 -0
  227. periplus/packs/php_basic@0.2.0/edges/edge_kinds.yaml +48 -0
  228. periplus/packs/php_basic@0.2.0/edges/find_structure.yaml +750 -0
  229. periplus/packs/php_basic@0.2.0/imports/imports.yaml +19 -0
  230. periplus/packs/php_basic@0.2.0/pack.yaml +94 -0
  231. periplus/packs/php_basic@0.2.0/types/find_declarations.yaml +267 -0
  232. periplus/packs/php_basic@0.2.0/types/node_types.yaml +55 -0
  233. periplus/packs/profile_basic@0.0.1/bundle/profile_bundle_config.yaml +54 -0
  234. periplus/packs/profile_basic@0.0.1/pack.yaml +12 -0
  235. periplus/packs/salesforce_basic@0.0.1/event/salesforce_event.yaml +36 -0
  236. periplus/packs/salesforce_basic@0.0.1/pack.yaml +31 -0
  237. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping/salesforce_mapping.yaml +19 -0
  238. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping/salesforce_mapping_config.yaml +83 -0
  239. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping_field/salesforce_mapping_field.yaml +8 -0
  240. periplus/packs/salesforce_basic@0.0.1/salesforce_mapping_field/salesforce_mapping_field_annotation.yaml +43 -0
  241. periplus/packs/twig@0.0.1/pack.yaml +26 -0
  242. periplus/packs/twig@0.0.1/types/template.yaml +57 -0
  243. periplus/packs/twig_basic@0.1.0/pack.yaml +11 -0
  244. periplus/packs/twig_tweak_basic@0.0.1/pack.yaml +27 -0
  245. periplus/packs/twig_tweak_basic@0.0.1/rules/block.yaml +20 -0
  246. periplus/packs/twig_tweak_basic@0.0.1/rules/entity.yaml +35 -0
  247. periplus/packs/twig_tweak_basic@0.0.1/rules/field.yaml +21 -0
  248. periplus/packs/twig_tweak_basic@0.0.1/rules/menu.yaml +21 -0
  249. periplus/packs/webform_basic@0.0.1/bundle/webform_submission_bundle_config.yaml +54 -0
  250. periplus/packs/webform_basic@0.0.1/pack.yaml +20 -0
  251. periplus/packs/webform_basic@0.0.1/webform_handler/webform_handler.yaml +8 -0
  252. periplus/packs/webform_basic@0.0.1/webform_handler/webform_handler_annotation.yaml +43 -0
  253. periplus/packs/yaml@0.0.1/pack.yaml +72 -0
  254. periplus/packs/yaml@0.0.1/types/find_mapping_entry.yaml +21 -0
  255. periplus/packs/yaml@0.0.1/types/yaml_types.yaml +44 -0
  256. periplus/packs/yaml_basic@0.1.0/pack.yaml +10 -0
  257. periplus/packs.py +447 -0
  258. periplus/preflight.py +102 -0
  259. periplus/py.typed +0 -0
  260. periplus/report.py +596 -0
  261. periplus/resolution.py +355 -0
  262. periplus/settings.py +617 -0
  263. periplus/settings.stub.yml +99 -0
  264. periplus/spec.py +774 -0
  265. periplus/update.py +197 -0
  266. periplus/validate.py +655 -0
  267. periplus_map-0.2.1.dist-info/METADATA +566 -0
  268. periplus_map-0.2.1.dist-info/RECORD +272 -0
  269. periplus_map-0.2.1.dist-info/WHEEL +4 -0
  270. periplus_map-0.2.1.dist-info/entry_points.txt +2 -0
  271. periplus_map-0.2.1.dist-info/licenses/LICENSE +202 -0
  272. periplus_map-0.2.1.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,2321 @@
1
+ """Step one: read every rule file of the loaded packs into typed records and the type table."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass, field, replace
8
+ from typing import Any
9
+
10
+ from periplus.engine.pattern import compile_pattern
11
+ from periplus.engine.select import FileTypes, FolderUnresolved, holds_double_star
12
+ from periplus.engine.tree import STEPS, Grammar, ImportRule, Step, _parser
13
+ from periplus.errors import ExitCode, HarnessError, Problem
14
+ from periplus.manifest import MANIFEST_FILENAME, LoadedPack
15
+ from periplus.validate import MANIFEST_SCHEMA, PACK_FILE_SCHEMA, _load, _schema, _yaml_files
16
+
17
+ __all__ = [
18
+ "PATTERN_VALUE",
19
+ "PLACEHOLDER",
20
+ "TEXT",
21
+ "BoundaryEntry",
22
+ "Condition",
23
+ "EXECUTED_KEYS",
24
+ "UNEXECUTED_KEYS",
25
+ "EdgeKind",
26
+ "EdgeSpec",
27
+ "FileInfo",
28
+ "HasKey",
29
+ "IdMatches",
30
+ "KeyEquals",
31
+ "PackRulesUnreadable",
32
+ "Rule",
33
+ "RuleSet",
34
+ "TypeInfo",
35
+ "Value",
36
+ "load_rules",
37
+ "namespace_of",
38
+ "namespaces",
39
+ "node_types",
40
+ "twin_type",
41
+ "unexecuted",
42
+ ]
43
+
44
+ #: The prefix of a rule's key paths.
45
+ _RULE = "file.rules.*."
46
+
47
+ #: The key paths, below their holder, of resolution steps and the separator beside them.
48
+ _RESOLVE = (
49
+ *(".resolve", ".resolve.*.full_if_prefixed", ".resolve.*.prefix_with"),
50
+ *(".resolve.*.prefix_bare_with", ".separator"),
51
+ )
52
+
53
+ #: The key paths, below a rule's id or edge end, of the steps that look a name up in a table.
54
+ _TABLE_STEPS = (".resolve.*.lookup_last_segment_in",)
55
+
56
+ #: Every key path of the two pack schemas that the engine or the pack check executes. ``*`` is one
57
+ #: key or item. A path starts with the document it sits in: ``manifest.`` or ``file.``.
58
+ EXECUTED_KEYS = frozenset(
59
+ {
60
+ *(f"manifest.{k}" for k in ("pack", "version", "depends", "folders", "identity")),
61
+ "manifest.tables",
62
+ *(f"manifest.boundary{k}" for k in ("", ".*.type", ".*.ancestry", ".*.names")),
63
+ *(f"manifest.identity.call_{k}" for k in ("arguments", "argument", "argument_name")),
64
+ *(f"manifest.identity.call_literal{k}" for k in ("s", "_content", "_delimiters")),
65
+ *(f"manifest.detect{k}" for k in ("", ".any_of", ".any_of.*.path", ".any_of.*.contains")),
66
+ *(
67
+ f"manifest.grammar{k}"
68
+ for k in ("", ".provider", ".provider_version", ".language", ".grammar_version")
69
+ ),
70
+ *(
71
+ f"manifest.grammar.constant_access{k}"
72
+ for k in ("", ".node", ".scope", ".name", ".enclosing")
73
+ ),
74
+ *(f"manifest.grammar.constant_declaration{k}" for k in ("", ".node", ".name", ".value")),
75
+ *(
76
+ f"manifest.files{k}"
77
+ for k in (
78
+ *("", ".extends", ".extensions", ".add_extensions", ".exclude", ".add_excludes"),
79
+ *(".types", ".types.*.endings", ".types.*.names", ".types.*.reader"),
80
+ ".types.*.format",
81
+ *(f".types.*.path_values{k}" for k in ("", ".*.pattern", ".*.template")),
82
+ ".types.*.path_values.*.relative_to",
83
+ *(f".types.*.path_values.*{k}" for k in (".case", ".normalize")),
84
+ *(f".types.*.path_values.*.normalize.*{k}" for k in (".replace", ".with")),
85
+ *(f".types.*.sections{k}" for k in ("", ".*.name", ".*.open", ".*.close")),
86
+ *(f".types.*.spans{k}" for k in ("", ".*.name", ".*.open", ".*.balance")),
87
+ *(f".types.*{k}" for k in _RESOLVE),
88
+ )
89
+ ),
90
+ *(f"file.needs{k}" for k in ("", ".types", ".roles")),
91
+ *("file.roles", "file.roles.*.name"),
92
+ *(f"file.edge_kinds{k}" for k in ("", ".*.kind", ".*.from", ".*.to")),
93
+ *(
94
+ f"file.node_types{k}"
95
+ for k in ("", ".*.name", ".*.parent", ".*.abstract", ".*.id_namespace")
96
+ ),
97
+ *(f"file.imports{k}" for k in ("", ".*.ast", ".*.group_ast", ".*.clause", ".*.facts")),
98
+ *(f"file.imports.*.bind{k}" for k in ("", ".name", ".prefix_from_group", ".strip_prefix")),
99
+ "file.imports.*.bind.skip_when_field_set",
100
+ *(f"file.imports.*.bind{k}" for k in (".unbound_aliases", ".normalize")),
101
+ *(f"file.imports.*.bind.normalize.*{k}" for k in (".replace", ".with")),
102
+ "file.rules",
103
+ *(f"{_RULE}{k}" for k in ("rule", "reads", "in", "confidence", "match", "id", "emits")),
104
+ *(f"{_RULE}line{k}" for k in ("", ".capture")),
105
+ *(f"{_RULE}values{k}" for k in ("", ".*.from", ".*.from.capture", ".*.template")),
106
+ *(f"{_RULE}values.*{k}" for k in (".from.enclosing", ".join")),
107
+ *(
108
+ f"{_RULE}values.*{k}"
109
+ for k in (".normalize", ".normalize.*.replace", ".normalize.*.with")
110
+ ),
111
+ f"{_RULE}values.*.case",
112
+ *(
113
+ f"{_RULE}match.{k}"
114
+ for k in (
115
+ *("file", "ending", "filetype", "each", "where", "declaration", "name_child"),
116
+ *("reference", "skip_names", "whole_written"),
117
+ *("body_child", "attribute", "annotation", "text", "reads_sections"),
118
+ *("node_type", "follow"),
119
+ )
120
+ ),
121
+ *(f"{_RULE}match.follow.*.{k}" for k in ("edge", "ancestors", "node", "id_matches")),
122
+ *(
123
+ f"{_RULE}match.where.*.{k}"
124
+ for k in ("key", "has_key", "id_matches", "is_mapping", "applies_to", "has_child")
125
+ ),
126
+ *(f"{_RULE}match.where.*.name_matches{k}" for k in ("", ".child", ".pattern")),
127
+ *(
128
+ f"{_RULE}id.{k}"
129
+ for k in (
130
+ *("from", "from.capture", "from.attribute_argument", "from.annotation_key"),
131
+ *("from.argument", "from.*.qualified", "from.*.docblock"),
132
+ *("from.*.qualified.docblock", "pattern"),
133
+ *("template", "must_be", "normalize", "normalize.*.replace", "normalize.*.with"),
134
+ )
135
+ ),
136
+ *(f"{_RULE}id{k}" for k in (*_RESOLVE, *_TABLE_STEPS)),
137
+ *(f"{_RULE}emits.*.binding{k}" for k in ("", ".name", ".target")),
138
+ *(f"{_RULE}emits.*.{k}" for k in ("node", "node.type", "attribute", "attribute.name")),
139
+ *(f"{_RULE}emits.*.{k}" for k in ("attribute.from", "attribute.template", "edge")),
140
+ f"{_RULE}emits.*.edge.kind",
141
+ f"{_RULE}emits.*.edge.to.from.attribute_argument",
142
+ f"{_RULE}emits.*.edge.to.from.argument",
143
+ *(
144
+ f"{_RULE}emits.*.edge.{end}{k}"
145
+ for end in ("from", "to")
146
+ for k in (
147
+ *("", ".from", ".template", ".type", ".each", ".pattern", ".where", ".on_miss"),
148
+ *(".where.*.matches", ".where.*.listed_at", ".from.key", ".from.capture"),
149
+ ".where.*.not_matches",
150
+ *(".from.*.key", ".from.*.each", ".types", ".declared", *_RESOLVE),
151
+ *_TABLE_STEPS,
152
+ )
153
+ ),
154
+ }
155
+ )
156
+
157
+
158
+ def _schema_paths(schema: Mapping[str, Any], node: object, path: str) -> set[str]:
159
+ """Each key path below ``path`` the schema node accepts, its ``$defs`` followed. ``$schema``
160
+ names the contract a file is written against and carries no data, so it is left out."""
161
+ if not isinstance(node, Mapping):
162
+ return set()
163
+ if "$ref" in node:
164
+ return _schema_paths(schema, schema["$defs"][node["$ref"].rsplit("/", 1)[-1]], path)
165
+ found: set[str] = set()
166
+ for name, child in node.get("properties", {}).items():
167
+ if name != "$schema":
168
+ found |= {f"{path}.{name}", *_schema_paths(schema, child, f"{path}.{name}")}
169
+ for branch in (*node.get("oneOf", ()), *node.get("anyOf", ())):
170
+ found |= _schema_paths(schema, branch, path)
171
+ for key in ("items", "additionalProperties"):
172
+ found |= _schema_paths(schema, node.get(key), f"{path}.*")
173
+ return found
174
+
175
+
176
+ #: Every other key path the two pack schemas accept. A rule that holds one is not executed; a pack
177
+ #: that holds any other one is reported as a rule the engine cannot execute.
178
+ UNEXECUTED_KEYS: tuple[str, ...] = tuple(
179
+ sorted(
180
+ {
181
+ path
182
+ for name, document in (("manifest", MANIFEST_SCHEMA), ("file", PACK_FILE_SCHEMA))
183
+ for path in _schema_paths(_schema(document), _schema(document), name)
184
+ }
185
+ - EXECUTED_KEYS
186
+ )
187
+ )
188
+
189
+
190
+ def unexecuted(document: object, prefix: str) -> list[str]:
191
+ """The paths of ``UNEXECUTED_KEYS`` that start with ``prefix`` and that the document holds,
192
+ each without the prefix, leaving out a path below another one found. A rule's paths are found
193
+ only under the rule prefix ``file.rules.*.``, since the rule itself is reported."""
194
+ found = [
195
+ path.removeprefix(prefix)
196
+ for path in UNEXECUTED_KEYS
197
+ if path.startswith(prefix)
198
+ and (prefix == _RULE or not path.startswith(_RULE))
199
+ and _holds(document, path.removeprefix(prefix).split("."))
200
+ ]
201
+ return [path for path in found if not any(path.startswith(f"{q}.") for q in found)]
202
+
203
+
204
+ def _holds(value: object, segments: Sequence[str]) -> bool:
205
+ if not segments:
206
+ return True
207
+ head, rest = segments[0], segments[1:]
208
+ if head == "*":
209
+ children = list(value.values()) if isinstance(value, Mapping) else []
210
+ children += value if isinstance(value, list) else []
211
+ return any(_holds(child, rest) for child in children)
212
+ return isinstance(value, Mapping) and head in value and _holds(value[head], rest)
213
+
214
+
215
+ #: The values a reference rule gives its edge ends' templates, beside its name_matches groups.
216
+ _REFERENCE_VALUES = {"enclosing_type", "enclosing_method"}
217
+
218
+ #: The keys of the rule shapes this engine executes. A rule with any other key is not run.
219
+ _RULE_KEYS = {"rule", "reads", "in", "match", "id", "emits", "confidence"}
220
+
221
+ #: The match keys of a rule that reads a parse tree.
222
+ _DECLARATION_MATCH = {
223
+ *("declaration", "name_child", "body_child", "where", "filetype", "file", "ending"),
224
+ }
225
+
226
+ #: The names the engine fills for a declaration rule's id. Any other name its template holds is a
227
+ #: path value, a settings value or a field of the matched node.
228
+ _TREE_FILLED = {
229
+ *("declared_name", "namespace", "qualified_name", "enclosing_type", "enclosing_method"),
230
+ }
231
+
232
+ #: The edge ends of a tree rule that name a node, where the other kind of end is a written name.
233
+ _TREE_ENDS = {"this_node", "enclosing_class"}
234
+
235
+ #: The edge ends of a declaration rule: those, and the node the file's own rule made for it.
236
+ _DECLARATION_ENDS = {*_TREE_ENDS, "enclosing_file", "enclosing_method"}
237
+
238
+ #: The edge ends of an attribute rule that name a node; the other kind is an argument's value.
239
+ _ATTRIBUTE_ENDS = {"this_node", "enclosing_class", "enclosing_method"}
240
+
241
+ #: The edge ends whose value is the whole id another rule minted, namespace and all.
242
+ _WHOLE_ENDS = {"enclosing_declaration", "enclosing_class", "enclosing_method", "declared"}
243
+
244
+ #: A ``{name}`` in an id or edge template.
245
+ PLACEHOLDER = re.compile(r"\{(\w+)\}")
246
+
247
+ #: A ``{name}`` in an id pattern: a path or settings value, filled as literal text. A name starts
248
+ #: with a letter, so a quantifier such as ``{2}`` stays one.
249
+ PATTERN_VALUE = re.compile(r"\{([A-Za-z]\w*)\}")
250
+
251
+ #: The key path an edge source ``text`` reads: an entry whose value is text holds it under this key
252
+ #: alone, which no other key path reaches.
253
+ TEXT = ""
254
+
255
+
256
+ class PackRulesUnreadable(HarnessError):
257
+ """A rule file cannot be read, a type is declared twice, or a rule emits an undeclared type."""
258
+
259
+ code = ExitCode.PACK_RULES_UNREADABLE
260
+
261
+
262
+ @dataclass(frozen=True, slots=True)
263
+ class TypeInfo:
264
+ """One node type: its id namespace, its parent type, and the pack that declared it."""
265
+
266
+ name: str
267
+ id_namespace: str | None
268
+ parent: str | None
269
+ pack: str
270
+
271
+
272
+ @dataclass(frozen=True, slots=True)
273
+ class KeyEquals:
274
+ """A ``where`` condition: a value at the key path equals ``value``, type included, or with
275
+ ``negate`` none does.
276
+
277
+ An absent key equals ``None``.
278
+ """
279
+
280
+ path: str
281
+ value: Any
282
+ negate: bool = False
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class HasKey:
287
+ """A ``where`` condition: the key path reaches something."""
288
+
289
+ path: str
290
+
291
+
292
+ @dataclass(frozen=True, slots=True)
293
+ class IdMatches:
294
+ """A ``where`` condition: the pattern is found in the entry's key or the file's stem."""
295
+
296
+ pattern: re.Pattern[str]
297
+
298
+
299
+ @dataclass(frozen=True, slots=True)
300
+ class IsMapping:
301
+ """A ``where`` condition: whether the entry's value is a mapping, an empty value counting as an
302
+ empty mapping, equals ``value``. A whole file's root is a mapping."""
303
+
304
+ value: bool
305
+
306
+
307
+ Condition = KeyEquals | HasKey | IdMatches | IsMapping
308
+
309
+
310
+ @dataclass(frozen=True, slots=True)
311
+ class Value:
312
+ """A named value: one capture, or a template filled from the values known, then reshaped by
313
+ ``normalize`` and then ``case``, ``lower``, ``upper`` or ``""``. A path value holds the
314
+ ``pattern`` searched in the file's path, and its template is filled from that pattern's
315
+ captures; with ``relative_to``, a folder name, the pattern is searched in the path below the
316
+ deepest directory of that folder that holds the file. ``enclosing`` names a span and one
317
+ capture of its opening: the value joins that capture of each span holding the match with
318
+ ``join``."""
319
+
320
+ name: str
321
+ capture: str
322
+ template: str | None
323
+ normalize: tuple[tuple[re.Pattern[str], str], ...] = ()
324
+ case: str = ""
325
+ pattern: re.Pattern[str] | None = None
326
+ relative_to: str = ""
327
+ enclosing: tuple[str, str] | None = None
328
+ join: str = ""
329
+
330
+
331
+ @dataclass(frozen=True, slots=True)
332
+ class FileInfo:
333
+ """What a declared file type says about its files: the values its path gives, and its
334
+ sections, each a name, an opening pattern and a closing template; and the resolution steps,
335
+ with their separator, an edge end of a text rule takes when it declares none. Last its spans,
336
+ each a name, an opening pattern, and the opening and closing characters it balances."""
337
+
338
+ path_values: tuple[Value, ...] = ()
339
+ sections: tuple[tuple[str, re.Pattern[str], str], ...] = ()
340
+ resolve: tuple[Step, ...] = ()
341
+ separator: str = ""
342
+ spans: tuple[tuple[str, re.Pattern[str], str, str], ...] = ()
343
+
344
+
345
+ @dataclass(frozen=True, slots=True)
346
+ class Rule:
347
+ """One rule. ``supported`` is false for a shape this engine does not execute.
348
+
349
+ ``each`` is the key path whose mapping entries the rule fires on, ``""`` for the file's top
350
+ level, or ``None`` for the whole file. ``id_template`` names ``{file_stem}`` and ``{key}``.
351
+
352
+ A rule that reads a parse tree holds ``declaration``, the tree node type it fires on, and its
353
+ ``id_template`` names the tree sources instead; it is ``""`` for an edge rule with no id, and
354
+ for an id from the call's argument ``id_argument``, a position.
355
+
356
+ A rule that matches an attribute holds ``attribute``, the full name of its class, and
357
+ ``applies_to``, the tree node type the attribute sits on. Its id is the value of the argument
358
+ ``id_argument``, a name or a position, and ``must_be_literal`` says that value must be a text
359
+ literal. Its ``attributes`` pair a name with an argument.
360
+
361
+ A rule that matches a docblock annotation holds ``annotation``, the bare name written after the
362
+ at sign, and ``applies_to``; ``id_argument`` and the second of each ``attributes`` pair name a
363
+ key of the annotation's body.
364
+ """
365
+
366
+ pack: str
367
+ name: str
368
+ supported: bool
369
+ folders: tuple[str, ...]
370
+ glob: str
371
+ filetype: str
372
+ node_type: str
373
+ confidence: str
374
+ where: tuple[Condition, ...] = ()
375
+ normalize: tuple[tuple[re.Pattern[str], str], ...] = ()
376
+ attributes: tuple[tuple[str, str], ...] = ()
377
+ edges: tuple[EdgeSpec, ...] = ()
378
+ reason: str = ""
379
+ twin_type: str = ""
380
+ each: str | None = None
381
+ id_template: str = "{file_stem}"
382
+ #: A declaration rule: the pattern searched in the text of the source ``id_source``, its named
383
+ #: captures joining the values ``id_template`` may name; ``""`` for none.
384
+ id_pattern: str = ""
385
+ id_source: str = ""
386
+ declaration: str = ""
387
+ name_child: str = ""
388
+ #: A declaration rule: the children walked from the match to its name, each by field or node
389
+ #: type, when ``name_child`` is a list; ``name_child`` is then ``""``.
390
+ name_path: tuple[str, ...] = ()
391
+ #: A declaration rule: the pattern of its id's docblock source, whose one named group is the
392
+ #: value, and whether that value is resolved through the file's imports and namespace.
393
+ docblock: re.Pattern[str] | None = None
394
+ docblock_qualified: bool = False
395
+ body_child: str = ""
396
+ #: A declaration rule: the tree node types the match must have as direct children, and the
397
+ #: fields of the matched node its id names.
398
+ has_child: tuple[str, ...] = ()
399
+ fields: tuple[str, ...] = ()
400
+ #: A declaration rule: the fields its id reads resolved through the file's imports.
401
+ qualified: tuple[str, ...] = ()
402
+ #: A declaration or reference rule: each child, by field or node type, and the pattern its
403
+ #: whole text must match.
404
+ name_matches: tuple[tuple[str, re.Pattern[str]], ...] = ()
405
+ attribute: str = ""
406
+ applies_to: str = ""
407
+ id_argument: str = ""
408
+ must_be_literal: bool = False
409
+ annotation: str = ""
410
+ #: A reference rule: the tree node type it fires on, its ``name_child`` the written name's
411
+ #: child, and ``member_child`` the child whose text follows the resolved name, or ``""``.
412
+ #: ``skip_names`` holds the written names that make nothing, and ``whole_written`` says every
413
+ #: node below the written name must be a written name too.
414
+ reference: str = ""
415
+ member_child: str = ""
416
+ skip_names: frozenset[str] = frozenset()
417
+ whole_written: bool = False
418
+ #: A rule that reads its file as text: the pattern it fires on, and the capture of the id.
419
+ #: Its ``attributes`` and each edge's ``key`` name a capture.
420
+ text: re.Pattern[str] | None = None
421
+ id_capture: str = ""
422
+ #: A text rule's named values in order, the sections its match reads inside, and each
423
+ #: attribute given as a name and a template; a text rule's ``id_template`` is its id template.
424
+ values: tuple[Value, ...] = ()
425
+ reads_sections: tuple[str, ...] = ()
426
+ templates: tuple[tuple[str, str], ...] = ()
427
+ #: A text rule's bindings, each a name template and a target template, and its id's
428
+ #: resolution steps with their separator.
429
+ bindings: tuple[tuple[str, str], ...] = ()
430
+ id_resolve: tuple[Step, ...] = ()
431
+ id_separator: str = ""
432
+ #: A text rule's ``line`` capture: the node, its values and templates sit at its line.
433
+ line_capture: str = ""
434
+ #: A declaration, text or reference rule's ``match.ending``: the claimed endings of the files
435
+ #: it reads; empty for any.
436
+ endings: tuple[str, ...] = ()
437
+ #: A map rule: the node type it matches, ``""`` on a file rule, and its follow steps, each a
438
+ #: step name and its value: a kind, a tuple of kinds, a template or a compiled pattern.
439
+ matches_type: str = ""
440
+ follow: tuple[tuple[str, Any], ...] = ()
441
+
442
+
443
+ @dataclass(frozen=True, slots=True)
444
+ class EdgeSpec:
445
+ """One edge a rule emits. ``parts`` is the list form of the end's source.
446
+
447
+ A part is ``("key", path)``, ``("each", path)`` or ``("entry", "")`` for the entry's own key;
448
+ ``from: []`` is the one part ``("file", "")``, a template over the path values alone;
449
+ without parts the end reads ``key``. A rule
450
+ that reads data runs from ``this_node`` to that end, and ``finish`` is ``""``. A rule that
451
+ reads a parse tree names each end in ``start`` and ``finish``: ``this_node``,
452
+ ``enclosing_class``, ``declared`` for the node of the type ``declared`` holds at that end, or,
453
+ for ``finish``, ``qualified_name`` for each name written in the match. An attribute rule also
454
+ names ``enclosing_method``, and its ``finish`` may be ``attribute_argument`` with the argument
455
+ in ``key``. A reference rule's ``key`` is the position of the call's argument its end reads,
456
+ or ``""`` for the written name.
457
+ """
458
+
459
+ kind: str
460
+ key: str
461
+ each: bool
462
+ matches: str | None
463
+ pattern: re.Pattern[str] | None
464
+ template: str | None
465
+ parts: tuple[tuple[str, str], ...]
466
+ start: str = "this_node"
467
+ finish: str = ""
468
+ #: The template begins with a declared id namespace and two colons, so its value is a full id.
469
+ full_id: bool = False
470
+ #: The key path of a list in the same file that must hold the end's value; ``None`` for none.
471
+ listed_at: str | None = None
472
+ #: Globs a list-form end's each key must not match; the end's ``matches`` glob it must.
473
+ not_matches: tuple[str, ...] = ()
474
+ #: The type the composed end has when no rule finds it; ``""`` for the kind's types there.
475
+ #: A data or text rule's composed end is the from end when ``start`` is ``""``.
476
+ to_type: str = ""
477
+ #: The composed end becomes an unresolved node when no rule maps its id.
478
+ on_miss: bool = False
479
+ #: A text rule's end: its resolution steps and their separator.
480
+ resolve: tuple[Step, ...] = ()
481
+ separator: str = ""
482
+ #: The composed end's candidate types, in order, in place of ``to_type``; ``to_type`` is the
483
+ #: first of them.
484
+ types: tuple[str, ...] = ()
485
+ #: The node type of a ``declared`` end, at from and at to; ``""`` at an end that is not one.
486
+ declared: tuple[str, str] = ("", "")
487
+ #: A reference rule's from end written as a template, in place of ``enclosing_declaration``.
488
+ from_template: str | None = None
489
+
490
+
491
+ @dataclass(frozen=True, slots=True)
492
+ class EdgeKind:
493
+ """One edge kind: the types allowed at each end."""
494
+
495
+ name: str
496
+ from_types: tuple[str, ...]
497
+ to_types: tuple[str, ...]
498
+
499
+
500
+ @dataclass(frozen=True, slots=True)
501
+ class BoundaryEntry:
502
+ """One name a boundary group lists: the pack and group that list it, the group's node type,
503
+ the type that classifies a first-party descendant of it or ``None``, and the edge kinds that
504
+ carry descent toward it."""
505
+
506
+ pack: str
507
+ group: str
508
+ type: str
509
+ classifies: str | None
510
+ ancestry: tuple[str, ...]
511
+
512
+
513
+ @dataclass(frozen=True, slots=True)
514
+ class RuleSet:
515
+ """Every rule in load order, the table of declared types and the table of edge kinds.
516
+
517
+ ``grammars`` holds, for each file type a supported tree rule reads, the grammar of its pack.
518
+ ``values`` holds the project's settings values, which every template may name.
519
+ ``tables`` holds, for each pack, the tables its table steps may name.
520
+ ``boundary`` holds, by node id, each name a boundary group of a loaded pack lists.
521
+ """
522
+
523
+ rules: tuple[Rule, ...]
524
+ types: Mapping[str, TypeInfo]
525
+ edge_kinds: Mapping[str, EdgeKind]
526
+ conflicts: tuple[Problem, ...] = ()
527
+ grammars: Mapping[str, Grammar] = field(default_factory=dict)
528
+ files: Mapping[str, FileInfo] = field(default_factory=dict)
529
+ values: Mapping[str, str] = field(default_factory=dict)
530
+ tables: Mapping[str, Mapping[str, Mapping[str, str]]] = field(default_factory=dict)
531
+ boundary: Mapping[str, BoundaryEntry] = field(default_factory=dict)
532
+
533
+
534
+ def load_rules(
535
+ packs: Sequence[LoadedPack],
536
+ values: Mapping[str, str] | None = None,
537
+ origins: Mapping[str, str] | None = None,
538
+ ) -> RuleSet:
539
+ """Read each pack's rule files, in pack order and then file order, and check the types.
540
+
541
+ Raises ``PackRulesUnreadable`` for an unreadable file, a type entry with no string name, a
542
+ type declared twice, and a supported rule that emits a type with no id namespace. An edge kind
543
+ declared twice with different ends is left in ``RuleSet.conflicts``, so the run can report it
544
+ beside the other problems of the packs, and so is a name the settings ``values`` and a pack
545
+ both give, a path value template naming a value nothing gives, and a declaration rule's id
546
+ naming a name that is no field of its grammar and no value, and so is a boundary group whose
547
+ type, classifying type or ancestry kind the run cannot use. ``origins`` names the settings
548
+ file of each settings value. Every pack given has passed its schema.
549
+ """
550
+ values = dict(values or {})
551
+ types: dict[str, TypeInfo] = {}
552
+ kinds: dict[str, EdgeKind] = {}
553
+ conflicts: list[Problem] = []
554
+ declared_at: dict[str, tuple[str, str]] = {}
555
+ rules: list[Rule] = []
556
+ items: list[Mapping[str, Any]] = []
557
+ sources: list[str] = []
558
+ siblings: dict[str, list[Mapping[str, Any]]] = {}
559
+ imports: dict[str, list[Mapping[str, Any]]] = {}
560
+ tables = _tables(packs)
561
+ for pack in packs:
562
+ for relative, node in _yaml_files(pack.directory):
563
+ if relative == MANIFEST_FILENAME:
564
+ continue
565
+ document, readable = _load(node, [])
566
+ if not readable or not isinstance(document, Mapping):
567
+ raise PackRulesUnreadable(
568
+ f"{pack.name.pack}/{relative} cannot be read as a rule file",
569
+ {"pack": pack.name.pack, "file": relative},
570
+ )
571
+ _read_types(pack.name.pack, document.get("node_types"), types)
572
+ _read_kinds(
573
+ pack.name.pack, relative, document.get("edge_kinds"), kinds, conflicts, declared_at
574
+ )
575
+ imports.setdefault(pack.name.pack, []).extend(document.get("imports", []))
576
+ for item in document.get("rules", []):
577
+ rules.append(_read_rule(pack.name.pack, item, frozenset(tables[pack.name.pack])))
578
+ items.append(item)
579
+ sources.append(relative)
580
+ siblings.setdefault(pack.name.pack, []).append(item)
581
+ prefixes = tuple(f"{t.id_namespace}::" for t in types.values() if t.id_namespace)
582
+ rules = [
583
+ _check_edges(_full_ids(_with_twin(rule, item, siblings[rule.pack]), prefixes), types, kinds)
584
+ for rule, item in zip(rules, items, strict=True)
585
+ ]
586
+ file_types = FileTypes.build(packs, {})
587
+ rules = [_check_ending(_check_reader(rule, file_types), file_types) for rule in rules]
588
+ files = _file_info(packs)
589
+ rules = [_check_names(rule, files.get(rule.filetype, FileInfo()), values) for rule in rules]
590
+ conflicts.extend(_collisions(rules, sources, files, packs, values, origins or {}))
591
+ grammars: dict[str, Grammar] = {}
592
+ for index, rule in enumerate(rules):
593
+ tree = rule.declaration or rule.attribute or rule.annotation or rule.reference
594
+ if rule.supported and tree and rule.filetype not in grammars:
595
+ try:
596
+ grammars[rule.filetype] = _grammar(rule.filetype, packs, imports)
597
+ except _Unsupported as reason:
598
+ rules[index] = replace(rule, supported=False, reason=str(reason))
599
+ if rules[index].supported and rule.declaration:
600
+ conflicts.extend(_unknown_fields(rule, sources[index], grammars[rule.filetype]))
601
+ if rules[index].supported and rule.attribute and not grammars[rule.filetype].attribute:
602
+ missing = f"the grammar for {rule.filetype} declares no identity.attribute block"
603
+ rules[index] = replace(rule, supported=False, reason=missing)
604
+ if rules[index].supported and rule.annotation and not grammars[rule.filetype].comment:
605
+ missing = f"the grammar for {rule.filetype} declares no identity.comment"
606
+ rules[index] = replace(rule, supported=False, reason=missing)
607
+ for rule in rules:
608
+ if (
609
+ rule.supported
610
+ and rule.node_type in types
611
+ and namespace_of(types, rule.node_type) is None
612
+ ):
613
+ raise PackRulesUnreadable(
614
+ f"rule {rule.name} of {rule.pack} emits {rule.node_type}, "
615
+ "which no loaded pack declares with an id_namespace",
616
+ {"pack": rule.pack, "rule": rule.name, "type": rule.node_type},
617
+ )
618
+ boundary = _boundary_entries(packs, types, kinds, conflicts)
619
+ return RuleSet(
620
+ rules=tuple(rules),
621
+ types=types,
622
+ edge_kinds=kinds,
623
+ conflicts=tuple(conflicts),
624
+ grammars=grammars,
625
+ files=files,
626
+ values=values,
627
+ tables=tables,
628
+ boundary=boundary,
629
+ )
630
+
631
+
632
+ def _tables(packs: Sequence[LoadedPack]) -> dict[str, dict[str, Mapping[str, str]]]:
633
+ """The tables of each pack and of every pack it depends on, directly or not; of two tables of
634
+ one name, the one of the pack nearer in the walk from the pack wins."""
635
+ declared: dict[str, Mapping[str, Mapping[str, str]]] = {}
636
+ for p in packs:
637
+ own = p.manifest.document.get("tables")
638
+ declared[p.name.pack] = own if isinstance(own, Mapping) else {}
639
+ found: dict[str, dict[str, Mapping[str, str]]] = {}
640
+ for pack, walk in _visible_packs(packs).items():
641
+ visible: dict[str, Mapping[str, str]] = {}
642
+ for name in walk:
643
+ for table, entries in declared.get(name, {}).items():
644
+ visible.setdefault(table, entries)
645
+ found[pack] = visible
646
+ return found
647
+
648
+
649
+ def _visible_packs(packs: Sequence[LoadedPack]) -> dict[str, list[str]]:
650
+ """Each pack's name, then the name of every pack it depends on, directly or not, nearer
651
+ first."""
652
+ depends = {p.name.pack: [d.partition("@")[0] for d in p.manifest.depends] for p in packs}
653
+ found: dict[str, list[str]] = {}
654
+ for pack in depends:
655
+ seen: list[str] = []
656
+ pending = [pack]
657
+ while pending:
658
+ name = pending.pop(0)
659
+ if name not in seen:
660
+ seen.append(name)
661
+ pending.extend(depends.get(name, []))
662
+ found[pack] = seen
663
+ return found
664
+
665
+
666
+ def _boundary(packs: Sequence[LoadedPack]) -> dict[str, Mapping[str, Mapping[str, Any]]]:
667
+ """The boundary groups each pack declares, by pack. No rule names a group, so no group hides
668
+ another of the same name."""
669
+ declared: dict[str, Mapping[str, Mapping[str, Any]]] = {}
670
+ for p in packs:
671
+ own = p.manifest.document.get("boundary")
672
+ declared[p.name.pack] = own if isinstance(own, Mapping) else {}
673
+ return declared
674
+
675
+
676
+ #: The boundary block's working shape, named by each refusal of a group.
677
+ _BOUNDARY_SHAPE = (
678
+ "boundary: {<group>: {type: <node type>, ancestry: [<edge kind>], "
679
+ "names: {<name>: <node type or ~>}}}"
680
+ )
681
+
682
+
683
+ def _boundary_entries(
684
+ packs: Sequence[LoadedPack],
685
+ types: Mapping[str, TypeInfo],
686
+ kinds: Mapping[str, EdgeKind],
687
+ conflicts: list[Problem],
688
+ ) -> dict[str, BoundaryEntry]:
689
+ """Each name of every group a loaded pack declares, by node id.
690
+
691
+ A group whose type no pack the declaring pack sees declares with an id namespace, whose names
692
+ value no such pack declares, or whose ancestry names a kind no pack declares is left out, with
693
+ a problem in ``conflicts``. An id two packs list adds a problem too: which pack draws it is
694
+ precedence, not decided here.
695
+ """
696
+ declared = _boundary(packs)
697
+ found: dict[str, BoundaryEntry] = {}
698
+ for pack, walk in _visible_packs(packs).items():
699
+ for group, spec in declared[pack].items():
700
+ kind = str(spec["type"])
701
+ namespace = namespace_of(types, kind)
702
+ reasons: list[str] = []
703
+ if kind not in types or types[kind].pack not in walk or namespace is None:
704
+ reasons.append(
705
+ f"the type {kind}, which neither {pack} nor a pack it depends on declares "
706
+ "with an id_namespace"
707
+ )
708
+ else:
709
+ reasons.extend(
710
+ f"the names value {value} for {name}, which neither {pack} nor a pack it "
711
+ "depends on declares"
712
+ for name, value in spec["names"].items()
713
+ if value is not None and (value not in types or types[value].pack not in walk)
714
+ )
715
+ reasons.extend(
716
+ f"the ancestry kind {edge}, which no loaded pack declares"
717
+ for edge in spec["ancestry"]
718
+ if edge not in kinds
719
+ )
720
+ for reason in reasons:
721
+ conflicts.append(
722
+ Problem(
723
+ ExitCode.RULE_NOT_EXECUTABLE,
724
+ f"{pack}/{MANIFEST_FILENAME}: the boundary group {group} names {reason}; "
725
+ f"write {_BOUNDARY_SHAPE}",
726
+ {"pack": pack, "file": MANIFEST_FILENAME, "group": str(group)},
727
+ )
728
+ )
729
+ if reasons:
730
+ continue
731
+ for name, value in spec["names"].items():
732
+ node = f"{namespace}::{name}"
733
+ first = found.setdefault(
734
+ node, BoundaryEntry(pack, str(group), kind, value, tuple(spec["ancestry"]))
735
+ )
736
+ if first.pack != pack:
737
+ conflicts.append(
738
+ Problem(
739
+ ExitCode.RULE_NOT_EXECUTABLE,
740
+ f"{pack}/{MANIFEST_FILENAME}: the boundary group {group} names {node}, "
741
+ f"which the pack {first.pack} lists too in its group {first.group}; "
742
+ f"list it in one pack and write {_BOUNDARY_SHAPE}",
743
+ {"pack": pack, "file": MANIFEST_FILENAME, "group": str(group)},
744
+ )
745
+ )
746
+ return found
747
+
748
+
749
+ def _file_info(packs: Sequence[LoadedPack]) -> dict[str, FileInfo]:
750
+ """Each declared file type's path values, sections and spans, by type name.
751
+
752
+ Raises ``PackRulesUnreadable`` for a pattern that does not compile, a span whose balance is not
753
+ two different characters, and a ``relative_to`` naming a folder no loaded pack declares or
754
+ whose value holds ``**``, naming the pack.
755
+ """
756
+ found: dict[str, FileInfo] = {}
757
+ for pack in packs:
758
+ files = pack.manifest.document.get("files")
759
+ declared = files.get("types") if isinstance(files, Mapping) else None
760
+ for name, spec in declared.items() if isinstance(declared, Mapping) else ():
761
+ try:
762
+ values = tuple(
763
+ _read_value(str(key), value, path=True)
764
+ for key, value in spec.get("path_values", {}).items()
765
+ )
766
+ sections = tuple(
767
+ (str(s["name"]), _regex(s["open"]), _closing(s["close"], s["open"]))
768
+ for s in spec.get("sections", [])
769
+ )
770
+ steps = _read_steps(spec.get("resolve", []))
771
+ spans = tuple(
772
+ (str(s["name"]), _regex(s["open"]), *_balance(s["balance"]))
773
+ for s in spec.get("spans", [])
774
+ )
775
+ for value in values:
776
+ _flat_folder(value, packs)
777
+ except _Unsupported as reason:
778
+ raise PackRulesUnreadable(
779
+ f"{pack.name.pack}/{MANIFEST_FILENAME}: the file type {name}: {reason}",
780
+ {"pack": pack.name.pack, "file": MANIFEST_FILENAME, "name": str(name)},
781
+ ) from reason
782
+ known = found.get(name, FileInfo())
783
+ found[name] = FileInfo(
784
+ known.path_values + values,
785
+ known.sections + sections,
786
+ known.resolve or steps,
787
+ known.separator if known.resolve else str(spec.get("separator", "")),
788
+ known.spans + spans,
789
+ )
790
+ return found
791
+
792
+
793
+ def _balance(pair: object) -> tuple[str, str]:
794
+ """A span's opening and closing characters."""
795
+ if not (
796
+ isinstance(pair, list)
797
+ and len(pair) == 2
798
+ and all(isinstance(c, str) and len(c) == 1 for c in pair)
799
+ and pair[0] != pair[1]
800
+ ):
801
+ raise _Unsupported("a span's balance is not two different characters")
802
+ return pair[0], pair[1]
803
+
804
+
805
+ def _flat_folder(value: Value, packs: Sequence[LoadedPack]) -> None:
806
+ """Refuse a ``relative_to`` naming a folder no loaded pack declares, or whose value holds
807
+ ``**``: the deepest directory below it would win over the folder itself."""
808
+ if not value.relative_to:
809
+ return
810
+ key = f"path_values.{value.name}.relative_to"
811
+ try:
812
+ deep = holds_double_star(value.relative_to, packs, {})
813
+ except FolderUnresolved as error:
814
+ raise _Unsupported(f"{key} names the folder {value.relative_to}: {error}") from error
815
+ if deep:
816
+ raise _Unsupported(f"{key} names the folder {value.relative_to}, whose value holds **")
817
+
818
+
819
+ def _closing(close: object, opening: object) -> str:
820
+ """The closing template, checked to compile with each placeholder of a named capture of the
821
+ opening pattern standing for empty text."""
822
+ groups = _regex(opening).groupindex
823
+ if not isinstance(close, str):
824
+ raise _Unsupported("a section's close is not a string")
825
+ _regex(PLACEHOLDER.sub(lambda p: "" if p.group(1) in groups else p.group(0), close))
826
+ return close
827
+
828
+
829
+ def _check_names(rule: Rule, info: FileInfo, values: Mapping[str, str]) -> Rule:
830
+ """The rule, marked not executed when a template names a value nothing gives, a value's
831
+ template names a value defined after it, or the match reads a section its file type does not
832
+ declare. A declaration rule is given the fields its id names."""
833
+ tree = rule.declaration or rule.attribute or rule.annotation or rule.reference
834
+ known = {value.name for value in info.path_values} | set(values)
835
+ if rule.supported and rule.declaration:
836
+ captures = set(compile_pattern(PATTERN_VALUE.sub("", rule.id_pattern)).groupindex)
837
+ clash = captures & (_TREE_FILLED | known)
838
+ unknown = set(PATTERN_VALUE.findall(rule.id_pattern)) - known
839
+ if clash or unknown:
840
+ reason = (
841
+ f"the id pattern captures {', '.join(sorted(clash))}, a name the engine, a path "
842
+ "value or a settings value gives"
843
+ if clash
844
+ else f"the id pattern names {', '.join(sorted(unknown))}, which is no path value "
845
+ "or settings value"
846
+ )
847
+ return replace(rule, supported=False, reason=reason)
848
+ named = {*PLACEHOLDER.findall(rule.id_template), rule.id_source} - {""}
849
+ named = named - _TREE_FILLED - known - captures - set(_groups(rule.docblock))
850
+ if set(rule.qualified) - named:
851
+ lacking = ", ".join(sorted(set(rule.qualified) - named))
852
+ reason = f"the id source qualified names {lacking}, which is no field of the match"
853
+ return replace(rule, supported=False, reason=reason)
854
+ return replace(rule, fields=tuple(sorted(named)))
855
+ if rule.supported and rule.matches_type:
856
+ return _check_map_names(rule, values)
857
+ if not rule.supported or tree:
858
+ return rule
859
+ if rule.text is not None:
860
+ before = known | set(rule.text.groupindex)
861
+ for value in rule.values:
862
+ later = set(PLACEHOLDER.findall(value.template or "")) - before
863
+ if later:
864
+ missing = ", ".join(sorted(later))
865
+ reason = (
866
+ f"the value {value.name} names {missing}, which is no capture, path value, "
867
+ "settings value or value defined before it"
868
+ )
869
+ return replace(rule, supported=False, reason=reason)
870
+ before.add(value.name)
871
+ known |= {*rule.text.groupindex, *(value.name for value in rule.values)}
872
+ templates = [rule.id_template, *(v.template or "" for v in rule.values)]
873
+ templates += [t for _, t in rule.templates] + [e.template or "" for e in rule.edges]
874
+ templates += [t for binding in rule.bindings for t in binding]
875
+ else:
876
+ known |= {"file_stem", *(("key",) if rule.each is not None else ())}
877
+ templates = [rule.id_template]
878
+ names = {name for template in templates for name in PLACEHOLDER.findall(template)}
879
+ if names - known:
880
+ missing = ", ".join(sorted(names - known))
881
+ return replace(
882
+ rule,
883
+ supported=False,
884
+ reason=f"a template names {missing}, which is "
885
+ "no capture, value, path value or settings value the rule has",
886
+ )
887
+ if rule.text is not None:
888
+ steps = [*rule.id_resolve, *(step for edge in rule.edges for step in edge.resolve)]
889
+ prefixing = ("prefix_with", "prefix_bare_with")
890
+ named = {argument for kind, argument in steps if kind in prefixing} - known
891
+ if any(not edge.resolve for edge in rule.edges):
892
+ paths = {value.name for value in info.path_values} | set(values)
893
+ named |= {argument for kind, argument in info.resolve if kind in prefixing} - paths
894
+ if named:
895
+ return replace(
896
+ rule,
897
+ supported=False,
898
+ reason=f"a prefix_with step names {', '.join(sorted(named))}, which is "
899
+ "no capture, value, path value or settings value the rule has",
900
+ )
901
+ sections = {name for name, _, _ in info.sections}
902
+ if set(rule.reads_sections) - sections:
903
+ missing = ", ".join(sorted(set(rule.reads_sections) - sections))
904
+ reason = f"match.reads_sections names {missing}, which its file type does not declare"
905
+ return replace(rule, supported=False, reason=reason)
906
+ captured = {(name, group) for name, opening, _, _ in info.spans for group in opening.groupindex}
907
+ for value in rule.values:
908
+ if value.enclosing is not None and value.enclosing not in captured:
909
+ span, capture = value.enclosing
910
+ reason = (
911
+ f"the value {value.name} names the capture {capture} of the span {span}, which "
912
+ "its file type does not declare"
913
+ )
914
+ return replace(rule, supported=False, reason=reason)
915
+ return rule
916
+
917
+
918
+ def _check_map_names(rule: Rule, values: Mapping[str, str]) -> Rule:
919
+ """The map rule, marked not executed when a follow step's node template names a name that no
920
+ id_matches before it captures and no settings value gives, or its id or an edge template names
921
+ one that no id_matches of the rule captures and no settings value gives."""
922
+ known = set(values)
923
+ for condition in rule.where:
924
+ known |= set(condition.pattern.groupindex) if isinstance(condition, IdMatches) else set()
925
+ for name, value in rule.follow:
926
+ if name == "id_matches":
927
+ known |= set(value.groupindex)
928
+ elif name == "node" and set(PLACEHOLDER.findall(value)) - known:
929
+ lacking = ", ".join(sorted(set(PLACEHOLDER.findall(value)) - known))
930
+ reason = f"the follow step node {value} names {lacking}, which no id_matches captures"
931
+ return replace(rule, supported=False, reason=reason + " before it")
932
+ templates = [rule.id_template, *(edge.template or "" for edge in rule.edges)]
933
+ missing = sorted({name for t in templates for name in PLACEHOLDER.findall(t)} - known)
934
+ if missing:
935
+ reason = f"a template names {', '.join(missing)}, which no id_matches of the rule captures"
936
+ return replace(rule, supported=False, reason=reason)
937
+ return rule
938
+
939
+
940
+ def _unknown_fields(rule: Rule, source: str, grammar: Grammar) -> list[Problem]:
941
+ """A problem for each name the declaration rule's id reads as a node field that is no field of
942
+ the grammar, naming the rule, the template and the name. A grammar that cannot be imported
943
+ is left to the grammar check."""
944
+ try:
945
+ language = _parser(grammar.language).language
946
+ except Exception: # noqa: BLE001 - any failure of a grammar's import, reported elsewhere
947
+ language = None
948
+ if language is None:
949
+ return []
950
+ return [
951
+ Problem(
952
+ ExitCode.PACK_RULES_UNREADABLE,
953
+ f"{rule.pack}/{source}: the rule {rule.name} has the id template "
954
+ f"{rule.id_template}, which names {name}, no field of the {grammar.language} "
955
+ "grammar, path value or settings value",
956
+ {"pack": rule.pack, "file": source, "rule": rule.name, "name": name},
957
+ )
958
+ for name in rule.fields
959
+ if language.field_id_for_name(name) is None
960
+ ]
961
+
962
+
963
+ def _collisions(
964
+ rules: Sequence[Rule],
965
+ sources: Sequence[str],
966
+ files: Mapping[str, FileInfo],
967
+ packs: Sequence[LoadedPack],
968
+ values: Mapping[str, str],
969
+ origins: Mapping[str, str],
970
+ ) -> list[Problem]:
971
+ """Each path value name that the engine fills already (on a tree file type, also the names it
972
+ fills for a declaration rule and ``line``), that a rule on its file type captures or names as
973
+ a value, or that a settings value gives; each settings value name the engine fills, or a rule
974
+ captures or names as a value; and each path value template naming a name that is no capture
975
+ of its pattern and no settings value, or both: a problem naming the pack, the file and the
976
+ names, and the settings file of a settings value."""
977
+ problems: list[Problem] = []
978
+ tree_filled = {"file_stem", "key", "line", *_TREE_FILLED}
979
+
980
+ def setting(name: str) -> str:
981
+ origin = f" of {origins[name]}" if name in origins else ""
982
+ return f"the settings value {name}{origin}"
983
+
984
+ for pack in packs:
985
+ files_block = pack.manifest.document.get("files")
986
+ declared = files_block.get("types") if isinstance(files_block, Mapping) else None
987
+ for kind, spec in declared.items() if isinstance(declared, Mapping) else ():
988
+ path_values = spec.get("path_values", {}) if isinstance(spec, Mapping) else {}
989
+ tree = isinstance(spec, Mapping) and spec.get("reader") == "tree"
990
+ for name, value in path_values.items():
991
+ if name in (tree_filled if tree else {"file_stem", "key"}):
992
+ problems.append(_collision(pack.name.pack, MANIFEST_FILENAME, name, kind))
993
+ where = f"{pack.name.pack}/{MANIFEST_FILENAME}: the path value {name} of the file "
994
+ detail = {"pack": pack.name.pack, "file": MANIFEST_FILENAME, "name": name}
995
+ if name in values:
996
+ message = f"{where}type {kind} has the name of {setting(name)}"
997
+ problems.append(Problem(ExitCode.PACK_RULES_UNREADABLE, message, detail))
998
+ captures = set(compile_pattern(value["pattern"]).groupindex)
999
+ named = set(PLACEHOLDER.findall(value["template"]))
1000
+ for missing in sorted(named - captures - set(values)):
1001
+ message = (
1002
+ f"{where}type {kind} has the template {value['template']}, which names "
1003
+ f"{missing}, no capture of its pattern and no settings value"
1004
+ )
1005
+ problems.append(Problem(ExitCode.PACK_RULES_UNREADABLE, message, detail))
1006
+ for both in sorted(named & captures & set(values)):
1007
+ message = (
1008
+ f"{where}type {kind} has the template {value['template']}, which names "
1009
+ f"{both}, both a capture of its pattern and a settings value"
1010
+ )
1011
+ problems.append(Problem(ExitCode.PACK_RULES_UNREADABLE, message, detail))
1012
+ for rule, source in zip(rules, sources, strict=True):
1013
+ names = {value.name for value in files.get(rule.filetype, FileInfo()).path_values}
1014
+ given = {*(rule.text.groupindex if rule.text is not None else ())}
1015
+ given |= {value.name for value in rule.values}
1016
+ problems.extend(
1017
+ _collision(rule.pack, source, name, rule.filetype) for name in sorted(names & given)
1018
+ )
1019
+ problems.extend(
1020
+ Problem(
1021
+ ExitCode.PACK_RULES_UNREADABLE,
1022
+ f"{rule.pack}/{source}: the rule {rule.name} captures or names the value {name}, "
1023
+ f"which is also {setting(name)}",
1024
+ {"pack": rule.pack, "file": source, "name": name},
1025
+ )
1026
+ for name in sorted(set(values) & given)
1027
+ )
1028
+ problems.extend(
1029
+ Problem(
1030
+ ExitCode.PACK_RULES_UNREADABLE,
1031
+ f"{setting(name)} has a name the engine fills",
1032
+ {"name": name},
1033
+ )
1034
+ for name in sorted(set(values) & tree_filled)
1035
+ )
1036
+ return problems
1037
+
1038
+
1039
+ def _collision(pack: str, file: str, name: str, kind: str) -> Problem:
1040
+ return Problem(
1041
+ ExitCode.PACK_RULES_UNREADABLE,
1042
+ f"{pack}/{file}: the path value {name} of the file type {kind} is a name the engine or "
1043
+ "a rule already fills",
1044
+ {"pack": pack, "file": file, "name": name},
1045
+ )
1046
+
1047
+
1048
+ def _check_reader(rule: Rule, types: FileTypes) -> Rule:
1049
+ """The rule, marked not executed when every type of its file type is read otherwise than the
1050
+ rule reads: a text rule text, a tree rule a tree, any other rule that reads content data. A
1051
+ file type no pack declares is read as data."""
1052
+ if rule.text is not None:
1053
+ needed = "text"
1054
+ elif rule.declaration or rule.attribute or rule.annotation or rule.reference:
1055
+ needed = "tree"
1056
+ elif rule.where or rule.attributes or rule.edges or rule.each is not None:
1057
+ needed = "data"
1058
+ else:
1059
+ return rule
1060
+ readers = {kind.reader for kind in types.named(rule.filetype)} or {"data"}
1061
+ if not rule.supported or needed in readers:
1062
+ return rule
1063
+ reason = f"the rule reads {needed}, and the file type {rule.filetype} is read as "
1064
+ return replace(rule, supported=False, reason=reason + ", ".join(sorted(readers)))
1065
+
1066
+
1067
+ def _check_ending(rule: Rule, types: FileTypes) -> Rule:
1068
+ """The rule, marked not executed when its ``match.ending`` names an ending no pack claims."""
1069
+ claimed = sorted({ending for kind in types.types for ending in kind.endings})
1070
+ unclaimed = [ending for ending in rule.endings if ending not in claimed]
1071
+ if not rule.supported or not unclaimed:
1072
+ return rule
1073
+ reason = f"match.ending names {', '.join(unclaimed)}, which no pack claims; the claimed "
1074
+ return replace(rule, supported=False, reason=reason + f"endings are {', '.join(claimed)}")
1075
+
1076
+
1077
+ def _grammar(
1078
+ filetype: str, packs: Sequence[LoadedPack], imports: Mapping[str, list[Mapping[str, Any]]]
1079
+ ) -> Grammar:
1080
+ """The grammar of the pack that declares the file type read as a tree, and pins one.
1081
+
1082
+ Its ``identity`` block, an open object in the manifest schema, gives the full-name parts and
1083
+ separator, the namespace declaration and its name field, and the written-name node types. The
1084
+ class constant keys come from the ``grammar`` block of the first pack that reads the file type
1085
+ and declares them; the manifest schema refuses one without the other.
1086
+ """
1087
+ owners = {kind.pack for kind in FileTypes.build(packs, {}).named(filetype)}
1088
+ constants: Mapping[str, Any] = next(
1089
+ (
1090
+ block
1091
+ for pack in packs
1092
+ for block in [pack.manifest.document.get("grammar")]
1093
+ if pack.name.pack in owners
1094
+ and isinstance(block, Mapping)
1095
+ and "constant_access" in block
1096
+ ),
1097
+ {},
1098
+ )
1099
+ for pack in packs:
1100
+ document = pack.manifest.document
1101
+ grammar, identity = document.get("grammar"), document.get("identity")
1102
+ if pack.name.pack not in owners or not isinstance(grammar, Mapping):
1103
+ continue
1104
+ unread = _Unsupported(
1105
+ f"{pack.name.pack}/{MANIFEST_FILENAME} declares the grammar for {filetype}, and "
1106
+ "its identity block or an imports entry lacks a key the engine reads: "
1107
+ "qualified_name.parts and .separator, namespace.declaration and .name_child or "
1108
+ "namespace.path_value, "
1109
+ "written_names, an import's ast, clause and facts with a bind naming only its "
1110
+ "facts, last_segment or path, an optional attribute mapping, an optional comment "
1111
+ "and optional resolution steps"
1112
+ )
1113
+ if not isinstance(identity, Mapping):
1114
+ raise unread
1115
+ try:
1116
+ qualified, namespace = identity["qualified_name"], identity["namespace"]
1117
+ attribute = identity.get("attribute", {})
1118
+ access = constants.get("constant_access", {})
1119
+ declaration = constants.get("constant_declaration", {})
1120
+ if "path_value" in namespace:
1121
+ namespace = {"declaration": "", "name_child": "", **namespace}
1122
+ return Grammar(
1123
+ language=str(grammar["language"]),
1124
+ parts=tuple(str(part) for part in qualified["parts"]),
1125
+ separator=str(qualified["separator"]),
1126
+ namespace=str(namespace["declaration"]),
1127
+ namespace_name=str(namespace["name_child"]),
1128
+ namespace_value=str(namespace.get("path_value", "")),
1129
+ written=frozenset(str(name) for name in identity["written_names"]),
1130
+ imports=tuple(_import_rule(entry) for entry in imports.get(pack.name.pack, [])),
1131
+ attribute=str(attribute.get("node", "")),
1132
+ attribute_list=str(attribute.get("list", "")),
1133
+ arguments=str(attribute.get("arguments", "")),
1134
+ argument=str(attribute.get("argument", "")),
1135
+ argument_name=str(attribute.get("argument_name", "")),
1136
+ literals=frozenset(str(name) for name in attribute.get("literals", [])),
1137
+ literal_content=str(attribute.get("literal_content", "")),
1138
+ comment=str(identity.get("comment", "")),
1139
+ resolve=_read_steps(identity.get("resolve", [])),
1140
+ call_arguments=str(identity.get("call_arguments", "arguments")),
1141
+ call_argument=str(identity.get("call_argument", "")),
1142
+ call_argument_name=str(identity.get("call_argument_name", "")),
1143
+ call_literals=frozenset(map(str, identity.get("call_literals", []))),
1144
+ call_literal_content=frozenset(map(str, identity.get("call_literal_content", []))),
1145
+ call_literal_delimiters=frozenset(
1146
+ map(str, identity.get("call_literal_delimiters", []))
1147
+ ),
1148
+ constant_access=str(access.get("node", "")),
1149
+ constant_scope=frozenset(map(str, access.get("scope", []))),
1150
+ constant_name=frozenset(map(str, access.get("name", []))),
1151
+ constant_enclosing=frozenset(map(str, access.get("enclosing", []))),
1152
+ constant_declaration=str(declaration.get("node", "")),
1153
+ constant_declared_name=frozenset(map(str, declaration.get("name", []))),
1154
+ constant_value=frozenset(map(str, declaration.get("value", []))),
1155
+ )
1156
+ except (KeyError, TypeError) as error:
1157
+ raise unread from error
1158
+ raise _Unsupported(f"no loaded pack declares a grammar for the file type {filetype}")
1159
+
1160
+
1161
+ def _import_rule(entry: Mapping[str, Any]) -> ImportRule:
1162
+ """One ``imports`` entry. Raises ``KeyError`` when its ``bind`` names a fact it lacks."""
1163
+ bind, facts = entry.get("bind", {}), {str(k): str(v) for k, v in entry["facts"].items()}
1164
+ rule = ImportRule(
1165
+ declaration=entry["ast"],
1166
+ group=entry.get("group_ast"),
1167
+ clause=entry["clause"],
1168
+ facts=tuple(facts.items()),
1169
+ names=tuple(str(source) for source in bind.get("name", [])),
1170
+ prefix_from_group=bind.get("prefix_from_group") is True,
1171
+ strip_prefix=str(bind.get("strip_prefix", "")),
1172
+ skip_when=str(bind.get("skip_when_field_set", "")),
1173
+ normalize=_read_normalize(bind["normalize"]) if "normalize" in bind else (),
1174
+ unbound_aliases=frozenset(str(alias) for alias in bind.get("unbound_aliases", [])),
1175
+ )
1176
+ for fact in (*rule.names, *([rule.skip_when] if rule.skip_when else [])):
1177
+ if fact not in ("last_segment", "path") and fact not in facts:
1178
+ raise KeyError(fact)
1179
+ return rule
1180
+
1181
+
1182
+ def namespace_of(types: Mapping[str, TypeInfo], name: str) -> str | None:
1183
+ """The type's own id namespace, or the nearest ancestor's along ``parent``."""
1184
+ seen: set[str] = set()
1185
+ while name in types and name not in seen:
1186
+ seen.add(name)
1187
+ if types[name].id_namespace is not None:
1188
+ return types[name].id_namespace
1189
+ parent = types[name].parent
1190
+ if parent is None:
1191
+ return None
1192
+ name = parent
1193
+ return None
1194
+
1195
+
1196
+ def namespaces(types: Mapping[str, TypeInfo], names: Sequence[str]) -> list[str]:
1197
+ """The distinct id namespaces of the types that have one, sorted so the first is stable."""
1198
+ found = {namespace_of(types, name) for name in names}
1199
+ return sorted(namespace for namespace in found if namespace is not None)
1200
+
1201
+
1202
+ def node_types(rule: Mapping[str, Any]) -> list[str]:
1203
+ """The type of each node the rule entry emits."""
1204
+ return [str(emit["node"]["type"]) for emit in rule.get("emits", []) if "node" in emit]
1205
+
1206
+
1207
+ def twin_type(rule: Mapping[str, Any], siblings: Sequence[Mapping[str, Any]]) -> str | None:
1208
+ """The node type of the edge rule's twin, or ``None`` when it has none.
1209
+
1210
+ The twin is the first other rule of the same pack that emits a node and whose ``match``, ``id``
1211
+ and ``in`` equal the edge rule's as documents. ``siblings`` is every rule entry of that pack.
1212
+ """
1213
+ for other in siblings:
1214
+ nodes = node_types(other)
1215
+ if (
1216
+ other is not rule
1217
+ and nodes
1218
+ and all(other.get(key) == rule.get(key) for key in ("match", "id", "in"))
1219
+ ):
1220
+ return nodes[0]
1221
+ return None
1222
+
1223
+
1224
+ def _with_twin(rule: Rule, item: Mapping[str, Any], siblings: Sequence[Mapping[str, Any]]) -> Rule:
1225
+ """An edge rule, given the type of its twin node rule."""
1226
+ if not (rule.supported and rule.edges):
1227
+ return rule
1228
+ found = twin_type(item, siblings)
1229
+ return replace(rule, twin_type=found) if found else rule
1230
+
1231
+
1232
+ def _full_ids(rule: Rule, prefixes: tuple[str, ...]) -> Rule:
1233
+ """The rule, each edge marked ``full_id`` when its template begins with one of the prefixes."""
1234
+ edges = tuple(
1235
+ replace(edge, full_id=(edge.template or "").startswith(prefixes)) for edge in rule.edges
1236
+ )
1237
+ return replace(rule, edges=edges) if edges != rule.edges else rule
1238
+
1239
+
1240
+ def _check_edges(rule: Rule, types: Mapping[str, TypeInfo], kinds: Mapping[str, EdgeKind]) -> Rule:
1241
+ """The rule, marked not executed when an edge it emits cannot be minted."""
1242
+ # A map rule's node ends name nodes already in the map, by their whole id.
1243
+ whole = _WHOLE_ENDS | set(_MAP_ENDS) if rule.matches_type else _WHOLE_ENDS
1244
+ for edge in rule.edges if rule.supported else ():
1245
+ kind = kinds.get(edge.kind)
1246
+ if kind is None:
1247
+ continue
1248
+ from_twin = bool(rule.twin_type) and edge.start == "this_node"
1249
+ to_twin = bool(rule.twin_type) and edge.finish == "this_node"
1250
+ # A composed end that is a full id, or that lists its types, needs no namespace of its
1251
+ # kind's types; neither does an end that takes the whole id of a node another rule found.
1252
+ composed_full = edge.full_id or bool(edge.types)
1253
+ from_full = composed_full and not edge.start or edge.start in whole
1254
+ to_full = composed_full and bool(edge.start) or edge.finish in whole
1255
+ if any(namespace_of(types, name) is None for name in edge.types):
1256
+ reason = f"a type the end of {edge.kind} lists has no id namespace"
1257
+ return replace(rule, supported=False, reason=reason)
1258
+ if not from_twin and not from_full and len(namespaces(types, kind.from_types)) != 1:
1259
+ reason = f"the types at the from end of {edge.kind} do not share one id namespace"
1260
+ elif not to_twin and not to_full and len(namespaces(types, kind.to_types)) != 1:
1261
+ reason = f"the types at the to end of {edge.kind} do not share one id namespace"
1262
+ else:
1263
+ continue
1264
+ return replace(rule, supported=False, reason=reason)
1265
+ return rule
1266
+
1267
+
1268
+ def _read_kinds(
1269
+ pack: str,
1270
+ file: str,
1271
+ declared: object,
1272
+ kinds: dict[str, EdgeKind],
1273
+ conflicts: list[Problem],
1274
+ declared_at: dict[str, tuple[str, str]],
1275
+ ) -> None:
1276
+ for entry in declared if isinstance(declared, list) else []:
1277
+ name = entry.get("kind") if isinstance(entry, Mapping) else None
1278
+ ends: list[tuple[str, ...]] = []
1279
+ for end in (entry.get("from"), entry.get("to")) if isinstance(entry, Mapping) else ():
1280
+ listed = [end] if isinstance(end, str) else end
1281
+ if isinstance(listed, list) and all(isinstance(t, str) for t in listed):
1282
+ ends.append(tuple(sorted(listed)))
1283
+ if not isinstance(name, str) or len(ends) != 2:
1284
+ raise PackRulesUnreadable(
1285
+ f"an edge_kinds entry of {pack} is not a mapping with a string kind and its ends",
1286
+ {"pack": pack},
1287
+ )
1288
+ kind = EdgeKind(name, ends[0], ends[1])
1289
+ first_pack, first_file = declared_at.setdefault(name, (pack, file))
1290
+ if kinds.setdefault(name, kind) != kind:
1291
+ message = (
1292
+ f"the edge kind {name} is declared with different ends in {pack}/{file}, "
1293
+ f"after {first_pack}/{first_file}"
1294
+ )
1295
+ detail = {"kind": name, "pack": pack, "file": file}
1296
+ conflicts.append(Problem(ExitCode.PACK_RULES_UNREADABLE, message, detail))
1297
+
1298
+
1299
+ def _read_types(pack: str, declared: object, types: dict[str, TypeInfo]) -> None:
1300
+ for entry in declared if isinstance(declared, list) else []:
1301
+ name = entry.get("name") if isinstance(entry, Mapping) else None
1302
+ if not isinstance(name, str):
1303
+ raise PackRulesUnreadable(
1304
+ f"a node_types entry of {pack} is not a mapping with a string name",
1305
+ {"pack": pack},
1306
+ )
1307
+ if name in types:
1308
+ raise PackRulesUnreadable(
1309
+ f"the type {name} is declared by both {types[name].pack} and {pack}",
1310
+ {"type": name},
1311
+ )
1312
+ types[name] = TypeInfo(
1313
+ name=name,
1314
+ id_namespace=entry.get("id_namespace"),
1315
+ parent=entry.get("parent"),
1316
+ pack=pack,
1317
+ )
1318
+
1319
+
1320
+ class _Unsupported(Exception):
1321
+ """A rule shape the engine does not execute, with the reason."""
1322
+
1323
+
1324
+ def _read_rule(pack: str, item: Any, tables: frozenset[str] = frozenset()) -> Rule:
1325
+ """The rule as a record; ``supported`` holds only for the shapes the engine runs. ``tables``
1326
+ names the tables its steps may name."""
1327
+ name = str(item.get("rule")) if isinstance(item, Mapping) else ""
1328
+ try:
1329
+ keys = unexecuted(item, _RULE)
1330
+ if keys:
1331
+ raise _Unsupported(f"the key {', '.join(keys)} is accepted and not executed")
1332
+ return _build_rule(pack, item, tables)
1333
+ except _Unsupported as reason:
1334
+ return Rule(pack, name, False, (), "", "", "", "", reason=str(reason))
1335
+
1336
+
1337
+ def _build_rule(pack: str, item: Any, tables: frozenset[str]) -> Rule:
1338
+ if item.get("reads") == "map":
1339
+ return _build_map(pack, item)
1340
+ if (
1341
+ isinstance(item.get("id"), Mapping)
1342
+ and "pattern" in item["id"]
1343
+ and "declaration" not in item["match"]
1344
+ ):
1345
+ raise _Unsupported("only the id of a tree declaration rule takes a pattern")
1346
+ if "text" in item["match"]:
1347
+ return _build_text(pack, item, tables)
1348
+ if isinstance(item.get("id"), Mapping) and {"resolve", "separator"} & set(item["id"]):
1349
+ raise _Unsupported("only the id of a text rule takes resolution steps")
1350
+ if "declaration" in item["match"]:
1351
+ return _build_declaration(pack, item)
1352
+ if "reference" in item["match"]:
1353
+ return _build_reference(pack, item, tables)
1354
+ if "attribute" in item["match"] or "annotation" in item["match"]:
1355
+ return _build_attribute(pack, item)
1356
+ shape = _Unsupported("the rule has a shape the engine does not execute")
1357
+ if not isinstance(item, Mapping) or set(item) != _RULE_KEYS or item["reads"] != "file":
1358
+ raise shape
1359
+ match, emits, folders = item["match"], item["emits"], item["in"]
1360
+ if (
1361
+ not isinstance(folders, list)
1362
+ or not all(isinstance(folder, str) for folder in folders)
1363
+ or not isinstance(match, dict)
1364
+ or not {"file", "filetype"} <= set(match) <= {"file", "filetype", "where", "each"}
1365
+ or not isinstance(match["file"], str)
1366
+ or not isinstance(match["filetype"], str)
1367
+ or not isinstance(item["id"], dict)
1368
+ or not set(item["id"]) <= {"from", "normalize", "template"}
1369
+ or not isinstance(emits, list)
1370
+ or not isinstance(item["confidence"], str)
1371
+ ):
1372
+ raise shape
1373
+ entries = [e for e in emits if isinstance(e, dict) and len(e) == 1]
1374
+ if len(entries) != len(emits) or not entries:
1375
+ raise shape
1376
+ nodes = [e["node"] for e in entries if "node" in e]
1377
+ attributes = [e["attribute"] for e in entries if "attribute" in e]
1378
+ edges = [e["edge"] for e in entries if "edge" in e]
1379
+ if len(nodes) + len(attributes) + len(edges) != len(entries) or (
1380
+ edges and (nodes or attributes)
1381
+ ):
1382
+ raise shape
1383
+ if not edges and (
1384
+ len(nodes) != 1 or not isinstance(nodes[0], dict) or set(nodes[0]) != {"type"}
1385
+ ):
1386
+ raise shape
1387
+ each = _read_each(match["each"]) if "each" in match else None
1388
+ read_edges = tuple(_read_edge(e) for e in edges)
1389
+ if each is None and any(edge.key == TEXT and not edge.parts for edge in read_edges):
1390
+ raise _Unsupported("an edge end from text needs match.each")
1391
+ return Rule(
1392
+ pack=pack,
1393
+ name=str(item["rule"]),
1394
+ supported=True,
1395
+ folders=tuple(folders),
1396
+ glob=match["file"],
1397
+ filetype=match["filetype"],
1398
+ node_type=str(nodes[0]["type"]) if nodes else "",
1399
+ confidence=item["confidence"],
1400
+ where=_read_where(match.get("where", [])),
1401
+ normalize=_read_normalize(item["id"].get("normalize", [])),
1402
+ attributes=tuple(_read_attribute(a) for a in attributes),
1403
+ edges=read_edges,
1404
+ each=each,
1405
+ id_template=_id_template(item["id"], each is not None),
1406
+ )
1407
+
1408
+
1409
+ def _build_text(pack: str, item: Mapping[str, Any], tables: frozenset[str]) -> Rule:
1410
+ """A rule that reads its file as text and fires on each match of ``match.text``. The id comes
1411
+ from one capture or a template; each attribute from one capture, one value or a template; each
1412
+ edge runs between this_node and an end from one capture or a template, in either direction.
1413
+ A template names captures, path values and the rule's ``values``. A rule that emits bindings
1414
+ emits nothing else and has no id; the id and an edge end may hold resolution steps."""
1415
+ match, emits, block = item["match"], item["emits"], item.get("id")
1416
+ bindings = [emit["binding"] for emit in emits if "binding" in emit]
1417
+ if bindings:
1418
+ if "id" in item or len(bindings) != len(emits):
1419
+ raise _Unsupported("a text rule with a binding emits only bindings and has no id")
1420
+ block = {"template": ""}
1421
+ if (
1422
+ not _RULE_KEYS - {"id"} <= set(item) <= {*_RULE_KEYS, "values", "line"}
1423
+ or item["reads"] != "file"
1424
+ or not {"file", "filetype", "text"} <= set(match)
1425
+ or not set(match) <= {"file", "ending", "filetype", "text", "reads_sections"}
1426
+ or not isinstance(block, Mapping)
1427
+ or not set(block) <= {"from", "template", "normalize", "resolve", "separator"}
1428
+ or len({"from", "template"} & set(block)) != 1
1429
+ or not isinstance(block.get("template", ""), str)
1430
+ or not isinstance(match.get("reads_sections", []), list)
1431
+ ):
1432
+ raise _Unsupported(
1433
+ "a text rule is not a file rule with in, an id from one capture or a template, and "
1434
+ "match.file, match.filetype and match.text, with optional ending, reads_sections, "
1435
+ "values and line"
1436
+ )
1437
+ pattern = _regex(match["text"])
1438
+ values = item.get("values", {})
1439
+ if not isinstance(values, Mapping):
1440
+ raise _Unsupported("the values of a text rule are not a mapping")
1441
+ read_values = tuple(_read_value(str(name), spec) for name, spec in values.items())
1442
+ nodes = node_types(item)
1443
+ attributes = [emit["attribute"] for emit in emits if "attribute" in emit]
1444
+ edges = [emit["edge"] for emit in emits if "edge" in emit]
1445
+ if (
1446
+ len(nodes) + len(attributes) + len(edges) + len(bindings) != len(emits)
1447
+ or len(nodes) > 1
1448
+ or any(emit["node"].keys() != {"type"} for emit in emits if "node" in emit)
1449
+ or (edges and (nodes or attributes))
1450
+ or (not edges and not nodes and not bindings)
1451
+ or not all(isinstance(b.get(k), str) for b in bindings for k in ("name", "target"))
1452
+ ):
1453
+ raise _Unsupported(
1454
+ "a text rule emits one node with a type and attributes, edges, or bindings"
1455
+ )
1456
+ read: list[tuple[str, str]] = []
1457
+ templates: list[tuple[str, str]] = []
1458
+ captures = [_capture(block["from"])] if "from" in block else []
1459
+ named = [value.name for value in read_values]
1460
+ for attribute in attributes:
1461
+ name, source = attribute.get("name"), attribute.get("from")
1462
+ if not isinstance(name, str) or set(attribute) not in (
1463
+ {"name", "from"},
1464
+ {"name", "template"},
1465
+ ):
1466
+ raise _Unsupported(
1467
+ "an attribute of a text rule is not a name and a capture, a value or a template"
1468
+ )
1469
+ if "template" in attribute and isinstance(attribute["template"], str):
1470
+ templates.append((name, attribute["template"]))
1471
+ elif isinstance(source, Mapping) and set(source) == {"value"} and source["value"] in named:
1472
+ read.append((name, str(source["value"])))
1473
+ else:
1474
+ read.append((name, _capture(source)))
1475
+ captures.append(read[-1][1])
1476
+ specs = []
1477
+ for edge in edges:
1478
+ flipped = edge.get("to") == "this_node" != edge.get("from")
1479
+ end = edge.get("from") if flipped else edge.get("to")
1480
+ if (
1481
+ set(edge) != {"kind", "from", "to"}
1482
+ or "this_node" not in (edge.get("from"), edge.get("to"))
1483
+ or not isinstance(end, Mapping)
1484
+ or not set(end)
1485
+ <= {"from", "template", "type", "types", "on_miss", "resolve", "separator"}
1486
+ or not {"from", "template"} & set(end)
1487
+ or not isinstance(end.get("template", ""), str)
1488
+ or not _types_ok(end)
1489
+ ):
1490
+ raise _Unsupported(
1491
+ "an edge of a text rule does not run between this_node and a capture or a template"
1492
+ )
1493
+ key = _capture(end["from"]) if "from" in end else ""
1494
+ captures += [key] if key else []
1495
+ specs.append(
1496
+ EdgeSpec(
1497
+ edge["kind"],
1498
+ key,
1499
+ False,
1500
+ None,
1501
+ None,
1502
+ end.get("template"),
1503
+ (),
1504
+ start="" if flipped else "this_node",
1505
+ finish="this_node" if flipped else "",
1506
+ to_type=end.get("type", next(iter(end.get("types", [])), "")),
1507
+ on_miss="on_miss" in end,
1508
+ resolve=_read_steps(end.get("resolve", []), tables),
1509
+ separator=str(end.get("separator", "")),
1510
+ types=tuple(end.get("types", ())),
1511
+ )
1512
+ )
1513
+ captures += [value.capture for value in read_values if value.capture]
1514
+ line = _capture(item["line"]) if "line" in item else ""
1515
+ captures += [line] if line else []
1516
+ if any(capture not in pattern.groupindex for capture in captures):
1517
+ raise _Unsupported("a capture a text rule names is not a named group of match.text")
1518
+ if set(named) & set(pattern.groupindex):
1519
+ raise _Unsupported("a value of a text rule has the name of a capture")
1520
+ return Rule(
1521
+ pack=pack,
1522
+ name=str(item["rule"]),
1523
+ supported=True,
1524
+ folders=tuple(item["in"]),
1525
+ glob=match["file"],
1526
+ endings=_read_ending(match),
1527
+ filetype=match["filetype"],
1528
+ node_type=nodes[0] if nodes else "",
1529
+ confidence=item["confidence"],
1530
+ normalize=_read_normalize(block.get("normalize", [])),
1531
+ attributes=tuple(read),
1532
+ edges=tuple(specs),
1533
+ id_template=block.get("template", ""),
1534
+ text=pattern,
1535
+ id_capture=_capture(block["from"]) if "from" in block else "",
1536
+ values=read_values,
1537
+ reads_sections=tuple(str(name) for name in match.get("reads_sections", [])),
1538
+ templates=tuple(templates),
1539
+ bindings=tuple((b["name"], b["target"]) for b in bindings),
1540
+ id_resolve=_read_steps(block.get("resolve", []), tables),
1541
+ id_separator=str(block.get("separator", "")),
1542
+ line_capture=line,
1543
+ )
1544
+
1545
+
1546
+ def _read_steps(steps: object, tables: frozenset[str] = frozenset()) -> tuple[Step, ...]:
1547
+ """Resolution steps: each a kind that takes no text, or a mapping of one kind to its text. A
1548
+ table step's text is one of ``tables``."""
1549
+ read: list[Step] = []
1550
+ for step in steps if isinstance(steps, list) else [None]:
1551
+ pair = next(iter(step.items()), (None, "")) if isinstance(step, Mapping) else (step, "")
1552
+ kind, text = pair
1553
+ if (
1554
+ not isinstance(kind, str)
1555
+ or kind not in STEPS
1556
+ or (isinstance(step, Mapping) and len(step) != 1)
1557
+ or STEPS[kind] != (isinstance(text, str) and text != "")
1558
+ ):
1559
+ raise _Unsupported(
1560
+ "a resolution step is not bound_first_segment, as_written, or one of "
1561
+ "full_if_prefixed, prefix_with, prefix_bare_with and lookup_last_segment_in with "
1562
+ "its text"
1563
+ )
1564
+ if kind.startswith("lookup_") and text not in tables:
1565
+ raise _Unsupported(
1566
+ f"the resolution step {kind} names {text}, which no tables key of the pack or a "
1567
+ "pack it depends on declares"
1568
+ )
1569
+ read.append((kind, text))
1570
+ return tuple(read)
1571
+
1572
+
1573
+ def _read_value(name: str, spec: object, path: bool = False) -> Value:
1574
+ """One value: from one capture, the capture of each enclosing span with a join, or a
1575
+ template, or for a path value a pattern and a template with an optional ``relative_to``; with
1576
+ optional normalize and case."""
1577
+ keys = set(spec) - {"normalize", "case"} if isinstance(spec, Mapping) else set()
1578
+ source = spec.get("from") if isinstance(spec, Mapping) else None
1579
+ span = (
1580
+ source if isinstance(source, Mapping) and set(source) == {"enclosing", "capture"} else None
1581
+ )
1582
+ enclosing = span is not None
1583
+ if path:
1584
+ wanted = keys - {"relative_to"} == {"pattern", "template"}
1585
+ else:
1586
+ wanted = keys in ({"from"}, {"template"}) or (enclosing and keys == {"from", "join"})
1587
+ if (
1588
+ not isinstance(spec, Mapping)
1589
+ or not wanted
1590
+ or not all(isinstance(spec.get(k, ""), str) for k in ("template", "relative_to", "join"))
1591
+ or spec.get("case", "") not in ("", "lower", "upper")
1592
+ ):
1593
+ raise _Unsupported(
1594
+ f"the value {name} is not from one capture, from an enclosing span's capture, or a "
1595
+ "template, with optional normalize and case"
1596
+ )
1597
+ return Value(
1598
+ name,
1599
+ _capture(source) if "from" in spec and not enclosing else "",
1600
+ spec.get("template"),
1601
+ _read_normalize(spec.get("normalize", [])),
1602
+ str(spec.get("case", "")),
1603
+ _regex(spec["pattern"]) if path else None,
1604
+ str(spec.get("relative_to", "")),
1605
+ (str(span["enclosing"]), str(span["capture"])) if span is not None else None,
1606
+ str(spec.get("join", "")),
1607
+ )
1608
+
1609
+
1610
+ def _capture(source: object) -> str:
1611
+ """The group ``{capture: name}`` names."""
1612
+ if not isinstance(source, Mapping) or set(source) != {"capture"}:
1613
+ raise _Unsupported("a source of a text rule is not one capture")
1614
+ return str(source["capture"])
1615
+
1616
+
1617
+ def _build_declaration(pack: str, item: Mapping[str, Any]) -> Rule:
1618
+ """A rule that reads a parse tree: it fires on each tree node of the ``declaration`` type."""
1619
+ match, emits = item["match"], item["emits"]
1620
+ if (
1621
+ set(item) | {"id"} != _RULE_KEYS
1622
+ or item["reads"] != "file"
1623
+ or not set(match) <= _DECLARATION_MATCH
1624
+ or "filetype" not in match
1625
+ or not isinstance(match.get("name_child", ""), (str, list))
1626
+ ):
1627
+ raise _Unsupported(
1628
+ "a declaration rule is not a file rule with in, match.filetype and no match keys "
1629
+ "beyond declaration, name_child, body_child, where, file and ending"
1630
+ )
1631
+ where = match.get("where", [])
1632
+ if not isinstance(where, list) or not all(
1633
+ isinstance(c, Mapping)
1634
+ and len(c) == 1
1635
+ and set(c) <= {"has_child", "name_matches"}
1636
+ and (isinstance(next(iter(c.values())), str) or "name_matches" in c)
1637
+ for c in where
1638
+ ):
1639
+ raise _Unsupported(
1640
+ "the match.where of a declaration rule holds only has_child and name_matches conditions"
1641
+ )
1642
+ nodes = node_types(item)
1643
+ edges = [_read_tree_edge(emit["edge"]) for emit in emits if "edge" in emit]
1644
+ if (
1645
+ len(nodes) + len(edges) != len(emits)
1646
+ or len(nodes) > 1
1647
+ or any(emit["node"].keys() != {"type"} for emit in emits if "node" in emit)
1648
+ ):
1649
+ raise _Unsupported("a declaration rule emits one node with a type, or edges only")
1650
+ block = item.get("id", {})
1651
+ argument = _argument(block.get("from"), "argument") if isinstance(block, Mapping) else None
1652
+ if argument is not None and not set(block) <= {"from", "normalize"}:
1653
+ raise _Unsupported("an id from a call's argument takes only an optional normalize")
1654
+ if argument is not None and "body_child" in match:
1655
+ raise _Unsupported("an id from a call's argument cannot be combined with match.body_child")
1656
+ template = _tree_id(item["id"]) if "id" in item and argument is None else ""
1657
+ pattern = block.get("pattern", "") if template else ""
1658
+ if pattern:
1659
+ _regex(PATTERN_VALUE.sub("", pattern))
1660
+ if not (template or argument) and (
1661
+ nodes or any("this_node" in (e.start, e.finish) for e in edges)
1662
+ ):
1663
+ raise _Unsupported("a declaration rule that emits a node or names this_node needs an id")
1664
+ if "name_child" not in match and {"declared_name", "qualified_name"} & set(
1665
+ PLACEHOLDER.findall(template + (f"{{{block['from']}}}" if pattern else ""))
1666
+ ):
1667
+ raise _Unsupported("an id from declared_name or qualified_name needs match.name_child")
1668
+ sources = [s for s in block.get("from", ()) if isinstance(s, Mapping)] if template else []
1669
+ docblocks = [d for d in map(_docblock, sources) if d is not None]
1670
+ if len(docblocks) > 1:
1671
+ raise _Unsupported("a declaration rule's id has one docblock source")
1672
+ name_child = match.get("name_child", "")
1673
+ path = tuple(name_child) if isinstance(name_child, list) else ()
1674
+ return Rule(
1675
+ pack=pack,
1676
+ name=str(item["rule"]),
1677
+ supported=True,
1678
+ folders=tuple(item["in"]),
1679
+ glob=match.get("file", "*"),
1680
+ filetype=match["filetype"],
1681
+ node_type=nodes[0] if nodes else "",
1682
+ confidence=item["confidence"],
1683
+ edges=tuple(edges),
1684
+ id_template=template,
1685
+ id_pattern=pattern,
1686
+ id_source=str(block["from"]) if pattern else "",
1687
+ qualified=tuple(s["qualified"] for s in sources if _docblock(s) is None),
1688
+ docblock=docblocks[0][0] if docblocks else None,
1689
+ docblock_qualified=bool(docblocks) and docblocks[0][1],
1690
+ normalize=_read_normalize(item["id"].get("normalize", [])) if "id" in item else (),
1691
+ declaration=match["declaration"],
1692
+ endings=_read_ending(match),
1693
+ name_child="" if path else name_child,
1694
+ name_path=path,
1695
+ body_child=match.get("body_child", ""),
1696
+ has_child=tuple(c["has_child"] for c in where if "has_child" in c),
1697
+ id_argument=argument or "",
1698
+ name_matches=_name_matches(where, None if path else name_child or None),
1699
+ )
1700
+
1701
+
1702
+ def _build_map(pack: str, item: Mapping[str, Any]) -> Rule:
1703
+ """A rule that reads the map: it fires on each node of ``match.node_type`` or a type below it
1704
+ that its ``where`` holds for, once for each node its ``follow`` steps reach."""
1705
+ match, emits = item["match"], item["emits"]
1706
+ if (
1707
+ "in" in item
1708
+ or not set(match) <= {"node_type", "where", "follow"}
1709
+ or "node_type" not in match
1710
+ ):
1711
+ raise _Unsupported(
1712
+ "a map rule is not a rule with match.node_type, optional where and follow, and no in"
1713
+ )
1714
+ where = _read_where(match.get("where", []))
1715
+ if not all(isinstance(condition, IdMatches) for condition in where):
1716
+ raise _Unsupported("the match.where of a map rule holds only id_matches conditions")
1717
+ follow: list[tuple[str, Any]] = []
1718
+ for step in match.get("follow", []):
1719
+ ((name, value),) = step.items()
1720
+ if name == "id_matches":
1721
+ value = _regex(value)
1722
+ elif name == "ancestors":
1723
+ value = tuple(value)
1724
+ follow.append((name, value))
1725
+ nodes = node_types(item)
1726
+ edges = [_read_map_edge(emit["edge"], bool(follow)) for emit in emits if "edge" in emit]
1727
+ block = item.get("id")
1728
+ if (
1729
+ len(nodes) + len(edges) != len(emits)
1730
+ or len(nodes) > 1
1731
+ or any(emit["node"].keys() != {"type"} for emit in emits if "node" in emit)
1732
+ or (edges and block is not None)
1733
+ or (nodes and not (isinstance(block, Mapping) and set(block) == {"template"}))
1734
+ ):
1735
+ raise _Unsupported(
1736
+ "a map rule emits one node with a type and an id from one template, or edges and no id"
1737
+ )
1738
+ return Rule(
1739
+ pack=pack,
1740
+ name=str(item["rule"]),
1741
+ supported=True,
1742
+ folders=(),
1743
+ glob="",
1744
+ filetype="",
1745
+ node_type=nodes[0] if nodes else "",
1746
+ confidence=item["confidence"],
1747
+ where=where,
1748
+ edges=tuple(edges),
1749
+ id_template=str(block["template"]) if nodes and block is not None else "",
1750
+ matches_type=str(match["node_type"]),
1751
+ follow=tuple(follow),
1752
+ )
1753
+
1754
+
1755
+ #: The ends of a map rule's edge that name a node: the node it matched, and the node its follow
1756
+ #: steps reached.
1757
+ _MAP_ENDS = ("this_node", "matched_node")
1758
+
1759
+
1760
+ def _read_map_edge(edge: Mapping[str, Any], followed: bool) -> EdgeSpec:
1761
+ """An edge of a map rule: between two node ends, or a node end and one composed end."""
1762
+ start, end = edge.get("from"), edge.get("to")
1763
+ composed = [e for e in (start, end) if isinstance(e, Mapping)]
1764
+ named = [e for e in (start, end) if isinstance(e, str) and e in _MAP_ENDS]
1765
+ if (
1766
+ set(edge) != {"kind", "from", "to"}
1767
+ or len(named) + len(composed) != 2
1768
+ or len(composed) > 1
1769
+ or ("matched_node" in named and not followed)
1770
+ or any(
1771
+ not {"template"} <= set(c) <= {"template", "type", "types", "on_miss"}
1772
+ or not isinstance(c["template"], str)
1773
+ or not _types_ok(c)
1774
+ for c in composed
1775
+ )
1776
+ ):
1777
+ raise _Unsupported(
1778
+ "an edge of a map rule does not run between this_node or matched_node and one of them "
1779
+ "or a template with type or types and on_miss; matched_node needs match.follow"
1780
+ )
1781
+ if not composed:
1782
+ return EdgeSpec(edge["kind"], "", False, None, None, None, (), str(start), str(end))
1783
+ part, flipped = composed[0], isinstance(start, Mapping)
1784
+ return EdgeSpec(
1785
+ edge["kind"],
1786
+ "",
1787
+ False,
1788
+ None,
1789
+ None,
1790
+ part["template"],
1791
+ (),
1792
+ start="" if flipped else str(start),
1793
+ finish=str(end) if flipped else "",
1794
+ to_type=part.get("type", next(iter(part.get("types", [])), "")),
1795
+ on_miss="on_miss" in part,
1796
+ types=tuple(part.get("types", ())),
1797
+ )
1798
+
1799
+
1800
+ def _read_ending(match: Mapping[str, Any]) -> tuple[str, ...]:
1801
+ """A rule's ``match.ending``, one ending or a list of them; empty when absent."""
1802
+ ending = match.get("ending", [])
1803
+ return (ending,) if isinstance(ending, str) else tuple(ending)
1804
+
1805
+
1806
+ def _template_end(end: object) -> bool:
1807
+ """Whether an edge end is ``{template: <text>}`` and nothing else."""
1808
+ return (
1809
+ isinstance(end, Mapping) and set(end) == {"template"} and isinstance(end["template"], str)
1810
+ )
1811
+
1812
+
1813
+ def _types_ok(end: Mapping[str, Any]) -> bool:
1814
+ """Whether an end gives ``type`` or a list of ``types``, not both."""
1815
+ listed = end.get("types", [])
1816
+ return not {"type", "types"} <= set(end) and all(isinstance(t, str) for t in listed)
1817
+
1818
+
1819
+ def _build_reference(pack: str, item: Mapping[str, Any], tables: frozenset[str]) -> Rule:
1820
+ """A rule that fires on each tree node of the ``reference`` type: an edge from the node the
1821
+ nearest enclosing declaration was given to the name its ``name_child`` writes."""
1822
+ match, emits = item["match"], item["emits"]
1823
+ child = match.get("name_child")
1824
+ fields = [child] if isinstance(child, str) else child if isinstance(child, list) else []
1825
+ if (
1826
+ set(item) != _RULE_KEYS - {"id"}
1827
+ or item["reads"] != "file"
1828
+ or not set(match)
1829
+ <= {
1830
+ *("reference", "name_child", "filetype", "file", "ending"),
1831
+ *("skip_names", "whole_written", "where"),
1832
+ }
1833
+ or "filetype" not in match
1834
+ or not fields
1835
+ or not all(isinstance(field, str) for field in fields)
1836
+ or any("edge" not in emit for emit in emits)
1837
+ ):
1838
+ raise _Unsupported(
1839
+ "a reference rule is not a file rule with in, match.reference, match.name_child, "
1840
+ "match.filetype and edges only, and no id"
1841
+ )
1842
+ where = match.get("where", [])
1843
+ if not isinstance(where, list) or not all(
1844
+ isinstance(c, Mapping) and set(c) == {"name_matches"} for c in where
1845
+ ):
1846
+ raise _Unsupported("the match.where of a reference rule holds only name_matches conditions")
1847
+ name_matches = _name_matches(where, fields[0])
1848
+ groups = {group for _, pattern in name_matches for group in pattern.groupindex}
1849
+ specs = []
1850
+ for emit in emits:
1851
+ edge = emit["edge"]
1852
+ end = edge.get("to")
1853
+ argument = _argument(end.get("from"), "argument") if isinstance(end, Mapping) else None
1854
+ start = edge.get("from")
1855
+ if (
1856
+ set(edge) != {"kind", "from", "to"}
1857
+ or (start != "enclosing_declaration" and not _template_end(start))
1858
+ or not isinstance(end, Mapping)
1859
+ or not set(end)
1860
+ <= {"type", "types", "on_miss", "resolve", "separator", "from", "template"}
1861
+ or ("from" in end and argument is None)
1862
+ or not _types_ok(end)
1863
+ ):
1864
+ raise _Unsupported(
1865
+ "an edge of a reference rule does not run from enclosing_declaration or a template "
1866
+ "to an end of type or types, resolve, separator and on_miss, and from a call's "
1867
+ "argument or a template"
1868
+ )
1869
+ to_template = end.get("template")
1870
+ from_template = start["template"] if isinstance(start, Mapping) else None
1871
+ names = set(PLACEHOLDER.findall(f"{to_template or ''}{from_template or ''}"))
1872
+ if (
1873
+ (to_template is not None and ("from" in end or not isinstance(to_template, str)))
1874
+ or (to_template is not None and from_template is not None)
1875
+ or not names <= _REFERENCE_VALUES | groups
1876
+ ):
1877
+ raise _Unsupported(
1878
+ "a template end of a reference rule is one end of an edge, naming only "
1879
+ "enclosing_type, enclosing_method and the named groups of its name_matches "
1880
+ "conditions"
1881
+ )
1882
+ specs.append(
1883
+ EdgeSpec(
1884
+ edge["kind"],
1885
+ argument or "",
1886
+ False,
1887
+ None,
1888
+ None,
1889
+ to_template,
1890
+ (),
1891
+ start="enclosing_declaration" if from_template is None else "template",
1892
+ to_type=end.get("type", next(iter(end.get("types", [])), "")),
1893
+ on_miss="on_miss" in end,
1894
+ resolve=_read_steps(end.get("resolve", []), tables),
1895
+ separator=str(end.get("separator", "")),
1896
+ # The one-type form is a list of that one type.
1897
+ types=tuple(end.get("types", ())) or tuple(end.get("type", "").split()),
1898
+ from_template=from_template,
1899
+ )
1900
+ )
1901
+ return Rule(
1902
+ pack=pack,
1903
+ name=str(item["rule"]),
1904
+ supported=True,
1905
+ folders=tuple(item["in"]),
1906
+ glob=match.get("file", "*"),
1907
+ filetype=match["filetype"],
1908
+ node_type="",
1909
+ confidence=item["confidence"],
1910
+ edges=tuple(specs),
1911
+ id_template="",
1912
+ name_child=fields[0],
1913
+ reference=match["reference"],
1914
+ endings=_read_ending(match),
1915
+ member_child=fields[1] if len(fields) > 1 else "",
1916
+ skip_names=frozenset(match.get("skip_names", ())),
1917
+ whole_written=match.get("whole_written") is True,
1918
+ name_matches=name_matches,
1919
+ )
1920
+
1921
+
1922
+ def _build_attribute(pack: str, item: Mapping[str, Any]) -> Rule:
1923
+ """A rule that fires on each attribute of one class, or each docblock annotation of one name,
1924
+ written on one kind of declaration."""
1925
+ match, emits = item["match"], item["emits"]
1926
+ where = match.get("where")
1927
+ shape = "annotation" if "annotation" in match else "attribute"
1928
+ key = "annotation_key" if shape == "annotation" else "attribute_argument"
1929
+ if (
1930
+ set(item) | {"id"} != _RULE_KEYS
1931
+ or item["reads"] != "file"
1932
+ or not set(match) <= {shape, "filetype", "where"}
1933
+ or "filetype" not in match
1934
+ or not (isinstance(where, list) and len(where) == 1 and set(where[0]) == {"applies_to"})
1935
+ ):
1936
+ raise _Unsupported(
1937
+ f"an {shape} rule is not a file rule with in, match.filetype and one where condition, "
1938
+ "applies_to"
1939
+ )
1940
+ nodes = node_types(item)
1941
+ attributes = [emit["attribute"] for emit in emits if "attribute" in emit]
1942
+ edges = [_read_attribute_edge(emit["edge"]) for emit in emits if "edge" in emit]
1943
+ if (
1944
+ len(nodes) + len(attributes) + len(edges) != len(emits)
1945
+ or len(nodes) > 1
1946
+ or any(emit["node"].keys() != {"type"} for emit in emits if "node" in emit)
1947
+ or (edges and (nodes or attributes))
1948
+ or (not edges and not nodes)
1949
+ ):
1950
+ raise _Unsupported(f"an {shape} rule emits one node with a type and attributes, or edges")
1951
+ if shape == "annotation" and any({e.start, e.finish} - _TREE_ENDS for e in edges):
1952
+ raise _Unsupported("an annotation rule's edge runs between this_node and enclosing_class")
1953
+ block = item.get("id", {})
1954
+ source = block.get("from") if isinstance(block, Mapping) else None
1955
+ if "id" in item and (not set(block) <= {"from", "must_be"} or _argument(source, key) is None):
1956
+ raise _Unsupported(f"the id block of an {shape} rule is not from one {key}")
1957
+ if "id" not in item and (nodes or any("this_node" in (e.start, e.finish) for e in edges)):
1958
+ raise _Unsupported(f"an {shape} rule that emits a node or names this_node needs an id")
1959
+ read = [(a.get("name"), _argument(a.get("from"), key)) for a in attributes]
1960
+ if any(not isinstance(name, str) or argument is None for name, argument in read):
1961
+ raise _Unsupported(f"an attribute of an {shape} rule is not a name and an {key}")
1962
+ return Rule(
1963
+ pack=pack,
1964
+ name=str(item["rule"]),
1965
+ supported=True,
1966
+ folders=tuple(item["in"]),
1967
+ glob="*",
1968
+ filetype=match["filetype"],
1969
+ node_type=nodes[0] if nodes else "",
1970
+ confidence=item["confidence"],
1971
+ attributes=tuple((str(name), str(argument)) for name, argument in read),
1972
+ edges=tuple(edges),
1973
+ id_template="",
1974
+ attribute=match.get("attribute", ""),
1975
+ applies_to=where[0]["applies_to"],
1976
+ id_argument=_argument(source, key) or "",
1977
+ must_be_literal=block.get("must_be") == "string_literal",
1978
+ annotation=match.get("annotation", ""),
1979
+ )
1980
+
1981
+
1982
+ def _name_matches(
1983
+ where: list[Mapping[str, Any]], name_child: str | None
1984
+ ) -> tuple[tuple[str, re.Pattern[str]], ...]:
1985
+ """The child and pattern of each ``name_matches`` condition: a ``{child, pattern}`` mapping, or
1986
+ a pattern alone, which tests ``name_child`` and so needs it."""
1987
+ found = []
1988
+ for condition in (c["name_matches"] for c in where if "name_matches" in c):
1989
+ if isinstance(condition, Mapping):
1990
+ if set(condition) != {"child", "pattern"} or not isinstance(condition["child"], str):
1991
+ raise _Unsupported("a name_matches mapping is not a child and a pattern")
1992
+ found.append((condition["child"], _regex(condition["pattern"])))
1993
+ elif name_child is None:
1994
+ raise _Unsupported("a name_matches condition needs match.name_child")
1995
+ else:
1996
+ found.append((name_child, _regex(condition)))
1997
+ return tuple(found)
1998
+
1999
+
2000
+ def _argument(source: object, key: str = "attribute_argument") -> str | None:
2001
+ """The argument or annotation key ``{key: name or position}`` names, or ``None``."""
2002
+ if not isinstance(source, Mapping) or set(source) != {key}:
2003
+ return None
2004
+ found = source[key]
2005
+ return str(found) if isinstance(found, (str, int)) and not isinstance(found, bool) else None
2006
+
2007
+
2008
+ def _read_attribute_edge(edge: Mapping[str, Any]) -> EdgeSpec:
2009
+ """An edge of an attribute rule: between node ends, or to the id an argument's value names."""
2010
+ start, end = edge["from"], edge["to"]
2011
+ argument = (
2012
+ _argument(end.get("from")) if isinstance(end, Mapping) and set(end) == {"from"} else None
2013
+ )
2014
+ if (
2015
+ set(edge) != {"kind", "from", "to"}
2016
+ or not (isinstance(start, str) and start in _ATTRIBUTE_ENDS)
2017
+ or (argument is None and not (isinstance(end, str) and end in _ATTRIBUTE_ENDS))
2018
+ ):
2019
+ raise _Unsupported(
2020
+ "an edge of an attribute rule does not run from this_node, enclosing_class or "
2021
+ "enclosing_method to one of them or to {from: {attribute_argument: ...}}"
2022
+ )
2023
+ if argument is not None:
2024
+ return EdgeSpec(
2025
+ edge["kind"], argument, False, None, None, None, (), start, "attribute_argument"
2026
+ )
2027
+ return EdgeSpec(edge["kind"], "", False, None, None, None, (), start, end)
2028
+
2029
+
2030
+ def _tree_id(block: Mapping[str, Any]) -> str:
2031
+ """The id block of a declaration rule as one template over its sources: a name the engine
2032
+ fills, a path value, a settings value or a field of the matched node."""
2033
+ source, template = block.get("from"), block.get("template")
2034
+ if "pattern" in block and set(block) <= {"from", "pattern", "template", "normalize"}:
2035
+ if not (isinstance(source, str) and PLACEHOLDER.fullmatch(f"{{{source}}}")):
2036
+ raise _Unsupported(
2037
+ "an id pattern reads one source, a name and not a list"
2038
+ if isinstance(source, list)
2039
+ else "an id pattern has no `from` naming the one source it reads"
2040
+ if source is None
2041
+ else "an id pattern reads one source, and its `from` is not a name"
2042
+ )
2043
+ return f"{{{source}}}" if template is None else str(template)
2044
+ if set(block) <= {"from", "template", "normalize"}:
2045
+ if isinstance(source, str) and PLACEHOLDER.fullmatch(f"{{{source}}}") and template is None:
2046
+ return f"{{{source}}}"
2047
+ gates: set[str] = set()
2048
+ if isinstance(source, list):
2049
+ found = [_docblock(s) for s in source if isinstance(s, Mapping)]
2050
+ gates = {g for d in found if d is not None for g in _groups(d[0])}
2051
+ source = [_qualified(s) if isinstance(s, Mapping) else s for s in source]
2052
+ if (
2053
+ isinstance(source, list)
2054
+ and isinstance(template, str)
2055
+ and set(PLACEHOLDER.findall(template)) | gates == set(source)
2056
+ ):
2057
+ return template
2058
+ raise _Unsupported(
2059
+ "the id block of a declaration rule is not from one source, or from several with a "
2060
+ "template naming each, and an optional normalize"
2061
+ )
2062
+
2063
+
2064
+ def _qualified(source: Mapping[str, Any]) -> str:
2065
+ """The field a source ``{qualified: <field>}`` names, or the value a docblock source's group
2066
+ names."""
2067
+ docblock = _docblock(source)
2068
+ if docblock is not None:
2069
+ return next(iter(_groups(docblock[0])))
2070
+ if set(source) != {"qualified"} or not isinstance(source["qualified"], str):
2071
+ raise _Unsupported("a source of a declaration rule's id is a name or {qualified: <field>}")
2072
+ return source["qualified"]
2073
+
2074
+
2075
+ def _docblock(source: Mapping[str, Any]) -> tuple[re.Pattern[str], bool] | None:
2076
+ """The pattern of a source ``{docblock: <pattern>}``, or of one wrapped in ``qualified``, and
2077
+ whether it is wrapped; ``None`` for any other source. The pattern has one named group."""
2078
+ inner = source.get("qualified")
2079
+ wrapped = isinstance(inner, Mapping) and set(source) == {"qualified"}
2080
+ found: Mapping[str, Any] = inner if wrapped and isinstance(inner, Mapping) else source
2081
+ if set(found) != {"docblock"}:
2082
+ return None
2083
+ pattern = _regex(found["docblock"])
2084
+ if len(pattern.groupindex) != 1:
2085
+ raise _Unsupported("the pattern of a docblock source has not exactly one named group")
2086
+ return pattern, wrapped
2087
+
2088
+
2089
+ def _groups(pattern: re.Pattern[str] | None) -> tuple[str, ...]:
2090
+ """The names of the pattern's named groups; none for no pattern."""
2091
+ return tuple(pattern.groupindex) if pattern is not None else ()
2092
+
2093
+
2094
+ def _read_tree_edge(edge: Mapping[str, Any]) -> EdgeSpec:
2095
+ """An edge of a declaration rule: from a node end to a node end or to the written names. A node
2096
+ end ``{declared: <type>}`` is the node of that type another rule declares at the match."""
2097
+ start, end = edge["from"], edge["to"]
2098
+ declared = (_declared(start), _declared(end))
2099
+ start = "declared" if declared[0] else start
2100
+ finish = "declared" if declared[1] else end if isinstance(end, str) else ""
2101
+ if end == {"from": "qualified_name"}:
2102
+ finish = "qualified_name"
2103
+ if (
2104
+ set(edge) != {"kind", "from", "to"}
2105
+ or not (declared[0] or isinstance(start, str) and start in _DECLARATION_ENDS)
2106
+ or not (declared[1] or finish in {*_DECLARATION_ENDS, "qualified_name"})
2107
+ ):
2108
+ raise _Unsupported(
2109
+ "an edge of a declaration rule does not run from this_node, enclosing_class, "
2110
+ "enclosing_file, enclosing_method or {declared: <type>} to one of them or to "
2111
+ "{from: qualified_name}"
2112
+ )
2113
+ return EdgeSpec(edge["kind"], "", False, None, None, None, (), start, finish, declared=declared)
2114
+
2115
+
2116
+ def _declared(end: object) -> str:
2117
+ """The node type an end ``{declared: <type>}`` names; ``""`` for any other end."""
2118
+ if isinstance(end, Mapping) and set(end) == {"declared"} and isinstance(end["declared"], str):
2119
+ return end["declared"]
2120
+ return ""
2121
+
2122
+
2123
+ def _read_each(path: object) -> str:
2124
+ """The key path of the mapping whose entries a rule fires on; ``""`` for the top level."""
2125
+ if not isinstance(path, str) or path.count("*") != 1 or path.split(".")[-1] != "*":
2126
+ raise _Unsupported("match.each is not a key path whose last segment alone is a star")
2127
+ return path.removesuffix("*").removesuffix(".")
2128
+
2129
+
2130
+ def _id_template(block: Mapping[str, Any], entries: bool) -> str:
2131
+ """The id block as one template over ``{file_stem}`` and ``{key}``."""
2132
+ source, template = block.get("from"), block.get("template")
2133
+ if source == "file_stem" and template is None:
2134
+ return "{file_stem}"
2135
+ if source == "key" and template is None and entries:
2136
+ return "{key}"
2137
+ named = [source] if isinstance(source, str) else source if source is not None else []
2138
+ allowed = {"file_stem", "key"} if entries else {"file_stem"}
2139
+ # A placeholder beyond the sources must be a path value, checked once the file types are read.
2140
+ if (
2141
+ isinstance(named, list)
2142
+ and all(isinstance(name, str) for name in named)
2143
+ and set(named) <= allowed
2144
+ and isinstance(template, str)
2145
+ and set(named) <= set(PLACEHOLDER.findall(template))
2146
+ ):
2147
+ return template
2148
+ raise _Unsupported(
2149
+ "the id block is not from file_stem, from key with match.each, or a template naming its "
2150
+ "sources, file_stem, key with match.each, and path values"
2151
+ )
2152
+
2153
+
2154
+ def _regex(expression: object) -> re.Pattern[str]:
2155
+ if not isinstance(expression, str):
2156
+ raise _Unsupported("a regular expression is not a string")
2157
+ try:
2158
+ return compile_pattern(expression)
2159
+ except re.error as error:
2160
+ raise _Unsupported(f"the expression {expression} does not compile: {error}") from error
2161
+
2162
+
2163
+ def _key_path(source: object) -> str:
2164
+ if not isinstance(source, dict) or set(source) != {"key"} or not isinstance(source["key"], str):
2165
+ raise _Unsupported("a source is not a single key path")
2166
+ return source["key"]
2167
+
2168
+
2169
+ def _read_where(where: object) -> tuple[Condition, ...]:
2170
+ conditions: list[Condition] = []
2171
+ for condition in where if isinstance(where, list) else [None]:
2172
+ name, found = (
2173
+ next(iter(condition.items()))
2174
+ if isinstance(condition, dict) and len(condition) == 1
2175
+ else (None, None)
2176
+ )
2177
+ if (
2178
+ name == "key"
2179
+ and isinstance(found, dict)
2180
+ and set(found) in ({"name", "equals"}, {"name", "not_equals"})
2181
+ and isinstance(found["name"], str)
2182
+ and not isinstance(found.get("equals", found.get("not_equals")), (dict, list))
2183
+ ):
2184
+ negate = "not_equals" in found
2185
+ value = found["not_equals" if negate else "equals"]
2186
+ conditions.append(KeyEquals(found["name"], value, negate))
2187
+ elif (
2188
+ name == "has_key"
2189
+ and isinstance(found, dict)
2190
+ and set(found) == {"path"}
2191
+ and isinstance(found["path"], str)
2192
+ ):
2193
+ conditions.append(HasKey(found["path"]))
2194
+ elif name == "id_matches":
2195
+ conditions.append(IdMatches(_regex(found)))
2196
+ elif name == "is_mapping" and isinstance(found, bool):
2197
+ conditions.append(IsMapping(found))
2198
+ else:
2199
+ raise _Unsupported(
2200
+ "a match.where condition is not a key with name and equals or not_equals, "
2201
+ "a has_key with one path, an id_matches, or an is_mapping of true or false"
2202
+ )
2203
+ return tuple(conditions)
2204
+
2205
+
2206
+ def _read_normalize(normalize: object) -> tuple[tuple[re.Pattern[str], str], ...]:
2207
+ steps = []
2208
+ for step in normalize if isinstance(normalize, list) else [None]:
2209
+ if not isinstance(step, dict) or set(step) != {"replace", "with"}:
2210
+ raise _Unsupported("a normalize step is not a replace and a with")
2211
+ if not isinstance(step["with"], str):
2212
+ raise _Unsupported("a normalize step has a replacement that is not a string")
2213
+ steps.append((_regex(step["replace"]), step["with"]))
2214
+ return tuple(steps)
2215
+
2216
+
2217
+ def _read_attribute(attribute: object) -> tuple[str, str]:
2218
+ if (
2219
+ not isinstance(attribute, dict)
2220
+ or set(attribute) != {"name", "from"}
2221
+ or not isinstance(attribute["name"], str)
2222
+ ):
2223
+ raise _Unsupported("an attribute is not a name and a key source")
2224
+ return attribute["name"], _key_path(attribute["from"])
2225
+
2226
+
2227
+ def _read_edge(edge: object) -> EdgeSpec:
2228
+ """An edge of a data rule: between this_node and a composed end, in either direction."""
2229
+ flipped = isinstance(edge, dict) and edge.get("to") == "this_node" != edge.get("from")
2230
+ end = (edge.get("from") if flipped else edge.get("to")) if isinstance(edge, dict) else None
2231
+ on_miss = isinstance(end, dict) and "on_miss" in end
2232
+ if isinstance(edge, dict) and isinstance(end, dict):
2233
+ end = {key: value for key, value in end.items() if key != "on_miss"}
2234
+ edge = {**edge, "from": "this_node", "to": end}
2235
+ spec = _read_forward(edge)
2236
+ if flipped:
2237
+ spec = replace(spec, start="", finish="this_node")
2238
+ return replace(spec, on_miss=on_miss)
2239
+
2240
+
2241
+ def _read_forward(edge: object) -> EdgeSpec:
2242
+ end = edge.get("to") if isinstance(edge, dict) else None
2243
+ if (
2244
+ not isinstance(edge, dict)
2245
+ or set(edge) != {"kind", "from", "to"}
2246
+ or not isinstance(edge["kind"], str)
2247
+ or edge["from"] != "this_node"
2248
+ or not isinstance(end, dict)
2249
+ or not {"from"} <= set(end) <= {"from", "each", "where", "pattern", "template", "type"}
2250
+ ):
2251
+ raise _Unsupported("an edge is not from this_node to a from end")
2252
+ template, to_type = end.get("template"), end.get("type", "")
2253
+ if template is not None and not isinstance(template, str):
2254
+ raise _Unsupported("an edge template is not a string")
2255
+ source = end["from"]
2256
+ if isinstance(source, list):
2257
+ parts = tuple(_read_part(part) for part in source) or (("file", ""),)
2258
+ where = end.get("where", [{"listed_at": None}])
2259
+ conditions = where if isinstance(where, list) else [None]
2260
+ listed: object = None
2261
+ seen = False
2262
+ matches: str | None = None
2263
+ not_matches: list[str] = []
2264
+ refused = "an edge end takes a key, or a list source with a template and at most one each, "
2265
+ refused += "or from: [] with a template alone, and where conditions "
2266
+ refused += "listed_at, matches (a glob on each key) and not_matches (a glob on each key)"
2267
+ for condition in conditions:
2268
+ if not isinstance(condition, dict) or len(condition) != 1:
2269
+ raise _Unsupported(refused)
2270
+ ((name, value),) = condition.items()
2271
+ if name == "listed_at" and not seen:
2272
+ listed, seen = value, True
2273
+ elif name == "matches" and matches is None and isinstance(value, str):
2274
+ matches = value
2275
+ elif name == "not_matches" and isinstance(value, str):
2276
+ not_matches.append(value)
2277
+ else:
2278
+ raise _Unsupported(refused)
2279
+ eaches = sum(mode == "each" for mode, _ in parts)
2280
+ if (
2281
+ eaches > 1
2282
+ or (eaches == 0 and (matches is not None or not_matches))
2283
+ or template is None
2284
+ or not {"from", "template"} <= set(end) <= {"from", "template", "where", "type"}
2285
+ or not (listed is None or (isinstance(listed, str) and listed))
2286
+ ):
2287
+ raise _Unsupported(refused)
2288
+ return EdgeSpec(
2289
+ edge["kind"],
2290
+ "",
2291
+ False,
2292
+ matches,
2293
+ None,
2294
+ template,
2295
+ parts,
2296
+ listed_at=listed,
2297
+ not_matches=tuple(not_matches),
2298
+ to_type=to_type,
2299
+ )
2300
+ where = end.get("where")
2301
+ matches = None
2302
+ if where is not None:
2303
+ found = where[0].get("matches") if isinstance(where, list) and len(where) == 1 else None
2304
+ if not isinstance(found, str) or set(where[0]) != {"matches"}:
2305
+ raise _Unsupported("an edge where is not one matches glob")
2306
+ matches = found
2307
+ pattern = _regex(end["pattern"]) if "pattern" in end else None
2308
+ if not isinstance(end.get("each", False), bool):
2309
+ raise _Unsupported("an edge each is not true or false")
2310
+ path = TEXT if source == "text" else _key_path(source)
2311
+ return EdgeSpec(
2312
+ edge["kind"], path, end.get("each", False), matches, pattern, template, (), to_type=to_type
2313
+ )
2314
+
2315
+
2316
+ def _read_part(part: object) -> tuple[str, str]:
2317
+ if isinstance(part, dict) and set(part) == {"each"} and isinstance(part["each"], str):
2318
+ return "each", part["each"]
2319
+ if part == "key":
2320
+ return "entry", ""
2321
+ return "key", _key_path(part)