tina4-python 3.13.102__tar.gz → 3.13.103__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. {tina4_python-3.13.102 → tina4_python-3.13.103}/PKG-INFO +1 -1
  2. {tina4_python-3.13.102 → tina4_python-3.13.103}/pyproject.toml +1 -1
  3. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/dev_admin/metrics.py +1 -1
  5. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/frond/engine.py +167 -234
  6. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/js/tina4-dev-admin.min.js +2 -2
  7. {tina4_python-3.13.102 → tina4_python-3.13.103}/.gitignore +0 -0
  8. {tina4_python-3.13.102 → tina4_python-3.13.103}/README.md +0 -0
  9. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/CLAUDE.md +0 -0
  10. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/HtmlElement.py +0 -0
  11. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/Testing.py +0 -0
  12. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/ai/__init__.py +0 -0
  13. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/ai/client.py +0 -0
  14. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/api/__init__.py +0 -0
  15. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/auth/__init__.py +0 -0
  16. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/cache/__init__.py +0 -0
  17. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/cli/__init__.py +0 -0
  18. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/container/__init__.py +0 -0
  19. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/context/__init__.py +0 -0
  20. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/context/chunker.py +0 -0
  21. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/__init__.py +0 -0
  22. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/cache.py +0 -0
  23. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/constants.py +0 -0
  24. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/events.py +0 -0
  25. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/middleware.py +0 -0
  26. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/port_takeover.py +0 -0
  27. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/rate_limiter.py +0 -0
  28. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/request.py +0 -0
  29. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/response.py +0 -0
  30. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/router.py +0 -0
  31. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/core/server.py +0 -0
  32. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/crud/__init__.py +0 -0
  33. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/__init__.py +0 -0
  34. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/adapter.py +0 -0
  35. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/connection.py +0 -0
  36. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/database_url.py +0 -0
  37. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/firebird.py +0 -0
  38. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/mongodb.py +0 -0
  39. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/mssql.py +0 -0
  40. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/mysql.py +0 -0
  41. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/odbc.py +0 -0
  42. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/postgres.py +0 -0
  43. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/sql_translator.py +0 -0
  44. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/database/sqlite.py +0 -0
  45. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/debug/__init__.py +0 -0
  46. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/debug/error_overlay.py +0 -0
  47. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/dev_admin/__init__.py +0 -0
  48. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/dev_admin/plan.py +0 -0
  49. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/dev_admin/project_index.py +0 -0
  50. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/docs.py +0 -0
  51. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/docstore/__init__.py +0 -0
  52. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/dotenv/__init__.py +0 -0
  53. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/env.py +0 -0
  54. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/frond/FROND.md +0 -0
  55. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/frond/__init__.py +0 -0
  56. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/frond/compiler.py +0 -0
  57. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/frond/parser.py +0 -0
  58. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/auth/meta.json +0 -0
  59. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  60. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/database/meta.json +0 -0
  61. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  62. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/error-overlay/meta.json +0 -0
  63. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  64. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/orm/meta.json +0 -0
  65. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  66. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  67. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/queue/meta.json +0 -0
  68. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  69. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/rest-api/meta.json +0 -0
  70. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  71. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/templates/meta.json +0 -0
  72. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  73. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  74. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/graphql/__init__.py +0 -0
  75. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/i18n/__init__.py +0 -0
  76. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/mcp/__init__.py +0 -0
  77. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/mcp/protocol.py +0 -0
  78. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/mcp/tools.py +0 -0
  79. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/messenger/__init__.py +0 -0
  80. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/migration/__init__.py +0 -0
  81. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/migration/runner.py +0 -0
  82. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/mqtt/__init__.py +0 -0
  83. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/mqtt/message.py +0 -0
  84. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/orm/__init__.py +0 -0
  85. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/orm/fields.py +0 -0
  86. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/orm/model.py +0 -0
  87. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/__feedback/widget.js +0 -0
  88. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/css/tina4.css +0 -0
  89. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/css/tina4.min.css +0 -0
  90. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/favicon.ico +0 -0
  91. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/images/logo.svg +0 -0
  92. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  93. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/js/frond.js +0 -0
  94. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/js/frond.min.js +0 -0
  95. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/js/tina4.min.js +0 -0
  96. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/js/tina4js.min.js +0 -0
  97. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/swagger/index.html +0 -0
  98. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  99. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/query_builder/__init__.py +0 -0
  100. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue/__init__.py +0 -0
  101. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue/amqp_url.py +0 -0
  102. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue/job.py +0 -0
  103. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue/kafka_backend.py +0 -0
  104. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue/lite_backend.py +0 -0
  105. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue/mongo_backend.py +0 -0
  106. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue/rabbitmq_backend.py +0 -0
  107. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue_backends/__init__.py +0 -0
  108. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue_backends/kafka_backend.py +0 -0
  109. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue_backends/mongo_backend.py +0 -0
  110. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  111. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/realtime/__init__.py +0 -0
  112. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/realtime/models/Attachment.py +0 -0
  113. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/realtime/models/Channel.py +0 -0
  114. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/realtime/models/ChannelMember.py +0 -0
  115. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/realtime/models/Message.py +0 -0
  116. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/realtime/models/Workspace.py +0 -0
  117. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/realtime/models/__init__.py +0 -0
  118. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/realtime/storage.py +0 -0
  119. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/seeder/__init__.py +0 -0
  120. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/service/__init__.py +0 -0
  121. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/session/__init__.py +0 -0
  122. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/session_handlers/__init__.py +0 -0
  123. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/session_handlers/memcached_handler.py +0 -0
  124. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  125. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/session_handlers/redis_handler.py +0 -0
  126. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/session_handlers/valkey_handler.py +0 -0
  127. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/swagger/__init__.py +0 -0
  128. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/components/crud.twig +0 -0
  129. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  130. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  131. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/docker/python/Dockerfile +0 -0
  132. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  133. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/errors/302.twig +0 -0
  134. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/errors/401.twig +0 -0
  135. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/errors/403.twig +0 -0
  136. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/errors/404.twig +0 -0
  137. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/errors/500.twig +0 -0
  138. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/errors/502.twig +0 -0
  139. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/errors/503.twig +0 -0
  140. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/errors/base.twig +0 -0
  141. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/frontend/README.md +0 -0
  142. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/templates/readme.md +0 -0
  143. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/test/__init__.py +0 -0
  144. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/test_client/__init__.py +0 -0
  145. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  148. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  149. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  150. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  151. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  152. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  153. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  154. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  155. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  156. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  157. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/validator/__init__.py +0 -0
  158. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/websocket/__init__.py +0 -0
  159. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/websocket/backplane.py +0 -0
  160. {tina4_python-3.13.102 → tina4_python-3.13.103}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.102
