expressir 2.4.0 → 2.4.2

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.bugs/01-stale-transformer-autoload.md +39 -0
  3. data/TODO.bugs/02-parser-class-instance-vars.md +36 -0
  4. data/TODO.bugs/03-builder-mutable-state.md +43 -0
  5. data/TODO.bugs/04-formatter-public-send-dispatch.md +53 -0
  6. data/TODO.bugs/05-anonymous-formatter-subclass.md +45 -0
  7. data/TODO.bugs/06-collection-registry-single-source.md +53 -0
  8. data/TODO.bugs/07-require-relative-cleanup.md +42 -0
  9. data/TODO.bugs/08-require-expressir-in-commands.md +34 -0
  10. data/TODO.bugs/09-parser-split.md +53 -0
  11. data/TODO.bugs/10-to-s-override.md +42 -0
  12. data/TODO.bugs/11-parser-class-variables.md +39 -0
  13. data/TODO.bugs/12-marker-modules-vs-registry.md +64 -0
  14. data/TODO.bugs/13-string-literal-scanner-limitation.md +52 -0
  15. data/TODO.bugs/14-model-formatting-leak.md +30 -0
  16. data/TODO.bugs/15-expression-children-macro.md +27 -0
  17. data/TODO.bugs/16-pretty-formatter-duplication.md +28 -0
  18. data/TODO.bugs/17-snake-case-cache-mutable-constant.md +28 -0
  19. data/TODO.bugs/18-const-get-private-constants.md +30 -0
  20. data/TODO.bugs/19-format-methods-public.md +22 -0
  21. data/TODO.bugs/20-coverage-nested-entities-dedup.md +20 -0
  22. data/TODO.bugs/21-operator-tokens-secondary-dispatch.md +21 -0
  23. data/TODO.bugs/22-builder-fast-path-wrappers.md +32 -0
  24. data/TODO.bugs/23-coverage-inverse-maps.md +21 -0
  25. data/TODO.bugs/24-streaming-builder-complexity.md +19 -0
  26. data/TODO.bugs/25-debug-puts-in-production.md +21 -0
  27. data/TODO.bugs/26-generic-entity-children-misplaced.md +21 -0
  28. data/TODO.bugs/27-package-build-god-method.md +19 -0
  29. data/TODO.bugs/28-package-god-class.md +30 -0
  30. data/TODO.bugs/29-validate-ascii-god-class.md +24 -0
  31. data/TODO.bugs/30-unicode-map-extraction.md +19 -0
  32. data/TODO.bugs/README.md +43 -0
  33. data/TODO.max-perf/01-restore-ci-green.md +29 -0
  34. data/TODO.max-perf/02-streaming-parse-path.md +31 -0
  35. data/TODO.max-perf/03-cli-parallel-opt-in.md +27 -0
  36. data/TODO.max-perf/04-benchmark-harness.md +28 -0
  37. data/TODO.max-perf/05-parallel-fidelity-specs.md +22 -0
  38. data/TODO.max-perf/06-builder-cpu-audit.md +41 -0
  39. data/TODO.max-perf/07-upstream-parsanol-roadmap.md +27 -0
  40. data/TODO.max-perf/08-builder-build-perf.md +45 -0
  41. data/TODO.max-perf/09-grammar-cold-start.md +25 -0
  42. data/TODO.max-perf/10-parser-facade-hygiene.md +23 -0
  43. data/TODO.max-perf/11-ci-green-closeout.md +25 -0
  44. data/TODO.max-perf/12-require-boot-profile.md +25 -0
  45. data/TODO.max-perf/13-key-conversion-specs.md +26 -0
  46. data/TODO.max-perf/14-builder-call-handler-audit.md +28 -0
  47. data/benchmark/srl_benchmark.rb +76 -17
  48. data/docs/_tutorials/formatting-schemas.adoc +89 -0
  49. data/docs/_tutorials/index.adoc +10 -0
  50. data/docs/lychee.toml +3 -0
  51. data/expressir.gemspec +4 -2
  52. data/lib/expressir/cli.rb +3 -0
  53. data/lib/expressir/commands/changes_import_eengine.rb +0 -6
  54. data/lib/expressir/commands/changes_validate.rb +0 -2
  55. data/lib/expressir/commands/clean.rb +1 -1
  56. data/lib/expressir/commands/coverage.rb +6 -2
  57. data/lib/expressir/commands/file_violations.rb +70 -0
  58. data/lib/expressir/commands/format.rb +1 -1
  59. data/lib/expressir/commands/non_ascii_character.rb +49 -0
  60. data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
  61. data/lib/expressir/commands/package.rb +143 -187
  62. data/lib/expressir/commands/validate_ascii.rb +0 -510
  63. data/lib/expressir/commands/version.rb +1 -1
  64. data/lib/expressir/commands.rb +3 -0
  65. data/lib/expressir/coverage.rb +49 -117
  66. data/lib/expressir/express/ast_key_converter.rb +114 -0
  67. data/lib/expressir/express/builder.rb +71 -194
  68. data/lib/expressir/express/builder_context.rb +22 -0
  69. data/lib/expressir/express/builders/expression_builder.rb +16 -16
  70. data/lib/expressir/express/cache.rb +35 -8
  71. data/lib/expressir/express/error.rb +24 -0
  72. data/lib/expressir/express/formatter.rb +33 -18
  73. data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
  74. data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
  75. data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
  76. data/lib/expressir/express/grammar/parser.rb +705 -0
  77. data/lib/expressir/express/grammar.rb +11 -0
  78. data/lib/expressir/express/node_position_index.rb +215 -0
  79. data/lib/expressir/express/parallel_files.rb +229 -0
  80. data/lib/expressir/express/parser.rb +138 -950
  81. data/lib/expressir/express/pretty_formatter.rb +23 -5
  82. data/lib/expressir/express/remark_attacher.rb +497 -581
  83. data/lib/expressir/express/remark_scanner.rb +84 -19
  84. data/lib/expressir/express/schema_block_scanner.rb +137 -0
  85. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  86. data/lib/expressir/express/scope_resolver.rb +223 -0
  87. data/lib/expressir/express/source_formatter.rb +15 -0
  88. data/lib/expressir/express/streaming_builder.rb +147 -176
  89. data/lib/expressir/express.rb +10 -6
  90. data/lib/expressir/model/concerns.rb +3 -0
  91. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  92. data/lib/expressir/model/declarations/entity.rb +5 -0
  93. data/lib/expressir/model/declarations/function.rb +5 -0
  94. data/lib/expressir/model/declarations/procedure.rb +5 -0
  95. data/lib/expressir/model/declarations/rule.rb +6 -0
  96. data/lib/expressir/model/declarations/schema.rb +21 -8
  97. data/lib/expressir/model/declarations/type.rb +3 -0
  98. data/lib/expressir/model/exp_file.rb +2 -0
  99. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  100. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  101. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  102. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  103. data/lib/expressir/model/expressions/function_call.rb +1 -0
  104. data/lib/expressir/model/expressions/interval.rb +1 -0
  105. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  106. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  107. data/lib/expressir/model/model_element.rb +133 -14
  108. data/lib/expressir/model/remark_format.rb +17 -0
  109. data/lib/expressir/model/remark_info.rb +31 -3
  110. data/lib/expressir/model/remark_placement.rb +29 -0
  111. data/lib/expressir/model/repository.rb +18 -5
  112. data/lib/expressir/model/statements/alias.rb +2 -0
  113. data/lib/expressir/model/statements/assignment.rb +4 -0
  114. data/lib/expressir/model/statements/case.rb +21 -0
  115. data/lib/expressir/model/statements/case_action.rb +1 -0
  116. data/lib/expressir/model/statements/compound.rb +4 -0
  117. data/lib/expressir/model/statements/escape.rb +3 -0
  118. data/lib/expressir/model/statements/if.rb +5 -0
  119. data/lib/expressir/model/statements/null.rb +3 -0
  120. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  121. data/lib/expressir/model/statements/repeat.rb +3 -0
  122. data/lib/expressir/model/statements/return.rb +3 -0
  123. data/lib/expressir/model/statements/skip.rb +3 -0
  124. data/lib/expressir/model.rb +12 -3
  125. data/lib/expressir/package/builder.rb +2 -2
  126. data/lib/expressir/version.rb +6 -1
  127. data/lib/expressir.rb +56 -3
  128. metadata +81 -7
  129. data/lib/expressir/express/transformer/remark_handling.rb +0 -194
