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
- .argument_block(source) ⇒ Object
-
.argument_blocks(source) ⇒ Object
From
value_object "ArgumentSeed"to ITS OWN closingend— seekeyword_block's own comment for why the first bareendafter the opener is already the right one, now thatmemberrows sit bare (S3, ADR 0025). - .argument_identity(row) ⇒ Object
- .argument_row?(line, keyword, context, at, named) ⇒ Boolean
-
.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.
-
.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
gsubon everykeyword: "word",substring in the file (which a coincidentally-matching row elsewhere could have corrupted, and which read nothing before writing). - .keyword_block(source) ⇒ Object
-
.keyword_blocks(source) ⇒ Object
From
value_object "KeywordSeed"to ITS OWN closingend—memberrows sit bare now (S3, ADR 0025 — theone_of do ... endwrapper is gone), so the first bareendline 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). -
.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.
- .member_row?(line, word, context) ⇒ Boolean
-
.option(argv, name, default = nil) ⇒ Object
bin/evolve's own
--name valueflag reader. -
.owner_path(context:, word:, opens: "", paths: syntax_paths) ⇒ Object
A new row belongs wherever that context's existing rows live.
- .path_holding_argument(keyword, context, at, named, paths = syntax_paths) ⇒ Object
- .path_holding_keyword(word, context, paths = syntax_paths) ⇒ Object
- .paths_for(path) ⇒ Object
- .propose(word:, context:, body: "none", inner: "", opens: "", fills: "", path: nil) ⇒ Object
-
.propose_argument(keyword:, context:, kind:, required: "false", at: "", named: "", fills: "", pairs_shape: nil, path: nil) ⇒ Object
pairs_shape— for apairsargument that fills ONE field with a whole key/value list rather than naming a field per pair (the shapeHandler.dispatch's ownwith:already carries). -
.rename(word:, context:, to:, path: nil) ⇒ Object
A rename respells the row's word and holds the old spelling in
was:— one hop only. -
.restore_on_raise(paths) ⇒ Object
The snapshot/restore ceremony bin/evolve wraps every mutating command in: read every
pathsfile, 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_renameabove) included, not only a declared gate failing after the block has already returned cleanly (bin/evolve's ownguardedstill handles that half on its own, since it depends on running the gate specs, not on anything this method knows about). - .seed_block(source, name, required: false) ⇒ Object
- .seed_blocks(source, name) ⇒ Object
- .set_argument_status(keyword:, context:, to:, at: "", named: "", path: nil) ⇒ Object
- .set_status(word:, context:, to:, path: nil) ⇒ Object
-
.syntax_path ⇒ Object
Kept as a narrow compatibility door for callers deliberately doing single-file surgery.
- .syntax_paths ⇒ Object
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
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 end — member
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
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
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).
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
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
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_path ⇒ Object
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_paths ⇒ Object
53 54 55 56 57 58 |
# File 'lib/hecks/grammar/evolve.rb', line 53 def syntax_paths Dir.glob(File.("../language/**/*.bluebook", __dir__)).select do |path| source = File.read(path) source.include?('value_object "KeywordSeed"') || source.include?('value_object "ArgumentSeed"') end end |