3
+ Version: 3.13.103
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.102"
3
+ version = "3.13.103"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.101"
54
+ return "3.13.103"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -12,7 +12,7 @@ class MetricsEngineError(RuntimeError):
12
12
 
13
13
  _TIMEOUT_SECONDS = 60
14
14
  _SUMMARY_KEYS = ("files_analyzed", "total_functions", "avg_complexity", "avg_maintainability")
15
- _FILE_KEYS = ("path", "loc", "avg_complexity", "maintainability", "has_tests")
15
+ _FILE_KEYS = ("path", "loc", "avg_complexity", "maintainability", "has_referencing_test")
16
16
  _FUNCTION_KEYS = ("name", "file", "line", "complexity", "loc")
17
17
  _INSTALL_HINT = "update the native tina4 CLI: https://tina4.com/cli"
18
18
  _last_scan_root = ""
@@ -643,86 +643,64 @@ def _expr_descriptor(expr: str, has_filters: bool):
643
643
  operator" — fall through to the function-call / ``_resolve`` path, which stays
644
644
  inline in :func:`_eval_expr` because its callable check is context-dependent.
645
645
  """
646
- # Array literal: ["a", "b", "c"] or [1, 2, 3]
647
- if expr.startswith("[") and expr.endswith("]"):
648
- inner = expr[1:-1].strip()
649
- if not inner:
650
- return ("array", [])
651
- # Split on commas, respecting quotes
652
- items = []
653
- current = ""
654
- in_q = None
655
- depth = 0
656
- for ch in inner:
657
- if ch in ('"', "'") and in_q is None:
658
- in_q = ch
659
- current += ch
660
- elif ch == in_q:
661
- in_q = None
662
- current += ch
663
- elif ch == "[" and in_q is None:
664
- depth += 1
665
- current += ch
666
- elif ch == "]" and in_q is None:
667
- depth -= 1
668
- current += ch
669
- elif ch == "," and in_q is None and depth == 0:
670
- items.append(current.strip())
671
- current = ""
672
- else:
673
- current += ch
674
- if current.strip():
646
+ for detector in _EXPRESSION_DETECTORS:
647
+ descriptor = detector(expr, has_filters)
648
+ if descriptor is not None:
649
+ return descriptor
650
+ return ("tail",)
651
+
652
+
653
+ def _split_literal_items(inner: str, opening: str, closing: str) -> list[str]:
654
+ """Split collection members without splitting nested collections."""
655
+ items = []
656
+ current = ""
657
+ in_q = None
658
+ depth = 0
659
+ for ch in inner:
660
+ if ch in ('"', "'") and in_q is None:
661
+ in_q = ch
662
+ elif ch == in_q:
663
+ in_q = None
664
+ elif ch in opening and in_q is None:
665
+ depth += 1
666
+ elif ch in closing and in_q is None:
667
+ depth -= 1
668
+ elif ch == "," and in_q is None and depth == 0:
675
669
  items.append(current.strip())
676
- return ("array", items)
670
+ current = ""
671
+ continue
672
+ current += ch
673
+ if current.strip():
674
+ items.append(current.strip())
675
+ return items
676
+
677
677
 
678
- # Dict literal: {"key": "value", ...}
678
+ def _describe_collection(expr: str, _has_filters: bool):
679
+ """Describe array and dictionary literals."""
680
+ if expr.startswith("[") and expr.endswith("]"):
681
+ inner = expr[1:-1].strip()
682
+ return ("array", _split_literal_items(inner, "[", "]") if inner else [])
679
683
  if expr.startswith("{") and expr.endswith("}"):
680
684
  inner = expr[1:-1].strip()
681
685
  if not inner:
682
686
  return ("dict", [])
683
- # Split on commas, respecting quotes and nesting
684
- pairs = []
685
- current = ""
686
- in_q = None
687
- depth = 0
688
- for ch in inner:
689
- if ch in ('"', "'") and in_q is None:
690
- in_q = ch
691
- current += ch
692
- elif ch == in_q:
693
- in_q = None
694
- current += ch
695
- elif ch in ("{", "[") and in_q is None:
696
- depth += 1
697
- current += ch
698
- elif ch in ("}", "]") and in_q is None:
699
- depth -= 1
700
- current += ch
701
- elif ch == "," and in_q is None and depth == 0:
702
- pairs.append(current.strip())
703
- current = ""
704
- else:
705
- current += ch
706
- if current.strip():
707
- pairs.append(current.strip())
687
+ pairs = _split_literal_items(inner, "{[", "}]")
708
688
  kv = []
709
689
  for pair in pairs:
710
690
  colon_pos = _find_outside_quotes(pair, ":")
711
691
  if colon_pos > 0:
712
692
  kv.append((pair[:colon_pos].strip(), pair[colon_pos + 1:].strip()))
713
693
  return ("dict", kv)
694
+ return None
695
+
714
696
 
715
- # String literal — only match if the entire expression is a single quoted
716
- # string with no unescaped matching quotes inside (avoids catching
717
- # expressions like 'yes' if x else 'no').
697
+ def _describe_primary(expr: str, _has_filters: bool):
698
+ """Describe literals and a fully parenthesized sub-expression."""
718
699
  if len(expr) >= 2:
719
700
  q = expr[0]
720
701
  if q in ('"', "'") and expr.endswith(q) and q not in expr[1:-1]:
721
702
  return ("str", expr[1:-1])
722
-
723
- # Parenthesized sub-expression: (expr) — strip parens and evaluate inner
724
703
  if expr.startswith("(") and expr.endswith(")"):
725
- # Verify matching parens (not just any parens)
726
704
  depth = 0
727
705
  matched = True
728
706
  for i, ch in enumerate(expr):
@@ -735,8 +713,11 @@ def _expr_descriptor(expr: str, has_filters: bool):
735
713
  break
736
714
  if matched:
737
715
  return ("paren", expr[1:-1])
716
+ return None
738
717
 
739
- # Ternary: condition ? "yes" : "no" — quote-aware
718
+
719
+ def _describe_conditional(expr: str, _has_filters: bool):
720
+ """Describe ternary, inline-if, and null-coalescing expressions."""
740
721
  q_pos = _find_outside_quotes(expr, "?")
741
722
  if q_pos > 0:
742
723
  rest = expr[q_pos + 1:]
@@ -744,66 +725,152 @@ def _expr_descriptor(expr: str, has_filters: bool):
744
725
  if c_pos >= 0:
745
726
  return ("ternary", expr[:q_pos].strip(),
746
727
  rest[:c_pos].strip(), rest[c_pos + 1:].strip())
747
-
748
- # Jinja2-style inline if: value if condition else other_value — quote-aware
749
728
  if_pos = _find_outside_quotes(expr, " if ")
750
729
  if if_pos >= 0:
751
730
  else_pos = _find_outside_quotes(expr, " else ")
752
731
  if else_pos > if_pos:
753
732
  return ("inline_if", expr[:if_pos].strip(),
754
733
  expr[if_pos + 4:else_pos].strip(), expr[else_pos + 6:].strip())
755
-
756
- # Null coalescing: value ?? "default"
757
734
  nc_pos = _find_outside_quotes(expr, "??")
758
735
  if nc_pos >= 0:
759
736
  return ("coalesce", expr[:nc_pos].strip(), expr[nc_pos + 2:].strip())
737
+ return None
760
738
 
761
- # String concatenation with ~
739
+
740
+ def _describe_binary(expr: str, _has_filters: bool):
741
+ """Describe concat, comparison, logical, and arithmetic expressions."""
762
742
  tilde_pos = _find_outside_quotes(expr, "~")
763
743
  if tilde_pos >= 0:
764
744
  return ("concat", _split_outside_quotes(expr, "~"))
765
-
766
- # Comparison / logical operators -> _eval_comparison, the SAME evaluator
767
- # {% if %} uses, so a condition means the same thing in a condition and in
768
- # an output expression.
769
- #
770
- # A LEADING unary ``not`` needs its own check: every operator in the tuple
771
- # below is matched WITH surrounding spaces, so ``not x`` (nothing to its
772
- # left) matched none of them, fell through to the ``_resolve`` tail, and was
773
- # looked up as a variable literally named "not x" -- found nothing, rendered
774
- # EMPTY. ``{% if not x %}`` and ``x and not y`` always worked; only the
775
- # standalone ``{{ not x }}`` was dropped, and before booleans rendered
776
- # lowercase a dropped expression and ``False -> ''`` looked identical, which
777
- # is why it survived. Fixed in 3.13.87 alongside the boolean contract.
778
745
  if expr.startswith("not "):
779
746
  return ("comparison",)
780
747
  for op in (" not in ", " in ", " is not ", " is ", "!=", "==", ">=", "<=", ">", "<", " and ", " or ", " not "):
781
748
  if _find_outside_quotes(expr, op) >= 0:
782
749
  return ("comparison",)
783
-
784
- # Arithmetic operators: +, -, *, /, //, %, ** (lowest to highest precedence)
785
- # Check for +/- first (lower precedence), then *//, then %, then **
786
750
  for op in (" + ", " - ", " * ", " // ", " / ", " % ", " ** "):
787
751
  pos = _find_outside_quotes(expr, op)
788
752
  if pos >= 0:
789
753
  return ("arith", op.strip(),
790
754
  expr[:pos].strip(), expr[pos + len(op):].strip())
755
+ return None
791
756
 
792
- # Filter pipe (Twig ``|``): the highest-precedence binary operator, so it
793
- # is resolved LAST here — after ~, comparisons and arithmetic have already
794
- # been split off — which makes ``|`` bind TIGHTER than all of them at any
795
- # nesting depth (issue #171). Only fires when the engine threaded its bound
796
- # applier in; the many internal callers pass None and skip it. A pipe inside
797
- # quotes/parens is not top-level (``_parse_filter_chain`` respects both), so
798
- # ``{{ (x|f) ~ y }}`` and ``{{ items|join(' ~ ') }}`` resolve correctly.
757
+
758
+ def _describe_filter(expr: str, has_filters: bool):
759
+ """Describe a top-level Twig filter chain when filter support is active."""
799
760
  if has_filters:
800
761
  var_name, pipe_filters = _parse_filter_chain(expr)
801
762
  if pipe_filters:
802
763
  return ("pipe", var_name, pipe_filters)
764
+ return None
803
765
 
804
- # No top-level operator — function call / _resolve tail (kept inline in
805
- # _eval_expr because its callable check is context-dependent).
806
- return ("tail",)
766
+
767
+ _EXPRESSION_DETECTORS = (
768
+ _describe_collection,
769
+ _describe_primary,
770
+ _describe_conditional,
771
+ _describe_binary,
772
+ _describe_filter,
773
+ )
774
+
775
+
776
+ def _eval_array(_expr, desc, context, apply_filters):
777
+ return [_eval_expr(item, context, apply_filters) for item in desc[1]]
778
+
779
+
780
+ def _eval_dict(_expr, desc, context, apply_filters):
781
+ return {
782
+ _eval_expr(key, context, apply_filters): _eval_expr(value, context, apply_filters)
783
+ for key, value in desc[1]
784
+ }
785
+
786
+
787
+ def _eval_primary(_expr, desc, context, apply_filters):
788
+ if desc[0] == "str":
789
+ return desc[1]
790
+ return _eval_expr(desc[1], context, apply_filters)
791
+
792
+
793
+ def _eval_conditional(_expr, desc, context, apply_filters):
794
+ _, condition, when_true, when_false = desc
795
+ branch = when_true if _eval_expr(condition, context, apply_filters) else when_false
796
+ return _eval_expr(branch, context, apply_filters)
797
+
798
+
799
+ def _eval_inline_if(_expr, desc, context, apply_filters):
800
+ _, when_true, condition, when_false = desc
801
+ branch = when_true if _eval_expr(condition, context, apply_filters) else when_false
802
+ return _eval_expr(branch, context, apply_filters)
803
+
804
+
805
+ def _eval_coalesce(_expr, desc, context, apply_filters):
806
+ value = _eval_expr(desc[1], context, apply_filters)
807
+ return _eval_expr(desc[2], context, apply_filters) if value is None else value
808
+
809
+
810
+ def _eval_concat(_expr, desc, context, apply_filters):
811
+ return "".join(str(_eval_expr(part, context, apply_filters) or "") for part in desc[1])
812
+
813
+
814
+ def _eval_arithmetic(_expr, desc, context, apply_filters):
815
+ operator, left, right = desc[1:]
816
+ try:
817
+ left_value = _eval_expr(left, context, apply_filters)
818
+ right_value = _eval_expr(right, context, apply_filters)
819
+ left_num = float(left_value) if left_value is not None else 0
820
+ right_num = float(right_value) if right_value is not None else 0
821
+ if left_num == int(left_num) and right_num == int(right_num) and operator != "/":
822
+ left_num, right_num = int(left_num), int(right_num)
823
+ operations = {
824
+ "+": lambda: left_num + right_num,
825
+ "-": lambda: left_num - right_num,
826
+ "*": lambda: left_num * right_num,
827
+ "//": lambda: left_num // right_num if right_num != 0 else 0,
828
+ "/": lambda: left_num / right_num if right_num != 0 else 0,
829
+ "%": lambda: left_num % right_num if right_num != 0 else 0,
830
+ "**": lambda: left_num ** right_num,
831
+ }
832
+ return operations[operator]()
833
+ except (ValueError, TypeError):
834
+ return None
835
+
836
+
837
+ def _eval_pipe(_expr, desc, context, apply_filters):
838
+ base = _eval_expr(desc[1], context, apply_filters)
839
+ return apply_filters(base, desc[2], context)
840
+
841
+
842
+ def _eval_function_call(expr, context, apply_filters):
843
+ match = _FUNC_CALL_RE.match(expr)
844
+ if not match:
845
+ return False, None
846
+ name, raw_args = match.group(1), match.group(2) or ""
847
+ if "." in name:
848
+ owner_name, member = name.rsplit(".", 1)
849
+ owner = _resolve(owner_name, context)
850
+ if isinstance(owner, dict):
851
+ function = owner.get(member)
852
+ else:
853
+ function = getattr(owner, member, None) if owner is not None else None
854
+ else:
855
+ function = context.get(name) or _resolve(name, context)
856
+ if not callable(function):
857
+ return False, None
858
+ args = [_eval_expr(arg, context, apply_filters) for arg in _split_args(raw_args)] if raw_args.strip() else []
859
+ return True, function(*args)
860
+
861
+
862
+ _EXPRESSION_EVALUATORS = {
863
+ "array": _eval_array,
864
+ "dict": _eval_dict,
865
+ "str": _eval_primary,
866
+ "paren": _eval_primary,
867
+ "ternary": _eval_conditional,
868
+ "inline_if": _eval_inline_if,
869
+ "coalesce": _eval_coalesce,
870
+ "concat": _eval_concat,
871
+ "arith": _eval_arithmetic,
872
+ "pipe": _eval_pipe,
873
+ }
807
874
 
808
875
 
809
876
  def _eval_expr(expr: str, context: dict, apply_filters=None):
@@ -821,150 +888,16 @@ def _eval_expr(expr: str, context: dict, apply_filters=None):
821
888
  """
