Module: LcpRuby::Generators::EntityMenuWriter

Defined in:
lib/lcp_ruby/generators/entity_menu_writer.rb

Overview

Inserts a ‘- view_group: <name>` entry into `menu.yml` at the location named by `–menu=…` on the entity generator. Parser + planner are pure-functional (string/array in, struct out) so tests don’t need a tempdir; the I/O wrapper ‘apply!` is the only side-effecting method.

Spec: docs/design/entity_generator_menu_flag.md

Constant Summary collapse

MARKER_RE =

Recognised marker forms in menu.yml. The “label” capture is optional —absent for section-level markers (‘# lcp:menu top`), present for dropdown markers (`# lcp:menu top > “School”`). Trailing comment text (e.g. `# entity generator marker — do not edit/delete`) is ignored.

/^(\s*)# lcp:menu (top|sidebar)(?: > "([^"]+)")?\s*(?:#.*)?$/
SECTIONS =
%w[top sidebar].freeze
POSITIONS =
%w[bottom right].freeze
ROOT =
"ROOT"
MARKER_WARNING =
"# entity generator marker - do not edit/delete"
ICON_PATTERN =

Lucide icon names are kebab-case alphanumeric (e.g. ‘users`, `chart-bar`, `arrow-up-right`). Rejecting anything else prevents YAML injection via `–menu-icon=’foon malicious: x’‘ — CLI is a trusted surface but the check is cheap and symmetric with `validate_parent_label!`.

/\A[a-z0-9][a-z0-9-]*\z/

Class Method Summary collapse

Class Method Details

.apply!(menu_path, plan, cached_lines: nil) ⇒ Object

Side-effecting wrapper. Splices in ‘plan` at `plan` and writes the result back to disk. No-op for :skip plans.

‘cached_lines:` lets the caller pass the lines array it already read when computing the plan (entity_generator does this in plan_menu_entry). When omitted, apply! re-reads the file — useful for callers that have only the plan and the path.



207
208
209
210
211
212
213
214
215
216
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 207

def apply!(menu_path, plan, cached_lines: nil)
  return if plan.nil? || plan[:action] == :skip
  unless %i[insert create_dropdown].include?(plan[:action])
    raise ArgumentError, "Unknown plan action: #{plan[:action].inspect}"
  end

  lines = cached_lines || File.readlines(menu_path)
  lines.insert(plan[:idx], *plan[:lines])
  File.write(menu_path, lines.join)
end

.build_dropdown_block(label:, view_group:, icon: nil, indent: " ", section:) ⇒ Object

Builds the auto-created dropdown block plus its child marker. Icon attaches to the dropdown (more visible at top level than on a single hidden child — D7 §“Modifier-attachment rule”).



189
190
191
192
193
194
195
196
197
198
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 189

def build_dropdown_block(label:, view_group:, icon: nil, indent: "    ", section:)
  child_indent = indent + "  " # children indent = marker-indent + 2 spaces
  lines = []
  lines << "#{indent}- label: \"#{label}\"\n"
  lines << "#{indent}  icon: #{icon}\n" if icon
  lines << "#{indent}  children:\n"
  lines << "#{child_indent}- view_group: #{view_group}\n"
  lines << "#{child_indent}# lcp:menu #{section} > \"#{label}\"  #{MARKER_WARNING}\n"
  lines
end

.build_entry_lines(view_group:, icon: nil, position: nil, indent: " ") ⇒ Object

Builds an ordered array of “<indent>…n” lines for a single view_group entry. Field order: view_group → icon → position. Position is only emitted at ROOT (caller passes nil for dropdown nesting).



178
179
180
181
182
183
184
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 178

def build_entry_lines(view_group:, icon: nil, position: nil, indent: "    ")
  lines = []
  lines << "#{indent}- view_group: #{view_group}\n"
  lines << "#{indent}  icon: #{icon}\n" if icon
  lines << "#{indent}  position: #{position}\n" if position
  lines
end

.default_section(available_sections) ⇒ Object

Raises:

  • (Thor::Error)


231
232
233
234
235
236
237
238
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 231

def default_section(available_sections)
  return "top" if available_sections.include?("top")
  return "sidebar" if available_sections.include?("sidebar")
  raise Thor::Error,
    "menu.yml has no insertion markers. " \
    "Re-run install with --menu-layout=top, sidebar, or both, " \
    "or add a `# lcp:menu <section>` marker manually."
end

.find_section_marker(markers, section) ⇒ Object



227
228
229
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 227

def find_section_marker(markers, section)
  markers.find { |m| m[:section] == section && m[:label].nil? }
end

.invalid_format_message(value) ⇒ Object



218
219
220
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 218

def invalid_format_message(value)
  "Invalid --menu format: '#{value}'. Expected: [<section>:]ROOT|<DropdownLabel>[:bottom|:right]"
end

.marker_missing_message(section, _parent) ⇒ Object



222
223
224
225
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 222

def marker_missing_message(section, _parent)
  "menu.yml at config/lcp_ruby/menu.yml is missing '# lcp:menu #{section}' marker. " \
  "Add the marker manually or restore from the install_generator template."
end

.parse_flag(value, available_sections:) ⇒ Object

Parses the value of ‘–menu=[<section>:]ROOT|<DropdownLabel>`. Returns `parent:, position:` or raises Thor::Error.

‘available_sections` is the set of sections actually present in menu.yml (derived from `scan` results). The default section is `top` when both sections are present, else the only section present. When the requested section is absent from menu.yml, raises with the “not initialized” message from D7 §Error Cases.

Raises:

  • (Thor::Error)


42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 42

def parse_flag(value, available_sections:)
  raise Thor::Error, invalid_format_message(value) if value.nil? || value.empty?

  parts = value.split(":", -1)
  raise Thor::Error, invalid_format_message(value) if parts.empty? || parts.size > 3

  # Three legal shapes:
  #   ROOT | <Label>                            → 1 part
  #   <section>:ROOT | <section>:<Label> | <Label>:<pos> | ROOT:<pos>  → 2 parts
  #   <section>:ROOT:<pos> | <section>:<Label>:<pos>                  → 3 parts
  section = nil
  parent = nil
  position = nil

  case parts.size
  when 1
    parent = parts[0]
  when 2
    if SECTIONS.include?(parts[0])
      section = parts[0]
      parent = parts[1]
    elsif POSITIONS.include?(parts[1])
      parent = parts[0]
      position = parts[1]
    else
      raise Thor::Error, invalid_format_message(value)
    end
  when 3
    unless SECTIONS.include?(parts[0]) && POSITIONS.include?(parts[2])
      raise Thor::Error, invalid_format_message(value)
    end
    section = parts[0]
    parent = parts[1]
    position = parts[2]
  end

  raise Thor::Error, invalid_format_message(value) if parent.nil? || parent.empty?
  validate_parent_label!(parent) unless parent == ROOT

  section ||= default_section(available_sections)
  unless available_sections.include?(section)
    raise Thor::Error,
      "Section '#{section}' not initialized in menu.yml. " \
      "Re-run install with --menu-layout=#{section} (or 'both'), " \
      "or add the section block manually with its marker."
  end

  if position && parent != ROOT
    raise Thor::Error,
      "--menu position keyword (':bottom', ':right') is only valid with parent=ROOT; " \
      "'#{parent}' is a dropdown."
  end

  if position == "bottom" && section != "sidebar"
    raise Thor::Error,
      "position ':bottom' is only valid in sidebar; got --menu=#{value}. " \
      "Use --menu=sidebar:ROOT:bottom."
  end

  { section: section, parent: parent, position: position }
end

.plan_insert(scan_result, section:, parent:, view_group:, icon: nil, position: nil) ⇒ Object

Plans the insertion. Returns one of:

{action: :insert,          lines: [..], idx:, message:, section:, parent:}
{action: :create_dropdown, lines: [..], idx:, message:, section:, parent:}
{action: :skip,            message:, view_group:}

Raises:

  • (Thor::Error)


129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 129

def plan_insert(scan_result, section:, parent:, view_group:, icon: nil, position: nil)
  validate_icon!(icon)

  if scan_result[:view_groups_present].include?(view_group)
    return { action: :skip, message: "[skip] view_group: #{view_group} already in menu.yml",
             view_group: view_group }
  end

  if parent == ROOT
    marker = find_section_marker(scan_result[:markers], section)
    raise Thor::Error, marker_missing_message(section, nil) unless marker

    lines = build_entry_lines(view_group: view_group, icon: icon, position: position,
                              indent: marker[:indent])
    return { action: :insert, lines: lines, idx: marker[:line_idx],
             section: section, parent: ROOT,
             message: "Menu entry added (section '#{section}', parent 'ROOT')" }
  end

  dropdown_markers = scan_result[:markers].select { |m| m[:section] == section && m[:label] == parent }
  if dropdown_markers.size > 1
    raise Thor::Error,
      "Dropdown label '#{parent}' appears multiple times in menu.yml. " \
      "Disambiguate manually before re-running."
  end

  if dropdown_markers.size == 1
    marker = dropdown_markers.first
    lines = build_entry_lines(view_group: view_group, icon: icon, position: nil,
                              indent: marker[:indent])
    return { action: :insert, lines: lines, idx: marker[:line_idx],
             section: section, parent: parent,
             message: "Menu entry added (section '#{section}', parent '#{parent}')" }
  end

  # Auto-create dropdown above section marker
  section_marker = find_section_marker(scan_result[:markers], section)
  raise Thor::Error, marker_missing_message(section, nil) unless section_marker

  lines = build_dropdown_block(label: parent, view_group: view_group, icon: icon,
                               indent: section_marker[:indent], section: section)
  { action: :create_dropdown, lines: lines, idx: section_marker[:line_idx],
    section: section, parent: parent,
    message: "Menu dropdown '#{parent}' auto-created (section '#{section}')" }
end

.scan(lines) ⇒ Object

Walks each line of menu.yml and collects:

markers          – ordered list of `{section:, label:, line_idx:, indent:}`
view_groups_present – Set of view group names already in the file
sections_present – Set of sections discovered (top, sidebar)


108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 108

def scan(lines)
  markers = []
  view_groups_present = Set.new
  sections_present = Set.new

  lines.each_with_index do |line, idx|
    if (m = line.match(MARKER_RE))
      sections_present << m[2]
      markers << { section: m[2], label: m[3], line_idx: idx, indent: m[1] }
    elsif (vg = line.match(/^\s*-\s+view_group:\s+([A-Za-z0-9_\-]+)\s*$/))
      view_groups_present << vg[1]
    end
  end

  { markers: markers, view_groups_present: view_groups_present, sections_present: sections_present }
end

.validate_icon!(icon) ⇒ Object



248
249
250
251
252
253
254
255
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 248

def validate_icon!(icon)
  return if icon.nil?
  unless ICON_PATTERN.match?(icon)
    raise Thor::Error,
      "Invalid --menu-icon value '#{icon}'. Expected a Lucide icon name " \
      "(lowercase letters, digits, hyphens; e.g. `users`, `chart-bar`)."
  end
end

.validate_parent_label!(label) ⇒ Object



240
241
242
243
244
245
246
# File 'lib/lcp_ruby/generators/entity_menu_writer.rb', line 240

def validate_parent_label!(label)
  if label.include?(":") || label.include?("\"")
    raise Thor::Error,
      "Dropdown label must not contain ':' or '\"' (got: '#{label}'). " \
      "Edit menu.yml manually for unusual labels."
  end
end