expressir 2.3.7 → 2.4.1

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 (110) 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/docs/_tutorials/formatting-schemas.adoc +89 -0
  34. data/docs/_tutorials/index.adoc +10 -0
  35. data/docs/lychee.toml +3 -0
  36. data/expressir.gemspec +4 -2
  37. data/lib/expressir/commands/changes_import_eengine.rb +0 -6
  38. data/lib/expressir/commands/changes_validate.rb +0 -2
  39. data/lib/expressir/commands/clean.rb +1 -1
  40. data/lib/expressir/commands/file_violations.rb +70 -0
  41. data/lib/expressir/commands/format.rb +1 -1
  42. data/lib/expressir/commands/non_ascii_character.rb +49 -0
  43. data/lib/expressir/commands/non_ascii_violation_collection.rb +301 -0
  44. data/lib/expressir/commands/package.rb +138 -185
  45. data/lib/expressir/commands/validate_ascii.rb +0 -510
  46. data/lib/expressir/commands/version.rb +1 -1
  47. data/lib/expressir/commands.rb +3 -0
  48. data/lib/expressir/coverage.rb +49 -117
  49. data/lib/expressir/express/builder.rb +74 -86
  50. data/lib/expressir/express/builder_context.rb +22 -0
  51. data/lib/expressir/express/builders/expression_builder.rb +16 -16
  52. data/lib/expressir/express/cache.rb +35 -8
  53. data/lib/expressir/express/error.rb +7 -0
  54. data/lib/expressir/express/formatter.rb +33 -18
  55. data/lib/expressir/express/formatters/declarations_formatter.rb +24 -2
  56. data/lib/expressir/express/formatters/remark_formatter.rb +75 -2
  57. data/lib/expressir/express/formatters/statements_formatter.rb +20 -3
  58. data/lib/expressir/express/grammar/parser.rb +705 -0
  59. data/lib/expressir/express/grammar.rb +11 -0
  60. data/lib/expressir/express/line_map.rb +48 -0
  61. data/lib/expressir/express/node_position_index.rb +215 -0
  62. data/lib/expressir/express/parser.rb +114 -884
  63. data/lib/expressir/express/pretty_formatter.rb +23 -5
  64. data/lib/expressir/express/remark_attacher.rb +483 -669
  65. data/lib/expressir/express/remark_scanner.rb +245 -0
  66. data/lib/expressir/express/schema_block_scanner.rb +136 -0
  67. data/lib/expressir/express/schema_source_formatter.rb +15 -0
  68. data/lib/expressir/express/scope_resolver.rb +194 -0
  69. data/lib/expressir/express/source_formatter.rb +15 -0
  70. data/lib/expressir/express/streaming_builder.rb +147 -176
  71. data/lib/expressir/express.rb +10 -6
  72. data/lib/expressir/model/concerns.rb +3 -0
  73. data/lib/expressir/model/data_types/generic_entity.rb +6 -6
  74. data/lib/expressir/model/declarations/entity.rb +5 -0
  75. data/lib/expressir/model/declarations/function.rb +5 -0
  76. data/lib/expressir/model/declarations/procedure.rb +5 -0
  77. data/lib/expressir/model/declarations/rule.rb +6 -0
  78. data/lib/expressir/model/declarations/schema.rb +21 -8
  79. data/lib/expressir/model/declarations/type.rb +3 -0
  80. data/lib/expressir/model/exp_file.rb +2 -0
  81. data/lib/expressir/model/expressions/aggregate_initializer.rb +1 -0
  82. data/lib/expressir/model/expressions/aggregate_initializer_item.rb +1 -0
  83. data/lib/expressir/model/expressions/binary_expression.rb +1 -0
  84. data/lib/expressir/model/expressions/entity_constructor.rb +1 -0
  85. data/lib/expressir/model/expressions/function_call.rb +1 -0
  86. data/lib/expressir/model/expressions/interval.rb +1 -0
  87. data/lib/expressir/model/expressions/query_expression.rb +2 -0
  88. data/lib/expressir/model/expressions/unary_expression.rb +1 -0
  89. data/lib/expressir/model/model_element.rb +128 -14
  90. data/lib/expressir/model/remark_format.rb +17 -0
  91. data/lib/expressir/model/remark_info.rb +31 -3
  92. data/lib/expressir/model/remark_placement.rb +29 -0
  93. data/lib/expressir/model/statements/alias.rb +2 -0
  94. data/lib/expressir/model/statements/assignment.rb +4 -0
  95. data/lib/expressir/model/statements/case.rb +21 -0
  96. data/lib/expressir/model/statements/case_action.rb +1 -0
  97. data/lib/expressir/model/statements/compound.rb +4 -0
  98. data/lib/expressir/model/statements/escape.rb +3 -0
  99. data/lib/expressir/model/statements/if.rb +5 -0
  100. data/lib/expressir/model/statements/null.rb +3 -0
  101. data/lib/expressir/model/statements/procedure_call.rb +3 -0
  102. data/lib/expressir/model/statements/repeat.rb +3 -0
  103. data/lib/expressir/model/statements/return.rb +3 -0
  104. data/lib/expressir/model/statements/skip.rb +3 -0
  105. data/lib/expressir/model.rb +12 -3
  106. data/lib/expressir/package/builder.rb +2 -2
  107. data/lib/expressir/version.rb +6 -1
  108. data/lib/expressir.rb +38 -3
  109. metadata +65 -5
  110. 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