822
889
  expr = expr.strip()
823
890
 
824
- # Structural analysis (which top-level operator this expression splits on
825
- # and where) is invariant across renders, so it is computed once and cached
826
- # by _expr_descriptor keyed on (expr, has_filters). Only the value
827
- # resolution below runs every call. The dispatch order below is IDENTICAL to
828
- # the linear branch order it replaced — same precedence, same behaviour.
829
891
  desc = _expr_descriptor(expr, apply_filters is not None)
830
892
  kind = desc[0]
831
-
832
- # Array literal: ["a", "b", "c"] or [1, 2, 3]
833
- if kind == "array":
834
- return [_eval_expr(item, context, apply_filters) for item in desc[1]]
835
-
836
- # Dict literal: {"key": "value", ...}
837
- if kind == "dict":
838
- result = {}
839
- for key_expr, val_expr in desc[1]:
840
- key = _eval_expr(key_expr, context, apply_filters)
841
- val = _eval_expr(val_expr, context, apply_filters)
842
- result[key] = val
843
- return result
844
-
845
- # String literal
846
- if kind == "str":
847
- return desc[1]
848
-
849
- # Parenthesized sub-expression: (expr) — evaluate inner
850
- if kind == "paren":
851
- return _eval_expr(desc[1], context, apply_filters)
852
-
853
- # Ternary: condition ? "yes" : "no"
854
- if kind == "ternary":
855
- _, cond_part, true_part, false_part = desc
856
- cond = _eval_expr(cond_part, context, apply_filters)
857
- if cond:
858
- return _eval_expr(true_part, context, apply_filters)
859
- return _eval_expr(false_part, context, apply_filters)
860
-
861
- # Jinja2-style inline if: value if condition else other_value
862
- if kind == "inline_if":
863
- _, value_part, cond_part, else_part = desc
864
- cond = _eval_expr(cond_part, context, apply_filters)
865
- if cond:
866
- return _eval_expr(value_part, context, apply_filters)
867
- return _eval_expr(else_part, context, apply_filters)
868
-
869
- # Null coalescing: value ?? "default"
870
- if kind == "coalesce":
871
- _, left, right = desc
872
- val = _eval_expr(left, context, apply_filters)
873
- if val is None:
874
- return _eval_expr(right, context, apply_filters)
875
- return val
876
-
877
- # String concatenation with ~
878
- if kind == "concat":
879
- return "".join(str(_eval_expr(p, context, apply_filters) or "") for p in desc[1])
880
-
881
- # Comparison operators for if conditions
882
893
  if kind == "comparison":
