Module: Hecks::Grammar::Evolve

Defined in:
lib/hecks/grammar/evolve.rb

Overview

The file surgery under bin/evolve: reading and rewriting the aggregate-local KeywordSeed/ArgumentSeed rows as TEXT, so a proposed word enters the table exactly as a hand would write it and an admitted one loses its ceremony (an absent status reads as admitted — the grown-column convention).

Text, not IR, on purpose: the syntax table is source, its comments and grouping are part of the declaration, and a rewrite that round-tripped it through the IR would flatten both. Everything here touches only bare member lines inside Keyword's own body (S3, ADR 0025 — no one_of do ... end wrapper anymore) and leaves every other byte alone.

Defined Under Namespace

Classes: Refusal

Class Method Summary collapse

Class Method Details

.argument_block(source) ⇒ Object



321
# File 'lib/hecks/grammar/evolve.rb', line 321

def argument_block(source) = seed_block(source, "ArgumentSeed", required: true)

.argument_blocks(source) ⇒ Object

From value_object "ArgumentSeed" to ITS OWN closing end — see keyword_block's own comment for why the first bare end after the opener is already the right one, now that member rows sit bare (S3, ADR 0025).



320
# File 'lib/hecks/grammar/evolve.rb', line 320

def argument_blocks(source) = seed_blocks(source, "ArgumentSeed")

.argument_identity(row) ⇒ Object



292
# File 'lib/hecks/grammar/evolve.rb', line 292

def argument_identity(row) = [row[:keyword], row[:context], row[:at], row[:named]]

.argument_row?(line, keyword, context, at, named) ⇒ Boolean

Returns:

  • (Boolean)


286
287
288
289
290
# File 'lib/hecks/grammar/evolve.rb', line 286

def argument_row?(line, keyword, context, at, named)
  line =~ /^\s*member / &&
    line.include?(%(keyword: "#{keyword}")) && line.include?(%(context: "#{context}")) &&
    line.include?(%(at: "#{at}")) && line.include?(%(named: "#{named}"))
end

.argument_rows(path = nil) ⇒ Object

── the Argument rows — a word's own arguments, at last with tooling of their own rather than the rename-only cascade above. A word may carry SEVERAL argument rows (one per position, one per named kwarg), so identity here is the full (keyword, context, at, named) tuple, not the two-field key a Keyword row answers to.



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/hecks/grammar/evolve.rb', line 222

def argument_rows(path = nil)
  paths_for(path).flat_map do |candidate|
    blocks = seed_blocks(File.read(candidate), "ArgumentSeed")
    raise Refusal, "source declares no ArgumentSeed value object" if path && blocks.empty?

    blocks.flat_map do |block|
      block.scan(/^\s*member (.+)$/).map do |(cells)|
        row = cells.scan(/(\w+): "((?:[^"\\]|\\.)*)"/).to_h
        { keyword: row["keyword"], context: row["context"], at: row["at"].to_s,
          named: row["named"].to_s, kind: row["kind"], required: row["required"],
          fills: row["fills"].to_s, status: row.fetch("status", "admitted") }
      end
    end
  end
end

.cascade_argument_rename(keyword:, context:, to:, path: nil) ⇒ Object

The rename cascade, ROW-AWARE — only the rows that actually belong to the renamed word, spelling updated in place, rather than a blind gsub on every keyword: "word", substring in the file (which a coincidentally-matching row elsewhere could have corrupted, and which read nothing before writing).



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/hecks/grammar/evolve.rb', line 299

def cascade_argument_rename(keyword:, context:, to:, path: nil)
  paths_for(path).each do |candidate|
    source = File.read(candidate)
    original = source
    argument_blocks(source).each do |block|
      updated = block.lines.map do |line|
        next line unless line =~ /^\s*member / &&
                         line.include?(%(keyword: "#{keyword}")) && line.include?(%(context: "#{context}"))

        line.sub(%(keyword: "#{keyword}"), %(keyword: "#{to}"))
      end.join
      source = source.sub(block, updated) if updated != block
    end
    File.write(candidate, source) if source != original
  end
end

.keyword_block(source) ⇒ Object



214
# File 'lib/hecks/grammar/evolve.rb', line 214

def keyword_block(source) = seed_block(source, "KeywordSeed", required: true)

.keyword_blocks(source) ⇒ Object

From value_object "KeywordSeed" to ITS OWN closing endmember rows sit bare now (S3, ADR 0025 — the one_of do ... end wrapper is gone), so the first bare end line after the opener already IS the value object's own, the same fact the original one_of- nested version of this method leaned on (nothing else nested inside it either, before or after).