@@ -3,53 +3,39 @@ require "pathname"
3
3
  module Expressir
4
4
  # Coverage module for calculating documentation coverage of EXPRESS entities
5
5
  module Coverage
6
- # Mapping of EXPRESS entity type names to their corresponding class names
7
- ENTITY_TYPE_MAP = {
8
- "TYPE" => "Expressir::Model::Declarations::Type",
9
- "ENTITY" => "Expressir::Model::Declarations::Entity",
10
- "CONSTANT" => "Expressir::Model::Declarations::Constant",
11
- "FUNCTION" => "Expressir::Model::Declarations::Function",
12
- "RULE" => "Expressir::Model::Declarations::Rule",
13
- "PROCEDURE" => "Expressir::Model::Declarations::Procedure",
14
- "SUBTYPE_CONSTRAINT" => "Expressir::Model::Declarations::SubtypeConstraint",
15
- "PARAMETER" => "Expressir::Model::Declarations::Parameter",
16
- "VARIABLE" => "Expressir::Model::Declarations::Variable",
17
- "ATTRIBUTE" => "Expressir::Model::Declarations::Attribute",
18
- "DERIVED_ATTRIBUTE" => "Expressir::Model::Declarations::DerivedAttribute",
19
- "INVERSE_ATTRIBUTE" => "Expressir::Model::Declarations::InverseAttribute",
20
- "UNIQUE_RULE" => "Expressir::Model::Declarations::UniqueRule",
21
- "WHERE_RULE" => "Expressir::Model::Declarations::WhereRule",
22
- "ENUMERATION_ITEM" => "Expressir::Model::DataTypes::EnumerationItem",
23
- "INTERFACE" => "Expressir::Model::Declarations::Interface",
24
- "INTERFACE_ITEM" => "Expressir::Model::Declarations::InterfaceItem",
25
- "INTERFACED_ITEM" => "Expressir::Model::Declarations::InterfacedItem",
26
- "SCHEMA_VERSION" => "Expressir::Model::Declarations::SchemaVersion",
27
- "SCHEMA_VERSION_ITEM" => "Expressir::Model::Declarations::SchemaVersionItem",
28
- }.freeze
29
-
30
- # Mapping of class names to EXPRESS entity type names (for proper formatting)
31
- CLASS_TO_EXPRESS_TYPE_MAP = {
32
- "Type" => "TYPE",
33
- "Entity" => "ENTITY",
34
- "Constant" => "CONSTANT",
35
- "Function" => "FUNCTION",
36
- "Rule" => "RULE",
37
- "Procedure" => "PROCEDURE",
38
- "SubtypeConstraint" => "SUBTYPE_CONSTRAINT",
39
- "Parameter" => "PARAMETER",
40
- "Variable" => "VARIABLE",
41
- "Attribute" => "ATTRIBUTE",
42
- "DerivedAttribute" => "DERIVED_ATTRIBUTE",
43
- "InverseAttribute" => "INVERSE_ATTRIBUTE",
44
- "UniqueRule" => "UNIQUE_RULE",
45
- "WhereRule" => "WHERE_RULE",
46
- "EnumerationItem" => "ENUMERATION_ITEM",
47
- "Interface" => "INTERFACE",
48
- "InterfaceItem" => "INTERFACE_ITEM",
49
- "InterfacedItem" => "INTERFACED_ITEM",
50
- "SchemaVersion" => "SCHEMA_VERSION",
51
- "SchemaVersionItem" => "SCHEMA_VERSION_ITEM",
52
- }.freeze
6
+ # Single source of truth: EXPRESS type name Ruby class name pairs.
7
+ # ENTITY_TYPE_MAP and CLASS_TO_EXPRESS_TYPE_MAP are derived from this
8
+ # so the two never drift out of sync.
9
+ TYPE_PAIRS = [
10
+ ["TYPE", "Expressir::Model::Declarations::Type"],
11
+ ["ENTITY", "Expressir::Model::Declarations::Entity"],
12
+ ["CONSTANT", "Expressir::Model::Declarations::Constant"],
13
+ ["FUNCTION", "Expressir::Model::Declarations::Function"],
14
+ ["RULE", "Expressir::Model::Declarations::Rule"],
15
+ ["PROCEDURE", "Expressir::Model::Declarations::Procedure"],
16
+ ["SUBTYPE_CONSTRAINT", "Expressir::Model::Declarations::SubtypeConstraint"],
17
+ ["PARAMETER", "Expressir::Model::Declarations::Parameter"],
18
+ ["VARIABLE", "Expressir::Model::Declarations::Variable"],
19
+ ["ATTRIBUTE", "Expressir::Model::Declarations::Attribute"],
20
+ ["DERIVED_ATTRIBUTE", "Expressir::Model::Declarations::DerivedAttribute"],
21
+ ["INVERSE_ATTRIBUTE", "Expressir::Model::Declarations::InverseAttribute"],
22
+ ["UNIQUE_RULE", "Expressir::Model::Declarations::UniqueRule"],
23
+ ["WHERE_RULE", "Expressir::Model::Declarations::WhereRule"],
24
+ ["ENUMERATION_ITEM", "Expressir::Model::DataTypes::EnumerationItem"],
25
+ ["INTERFACE", "Expressir::Model::Declarations::Interface"],
26
+ ["INTERFACE_ITEM", "Expressir::Model::Declarations::InterfaceItem"],
27
+ ["INTERFACED_ITEM", "Expressir::Model::Declarations::InterfacedItem"],
28
+ ["SCHEMA_VERSION", "Expressir::Model::Declarations::SchemaVersion"],
29
+ ["SCHEMA_VERSION_ITEM", "Expressir::Model::Declarations::SchemaVersionItem"],
30
+ ].freeze
31
+
32
+ # EXPRESS entity type name → Ruby class name.
33
+ ENTITY_TYPE_MAP = TYPE_PAIRS.to_h.freeze
34
+
35
+ # Short Ruby class name → EXPRESS entity type name.
36
+ CLASS_TO_EXPRESS_TYPE_MAP = TYPE_PAIRS.to_h do |express_name, ruby_class|
37
+ [ruby_class.split("::").last, express_name]
38
+ end.freeze
53
39
 