883
894
  return _eval_comparison(expr, context)
884
-
885
- # Arithmetic operators: +, -, *, /, //, %, **
886
- if kind == "arith":
887
- _, op_s, left, right = desc
888
- l_val = _eval_expr(left, context, apply_filters)
889
- r_val = _eval_expr(right, context, apply_filters)
890
- try:
891
- l_num = float(l_val) if l_val is not None else 0
892
- r_num = float(r_val) if r_val is not None else 0
893
- # Preserve int type when both operands are int-like
894
- if l_num == int(l_num) and r_num == int(r_num) and op_s not in ("/",):
895
- l_num, r_num = int(l_num), int(r_num)
896
- if op_s == "+":
897
- return l_num + r_num
898
- elif op_s == "-":
899
- return l_num - r_num
900
- elif op_s == "*":
901
- return l_num * r_num
902
- elif op_s == "//":
903
- return l_num // r_num if r_num != 0 else 0
904
- elif op_s == "/":
905
- return l_num / r_num if r_num != 0 else 0
906
- elif op_s == "%":
907
- return l_num % r_num if r_num != 0 else 0
908
- elif op_s == "**":
909
- return l_num ** r_num
910
- except (ValueError, TypeError):
911
- return None
912
-
913
- # Filter pipe (Twig ``|``): the highest-precedence binary operator, resolved
914
- # only when the engine threaded its bound applier in (has_filters). See
915
- # _expr_descriptor / issue #171 for the precedence rationale.
916
- if kind == "pipe":
917
- _, var_name, pipe_filters = desc
918
- base = _eval_expr(var_name, context, apply_filters)
919
- return apply_filters(base, pipe_filters, context)
920
-
921
- # kind == "tail": no top-level operator — fall through to the
922
- # function-call / _resolve path below.
923
-
924
- # Function call: name("arg1", "arg2") or obj.method("arg1")
925
- fn_match = _FUNC_CALL_RE.match(expr)
926
- if fn_match:
927
- fn_name = fn_match.group(1)
928
- raw_args = fn_match.group(2) or ""
929
- # For dotted names like obj.method, resolve the object then get the method
930
- if "." in fn_name:
931
- parts = fn_name.rsplit(".", 1)
932
- obj = _resolve(parts[0], context)
933
- if obj is None:
934
- fn = None
935
- elif isinstance(obj, dict):
936
- fn = obj.get(parts[1])
937
- elif hasattr(obj, parts[1]):
938
- fn = getattr(obj, parts[1])
939
- else:
940
- fn = None
941
- else:
942
- fn = context.get(fn_name) or _resolve(fn_name, context)
943
- if callable(fn):
944
- if raw_args.strip():
945
- # Split args manually, evaluate each as expression
946
- parts = []
947
- current = ""
948
- in_q = None
949
- for ch in raw_args:
950
- if ch in ('"', "'") and not in_q:
951
- in_q = ch
952
- current += ch
953
- elif ch == in_q:
954
- in_q = None
955
- current += ch
956
- elif ch == "," and not in_q:
957
- parts.append(current.strip())
958
- current = ""
959
- else:
960
- current += ch
961
- if current.strip():
962
- parts.append(current.strip())
963
- eval_args = [_eval_expr(a, context, apply_filters) for a in parts]
964
- else:
965
- eval_args = []
966
- return fn(*eval_args)
967
-
895
+ evaluator = _EXPRESSION_EVALUATORS.get(kind)
896
+ if evaluator is not None:
897
+ return evaluator(expr, desc, context, apply_filters)
898
+ called, value = _eval_function_call(expr, context, apply_filters)
899
+ if called:
900
+ return value
968
901
  return _resolve(expr, context)