213
# File 'lib/hecks/grammar/evolve.rb', line 213

def keyword_blocks(source) = seed_blocks(source, "KeywordSeed")

.keyword_rows(path = nil) ⇒ Object

The Keyword one_of's member rows, parsed leniently off the text — enough to know each row's (word, context, status), which is all the tool ever asks.



70
71
72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/hecks/grammar/evolve.rb', line 70

def keyword_rows(path = nil)
  paths_for(path).flat_map do |candidate|
    blocks = seed_blocks(File.read(candidate), "KeywordSeed")
    raise Refusal, "source declares no KeywordSeed value object" if path && blocks.empty?

    blocks.flat_map do |block|
      block.scan(/^\s*member (.+)$/).map do |(cells)|
        row = cells.scan(/(\w+): "((?:[^"\\]|\\.)*)"/).to_h
        { word: row["word"], context: row["context"],
          status: row.fetch("status", "admitted"), was: row["was"] }
      end
    end
  end
end

.member_row?(line, word, context) ⇒ Boolean

Returns:

  • (Boolean)


152
153
154
# File 'lib/hecks/grammar/evolve.rb', line 152

def member_row?(line, word, context)
  line =~ /^\s*member / && line.include?(%(word: "#{word}")) && line.include?(%(context: "#{context}"))
end

.option(argv, name, default = nil) ⇒ Object

bin/evolve's own --name value flag reader. Only consumes the NEXT argv element as the value when that element doesn't itself look like a flag — otherwise --foo --bar would swallow --bar as --foo's value (and --bar would then never be seen at all), and a value-less --foo at the end of argv would bypass whatever default foo promised instead of falling back to it.



26
27
28
29
30
31
32
# File 'lib/hecks/grammar/evolve.rb', line 26

def option(argv, name, default = nil)
  index = argv.index("--#{name}")
  return default unless index

  value = argv[index + 1]
  value.nil? || value.start_with?("-") ? default : value
end

.owner_path(context:, word:, opens: "", paths: syntax_paths) ⇒ Object

A new row belongs wherever that context's existing rows live. File is intentionally wider than one aggregate; for a new entry point, opens identifies the aggregate concept whose file should own it.



171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/hecks/grammar/evolve.rb', line 171

def owner_path(context:, word:, opens: "", paths: syntax_paths)
  if context == "File" && !opens.to_s.empty?
    aggregate_path = paths.find { |candidate| File.read(candidate).match?(/^\s*aggregate "#{Regexp.escape(opens)}" do$/) }
    return aggregate_path if aggregate_path
  end

  paths.find do |candidate|
    source = File.read(candidate)
    %w[KeywordSeed ArgumentSeed].any? do |seed|
      seed_blocks(source, seed).any? { |block| block.include?(%(context: "#{context}")) }
    end
  end || raise(Refusal, "no aggregate-local syntax table owns context #{context.inspect} for #{word}")
end

.path_holding_argument(keyword, context, at, named, paths = syntax_paths) ⇒ Object



162
163
164
165
166
# File 'lib/hecks/grammar/evolve.rb', line 162

def path_holding_argument(keyword, context, at, named, paths = syntax_paths)
  paths.find do |candidate|
    argument_blocks(File.read(candidate)).any? { |block| block.lines.any? { |line| argument_row?(line, keyword, context, at, named) } }
  end || raise(Refusal, "#{context}.#{keyword}'s argument at #{at.inspect}/named #{named.inspect} is not declared")
end

.path_holding_keyword(word, context, paths = syntax_paths) ⇒ Object



156
157
158
159
160
# File 'lib/hecks/grammar/evolve.rb', line 156

def path_holding_keyword(word, context, paths = syntax_paths)
  paths.find do |candidate|
    keyword_blocks(File.read(candidate)).any? { |block| block.lines.any? { |line| member_row?(line, word, context) } }
  end || raise(Refusal, "#{context}.#{word} is not declared")
end

.paths_for(path) ⇒ Object



65
# File 'lib/hecks/grammar/evolve.rb', line 65

def paths_for(path) = path ? Array(path) : syntax_paths

.propose(word:, context:, body: "none", inner: "", opens: "", fills: "", path: nil) ⇒ Object

Raises:



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/hecks/grammar/evolve.rb', line 85