@@ -7,10 +7,34 @@ 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
30
+
31
+ # Thread-local snake_case conversion cache. Thread-local avoids the
32
+ # mutable-constant anti-pattern while remaining thread-safe.
33
+ # Each thread gets its own cache; the cache grows with the number of
34
+ # unique AST node-type names encountered (bounded by grammar size).
35
+ def snake_case_cache
36
+ Thread.current[:expressir_snake_case_cache] ||= {}
37
+ end
14
38
 
15
39
  # Register a builder for a node type.
16
40
  # @param node_type [Symbol] The AST node type
@@ -23,32 +47,10 @@ module Expressir
23
47
 
24
48
  # Build a Model object from AST data.
25
49
  # @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
50
  # @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)
51
+ def build(ast)
45
52
  return nil unless ast
46
53
 
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
54
  # Optimized: Hash is 90%+ of cases, check it first
53
55
  case ast
54
56
  when Hash
@@ -68,26 +70,27 @@ module Expressir
68
70
  return result
69
71
  end
70
72
 
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
73
+ # Slow path: first-key builder returned nil in a multi-key
74
+ # hash. Try remaining keys for actual content. This handles
75
+ # grammar patterns like `element >> (op_comma >> element).repeat`
76
+ # which produce {:op_comma => ..., :element => {...}} where
77
+ # the first key is an operator separator.
78
+ # Previously gated by a hand-maintained OPERATOR_TOKENS set;
79
+ # now tries all remaining keys unconditionally (TODO.bugs/21).
80
+ ast.each_key do |key|
81
+ next if key == node_type
82
+
83
+ h_key = cached_snake_case(key)
84
+ h_builder = @register[h_key]
85
+ next unless h_builder
86
+
87
+ n_data = ast[key]
88
+ s_data = fast_convert_keys(n_data)
89
+ result = h_builder.call(s_data)
90
+
91
+ unless result.nil?
92
+ attach_source_info(result, n_data)
93
+ return result
91
94
  end
92
95
  end
93
96
  else
@@ -105,12 +108,23 @@ module Expressir
105
108
  end
106
109
  end
107
110
 
108
- # Build with remark attachment
111
+ # Build with remark attachment.
112
+ #
113
+ # Sets up a BuilderContext as a thread-local for the duration of the
114
+ # build so recursive build calls can read source/include_source via
115
+ # `Builder.source` / `Builder.include_source`. The previous context
116
+ # (if any) is restored on exit so nested builds are reentrant.
109
117
  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
118
+ # Trigger BuilderRegistry autoload so all AST node type handlers
119
+ # are registered before build() runs. The reference to
120
+ # BuilderRegistry resolves the autoload defined in express.rb.
121
+ BuilderRegistry
122
+
123
+ previous = Thread.current[CONTEXT_KEY]
124
+ Thread.current[CONTEXT_KEY] = BuilderContext.new(
125
+ source: source,
126
+ include_source: include_source,
127
+ )
114
128
 