969
902
 
970
903
 
@@ -234,11 +234,11 @@ ${t.traceback||""}`;window.__switchTab("chat"),setTimeout(()=>{window.__prefillC
234
234
  </tr>`).join("")}
235
235
  </tbody>
236
236
  </table>
237
- `)}function Xy(i,e){const t=[],n={},r={};i.forEach((s,o)=>{n[Rd(s)]=o,r[s]=o});for(const[s,o]of Object.entries(e)){const a=r[s];if(a!==void 0)for(const l of o){const O=r[l];if(O!==void 0){O!==a&&t.push([a,O]);continue}const c=l.replace(/^\.\//,"").replace(/^\.\.\//,"").split(/[./]/);let d;for(let h=c.length-1;h>=0;h--){const f=c[h].toLowerCase();if(f&&f!=="js"&&f!=="py"&&f!=="rb"&&f!=="ts"&&f!=="index"&&(d=n[f],d!==void 0))break}d===void 0&&(d=n[Rd(l)]),d!==void 0&&a!==d&&t.push([a,d])}}return t}function Rd(i){const e=i.replace(/\\/g,"/").split("/").pop()||"",t=e.lastIndexOf(".");return(t>0?e.substring(0,t):e).toLowerCase()}function _y(i,e,t,n){var gi,$n,ps;const r=e.offsetWidth||900,s=Math.max(450,Math.min(650,r*.45)),o=Math.max(...i.map(G=>G.loc))||1,a=Math.max(...i.map(G=>G.dep_count||0))||1,l=Math.max(...i.map(G=>G.coupling_afferent||0))||1,O=i.some(G=>(G.coupling_afferent||0)>0),c=14,d=Math.min(70,r/10);function h(G){const I=Math.min((G.avg_complexity||0)/10,1),D=G.has_tests?0:1,de=Math.min((G.dep_count||0)/5,1),j=I*.4+D*.4+de*.2,W=Math.max(0,Math.min(1,j)),H=Math.round(120*(1-W)),te=Math.round(70+W*30),pe=Math.round(42+18*(1-W));return`hsl(${H},${te}%,${pe}%)`}function f(G){return G.loc/o*.4+(G.avg_complexity||0)/10*.4+(G.dep_count||0)/a*.2}const p=[...i].sort((G,I)=>f(G)-f(I)),m=r/2,$=s/2,g=[];let y=0,S=0;for(const G of p){const I=c+Math.sqrt(f(G))*(d-c),D=h(G);let de=!1;for(let j=0;j<800;j++){const W=m+S*Math.cos(y),H=$+S*Math.sin(y);let te=!1;for(const pe of g){const he=W-pe.x,ke=H-pe.y;if(Math.sqrt(he*he+ke*ke)<I+pe.r+2){te=!0;break}}if(!te&&W>I+2&&W<r-I-2&&H>I+25&&H<s-I-2){g.push({x:W,y:H,vx:0,vy:0,r:I,color:D,f:G}),de=!0;break}y+=.2,S+=.04}de||g.push({x:m+(Math.random()-.5)*r*.3,y:$+(Math.random()-.5)*s*.3,vx:0,vy:0,r:I,color:D,f:G})}const x=Xy(g.map(G=>G.f.path),t),w=document.createElement("canvas");w.width=r,w.height=s,w.style.cssText="display:block;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:#0f172a";const R=n==="framework"?'<span style="color:#cba6f7;font-weight:600">(Framework)</span> Add code to src/ to see your project':"";e.innerHTML=`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;font-size:0.85rem">Code Landscape ${R}</h3><span style="font-size:0.65rem;color:var(--muted)">Drag bubbles | Dbl-click to drill down</span></div><div style="position:relative" id="metrics-canvas-wrap"></div><div style="margin-top:0.4rem;font-size:0.62rem;color:var(--muted);display:flex;gap:0.9rem;flex-wrap:wrap">${O?'<span>Centre = most depended-on</span><span><span style="color:hsl(210,85%,60%)">&#9679;</span> stable ring</span><span><span style="color:hsl(30,85%,60%)">&#9679;</span> unstable ring</span>':""}<span id="metrics-hover-info" class="text-mono"></span></div>`,document.getElementById("metrics-canvas-wrap").appendChild(w);const _=document.getElementById("metrics-hover-info"),A=document.createElement("div");A.style.cssText="position:absolute;top:8px;left:8px;z-index:2;display:flex;gap:4px;flex-direction:column",A.innerHTML=`
237
+ `)}function Xy(i,e){const t=[],n={},r={};i.forEach((s,o)=>{n[Rd(s)]=o,r[s]=o});for(const[s,o]of Object.entries(e)){const a=r[s];if(a!==void 0)for(const l of o){const O=r[l];if(O!==void 0){O!==a&&t.push([a,O]);continue}const c=l.replace(/^\.\//,"").replace(/^\.\.\//,"").split(/[./]/);let d;for(let h=c.length-1;h>=0;h--){const f=c[h].toLowerCase();if(f&&f!=="js"&&f!=="py"&&f!=="rb"&&f!=="ts"&&f!=="index"&&(d=n[f],d!==void 0))break}d===void 0&&(d=n[Rd(l)]),d!==void 0&&a!==d&&t.push([a,d])}}return t}function Rd(i){const e=i.replace(/\\/g,"/").split("/").pop()||"",t=e.lastIndexOf(".");return(t>0?e.substring(0,t):e).toLowerCase()}function _y(i,e,t,n){var gi,$n,ps;const r=e.offsetWidth||900,s=Math.max(450,Math.min(650,r*.45)),o=Math.max(...i.map(G=>G.loc))||1,a=Math.max(...i.map(G=>G.dep_count||0))||1,l=Math.max(...i.map(G=>G.coupling_afferent||0))||1,O=i.some(G=>(G.coupling_afferent||0)>0),c=14,d=Math.min(70,r/10);function h(G){const I=Math.min((G.avg_complexity||0)/10,1),D=G.has_referencing_test?0:1,de=Math.min((G.dep_count||0)/5,1),j=I*.4+D*.4+de*.2,W=Math.max(0,Math.min(1,j)),H=Math.round(120*(1-W)),te=Math.round(70+W*30),pe=Math.round(42+18*(1-W));return`hsl(${H},${te}%,${pe}%)`}function f(G){return G.loc/o*.4+(G.avg_complexity||0)/10*.4+(G.dep_count||0)/a*.2}const p=[...i].sort((G,I)=>f(G)-f(I)),m=r/2,$=s/2,g=[];let y=0,S=0;for(const G of p){const I=c+Math.sqrt(f(G))*(d-c),D=h(G);let de=!1;for(let j=0;j<800;j++){const W=m+S*Math.cos(y),H=$+S*Math.sin(y);let te=!1;for(const pe of g){const he=W-pe.x,ke=H-pe.y;if(Math.sqrt(he*he+ke*ke)<I+pe.r+2){te=!0;break}}if(!te&&W>I+2&&W<r-I-2&&H>I+25&&H<s-I-2){g.push({x:W,y:H,vx:0,vy:0,r:I,color:D,f:G}),de=!0;break}y+=.2,S+=.04}de||g.push({x:m+(Math.random()-.5)*r*.3,y:$+(Math.random()-.5)*s*.3,vx:0,vy:0,r:I,color:D,f:G})}const x=Xy(g.map(G=>G.f.path),t),w=document.createElement("canvas");w.width=r,w.height=s,w.style.cssText="display:block;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:#0f172a";const R=n==="framework"?'<span style="color:#cba6f7;font-weight:600">(Framework)</span> Add code to src/ to see your project':"";e.innerHTML=`<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem"><h3 style="margin:0;font-size:0.85rem">Code Landscape ${R}</h3><span style="font-size:0.65rem;color:var(--muted)">Drag bubbles | Dbl-click to drill down</span></div><div style="position:relative" id="metrics-canvas-wrap"></div><div style="margin-top:0.4rem;font-size:0.62rem;color:var(--muted);display:flex;gap:0.9rem;flex-wrap:wrap">${O?'<span>Centre = most depended-on</span><span><span style="color:hsl(210,85%,60%)">&#9679;</span> stable ring</span><span><span style="color:hsl(30,85%,60%)">&#9679;</span> unstable ring</span>':""}<span id="metrics-hover-info" class="text-mono"></span></div>`,document.getElementById("metrics-canvas-wrap").appendChild(w);const _=document.getElementById("metrics-hover-info"),A=document.createElement("div");A.style.cssText="position:absolute;top:8px;left:8px;z-index:2;display:flex;gap:4px;flex-direction:column",A.innerHTML=`
238
238
  <button class="btn btn-sm" id="metrics-zoom-in" style="width:28px;height:28px;padding:0;font-size:14px;font-weight:700;line-height:1">+</button>