def propose(word:, context:, body: "none", inner: "", opens: "", fills: "", path: nil)
  raise Refusal, "#{context}.#{word} is already declared — one row per (word, context, form)" if keyword_rows(path).any? { |row| row[:word] == word && row[:context] == context }

  path = owner_path(context: context, word: word, opens: opens, paths: paths_for(path))
  source = File.read(path)
  block  = keyword_blocks(source).find { |candidate| candidate.include?(%(context: "#{context}")) } || keyword_block(source)
  indent = block[/^(\s*)member /, 1] || "        "
  row = %(#{indent}member word: "#{word}", context: "#{context}", body: "#{body}", ) +
        %(inner: "#{inner}", opens: "#{opens}", fills: "#{fills}", status: "proposed"\n)

  # At the END of the one_of — grouping by context is a courtesy of
  # the hand; a proposed row sits at the bottom until admission,
  # when whoever admits it may move it home.
  closing = block.rindex(/^\s*end\s*$/)
  updated = block[0...closing] + row + block[closing..]
  File.write(path, source.sub(block, updated))
end

.propose_argument(keyword:, context:, kind:, required: "false", at: "", named: "", fills: "", pairs_shape: nil, path: nil) ⇒ Object

pairs_shape — for a pairs argument that fills ONE field with a whole key/value list rather than naming a field per pair (the shape Handler.dispatch's own with: already carries). Without it, spec/syntax_conformance_spec.rb reads a pairs argument naming a single field as a row that "names a single field, which it cannot fill" — correctly, since the two shapes are genuinely different and only one of them can be checked the same way.



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# File 'lib/hecks/grammar/evolve.rb', line 245

def propose_argument(keyword:, context:, kind:, required: "false", at: "", named: "", fills: "",
                     pairs_shape: nil, path: nil)
  if argument_rows(path).any? { |r| argument_identity(r) == [keyword, context, at, named] }
    raise Refusal, "#{context}.#{keyword}'s argument at #{at.inspect}/named #{named.inspect} is " \
                   "already declared — one row per (keyword, context, at, named)"
  end

  path = owner_path(context: context, word: keyword, paths: paths_for(path))
  source = File.read(path)
  block  = argument_blocks(source).find { |candidate| candidate.include?(%(context: "#{context}")) } || argument_block(source)
  indent = block[/^(\s*)member /, 1] || "        "
  shape = pairs_shape.to_s.empty? ? "" : %(pairs_shape: "#{pairs_shape}", )
  row = %(#{indent}member keyword: "#{keyword}", context: "#{context}", at: "#{at}", ) +
        %(named: "#{named}", kind: "#{kind}", required: "#{required}", fills: "#{fills}", ) +
        shape + %(status: "proposed"\n)

  closing = block.rindex(/^\s*end\s*$/)
  updated = block[0...closing] + row + block[closing..]
  File.write(path, source.sub(block, updated))
end

.rename(word:, context:, to:, path: nil) ⇒ Object

A rename respells the row's word and holds the old spelling in was: — one hop only. Renaming an already-renamed word refuses until the language grows real eras for its own words; renaming onto a spelling the context already declares refuses too. The word's Argument rows follow it — row-aware now, not the blind substitution this used to be (see cascade_argument_rename).

Raises:



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/hecks/grammar/evolve.rb', line 130

def rename(word:, context:, to:, path: nil)
  row = keyword_rows(path).find { |r| r[:word] == word && r[:context] == context }
  raise Refusal, "#{context}.#{word} is not declared" unless row
  raise Refusal, "#{context}.#{word} was already #{row[:was]} — one rename hop, then eras" if row[:was]
  raise Refusal, "#{context}.#{to} is already declared — a rename cannot land on a living word" if keyword_rows(path).any? { |r| r[:word] == to && r[:context] == context }

  paths = paths_for(path)
  path = path_holding_keyword(word, context, paths)
  source = File.read(path)
  block  = keyword_blocks(source).find { |candidate| candidate.lines.any? { |line| member_row?(line, word, context) } }
  updated = block.lines.map do |line|
    next line unless member_row?(line, word, context)

    line.sub(%(word: "#{word}"), %(word: "#{to}"))
        .sub(/\n\z/, %(, was: "#{word}"\n))
  end.join
  source = source.sub(block, updated)
  File.write(path, source)

  cascade_argument_rename(keyword: word, context: context, to: to, path: paths)
end

.restore_on_raise(paths) ⇒ Object

The snapshot/restore ceremony bin/evolve wraps every mutating command in: read every paths file, run the block, and put every file back exactly as it was if the block raises partway through — rename's keyword-row write followed by its argument cascade across several files (cascade_argument_rename above) included, not only a declared gate failing after the block has already returned cleanly (bin/evolve's own guarded still handles that half on its own, since it depends on running the gate specs, not on anything this method knows about). A clean return leaves the snapshots unused; the caller decides from there whether the tree stands.



45
46
47
48
49
50
51
# File 'lib/hecks/grammar/evolve.rb', line 45

def restore_on_raise(paths)
  snapshots = paths.to_h { |path| [path, File.read(path)] }
  yield
rescue StandardError
  snapshots.each { |path, content| File.write(path, content) }
  raise
end

.seed_block(source, name, required: false) ⇒ Object



197
198
199
200
201
202
203
204
205
# File 'lib/hecks/grammar/evolve.rb', line 197

def seed_block(source, name, required: false)
  block = seed_blocks(source, name).first
  unless block
    raise Refusal, "source declares no #{name} value object" if required

    return
  end
  block
end

.seed_blocks(source, name) ⇒ Object



185
186
187
188
189
190
191
192
193
194
195
# File 'lib/hecks/grammar/evolve.rb', line 185

def seed_blocks(source, name)
  opener = /^([ \t]*)value_object "#{Regexp.escape(name)}" do$/
  source.to_enum(:scan, opener).map do
    match = Regexp.last_match
    start = match.begin(0)
    indent = match[1]
    closing = source.index(/^#{Regexp.escape(indent)}end\s*$/, match.end(0))
    closing = source.index(/\n/, closing) + 1
    source[start...closing]
  end
end

.set_argument_status(keyword:, context:, to:, at: "", named: "", path: nil) ⇒ Object

Raises:



266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/hecks/grammar/evolve.rb', line 266

def set_argument_status(keyword:, context:, to:, at: "", named: "", path: nil)
  raise Refusal, "#{to.inspect} is not a station an argument's life admits" unless %w[proposed admitted deprecated retired].include?(to)

  path = path_holding_argument(keyword, context, at, named, paths_for(path))
  source = File.read(path)
  block  = argument_blocks(source).find { |candidate| candidate.lines.any? { |line| argument_row?(line, keyword, context, at, named) } }
  rows   = block.lines.select { |line| argument_row?(line, keyword, context, at, named) }
  raise Refusal, "#{context}.#{keyword}'s argument at #{at.inspect}/named #{named.inspect} is not " \
                 "declared" if rows.empty?

  updated = block.lines.map do |line|
    next line unless argument_row?(line, keyword, context, at, named)

    stripped = line.sub(/,\s*status: "[^"]*"/, "")
    to == "admitted" ? stripped : stripped.sub(/\n\z/, %(, status: "#{to}"\n))
  end.join

  File.write(path, source.sub(block, updated))
end

.set_status(word:, context:, to:, path: nil) ⇒ Object

Raises:



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/hecks/grammar/evolve.rb', line 103

def set_status(word:, context:, to:, path: nil)
  raise Refusal, "#{to.inspect} is not a station a word's life admits" unless %w[proposed admitted deprecated retired].include?(to)

  path = path_holding_keyword(word, context, paths_for(path))
  source = File.read(path)
  block  = keyword_blocks(source).find { |candidate| candidate.lines.any? { |line| member_row?(line, word, context) } }
  rows   = block.lines.select { |line| member_row?(line, word, context) }
  raise Refusal, "#{context}.#{word} is not declared" if rows.empty?

  updated = block.lines.map do |line|
    next line unless member_row?(line, word, context)

    stripped = line.sub(/,\s*status: "[^"]*"/, "")
    # Admitted is the default and stays UNSPELLED — only a word
    # entering or leaving the language carries its status.
    to == "admitted" ? stripped : stripped.sub(/\n\z/, %(, status: "#{to}"\n))
  end.join

  File.write(path, source.sub(block, updated))
end

.syntax_pathObject

Kept as a narrow compatibility door for callers deliberately doing single-file surgery. Normal operation uses syntax_paths and discovers the owning concept from the row itself.



63
# File 'lib/hecks/grammar/evolve.rb', line 63

def syntax_path = syntax_paths.first

.syntax_pathsObject



53
54
55
56
57
58
# File 'lib/hecks/grammar/evolve.rb', line 53

def syntax_paths
  Dir.glob(File.expand_path("../language/**/*.bluebook", __dir__)).select do |path|
    source = File.read(path)
    source.include?('value_object "KeywordSeed"') || source.include?('value_object "ArgumentSeed"')
  end
end