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
@@ -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,30 @@ 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
+
48
+ # Error raised when parallel file parsing loses a worker or its data
49
+ class ParallelParseError < ExpressError
50
+ def initialize(message = "Parallel parsing failed")
51
+ super
52
+ end
53
+ end
54
+
55
+ # Error raised when streaming parsing is requested but unavailable.
56
+ # The streaming paths require a parsanol release whose
57
+ # parse_with_builder is stable; parse_fresh cannot parse EXPRESS
58
+ # without packrat memoization (see parsanol-ruby#52).
59
+ class StreamingUnsupportedError < ExpressError
60
+ def initialize(message = "Streaming parsing is not supported by the installed parsanol release")
61
+ super
62
+ end
63
+ end
64
+
41
65
  # Base class for visitor-related errors
42
66
  class VisitorError < ExpressError; end
43
67
 
@@ -9,6 +9,15 @@ module Expressir
9
9
  end
10
10
 
11
11
  def self.register_formatter(model_class, method_name)
12
+ # Guard: fail at registration time if the method doesn't exist on
13
+ # this class (or one of its included modules). Includes private
14
+ # methods since format handlers are private (TODO.bugs/19).
15
+ unless method_defined?(method_name) || private_method_defined?(method_name)
16
+ raise ArgumentError,
17
+ "Formatter.register_formatter: method :#{method_name} " \
18
+ "is not defined for #{model_class}"
19
+ end
20
+
12
21
  @format_registry[model_class] = method_name
13
22
  end
14
23
 
@@ -23,14 +32,6 @@ module Expressir
23
32
  include Formatters::DataTypesFormatter
24
33
  include Formatters::DeclarationsFormatter
25
34
 
26
- # Handlers for types implemented directly in this class
27
- register_formatter Model::Repository, :format_repository
28
- register_formatter Model::ExpFile, :format_exp_file
29
- register_formatter Model::Declarations::SchemaVersionItem, :format_noop
30
- register_formatter Model::Declarations::InterfacedItem, :format_noop
31
- register_formatter Model::Cache, :format_noop
32
- register_formatter Model::ModelElement, :format_noop
33
-
34
35
  INDENT_CHAR = " ".freeze
35
36
  INDENT_WIDTH = 2
36
37
  INDENT = INDENT_CHAR * INDENT_WIDTH
@@ -62,12 +63,6 @@ module Expressir
62
63
  Model::SupertypeExpressions::BinarySupertypeExpression::ANDOR => 2,
63
64
  }.freeze
64
65
 
65
- private_constant :INDENT_CHAR
66
- private_constant :INDENT_WIDTH
67
- private_constant :INDENT
68
- private_constant :OPERATOR_PRECEDENCE
69
- private_constant :SUPERTYPE_OPERATOR_PRECEDENCE
70
-
71
66
  attr_accessor :no_remarks
72
67
 
73
68
  def initialize(no_remarks: false)
@@ -82,12 +77,22 @@ module Expressir
82
77
  return "" if node.nil?
83
78
 
84
79
  handler = self.class.format_registry[node.class]
85
- return public_send(handler, node) if handler
86
-
87
- warn "#{node.class.name} format not implemented"
88
- ""
80
+ unless handler
81
+ warn "#{node.class.name} format not implemented"
82
+ return ""
83
+ end
84
+
85
+ formatted = send(handler, node)
86
+ # Some handlers return nil; appending must not turn that into "".
87
+ inline = format_inline_statement_remarks(node)
88
+ formatted += inline if formatted.is_a?(String) && !inline.empty?
89
+
90
+ leading = format_leading_statement_remarks(node)
91
+ leading.empty? ? formatted : [*leading, formatted].join("\n")
89
92
  end
90
93
 
94
+ private
95
+
91
96
  def format_noop(_node)
92
97
  ""
93
98
  end
@@ -107,6 +112,16 @@ module Expressir
107
112
 
108
113
  str.split("\n").map { |x| "#{INDENT}#{x}" }.join("\n")
109
114
  end
115
+
116
+ # Handlers for types implemented directly in this class.
117
+ # Registered at the bottom so the methods exist when the
118
+ # registration-time method_defined? guard runs.
119
+ register_formatter Model::Repository, :format_repository
120
+ register_formatter Model::ExpFile, :format_exp_file
121
+ register_formatter Model::Declarations::SchemaVersionItem, :format_noop
122
+ register_formatter Model::Declarations::InterfacedItem, :format_noop
123
+ register_formatter Model::Cache, :format_noop
124
+ register_formatter Model::ModelElement, :format_noop
110
125
  end
111
126
  end
112
127
  end
@@ -292,6 +292,7 @@ module Expressir
292
292
  *if node.statements&.length&.positive?
293
293
  indent(node.statements.map { |x| format(x) }.join("\n"))
294
294
  end,
