Module: Seams::Generators::Splicer
- Defined in:
- lib/seams/generators/splicer.rb
Overview
Idempotent splice operations against files containing ‘# seams:insertion-point <name>` markers. Wave 10 introduces follow-up generators (e.g. `seams:auth:add_oauth_provider`) that need to extend already-generated engines without re-templating the whole file. The Splicer is the shared primitive: every follow-up generator funnels through these methods.
See doc/INSERTION_POINTS.md for the marker format spec and doc/INSERTION_POINTS_CATALOGUE.md for the canonical list of markers each engine ships.
Example:
Seams::Generators::Splicer.splice_after_marker(
file_path: "engines/auth/lib/auth/engine.rb",
marker: "auth.engine.events",
content: " Seams::EventRegistry.register(\"identity.passkey_added.auth\", emitted_by: \"Auth\")\n"
)
The Splicer owns four design choices worth surfacing:
-
Markers are looked up by NAME, never by line number. A follow-up generator written today must keep working after the host adds twenty unrelated lines above the marker.
-
Idempotency is checked by string-matching the splice content inside a 50-line window after the marker. Re-running the same splice is a no-op rather than an error.
-
Indentation is auto-detected from the marker line itself. Follow-up generators don’t have to know whether the marker sits at column 0, column 4, or column 6.
-
The Splicer is pure file I/O — no Rails dep, no Thor — so it can be tested in isolation and reused outside the generator stack (e.g. by ‘bin/seams resolve –eject`).
Defined Under Namespace
Classes: Result
Constant Summary collapse
- MARKER_PREFIX =
Pattern matched by every Splicer method. The character class for the marker name allows lowercase letters, digits, dot, and underscore — see INSERTION_POINTS.md naming rules.
"# seams:insertion-point"- MARKER_NAME_RE =
/[a-z0-9_.]+/- MARKER_LINE_RE =
/^(\s*)#{Regexp.escape(MARKER_PREFIX)}\s+(#{MARKER_NAME_RE})\s*$/- IDEMPOTENCY_WINDOW =
50
Class Method Summary collapse
-
.already_present?(lines, prepared, marker_index, position) ⇒ Boolean
The marker line plus every line in the idempotency window following (or preceding for :before) is inspected.
-
.apply_indent(content, indent) ⇒ Object
Apply ‘indent` to every non-blank line of `content`.
- .detect_indent(marker_line) ⇒ Object
- .file_not_found_result(file_path) ⇒ Object
-
.find_marker(file_path:, marker:) ⇒ Hash?
Locate a marker without modifying the file.
- .haystack_for(lines, marker_index, position, prepared) ⇒ Object
- .insert_at(lines, prepared, marker_index, position) ⇒ Object
-
.list_markers(file_path:) ⇒ Array<Hash>
Enumerate every ‘seams:insertion-point` marker in a file in source order.
- .locate_marker_index(lines, marker) ⇒ Object
- .marker_not_found_result(marker, file_path) ⇒ Object
-
.splice(file_path:, marker:, content:, indent:, position:) ⇒ Object
Internal: shared body for splice_after,before_marker.
-
.splice_after_marker(file_path:, marker:, content:, indent: nil) ⇒ Result
Splice ‘content` immediately after the line containing `# seams:insertion-point <marker>`.
-
.splice_before_marker(file_path:, marker:, content:, indent: nil) ⇒ Result
Splice ‘content` immediately before the line containing the marker.
- .write_splice(file_path, lines, prepared, marker_index, position) ⇒ Object
Class Method Details
.already_present?(lines, prepared, marker_index, position) ⇒ Boolean
The marker line plus every line in the idempotency window following (or preceding for :before) is inspected. The check looks for the FULL prepared content as a contiguous block, not for individual lines — a partial overlap is treated as “not yet spliced” and re-splices, which is the safer default for a tool that prefers re-runs to silent partials.
The window grows to accommodate snippets larger than IDEMPOTENCY_WINDOW lines: a 60-line follow-up splice would never round-trip with a fixed 50-line window because the haystack would be smaller than the needle. The effective window is ‘max(IDEMPOTENCY_WINDOW, prepared.lines.size)`, which guarantees idempotency regardless of snippet size while keeping the small-splice fast path identical.
164 165 166 167 |
# File 'lib/seams/generators/splicer.rb', line 164 def already_present?(lines, prepared, marker_index, position) haystack = haystack_for(lines, marker_index, position, prepared) haystack.include?(prepared) end |
.apply_indent(content, indent) ⇒ Object
Apply ‘indent` to every non-blank line of `content`. Blank lines stay blank — re-indenting them would leave trailing whitespace that some linters (and our own RuboCop config) flag.
204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/seams/generators/splicer.rb', line 204 def apply_indent(content, indent) return content if indent.empty? content.lines.map do |line| if line.strip.empty? line else "#{indent}#{line}" end end.join end |
.detect_indent(marker_line) ⇒ Object
197 198 199 |
# File 'lib/seams/generators/splicer.rb', line 197 def detect_indent(marker_line) marker_line[/^\s*/].to_s end |
.file_not_found_result(file_path) ⇒ Object
141 142 143 |
# File 'lib/seams/generators/splicer.rb', line 141 def file_not_found_result(file_path) Result.new(ok?: false, lines_added: 0, error: "file not found: #{file_path}") end |
.find_marker(file_path:, marker:) ⇒ Hash?
Locate a marker without modifying the file. Useful for follow-up generators that want to verify multiple markers exist before they start writing.
89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/seams/generators/splicer.rb', line 89 def find_marker(file_path:, marker:) return nil unless File.exist?(file_path) lines = File.read(file_path).lines lines.each_with_index do |line, index| match = line.match(MARKER_LINE_RE) next unless match next unless match[2] == marker return { line_number: index + 1, indent: match[1], marker: marker } end nil end |
.haystack_for(lines, marker_index, position, prepared) ⇒ Object
169 170 171 172 173 174 175 176 177 178 |
# File 'lib/seams/generators/splicer.rb', line 169 def haystack_for(lines, marker_index, position, prepared) window = [IDEMPOTENCY_WINDOW, prepared.lines.size].max if position == :after slice_end = [marker_index + window, lines.size - 1].min lines[(marker_index + 1)..slice_end].to_a.join else slice_start = [marker_index - window, 0].max lines[slice_start...marker_index].to_a.join end end |
.insert_at(lines, prepared, marker_index, position) ⇒ Object
180 181 182 183 184 185 186 187 |
# File 'lib/seams/generators/splicer.rb', line 180 def insert_at(lines, prepared, marker_index, position) prepared_lines = prepared.lines if position == :after lines[0..marker_index] + prepared_lines + (lines[(marker_index + 1)..] || []) else lines[0...marker_index] + prepared_lines + lines[marker_index..] end end |
.list_markers(file_path:) ⇒ Array<Hash>
Enumerate every ‘seams:insertion-point` marker in a file in source order. Used by the eject CLI’s ‘–list-markers` flag and by `bin/seams resolve` for human-readable diagnostics.
108 109 110 111 112 113 114 115 116 117 118 119 |
# File 'lib/seams/generators/splicer.rb', line 108 def list_markers(file_path:) return [] unless File.exist?(file_path) result = [] File.read(file_path).lines.each_with_index do |line, index| match = line.match(MARKER_LINE_RE) next unless match result << { line_number: index + 1, indent: match[1], marker: match[2] } end result end |
.locate_marker_index(lines, marker) ⇒ Object
189 190 191 192 193 194 195 |
# File 'lib/seams/generators/splicer.rb', line 189 def locate_marker_index(lines, marker) lines.each_with_index do |line, index| match = line.match(MARKER_LINE_RE) return index if match && match[2] == marker end nil end |
.marker_not_found_result(marker, file_path) ⇒ Object
145 146 147 148 |
# File 'lib/seams/generators/splicer.rb', line 145 def marker_not_found_result(marker, file_path) Result.new(ok?: false, lines_added: 0, error: "marker '#{marker}' not found in #{file_path}") end |
.splice(file_path:, marker:, content:, indent:, position:) ⇒ Object
Internal: shared body for splice_after,before_marker.
122 123 124 125 126 127 128 129 130 131 |
# File 'lib/seams/generators/splicer.rb', line 122 def splice(file_path:, marker:, content:, indent:, position:) return file_not_found_result(file_path) unless File.exist?(file_path) lines = File.read(file_path).lines marker_index = locate_marker_index(lines, marker) return marker_not_found_result(marker, file_path) unless marker_index prepared = apply_indent(content, indent || detect_indent(lines[marker_index])) write_splice(file_path, lines, prepared, marker_index, position) end |
.splice_after_marker(file_path:, marker:, content:, indent: nil) ⇒ Result
Splice ‘content` immediately after the line containing `# seams:insertion-point <marker>`.
71 72 73 |
# File 'lib/seams/generators/splicer.rb', line 71 def splice_after_marker(file_path:, marker:, content:, indent: nil) splice(file_path: file_path, marker: marker, content: content, indent: indent, position: :after) end |
.splice_before_marker(file_path:, marker:, content:, indent: nil) ⇒ Result
Splice ‘content` immediately before the line containing the marker. Same semantics as splice_after_marker otherwise.
79 80 81 |
# File 'lib/seams/generators/splicer.rb', line 79 def splice_before_marker(file_path:, marker:, content:, indent: nil) splice(file_path: file_path, marker: marker, content: content, indent: indent, position: :before) end |
.write_splice(file_path, lines, prepared, marker_index, position) ⇒ Object
133 134 135 136 137 138 139 |
# File 'lib/seams/generators/splicer.rb', line 133 def write_splice(file_path, lines, prepared, marker_index, position) return Result.new(ok?: true, lines_added: 0, error: nil) if already_present?(lines, prepared, marker_index, position) File.write(file_path, insert_at(lines, prepared, marker_index, position).join) Result.new(ok?: true, lines_added: prepared.lines.size, error: nil) end |