115
129
  result = build(ast)
116
130
 
@@ -122,6 +136,8 @@ module Expressir
122
136
  end
123
137
 
124
138
  result
139
+ ensure
140
+ Thread.current[CONTEXT_KEY] = previous
125
141
  end
126
142
 
127
143
  # Check if a builder is registered for a node type.
@@ -203,41 +219,11 @@ module Expressir
203
219
  builder.call(data)
204
220
  end
205
221
 
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
222
  private
237
223
 
238
224
  # Cached snake_case conversion
239
225
  def cached_snake_case(name)
240
- SNAKE_CASE_CACHE[name] ||= begin
226
+ snake_case_cache[name] ||= begin
241
227
  str = name.to_s
242
228
  # Check if already snake_case
243
229
  if /^[a-z_]+$/.match?(str)
@@ -342,25 +328,27 @@ module Expressir
342
328
 
343
329
  def extract_source_info(data)
344
330
  return nil unless data
345
- return nil unless @source
331
+
332
+ src = source
333
+ return nil unless src
346
334
 
347
335
  slice = find_slice(data)
348
336
  return nil unless slice
349
337
 
350
338
  {
351
- text: @source[slice.offset...(slice.offset + slice.length)]&.strip,
339
+ text: src[slice.offset...(slice.offset + slice.length)]&.strip,
352
340
  offset: slice.offset,
353
341
  }
354
342
  end
355
343
 
356
344
  def attach_source_info(result, data)
357
- return unless @source && result.is_a?(Expressir::Model::ModelElement)
345
+ return unless source && result.is_a?(Expressir::Model::ModelElement)
358
346
 
359
347
  source_info = extract_source_info(data)
360
348
  return unless source_info
361
349
 
362
350
  result.source_offset = source_info[:offset]
363
- result.source = source_info[:text] if @include_source
351
+ result.source = source_info[:text] if include_source
364
352
  end
365
353
 
366
354
  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
@@ -10,9 +10,9 @@ module Expressir
10
10
  # Expression
11
11
  def build_expression(ast_data)
12
12
  left = if ast_data[:simple_expression]
13
- Builder.build_simple_expression(ast_data[:simple_expression])
13
+ Builder.build_node(:simple_expression, ast_data[:simple_expression])
14
14
  elsif ast_data[:logical_expression]
15
- Builder.build_simple_expression(ast_data[:logical_expression][:simple_expression])
15
+ Builder.build_node(:simple_expression, ast_data[:logical_expression][:simple_expression])
16
16
  end
17
17
 
18
18
  if ast_data[:rel_op_extended] && ast_data[:rhs]
@@ -29,18 +29,18 @@ module Expressir
29
29
  end
30
30
 
31
31
  def build_logical_expression(ast_data)
32
- Builder.build_simple_expression(ast_data[:simple_expression])
32
+ Builder.build_node(:simple_expression, ast_data[:simple_expression])
33
33
  end
34
34
 
35
35
  def build_numeric_expression(ast_data)
36
- Builder.build_simple_expression(ast_data[:simple_expression])
36
+ Builder.build_node(:simple_expression, ast_data[:simple_expression])
37
37
  end
38
38
 
39
39
  # Simple expression (addition/subtraction chain)
40
40
  def build_simple_expression(ast_data)
41
41
  return nil unless ast_data[:term]
42
42
 
43
- term = Builder.build_term(ast_data[:term])
43
+ term = Builder.build_node(:term, ast_data[:term])
44
44
  rhs = ast_data[:rhs]
45
45
 
46
46
  return term if rhs.nil? || (rhs.is_a?(Array) && rhs.empty?)
@@ -60,7 +60,7 @@ module Expressir
60
60
  op_data = item[:operator]
61
61
  operators << extract_operator(op_data[:add_like_op]) if op_data
62
62
  if item[:term]
63
- operands << Builder.build_term(item[:term])
63
+ operands << Builder.build_node(:term, item[:term])
64
64
  end