54
40
  # Available TYPE subtypes based on data types
55
41
  TYPE_SUBTYPES = %w[
@@ -401,78 +387,24 @@ module Expressir
401
387
  entities.concat(container.unique_rules) if container.unique_rules
402
388
  entities.concat(container.where_rules) if container.where_rules
403
389
 
404
- when Expressir::Model::Declarations::Function
405
- # Function nested entities
406
- entities.concat(container.parameters) if container.parameters
407
- entities.concat(container.variables) if container.variables
408
- entities.concat(container.constants) if container.constants
409
- entities.concat(container.types) if container.types
410
- entities.concat(container.entities) if container.entities
411
- entities.concat(container.functions) if container.functions
412
- entities.concat(container.procedures) if container.procedures
413
- entities.concat(container.subtype_constraints) if container.subtype_constraints
414
-
415
- # Recursively find nested entities in nested containers
416
- container.types&.each do |type|
417
- entities.concat(find_nested_entities(type))
418
- end
419
- container.entities&.each do |entity|
420
- entities.concat(find_nested_entities(entity))
421
- end
422
- container.functions&.each do |function|
423
- entities.concat(find_nested_entities(function))
424
- end
425
- container.procedures&.each do |procedure|
426
- entities.concat(find_nested_entities(procedure))
427
- end
428
-
429
- when Expressir::Model::Declarations::Rule
430
- # Rule nested entities
431
- entities.concat(container.variables) if container.variables
432
- entities.concat(container.constants) if container.constants
433
- entities.concat(container.types) if container.types
434
- entities.concat(container.entities) if container.entities
435
- entities.concat(container.functions) if container.functions
436
- entities.concat(container.procedures) if container.procedures
437
- entities.concat(container.subtype_constraints) if container.subtype_constraints
438
-
439
- # Recursively find nested entities in nested containers
440
- container.types&.each do |type|
441
- entities.concat(find_nested_entities(type))
442
- end
443
- container.entities&.each do |entity|
444
- entities.concat(find_nested_entities(entity))
445
- end
446
- container.functions&.each do |function|
447
- entities.concat(find_nested_entities(function))
448
- end
449
- container.procedures&.each do |procedure|
450
- entities.concat(find_nested_entities(procedure))
390
+ when Expressir::Model::Declarations::Function,
391
+ Expressir::Model::Declarations::Procedure,
392
+ Expressir::Model::Declarations::Rule
393
+ # All three scope containers follow the same nested-entity pattern.
394
+ # Derive the collection list from the model's own declaration rather
395
+ # than maintaining three separate hand-coded branches.
396
+ nested_attrs = container.class.collection_attributes_list - %i[
397
+ statements remark_items where_rules informal_propositions applies_to
398
+ ]
399
+ nested_attrs.each do |attr|
400
+ collection = container.public_send(attr)
401
+ entities.concat(collection) if collection.is_a?(Array)
451
402
  end
452
403
 
453
- when Expressir::Model::Declarations::Procedure
454
- # Procedure nested entities
455
- entities.concat(container.parameters) if container.parameters
456
- entities.concat(container.variables) if container.variables
457
- entities.concat(container.constants) if container.constants
458
- entities.concat(container.types) if container.types
459
- entities.concat(container.entities) if container.entities
460
- entities.concat(container.functions) if container.functions
461
- entities.concat(container.procedures) if container.procedures
462
- entities.concat(container.subtype_constraints) if container.subtype_constraints
463
-
464
- # Recursively find nested entities in nested containers
465
- container.types&.each do |type|
466
- entities.concat(find_nested_entities(type))
467
- end
468
- container.entities&.each do |entity|
469
- entities.concat(find_nested_entities(entity))
470
- end
471
- container.functions&.each do |function|
472
- entities.concat(find_nested_entities(function))
473
- end
474
- container.procedures&.each do |procedure|
475
- entities.concat(find_nested_entities(procedure))
404
+ # Recursively find nested entities in nested scope containers
405
+ %i[types entities functions procedures].each do |attr|
406
+ collection = container.public_send(attr)
407
+ collection&.each { |item| entities.concat(find_nested_entities(item)) }
476
408
  end
477
409
 
478
410
  when Expressir::Model::Declarations::Interface
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Converts native-AST CamelCase keys to the snake_case keys the
6
+ # builders consume.
7
+ #
8
+ # build() descends into child nodes whose subtrees the parent already
9
+ # converted, so unchanged containers are marked with an invisible
10
+ # instance variable and skipped on re-visits — without it, every
11
+ # subtree is re-scanned once per ancestor level (~60 convert calls per
12
+ # model node on real schemas). The marker is invisible to equality,
13
+ # hashing, and inspection.
14
+ class AstKeyConverter
15
+ SNAKED_MARKER = :@_expressir_keys_snaked
16
+ UPPERCASE_PATTERN = /[A-Z]/
17
+
18
+ class << self
19
+ # Thread-local snake_case conversion cache. Thread-local avoids the
20
+ # mutable-constant anti-pattern while remaining thread-safe. The
21
+ # cache is bounded by the number of unique AST node-type names.
22
+ def snake_case(name)
23
+ cache[name] ||= begin
24
+ str = name.to_s
25
+ if /^[a-z_]+$/.match?(str)
26
+ str.to_sym
27
+ else
28
+ str
29
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
30
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
31
+ .downcase
32
+ .to_sym
33
+ end
34
+ end
35
+ end
36
+
37
+ # Returns the original object when no conversion is needed; the
38
+ # common no-conversion case allocates nothing and the conversion
39
+ # case walks the keys once.
40
+ def convert(obj)
41
+ case obj
42
+ when Hash
43
+ return obj if obj.empty?
44
+ return obj if obj.instance_variable_defined?(SNAKED_MARKER)
45
+
46
+ keys = obj.keys
47
+ converted_values = nil
48
+ new_keys = nil
49
+
50
+ keys.each_with_index do |k, i|
51
+ val = obj[k]
52
+ case val
53
+ when Hash, Array
54
+ unless val.empty?
55
+ converted_val = convert(val)
56
+ (converted_values ||= {})[k] = converted_val unless converted_val.equal?(val)
57
+ end
58
+ end
59
+
60
+ if k.match?(UPPERCASE_PATTERN)
61
+ (new_keys ||= keys.dup)[i] = snake_case(k)
62
+ end
63
+ end
64
+
65
+ return mark_snaked(obj) unless new_keys || converted_values
66
+
67
+ result = {}
68
+ keys.each_with_index do |k, i|
69
+ key = new_keys&.[](i) || k
70
+ result[key] = converted_values&.key?(k) ? converted_values[k] : obj[k]
71
+ end
72
+ mark_snaked(result)
73
+ when Array
74
+ return obj if obj.empty?
75
+ return obj if obj.instance_variable_defined?(SNAKED_MARKER)
76
+
77
+ needs_conversion = false
78
+ result = []
79
+
80
+ obj.each do |item|
81
+ case item
82
+ when Hash, Array
83
+ next if item.empty?
84
+
85
+ converted = convert(item)
86
+ result << converted
87
+ needs_conversion = true unless converted.equal?(item)
88
+ else
89
+ result << item
90
+ end
91
+ end
92
+
93
+ needs_conversion ? mark_snaked(result) : mark_snaked(obj)
94
+ else
95
+ obj
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def cache
102
+ Thread.current[:expressir_snake_case_cache] ||= {}
103
+ end
104
+
105
+ def mark_snaked(obj)
106
+ obj.instance_variable_set(SNAKED_MARKER, true)
107
+ obj
108
+ rescue FrozenError
109
+ obj
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -7,10 +7,26 @@ module Expressir
7
7
  # This is the ONLY way to build models from AST - no Transformer fallback.
8
8
  module Builder
9
9
  class << self
10
- attr_reader :source, :include_source
10
+ # Thread-local key under which the current BuilderContext is stored
11
+ # for the duration of a build_with_remarks call. Recursive build
12
+ # calls read source/include_source from here instead of from
13
+ # class-level mutable state.
14
+ CONTEXT_KEY = :expressir_builder_context
15
+
16
+ # Returns the BuilderContext for the current thread, or nil if no
17
+ # build is in progress.
18
+ def current_context
19
+ Thread.current[CONTEXT_KEY]
20
+ end
21
+
22
+ # Convenience accessors — read from the current context.
23
+ def source
24
+ current_context&.source
25
+ end
11
26
 
12
- # Cache for snake_case conversions
13
- SNAKE_CASE_CACHE = {} # rubocop:disable Style/MutableConstant
27
+ def include_source
28
+ current_context&.include_source
29
+ end
14
30
 
15
31
  # Register a builder for a node type.
16
32
  # @param node_type [Symbol] The AST node type
@@ -23,40 +39,18 @@ module Expressir
23
39
 
24
40
  # Build a Model object from AST data.
25
41
  # @param ast [Hash] The AST with node type as key
26
- # @param source [String, nil] The original source code
27
- # @param include_source [Boolean, nil] Whether to include source
28
42
  # @return [Model::ModelElement] The built model object
29
- # Operator tokens that return nil (separators, punctuation)
30
- # When these appear as the first key in a multi-key hash, they should be
31
- # skipped in favor of the content key. This handles grammar patterns like
32
- # `element >> (op_comma >> element).repeat` which produce
33
- # {:op_comma => ..., :element => {...}}.
34
- OPERATOR_TOKENS = Set.new(%i[
35
- op_comma op_colon op_decl op_delim op_leftparen op_rightparen
36
- op_leftbracket op_rightbracket op_left_curly_brace op_right_curly_brace
37
- op_period op_pipe op_double_backslash op_double_pipe op_double_asterisk
38
- op_asterisk op_slash op_plus op_minus op_less_equal op_greater_equal
39
- op_less_greater op_less_than op_greater_than op_equals
40
- op_colon_less_greater_colon op_colon_equals_colon
41
- op_query_begin op_query_end op_question_mark
42
- ]).freeze
43
-
44
- def build(ast, source: nil, include_source: nil)
43
+ def build(ast)
45
44
  return nil unless ast
46
45
 
47
- # Only set instance variables on first call (when they're provided)
48
- # Recursive calls pass nil which shouldn't override the saved values
49
- @source = source unless source.nil?
50
- @include_source = include_source unless include_source.nil?
51
-
52
46
  # Optimized: Hash is 90%+ of cases, check it first
53
47
  case ast
54
48
  when Hash
55
49
  node_type = ast.keys.first
56
50
  node_data = ast[node_type]
57
51
 
58
- handler_key = cached_snake_case(node_type)
59
- snake_data = fast_convert_keys(node_data)
52
+ handler_key = AstKeyConverter.snake_case(node_type)
53
+ snake_data = AstKeyConverter.convert(node_data)
60
54
 
61
55
  builder = @register[handler_key]
62
56
  if builder
@@ -68,26 +62,27 @@ module Expressir
68
62
  return result
69
63
  end
70
64
 
71
- # Slow path: operator token returned nil in multi-key hash.
72
- # Try other keys for actual content. This handles
73
- # {:op_comma => ..., :element => {...}} where the first key
74
- # is an operator separator rather than a content key.
75
- if OPERATOR_TOKENS.include?(handler_key)
76
- ast.each_key do |key|
77
- next if key == node_type
78
-
79
- h_key = cached_snake_case(key)
80
- h_builder = @register[h_key]
81
- next unless h_builder
82
-
83
- n_data = ast[key]
84
- s_data = fast_convert_keys(n_data)
85
- result = h_builder.call(s_data)
86
-
87
- unless result.nil?
88
- attach_source_info(result, n_data)
89
- return result
90
- end
65
+ # Slow path: first-key builder returned nil in a multi-key
66
+ # hash. Try remaining keys for actual content. This handles
67
+ # grammar patterns like `element >> (op_comma >> element).repeat`
68
+ # which produce {:op_comma => ..., :element => {...}} where
69
+ # the first key is an operator separator.
70
+ # Previously gated by a hand-maintained OPERATOR_TOKENS set;
71
+ # now tries all remaining keys unconditionally (TODO.bugs/21).
72
+ ast.each_key do |key|
73
+ next if key == node_type
74
+
75
+ h_key = AstKeyConverter.snake_case(key)
76
+ h_builder = @register[h_key]
77
+ next unless h_builder
78
+
79
+ n_data = ast[key]
80
+ s_data = AstKeyConverter.convert(n_data)
81
+ result = h_builder.call(s_data)
82
+
83
+ unless result.nil?
84
+ attach_source_info(result, n_data)
85
+ return result
91
86
  end
92
87
  end
93
88
  else
@@ -105,12 +100,23 @@ module Expressir
105
100
  end
106
101
  end
107
102
 
108
- # Build with remark attachment
103
+ # Build with remark attachment.
104
+ #
105
+ # Sets up a BuilderContext as a thread-local for the duration of the
106
+ # build so recursive build calls can read source/include_source via
107
+ # `Builder.source` / `Builder.include_source`. The previous context
108
+ # (if any) is restored on exit so nested builds are reentrant.
109
109
  def build_with_remarks(ast, source: nil, include_source: nil)
110
- # Reset instance variables at the start of a top-level build
111
- # This ensures state from previous parses is cleared
112
- @source = source
113
- @include_source = include_source
110
+ # Trigger BuilderRegistry autoload so all AST node type handlers
111
+ # are registered before build() runs. The reference to
112
+ # BuilderRegistry resolves the autoload defined in express.rb.
113
+ BuilderRegistry
114
+
115
+ previous = Thread.current[CONTEXT_KEY]
116
+ Thread.current[CONTEXT_KEY] = BuilderContext.new(
117
+ source: source,
118
+ include_source: include_source,
119
+ )
114
120
 
115
121
  result = build(ast)
116
122
 
@@ -122,6 +128,8 @@ module Expressir
122
128
  end
123
129
 
124
130
  result
131
+ ensure
132
+ Thread.current[CONTEXT_KEY] = previous
125
133
  end
126
134
 
127
135
  # Check if a builder is registered for a node type.
@@ -203,164 +211,33 @@ module Expressir
203
211
  builder.call(data)
204
212
  end
205
213
 
206
- # Fast path for term nodes
207
- def build_term(data)
208
- build_node(:term, data)
209
- end
210
-
211
- # Fast path for factor nodes
212
- def build_factor(data)
213
- build_node(:factor, data)
214
- end
215
-
216
- # Fast path for simple_factor nodes
217
- def build_simple_factor(data)
218
- build_node(:simple_factor, data)
219
- end
220
-
221
- # Fast path for primary nodes
222
- def build_primary(data)
223
- build_node(:primary, data)
224
- end
225
-
226
- # Fast path for expression nodes
227
- def build_expression(data)
228
- build_node(:expression, data)
229
- end
230
-
231
- # Fast path for simple_expression nodes
232
- def build_simple_expression(data)
233
- build_node(:simple_expression, data)
234
- end
235
-
236
- private
237
-
238
- # Cached snake_case conversion
239
- def cached_snake_case(name)
240
- SNAKE_CASE_CACHE[name] ||= begin
241
- str = name.to_s
242
- # Check if already snake_case
243
- if /^[a-z_]+$/.match?(str)
244
- str.to_sym
245
- else
246
- str
247
- .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
248
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
249
- .downcase
250
- .to_sym
251
- end
252
- end
253
- end
254
-
255
- # Optimized key conversion - returns original object when no conversion needed
256
- # This avoids unnecessary allocations for AST nodes that don't need key conversion
257
- def fast_convert_keys(obj)
258
- case obj
259
- when Hash
260
- return obj if obj.empty?
261
-
262
- # First pass: check if any conversion is needed
263
- keys = obj.keys
264
- needs_conversion = false
265
- converted_values = nil
266
-
267
- keys.each do |k|
268
- key_str = k.to_s
269
- # Check if key needs conversion (has uppercase)
270
- if key_str.match?(/[A-Z]/)
271
- needs_conversion = true
272
- end
273
-
274
- # Check if value needs conversion
275
- val = obj[k]
276
- case val
277
- when Hash
278
- next if val.empty?
279
-
280
- converted_val = fast_convert_keys(val)
281
- if !converted_val.equal?(val) # Identity check - same object?
282
- needs_conversion = true
283
- converted_values ||= {}
284
- converted_values[k] = converted_val
285
- end
286
- when Array
287
- next if val.empty?
288
-
289
- converted_val = fast_convert_keys(val)
290
- if !converted_val.equal?(val)
291
- needs_conversion = true
292
- converted_values ||= {}
293
- converted_values[k] = converted_val
294
- end
295
- end
296
- end
297
-
298
- # Return original if no conversion needed (zero allocation!)
299
- return obj unless needs_conversion
300
-
301
- # Build result only when necessary
302
- result = {}
303
- keys.each do |k|
304
- key_str = k.to_s
305
- new_key = key_str.match?(/[A-Z]/) ? cached_snake_case(k) : k
306
- new_val = converted_values&.key?(k) ? converted_values[k] : obj[k]
307
- result[new_key] = new_val
308
- end
309
- result
310
- when Array
311
- return obj if obj.empty?
312
-
313
- # Check if any element needs conversion
314
- needs_conversion = false
315
- result = []
316
-
317
- obj.each do |item|
318
- case item
319
- when Hash
320
- next if item.empty?
321
-
322
- converted = fast_convert_keys(item)
323
- result << converted
324
- needs_conversion = true unless converted.equal?(item)
325
- when Array
326
- next if item.empty?
327
-
328
- converted = fast_convert_keys(item)
329
- result << converted
330
- needs_conversion = true unless converted.equal?(item)
331
- else
332
- result << item
333
- end
334
- end
335
-
336
- # Return original if no conversion needed
337
- needs_conversion ? result : obj
338
- else
339
- obj
340
- end
341
- end
342
-
214
+ # Keys containing uppercase need snake-casing; testing the key
215
+ # directly avoids allocating `to_s` strings per key per pass.
216
+ # Key conversion lives in AstKeyConverter (MECE: converting AST
217
+ # keys is not building models).
343
218
  def extract_source_info(data)
344
219
  return nil unless data
345
- return nil unless @source
220
+
221
+ src = source
222
+ return nil unless src
346
223
 
347
224
  slice = find_slice(data)
348
225
  return nil unless slice
349
226
 
350
227
  {
351
- text: @source[slice.offset...(slice.offset + slice.length)]&.strip,
228
+ text: src[slice.offset...(slice.offset + slice.length)]&.strip,
352
229
  offset: slice.offset,
353
230
  }
354
231
  end
355
232
 
356
233
  def attach_source_info(result, data)
357
- return unless @source && result.is_a?(Expressir::Model::ModelElement)
234
+ return unless source && result.is_a?(Expressir::Model::ModelElement)
358
235
 
359
236
  source_info = extract_source_info(data)
360
237
  return unless source_info
361
238
 
362
239
  result.source_offset = source_info[:offset]
363
- result.source = source_info[:text] if @include_source
240
+ result.source = source_info[:text] if include_source
364
241
  end
365
242
 
366
243
  def find_slice(data, depth = 0)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Expressir
4
+ module Express
5
+ # Value object carrying the per-build context that Builder needs to
6
+ # attach source info to model elements.
7
+ #
8
+ # Stored as a thread-local by Builder.build_with_remarks so that recursive
9
+ # build calls can read `Builder.source` / `Builder.include_source` without
10
+ # the module mutating class-level state. Thread-local means concurrent
11
+ # builds in different threads don't corrupt each other; the save/restore
12
+ # pattern in build_with_remarks means nested builds are reentrant.
13
+ class BuilderContext
14
+ attr_reader :source, :include_source
15
+
16
+ def initialize(source:, include_source:)
17
+ @source = source
18
+ @include_source = include_source
19
+ end
20
+ end
21
+ end
22
+ end