295
+ *format_trailing_region_remarks(node, :statements).map { |x| indent(x) },
295
296
  [
296
297
  "END_FUNCTION",
297
298
  ";",
@@ -413,6 +414,7 @@ module Expressir
413
414
  *if node.statements&.length&.positive?
414
415
  indent(node.statements.map { |x| format(x) }.join("\n"))
415
416
  end,
417
+ *format_trailing_region_remarks(node, :statements).map { |x| indent(x) },
416
418
  [
417
419
  "END_PROCEDURE",
418
420
  ";",
@@ -421,14 +423,29 @@ module Expressir
421
423
  ].join("\n")
422
424
  end
423
425
 
426
+ # True when the statement holds a remark that only it can render.
427
+ # Under no_remarks nothing is emitted anyway, so the statement stays
428
+ # filtered and remark-free output is byte-identical to before.
429
+ def carries_placed_remark?(statement)
430
+ return false if @no_remarks
431
+
432
+ Array(statement.untagged_remarks).any?(&:placement)
433
+ end
434
+
424
435
  def format_declarations_rule(node)
425
436
  # Filter out statements that only exist to hold remarks in rules
426
437
  # (ALIAS/REPEAT with only Null sub-statements, query assignments, or Null statements)
427
438
  formatted_statements = []
428
439
  if node.statements&.length&.positive?
429
440
  formatted_statements = node.statements.reject do |stmt|
441
+ # A statement holding a placed remark is the only thing that can
442
+ # render it, so filtering it would silently drop the comment —
443
+ # and PrettyFormatter, which does not filter, would then produce
444
+ # different content from this formatter.
445
+ if carries_placed_remark?(stmt)
446
+ false
430
447
  # Filter ALIAS/REPEAT with only Null statements
431
- if stmt.is_a?(Model::Statements::Alias) || stmt.is_a?(Model::Statements::Repeat)
448
+ elsif stmt.is_a?(Model::Statements::Alias) || stmt.is_a?(Model::Statements::Repeat)
432
449
  stmt.statements&.all?(Model::Statements::Null)
433
450
  # Filter query assignments (assignments with QueryExpression that exist only for remarks)
434
451
  elsif stmt.is_a?(Model::Statements::Assignment) &&
@@ -494,12 +511,17 @@ module Expressir
494
511
  *if formatted_statements.length.positive?
495
512
  indent(formatted_statements.map { |x| format(x) }.join("\n"))
496
513
  end,
514
+ # A RULE's executable body ends at WHERE, so its trailing
515
+ # remarks belong above that keyword, not after the where rules.
516
+ *format_trailing_region_remarks(node, :statements).map { |x| indent(x) },
497
517
  *if node.where_rules&.length&.positive?
498
518
  [
499
519
  "WHERE",
500
520
  indent(node.where_rules.map { |x| format(x) }.join("\n")),
501
521
  ]
502
522
  end,
523
+ # A comment after the last where rule closes the WHERE section.
524
+ *format_trailing_region_remarks(node, :where_rules).map { |x| indent(x) },
503
525
  [
504
526
  "END_RULE",
505
527
  ";",
@@ -525,7 +547,7 @@ module Expressir
525
547
  *if node.interfaces&.length&.positive?
526
548
  [
527
549
  "",
528
- node.interfaces.map { |x| format(x) }.join("\n"),
550
+ node.interfaces.map { |x| format(x) }.join("\n\n"),
529
551
  ]
530
552
  end,
531
553
  ].join("\n")
@@ -68,9 +68,11 @@ module Expressir
68
68
  def format_end_scope_remark(node)
69
69
  return "" if @no_remarks
70
70
  return "" unless node.is_a?(Model::ModelElement)
71
- return "" if node.untagged_remarks.nil? || node.untagged_remarks.empty?
72
71
 
73
- remark = node.untagged_remarks.last
72
+ # Only legacy (unplaced) remarks belong after the END_* keyword.
73
+ # Remarks carrying an explicit placement are rendered by the
74
+ # placement-aware emitters, and must not be emitted twice.
75
+ remark = Array(node.untagged_remarks).reject(&:placement).last
74
76
  return "" unless remark.is_a?(Model::RemarkInfo)
75
77
 
76
78
  text = remark.text
@@ -136,6 +138,77 @@ module Expressir
136
138
  end.join("\n")
137
139
  end
138
140
 
141
+ # Remarks that close one body of `node`, emitted just before that
142
+ # body's closing keyword. Only explicit TRAILING remarks for the named
143
+ # region qualify: legacy (nil) remarks keep their historical emission
144
+ # path so old caches render exactly as they did before.
145
+ def format_trailing_region_remarks(node, region)
146
+ return [] if @no_remarks
147
+ return [] unless node.is_a?(Model::ModelElement)
148
+
149
+ Array(node.untagged_remarks).filter_map do |remark|
150
+ next unless remark.trailing_region?(region)
151
+
152
+ formatted = format_untagged_remark(remark)
153
+ formatted unless formatted.empty?
154
+ end
155
+ end
156
+
157
+ # Remarks written after their statement on the same line. Attachment
158
+ # only assigns these to single-line statements, so appending keeps
159
+ # them on that statement's line.
160
+ def format_inline_statement_remarks(node)
161
+ return "" if @no_remarks
162
+ return "" unless node.is_a?(Model::Statement)
163
+
164
+ Array(node.untagged_remarks).filter_map do |remark|
165
+ next unless remark.inline?
166
+
167
+ formatted = format_untagged_remark(remark)
168
+ formatted unless formatted.empty?
169
+ end.map { |text| " #{text}" }.join
170
+ end
171
+
172
+ def format_leading_statement_remarks(node)
173
+ return [] if @no_remarks
174
+ return [] unless node.is_a?(Model::Statement)
175
+
176
+ Array(node.untagged_remarks).filter_map do |remark|
177
+ next unless remark.leading?
178
+
179
+ formatted = format_untagged_remark(remark)
180
+ formatted unless formatted.empty?
181
+ end
182
+ end
183
+
184
+ # Block-end emission for ALIAS/REPEAT bodies. Tagged remarks are
185
+ # stored as bare text in node.remarks alongside a mirror of every
186
+ # untagged text (add_remark dual-store), so "genuinely tagged" is
187
+ # derivable only by multiset-subtracting the untagged texts.
188
+ def format_block_end_remarks(node)
189
+ return [] if @no_remarks
190
+
191
+ untagged = Array(node.untagged_remarks)
192
+ remaining = untagged.map(&:text).tally
193
+ tagged = Array(node.remarks).compact.reject do |text|
194
+ next false unless remaining[text]&.positive?
195
+
196
+ remaining[text] -= 1
197
+ true
198
+ end
199
+
200
+ [
201
+ *tagged.map { |text| format_remark(node, text) },
202
+ # Leading and inline remarks are emitted by their own paths;
203
+ # including them here would render them a second time.
204
+ *untagged.reject { |r| r.leading? || r.inline? }
205
+ .filter_map do |remark|
206
+ formatted = format_untagged_remark(remark)
207
+ formatted unless formatted.empty?
208
+ end,
209
+ ]
210
+ end
211
+
139
212
  def format_remarks(node)
140
213
  remarks = []
141
214
 
@@ -43,7 +43,7 @@ module Expressir
43
43
  *if node.statements&.length&.positive?
44
44
  indent(node.statements.map { |x| format(x) }.join("\n"))
45
45
  end,
46
- *format_remarks(node),
46
+ *format_block_end_remarks(node).map { |x| indent(x) },
47
47
  [
48
48
  "END_ALIAS",
49
49
  ";",
@@ -88,6 +88,10 @@ module Expressir
88
88
  *if node.actions&.length&.positive?
89
89
  node.actions.map { |x| format(x) }
90
90
  end,
91
+ # Closes the last action: before OTHERWISE when one exists,
92
+ # otherwise before END_CASE.
93
+ *format_trailing_region_remarks(node, :action_statements)
94
+ .map { |x| indent(x) },
91
95
  *if node.otherwise_statement
92
96
  [
93
97
  [
@@ -98,6 +102,8 @@ module Expressir
98
102
  indent(format(node.otherwise_statement)),
99
103
  ]
100
104
  end,
105
+ *format_trailing_region_remarks(node, :otherwise_statements)
106
+ .map { |x| indent(x) },
101
107
  [
102
108
  "END_CASE",
103
109
  ";",
@@ -123,6 +129,7 @@ module Expressir
123
129
  *if statements.length.positive?
124
130
  indent(statements.map { |x| format(x) }.join("\n"))
125
131
  end,
132
+ *format_trailing_region_remarks(node, :statements).map { |x| indent(x) },
126
133
  [
127
134
  "END",
128
135
  ";",
@@ -138,6 +145,14 @@ module Expressir
138
145
  end
139
146
 
140
147
  def format_statements_if(node)
148
+ has_else = node.else_statements&.length&.positive?
149
+ # A comment closing the THEN body sits before ELSE when there is
150
+ # one, and before END_IF otherwise.
151
+ then_trailing = format_trailing_region_remarks(node, :statements)
152
+ .map { |x| indent(x) }
153
+ else_trailing = format_trailing_region_remarks(node, :else_statements)
154
+ .map { |x| indent(x) }
155
+
141
156
  [
142
157
  [
143
158
  "IF",
@@ -149,12 +164,14 @@ module Expressir
149
164
  *if node.statements&.length&.positive?
150
165
  indent(node.statements.map { |x| format(x) }.join("\n"))
151
166
  end,
152
- *if node.else_statements&.length&.positive?
167
+ *(then_trailing if has_else),
168
+ *if has_else
153
169
  [
154
170
  "ELSE",
155
171
  indent(node.else_statements.map { |x| format(x) }.join("\n")),
156
172
  ].join("\n")
157
173
  end,
174
+ *(has_else ? else_trailing : then_trailing),
158
175
  [
159
176
  "END_IF",
160
177
  ";",
@@ -214,7 +231,7 @@ module Expressir
214
231
  *if statements.length.positive?
215
232
  indent(statements.map { |x| format(x) }.join("\n"))
216
233
  end,
217
- *format_remarks(node),
234
+ *format_block_end_remarks(node).map { |x| indent(x) },
218
235
  [
219
236
  "END_REPEAT",
220
237
  ";",