65
65
  end
66
66
 
@@ -71,7 +71,7 @@ module Expressir
71
71
  def build_term(ast_data)
72
72
  return nil unless ast_data[:factor]
73
73
 
74
- factor = Builder.build_factor(ast_data[:factor])
74
+ factor = Builder.build_node(:factor, ast_data[:factor])
75
75
  rhs = ast_data[:rhs]
76
76
 
77
77
  return factor if rhs.nil? || (rhs.is_a?(Array) && rhs.empty?)
@@ -90,7 +90,7 @@ module Expressir
90
90
  item = r[:item] || r
91
91
  op_data = item[:multiplication_like_op] || item[:mul_like_op]
92
92
  operators << extract_operator(op_data) if op_data
93
- operands << Builder.build_factor(item[:factor]) if item[:factor]
93
+ operands << Builder.build_node(:factor, item[:factor]) if item[:factor]
94
94
  end
95
95
 
96
96
  build_binary_expression(operands, operators)
@@ -99,14 +99,14 @@ module Expressir
99
99
  def build_factor(ast_data)
100
100
  return nil unless ast_data[:simple_factor]
101
101
 
102
- Builder.build_simple_factor(ast_data[:simple_factor])
102
+ Builder.build_node(:simple_factor, ast_data[:simple_factor])
103
103
  end
104
104
 
105
105
  def build_simple_factor(ast_data)
106
106
  return nil unless ast_data
107
107
 
108
108
  if ast_data[:primary]
109
- Builder.build_primary(ast_data[:primary])
109
+ Builder.build_node(:primary, ast_data[:primary])
110
110
  elsif ast_data[:simple_factor_expression]
111
111
  Builder.build_node(:simple_factor_expression,
112
112
  ast_data[:simple_factor_expression])
@@ -116,7 +116,7 @@ module Expressir
116
116
  elsif ast_data[:constant_factor]
117
117
  Builder.build_node(:constant_factor, ast_data[:constant_factor])
118
118
  elsif ast_data[:expression]
119
- Builder.build_expression(ast_data[:expression])
119
+ Builder.build_node(:expression, ast_data[:expression])
120
120
  elsif ast_data[:aggregate_initializer]
121
121
  Builder.build_node(:aggregate_initializer,
122
122
  ast_data[:aggregate_initializer])
@@ -137,21 +137,21 @@ module Expressir
137
137
 
138
138
  def build_simple_factor_expression(ast_data)
139
139
  if ast_data[:primary]
140
- Builder.build_primary(ast_data[:primary])
140
+ Builder.build_node(:primary, ast_data[:primary])
141
141
  elsif ast_data[:expression]
142
- Builder.build_expression(ast_data[:expression])
142
+ Builder.build_node(:expression, ast_data[:expression])
143
143
  end
144
144
  end
145
145
 
146
146
  def build_simple_factor_unary_expression(ast_data)
147
147
  op = extract_unary_op(ast_data[:unary_op])
148
148
  operand = if ast_data[:simple_factor]
149
- Builder.build_simple_factor(ast_data[:simple_factor])
149
+ Builder.build_node(:simple_factor, ast_data[:simple_factor])
150
150
  elsif ast_data[:simple_factor_expression]
151
151
  Builder.build_node(:simple_factor_expression,
152
152
  ast_data[:simple_factor_expression])
153
153
  elsif ast_data[:primary]
154
- Builder.build_primary(ast_data[:primary])
154
+ Builder.build_node(:primary, ast_data[:primary])
155
155
  end
156
156
 
157
157
  if op
@@ -181,7 +181,7 @@ module Expressir
181
181
  elsif ast_data[:population]
182
182
  build_population(ast_data[:population], ast_data[:qualifier])
183
183
  elsif ast_data[:expression]
184
- Builder.build_expression(ast_data[:expression])
184
+ Builder.build_node(:expression, ast_data[:expression])
185
185
  end
186
186
  end
187
187
 
@@ -1,8 +1,17 @@
1
+ require "digest"
1
2
  require "zlib"
2
3
 
3
4
  module Expressir
4
5
  module Express
5
6
  class Cache