239
239
  <button class="btn btn-sm" id="metrics-zoom-out" style="width:28px;height:28px;padding:0;font-size:14px;font-weight:700;line-height:1">&minus;</button>
240
240
  <button class="btn btn-sm" id="metrics-zoom-fit" style="width:28px;height:28px;padding:0;font-size:10px;font-weight:700;line-height:1">Fit</button>
241
- `,document.getElementById("metrics-canvas-wrap").appendChild(A),(gi=document.getElementById("metrics-zoom-in"))==null||gi.addEventListener("click",()=>{le=Math.min(5,le*1.3)}),($n=document.getElementById("metrics-zoom-out"))==null||$n.addEventListener("click",()=>{le=Math.max(.3,le*.7)}),(ps=document.getElementById("metrics-zoom-fit"))==null||ps.addEventListener("click",()=>{le=1,U=0,oe=0});const P=w.getContext("2d");let v=-1,k=-1,X=0,z=0,U=0,oe=0,le=1,rt=!1,mn=0,Gt=0,us=0,ft=0;function De(){for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j],H=m-W.x,te=$-W.y,pe=.3+W.r/d*.7,he=O?.3+(W.f.coupling_afferent||0)/l*.7:pe,ke=.008*pe*he;W.vx+=H*ke,W.vy+=te*ke}for(const[j,W]of x){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20,Bt=(ke-Dt)*.002,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx+=ms,H.vy+=$s),W!==k&&(te.vx-=ms,te.vy-=$s)}for(let j=0;j<g.length;j++)for(let W=j+1;W<g.length;W++){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20;if(ke<Dt){const Bt=40*(Dt-ke)/Dt,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx-=ms,H.vy-=$s),W!==k&&(te.vx+=ms,te.vy+=$s)}}for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j];W.vx*=.65,W.vy*=.65;const H=2;W.vx=Math.max(-H,Math.min(H,W.vx)),W.vy=Math.max(-H,Math.min(H,W.vy)),W.x+=W.vx,W.y+=W.vy,W.x=Math.max(W.r+2,Math.min(r-W.r-2,W.x)),W.y=Math.max(W.r+25,Math.min(s-W.r-2,W.y))}}function st(){var G;De(),P.clearRect(0,0,r,s),P.save(),P.translate(U,oe),P.scale(le,le),P.strokeStyle="rgba(255,255,255,0.03)",P.lineWidth=1/le;for(let I=0;I<r/le;I+=50)P.beginPath(),P.moveTo(I,0),P.lineTo(I,s/le),P.stroke();for(let I=0;I<s/le;I+=50)P.beginPath(),P.moveTo(0,I),P.lineTo(r/le,I),P.stroke();for(const[I,D]of x){const de=g[I],j=g[D],W=j.x-de.x,H=j.y-de.y,te=Math.sqrt(W*W+H*H)||1,pe=v===I||v===D;P.beginPath(),P.moveTo(de.x+W/te*de.r,de.y+H/te*de.r);const he=j.x-W/te*j.r,ke=j.y-H/te*j.r;P.lineTo(he,ke),P.strokeStyle=pe?"rgba(139,180,250,0.9)":"rgba(255,255,255,0.15)",P.lineWidth=pe?3:1,P.stroke();const Dt=pe?12:6,Bt=Math.atan2(H,W);P.beginPath(),P.moveTo(he,ke),P.lineTo(he-Dt*Math.cos(Bt-.4),ke-Dt*Math.sin(Bt-.4)),P.lineTo(he-Dt*Math.cos(Bt+.4),ke-Dt*Math.sin(Bt+.4)),P.closePath(),P.fillStyle=P.strokeStyle,P.fill()}for(let I=0;I<g.length;I++){const D=g[I],de=I===v,j=de?D.r+4:D.r;de&&(P.beginPath(),P.arc(D.x,D.y,j+8,0,Math.PI*2),P.fillStyle="rgba(255,255,255,0.08)",P.fill()),P.beginPath(),P.arc(D.x,D.y,j,0,Math.PI*2),P.fillStyle=D.color,P.globalAlpha=de?1:.85,P.fill(),P.globalAlpha=1,P.strokeStyle=de?"rgba(255,255,255,0.6)":"rgba(255,255,255,0.25)",P.lineWidth=de?2.5:1.5,P.stroke();const W=D.f.instability;O&&typeof W=="number"&&(D.f.coupling_afferent||0)+(D.f.coupling_efferent||0)>0&&(P.beginPath(),P.arc(D.x,D.y,j+3,0,Math.PI*2),P.strokeStyle=`hsl(${Math.round(210-180*W)},85%,60%)`,P.lineWidth=1+W*2.5,P.stroke());const H=((G=D.f.path.split("/").pop())==null?void 0:G.replace(/\.\w+$/,""))||"?";if(j>16){const he=Math.max(8,Math.min(13,j*.38));P.fillStyle="#fff",P.font=`600 ${he}px monospace`,P.textAlign="center",P.fillText(H,D.x,D.y-2),P.fillStyle="rgba(255,255,255,0.65)",P.font=`${he-1}px monospace`,P.fillText(`${D.f.loc} LOC`,D.x,D.y+he)}const te=Math.max(9,j*.3),pe=te*.7;if(j>14&&D.f.dep_count>0){const he=D.y-j+pe+3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#ea580c",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("D",D.x,he+te*.35)}if(j>14&&D.f.has_tests){const he=D.y+j-pe-3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#16a34a",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("T",D.x,he+te*.35)}}if(P.restore(),v>=0){const I=g[v].f,D=I.coupling_afferent??0,de=I.coupling_efferent??0,j=typeof I.instability=="number"?I.instability.toFixed(2):"-";_.textContent=`${I.path} ca ${D} / ce ${de} / I ${j} / MI ${I.maintainability}`}else _.textContent&&(_.textContent="");requestAnimationFrame(st)}w.addEventListener("mousemove",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(rt){U=us+(G.clientX-mn),oe=ft+(G.clientY-Gt);return}if(k>=0){ut=!0,g[k].x=D+X,g[k].y=de+z,g[k].vx=0,g[k].vy=0;return}v=-1;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){v=j;break}}w.style.cursor=v>=0?"grab":"default"}),w.addEventListener("mousedown",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(G.button===2){rt=!0,mn=G.clientX,Gt=G.clientY,us=U,ft=oe,w.style.cursor="move";return}v>=0&&(k=v,X=g[k].x-D,z=g[k].y-de,ut=!1,w.style.cursor="grabbing")});let ut=!1;w.addEventListener("mouseup",G=>{if(rt){rt=!1,w.style.cursor="default";return}if(k>=0){ut||Xa(g[k].f.path),w.style.cursor="grab",k=-1,ut=!1;return}}),w.addEventListener("mouseleave",()=>{v=-1,k=-1,rt=!1}),w.addEventListener("dblclick",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){Xa(W.f.path);break}}}),w.addEventListener("contextmenu",G=>G.preventDefault()),requestAnimationFrame(st)}async function Xa(i){const e=document.getElementById("metrics-detail");if(!e)return;e.innerHTML='<p class="text-muted">Loading file analysis...</p>';const t=await L("/metrics/file?path="+encodeURIComponent(i));if(t.error){e.innerHTML=`<p style="color:var(--danger)">${Q(t.error)}</p>`;return}const n=t.functions||[],r=Math.max(1,...n.map(s=>s.complexity));e.innerHTML=`
241
+ `,document.getElementById("metrics-canvas-wrap").appendChild(A),(gi=document.getElementById("metrics-zoom-in"))==null||gi.addEventListener("click",()=>{le=Math.min(5,le*1.3)}),($n=document.getElementById("metrics-zoom-out"))==null||$n.addEventListener("click",()=>{le=Math.max(.3,le*.7)}),(ps=document.getElementById("metrics-zoom-fit"))==null||ps.addEventListener("click",()=>{le=1,U=0,oe=0});const P=w.getContext("2d");let v=-1,k=-1,X=0,z=0,U=0,oe=0,le=1,rt=!1,mn=0,Gt=0,us=0,ft=0;function De(){for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j],H=m-W.x,te=$-W.y,pe=.3+W.r/d*.7,he=O?.3+(W.f.coupling_afferent||0)/l*.7:pe,ke=.008*pe*he;W.vx+=H*ke,W.vy+=te*ke}for(const[j,W]of x){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20,Bt=(ke-Dt)*.002,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx+=ms,H.vy+=$s),W!==k&&(te.vx-=ms,te.vy-=$s)}for(let j=0;j<g.length;j++)for(let W=j+1;W<g.length;W++){const H=g[j],te=g[W],pe=te.x-H.x,he=te.y-H.y,ke=Math.sqrt(pe*pe+he*he)||1,Dt=H.r+te.r+20;if(ke<Dt){const Bt=40*(Dt-ke)/Dt,ms=pe/ke*Bt,$s=he/ke*Bt;j!==k&&(H.vx-=ms,H.vy-=$s),W!==k&&(te.vx+=ms,te.vy+=$s)}}for(let j=0;j<g.length;j++){if(j===k)continue;const W=g[j];W.vx*=.65,W.vy*=.65;const H=2;W.vx=Math.max(-H,Math.min(H,W.vx)),W.vy=Math.max(-H,Math.min(H,W.vy)),W.x+=W.vx,W.y+=W.vy,W.x=Math.max(W.r+2,Math.min(r-W.r-2,W.x)),W.y=Math.max(W.r+25,Math.min(s-W.r-2,W.y))}}function st(){var G;De(),P.clearRect(0,0,r,s),P.save(),P.translate(U,oe),P.scale(le,le),P.strokeStyle="rgba(255,255,255,0.03)",P.lineWidth=1/le;for(let I=0;I<r/le;I+=50)P.beginPath(),P.moveTo(I,0),P.lineTo(I,s/le),P.stroke();for(let I=0;I<s/le;I+=50)P.beginPath(),P.moveTo(0,I),P.lineTo(r/le,I),P.stroke();for(const[I,D]of x){const de=g[I],j=g[D],W=j.x-de.x,H=j.y-de.y,te=Math.sqrt(W*W+H*H)||1,pe=v===I||v===D;P.beginPath(),P.moveTo(de.x+W/te*de.r,de.y+H/te*de.r);const he=j.x-W/te*j.r,ke=j.y-H/te*j.r;P.lineTo(he,ke),P.strokeStyle=pe?"rgba(139,180,250,0.9)":"rgba(255,255,255,0.15)",P.lineWidth=pe?3:1,P.stroke();const Dt=pe?12:6,Bt=Math.atan2(H,W);P.beginPath(),P.moveTo(he,ke),P.lineTo(he-Dt*Math.cos(Bt-.4),ke-Dt*Math.sin(Bt-.4)),P.lineTo(he-Dt*Math.cos(Bt+.4),ke-Dt*Math.sin(Bt+.4)),P.closePath(),P.fillStyle=P.strokeStyle,P.fill()}for(let I=0;I<g.length;I++){const D=g[I],de=I===v,j=de?D.r+4:D.r;de&&(P.beginPath(),P.arc(D.x,D.y,j+8,0,Math.PI*2),P.fillStyle="rgba(255,255,255,0.08)",P.fill()),P.beginPath(),P.arc(D.x,D.y,j,0,Math.PI*2),P.fillStyle=D.color,P.globalAlpha=de?1:.85,P.fill(),P.globalAlpha=1,P.strokeStyle=de?"rgba(255,255,255,0.6)":"rgba(255,255,255,0.25)",P.lineWidth=de?2.5:1.5,P.stroke();const W=D.f.instability;O&&typeof W=="number"&&(D.f.coupling_afferent||0)+(D.f.coupling_efferent||0)>0&&(P.beginPath(),P.arc(D.x,D.y,j+3,0,Math.PI*2),P.strokeStyle=`hsl(${Math.round(210-180*W)},85%,60%)`,P.lineWidth=1+W*2.5,P.stroke());const H=((G=D.f.path.split("/").pop())==null?void 0:G.replace(/\.\w+$/,""))||"?";if(j>16){const he=Math.max(8,Math.min(13,j*.38));P.fillStyle="#fff",P.font=`600 ${he}px monospace`,P.textAlign="center",P.fillText(H,D.x,D.y-2),P.fillStyle="rgba(255,255,255,0.65)",P.font=`${he-1}px monospace`,P.fillText(`${D.f.loc} LOC`,D.x,D.y+he)}const te=Math.max(9,j*.3),pe=te*.7;if(j>14&&D.f.dep_count>0){const he=D.y-j+pe+3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#ea580c",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("D",D.x,he+te*.35)}if(j>14&&D.f.has_referencing_test){const he=D.y+j-pe-3;P.beginPath(),P.arc(D.x,he,pe,0,Math.PI*2),P.fillStyle="#16a34a",P.fill(),P.fillStyle="#fff",P.font=`bold ${te}px sans-serif`,P.textAlign="center",P.fillText("T",D.x,he+te*.35)}}if(P.restore(),v>=0){const I=g[v].f,D=I.coupling_afferent??0,de=I.coupling_efferent??0,j=typeof I.instability=="number"?I.instability.toFixed(2):"-";_.textContent=`${I.path} ca ${D} / ce ${de} / I ${j} / MI ${I.maintainability}`}else _.textContent&&(_.textContent="");requestAnimationFrame(st)}w.addEventListener("mousemove",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(rt){U=us+(G.clientX-mn),oe=ft+(G.clientY-Gt);return}if(k>=0){ut=!0,g[k].x=D+X,g[k].y=de+z,g[k].vx=0,g[k].vy=0;return}v=-1;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){v=j;break}}w.style.cursor=v>=0?"grab":"default"}),w.addEventListener("mousedown",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;if(G.button===2){rt=!0,mn=G.clientX,Gt=G.clientY,us=U,ft=oe,w.style.cursor="move";return}v>=0&&(k=v,X=g[k].x-D,z=g[k].y-de,ut=!1,w.style.cursor="grabbing")});let ut=!1;w.addEventListener("mouseup",G=>{if(rt){rt=!1,w.style.cursor="default";return}if(k>=0){ut||Xa(g[k].f.path),w.style.cursor="grab",k=-1,ut=!1;return}}),w.addEventListener("mouseleave",()=>{v=-1,k=-1,rt=!1}),w.addEventListener("dblclick",G=>{const I=w.getBoundingClientRect(),D=(G.clientX-I.left-U)/le,de=(G.clientY-I.top-oe)/le;for(let j=g.length-1;j>=0;j--){const W=g[j],H=D-W.x,te=de-W.y;if(Math.sqrt(H*H+te*te)<W.r+4){Xa(W.f.path);break}}}),w.addEventListener("contextmenu",G=>G.preventDefault()),requestAnimationFrame(st)}async function Xa(i){const e=document.getElementById("metrics-detail");if(!e)return;e.innerHTML='<p class="text-muted">Loading file analysis...</p>';const t=await L("/metrics/file?path="+encodeURIComponent(i));if(t.error){e.innerHTML=`<p style="color:var(--danger)">${Q(t.error)}</p>`;return}const n=t.functions||[],r=Math.max(1,...n.map(s=>s.complexity));e.innerHTML=`
242
242
  <div style="background:var(--surface);border:1px solid var(--border);border-radius:0.5rem;padding:1rem">
243
243
  <div class="flex items-center" style="justify-content:space-between;margin-bottom:0.75rem">
244
244
  <h3 style="font-size:0.9rem">${Q(t.path)}</h3>