7
+ # Format: magic + SHA-256 (raw, 32 bytes) + Zlib-deflated Marshal.dump
8
+ # of Model::Cache. The digest detects corruption; a corrupted cache is
9
+ # disposable — callers drop the file and regenerate. Marshal is only
10
+ # safe for trusted, locally generated cache files.
11
+ MAGIC = "EXPRC1".freeze
12
+
13
+ DIGEST_BYTES = Digest::SHA256.digest("").bytesize
14
+
6
15
  # Save Express model into a cache file
7
16
  # @param file [String] cache file path
8
17
  # @param content [Model::ModelElement] Express model
@@ -11,7 +20,7 @@ module Expressir
11
20
  # @return [nil]
12
21
  def self.to_file(file, content, root_path: nil,
13
22
  test_overwrite_version: nil)
14
- version = test_overwrite_version || VERSION
23
+ version = test_overwrite_version || Expressir::Version::VERSION
15
24
 
16
25
  cache = Model::Cache.new(
17
26
  version: version,
@@ -19,10 +28,9 @@ test_overwrite_version: nil)
19
28
  root_path: root_path,
20
29
  )
21
30
 
22
- yaml = cache.to_yaml
23
- yaml_compressed = Zlib::Deflate.deflate(yaml)
31
+ data = Zlib::Deflate.deflate(Marshal.dump(cache), 1)
32
+ File.binwrite(file, "#{MAGIC}#{Digest::SHA256.digest(data)}#{data}")
24
33
 
25
- File.binwrite(file, yaml_compressed)
26
34
  nil
27
35
  end
28
36
 
@@ -31,18 +39,37 @@ test_overwrite_version: nil)
31
39
  # @param root_path [String] Express repository root path, to be prepended to Express file paths if loading a portable cache file
32
40
  # @param test_overwrite_version [String] don't use, only for tests
33
41
  # @return [Model::ModelElement] Express model
42
+ # @raise [Error::CacheCorruptedError] if the file is not a valid cache file or its digest does not match
43
+ # @raise [Error::CacheVersionMismatchError] if the cache was written by another Expressir version
34
44
  def self.from_file(file, root_path: nil, test_overwrite_version: nil)
35
- version = test_overwrite_version || VERSION
45
+ version = test_overwrite_version || Expressir::Version::VERSION
36
46
 
37
- yaml_compressed = File.binread(file)
38
- yaml = Zlib::Inflate.inflate(yaml_compressed)
39
- cache = Model::Cache.from_yaml(yaml)
47
+ raw = File.binread(file)
48
+ data = Zlib::Inflate.inflate(validated_data(raw))
49
+ cache = Marshal.load(data) # rubocop:disable Security/MarshalLoad
40
50
 
41
51
  if cache.version != version
42
52
  raise Error::CacheVersionMismatchError.new(cache.version, version)
43
53
  end
44
54
 
45
55
  cache
56
+ rescue TypeError, ArgumentError, RangeError
57
+ raise Error::CacheCorruptedError
58
+ end
59
+
60
+ def self.validated_data(raw)
61
+ header = MAGIC.bytesize + DIGEST_BYTES
62
+ unless raw.bytesize > header && raw.start_with?(MAGIC)
63
+ raise Error::CacheCorruptedError
64
+ end
65
+
66
+ digest = raw.byteslice(MAGIC.bytesize, DIGEST_BYTES)
67
+ data = raw.byteslice(header, raw.bytesize - header)
68
+ unless Digest::SHA256.digest(data) == digest
69
+ raise Error::CacheCorruptedError
70
+ end
71
+
72
+ data
46
73
  end
47
74
  end
48
75
  end
@@ -38,6 +38,13 @@ module Expressir
38
38
  end
39
39
  end
40
40
 
41
+ # Error raised when a cache file is not a valid cache file or fails integrity validation
42
+ class CacheCorruptedError < ExpressError
43
+ def initialize(message = "Cache file is corrupted or not a valid Expressir cache file")
44
+ super
45
+ end
46
+ end
47
+
41
48
  # Base class for visitor-related errors
42
49
  class VisitorError < ExpressError; end
43
50