Class: Howzit::Topic

Inherits:
Object
  • Object
show all
Includes:
Comparable
Defined in:
lib/howzit/topic.rb

Overview

Topic Class

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(title, content, metadata = nil, source_file: nil) ⇒ Topic

Initialize a topic object

Parameters:

  • title (String)

    The topic title

  • content (String)

    The raw topic content

  • metadata (Hash) (defaults to: nil)

    Optional metadata hash

  • source_file (String) (defaults to: nil)

    Optional path to the build note file this topic came from



20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/howzit/topic.rb', line 20

def initialize(title, content,  = nil, source_file: nil)
  @title = title
  @content = content
  @parent = nil
  @nest_level = 0
  @named_args = {}
  @metadata = 
  @source_file = source_file
  arguments(from_cli_snapshot: true)

  @directives = parse_directives_with_conditionals
  @tasks = gather_tasks
  @results = { total: 0, success: 0, errors: 0, message: ''.c }
end

Instance Attribute Details

#arg_definitionsObject (readonly)

Returns the value of attribute arg_definitions.



10
11
12
# File 'lib/howzit/topic.rb', line 10

def arg_definitions
  @arg_definitions
end

#contentObject

Returns the value of attribute content.



8
9
10
# File 'lib/howzit/topic.rb', line 8

def content
  @content
end

#directivesObject (readonly)

Returns the value of attribute directives.



10
11
12
# File 'lib/howzit/topic.rb', line 10

def directives
  @directives
end

#named_argsObject (readonly)

Returns the value of attribute named_args.



10
11
12
# File 'lib/howzit/topic.rb', line 10

def named_args
  @named_args
end

#parent=(value) ⇒ Object (writeonly)

Sets the attribute parent

Parameters:

  • value

    the value to set the attribute parent to.



6
7
8
# File 'lib/howzit/topic.rb', line 6

def parent=(value)
  @parent = value
end

#postreqsObject (readonly)

Returns the value of attribute postreqs.



10
11
12
# File 'lib/howzit/topic.rb', line 10

def postreqs
  @postreqs
end

#prereqsObject (readonly)

Returns the value of attribute prereqs.



10
11
12
# File 'lib/howzit/topic.rb', line 10

def prereqs
  @prereqs
end

#resultsObject (readonly)

Returns the value of attribute results.



10
11
12
# File 'lib/howzit/topic.rb', line 10

def results
  @results
end

#source_fileObject (readonly)

Returns the value of attribute source_file.



10
11
12
# File 'lib/howzit/topic.rb', line 10

def source_file
  @source_file
end

#tasksObject (readonly)

Returns the value of attribute tasks.



10
11
12
# File 'lib/howzit/topic.rb', line 10

def tasks
  @tasks
end

#titleObject (readonly)

Returns the value of attribute title.



10
11
12
# File 'lib/howzit/topic.rb', line 10

def title
  @title
end

Instance Method Details

#<=>(other) ⇒ Object



376
377
378
# File 'lib/howzit/topic.rb', line 376

def <=>(other)
  @title <=> other.title
end

#arguments(from_cli_snapshot: false) ⇒ Object

Get named arguments from title from_cli_snapshot: use Howzit.cli_topic_positional_args (set once from argv after ‘ – `) so earlier topics’ gather_tasks cannot clobber positional binding. Re-entrant calls (e.g. @include with [a,b]) pass false to use live Howzit.arguments.



38
39
40
41
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
# File 'lib/howzit/topic.rb', line 38

def arguments(from_cli_snapshot: false)
  @arg_definitions = []
  return unless @title =~ /\(.*?\) *$/

  positional = if from_cli_snapshot
                 # Specs / non-CLI: leave unset to keep using Howzit.arguments
                 if Howzit.cli_topic_positional_args.nil?
                   Howzit.arguments || []
                 else
                   Howzit.cli_topic_positional_args
                 end
               else
                 Howzit.arguments || []
               end

  a = @title.match(/\((?<args>.*?)\) *$/)
  args = a['args'].split(/ *, */).each(&:strip)

  args.each_with_index do |arg, idx|
    arg_name, default = arg.split(/:/).map(&:strip)
    # Store original definition for display purposes
    @arg_definitions << (default ? "#{arg_name}:#{default}" : arg_name)

    @named_args[arg_name] = if positional && positional.count >= idx + 1
                              positional[idx]
                            else
                              default
                            end
  end

  @title = @title.sub(/\(.*?\) *$/, '').strip
end

#ask_task(task) ⇒ Object



80
81
82
83
84
85
86
87
88
89
# File 'lib/howzit/topic.rb', line 80

def ask_task(task)
  note = if task.type == :include
           task_count = Howzit.buildnote.find_topic(task.action)[0].tasks.count
           " (#{task_count} tasks)"
         else
           ''
         end
  q = %({bg}#{task.type.to_s.capitalize} {xw}"{bw}#{task.title}{xw}"#{note}{x}).c
  Prompt.yn(q, default: task.default)
end

#check_colsObject



91
92
93
94
95
# File 'lib/howzit/topic.rb', line 91

def check_cols
  TTY::Screen.columns > 60 ? 60 : TTY::Screen.columns
rescue StandardError
  60
end

#color_directive_yn(keys) ⇒ Object



245
246
247
248
249
250
251
252
# File 'lib/howzit/topic.rb', line 245

def color_directive_yn(keys)
  optional, default = define_optional(keys[:optional])
  if optional
    default ? ' {xk}[{g}Y{xk}/{dbw}n{xk}]{x}'.c : ' {xk}[{dbw}y{xk}/{g}N{xk}]{x}'.c
  else
    ''
  end
end

#colored_option(color, topic, keys) ⇒ Object



179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/howzit/topic.rb', line 179

def colored_option(color, topic, keys)
  if topic.tasks.empty?
    ''
  else
    optional = keys[:optional] =~ /[?!]+/ ? true : false
    default = keys[:optional] =~ /!/ ? false : true
    if optional
      colored_yn(color, default)
    else
      ''
    end
  end
end

#colored_yn(color, default) ⇒ Object



193
194
195
196
197
198
199
# File 'lib/howzit/topic.rb', line 193

def colored_yn(color, default)
  if default
    " {xKk}[{gbK}Y{xKk}/{dbwK}n{xKk}]{x}#{color}".c
  else
    " {xKk}[{dbwK}y{xKk}/{bgK}N{xKk}]{x}#{color}".c
  end
end

#define_optional(optional) ⇒ Object



272
273
274
275
276
# File 'lib/howzit/topic.rb', line 272

def define_optional(optional)
  is_optional = optional =~ /[?!]+/ ? true : false
  default = optional =~ /!/ ? false : true
  [is_optional, default]
end

#define_task_args(keys) ⇒ Object



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/howzit/topic.rb', line 380

def define_task_args(keys)
  cmd = keys[:cmd]
  obj = keys[:action]
  # Extract and clean the title
  raw_title = keys[:title]
  # Determine the title: use provided title if available, otherwise use action
  title = if raw_title.nil? || raw_title.to_s.strip.empty?
            obj
          else
            raw_title.to_s.strip
          end
  # Store the actual title (not overridden by show_all_code - that's only for display)
  task_args = { type: :include,
                arguments: nil,
                title: title.dup, # Make a copy to avoid reference issues
                action: obj,
                parent: self }
  # Set named_arguments before processing titles for variable substitution
  # Merge with existing named_arguments to preserve @set_var variables
  Howzit.named_arguments ||= {}
  Howzit.named_arguments.merge!(@named_args) if @named_args
  case cmd
  when /include/i
    if title =~ /\[(.*?)\] *$/
      args = Regexp.last_match(1).split(/ *, */).map(&:render_arguments)
      Howzit.arguments = args
      arguments
      title.sub!(/ *\[.*?\] *$/, '')
    end
    # Apply variable substitution to title after bracket processing
    task_args[:title] = title.render_arguments

    task_args[:type] = :include
    task_args[:arguments] = Howzit.named_arguments
  when /run/i
    task_args[:type] = :run
    task_args[:title] = title.render_arguments
    # Parse log_level from action if present (format: script, log_level=level)
    if obj =~ /,\s*log_level\s*=\s*(\w+)/i
      log_level = Regexp.last_match(1).downcase
      task_args[:log_level] = log_level
      # Remove log_level parameter from action
      obj = obj.sub(/,\s*log_level\s*=\s*\w+/i, '').strip
    end
    task_args[:action] = obj
  when /copy/i
    task_args[:type] = :copy
    task_args[:action] = Shellwords.escape(obj)
    task_args[:title] = title.render_arguments
  when /open|url/i
    task_args[:type] = :open
    task_args[:title] = title.render_arguments
  end

  task_args
end

#format_arg_definition(arg) ⇒ String

Format an argument definition with syntax highlighting Parentheses in blue, variable name in bright white, default in yellow

Parameters:

  • arg (String)

    The argument definition (e.g., “var” or “var:default”)

Returns:

  • (String)

    Colorized argument definition



345
346
347
348
349
350
351
352
# File 'lib/howzit/topic.rb', line 345

def format_arg_definition(arg)
  if arg.include?(':')
    name, default = arg.split(':', 2)
    "{bw}#{name}{l}:{y}#{default}{x}".c
  else
    "{bw}#{arg}{x}".c
  end
end

#grep(term) ⇒ Object

Search title and contents for a pattern

Parameters:

  • term (String)

    the search pattern



76
77
78
# File 'lib/howzit/topic.rb', line 76

def grep(term)
  @title =~ /#{term}/i || @content =~ /#{term}/i
end

#highlight_variables(text) ⇒ String

Highlight variable placeholders in content Format: $variable or $variable:default Dollar sign and braces in blue, variable name in bright white, default in yellow

Parameters:

  • text (String)

    The text to process

Returns:

  • (String)

    Text with highlighted variables



363
364
365
366
367
368
369
370
371
372
373
# File 'lib/howzit/topic.rb', line 363

def highlight_variables(text)
  text.gsub(/\$\{([A-Za-z0-9_]+)(?::([^}]*))?\}/) do
    var_name = Regexp.last_match(1)
    default = Regexp.last_match(2)
    if default
      "{l}\\$\\{{bw}#{var_name}{l}:{y}#{default}{l}\\}{x}".c
    else
      "{l}\\$\\{{bw}#{var_name}{l}\\}{x}".c
    end
  end
end

Output a topic with fancy title and bright white text.

Parameters:

  • options (Hash) (defaults to: {})

    The options

Returns:

  • (Array)

    array of formatted lines



292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/howzit/topic.rb', line 292

def print_out(options = {})
  defaults = { single: false, header: true }
  opt = defaults.merge(options)

  output = []
  if opt[:header]
    # Include argument definitions in header if present
    header_title = @title.dup
    unless @arg_definitions.nil? || @arg_definitions.empty?
      formatted_args = @arg_definitions.map { |arg| format_arg_definition(arg) }.join('{l}, '.c)
      header_title += " {l}({x}#{formatted_args}{l}){x}".c
    end
    output.push(header_title.format_header)
    output.push('')
  end
  # Process conditional blocks first
   = @metadata || Howzit.buildnote&.
  topic = ConditionalContent.process(@content.dup, { metadata:  })
  unless Howzit.options[:show_all_code]
    topic.gsub!(/(?mix)^(`{3,})run([?!]*)\s*
                ([^\n]*)[\s\S]*?\n\1\s*$/, '@@@run\2 \3')
  end
  topic.split(/\n/).each do |l|
    case l
    when /@(before|after|prereq|end|if|unless)/
      next
    when /@include(?<optional>[!?]{1,2})?\((?<action>[^)]+)\)/
      output.concat(process_include(Regexp.last_match.named_captures.symbolize_keys, opt))
    when /@(?<cmd>run|copy|open|url)(?<optional>[?!]{1,2})?\((?<action>.*?)\) *(?<title>.*?)$/
      output.push(process_directive(Regexp.last_match.named_captures.symbolize_keys))
    when /(?<fence>`{3,})run(?<optional>[!?]{1,2})? *(?<title>.*?)$/i
      desc = title_code_block(Regexp.last_match.named_captures.symbolize_keys)
      output.push("{bmK}\u{25B6} {bwK}#{desc}{x}\n```".c)
    when /@@@run(?<optional>[!?]{1,2})? *(?<title>.*?)$/i
      output.push("{bmK}\u{25B6} {bwK}#{title_code_block(Regexp.last_match.named_captures.symbolize_keys)}{x}".c)
    else
      l.wrap!(Howzit.options[:wrap]) if Howzit.options[:wrap].positive?
      # Highlight variable placeholders in content
      output.push(highlight_variables(l))
    end
  end
  Howzit.named_arguments = @named_args
  output.push('')
end

#process_directive(keys) ⇒ Object



254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/howzit/topic.rb', line 254

def process_directive(keys)
  cmd = keys[:cmd]
  obj = keys[:action]
  title = keys[:title].empty? ? obj : keys[:title].strip
  title = Howzit.options[:show_all_code] ? obj : title
  option = color_directive_yn(keys)
  icon = case cmd
         when 'run'
           "\u{25B6}"
         when 'copy'
           "\u{271A}"
         when /open|url/
           "\u{279A}"
         end

  "{bmK}#{icon} {bwK}#{title.preserve_escapes}{x}#{option}".c
end

#process_include(keys, opt) ⇒ Object

Handle an include statement

Parameters:

  • keys (Hash)

    The symbolized keys and values from the regex that found the statement

  • opt (Hash)

    Options



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
# File 'lib/howzit/topic.rb', line 208

def process_include(keys, opt)
  output = []

  if keys[:action] =~ / *\[(.*?)\] *$/
    Howzit.named_arguments ||= {}
    Howzit.named_arguments.merge!(@named_args) if @named_args
    Howzit.arguments = Regexp.last_match(1).split(/ *, */).map!(&:render_arguments)
  end

  matches = Howzit.buildnote.find_topic(keys[:action].sub(/ *\[.*?\] *$/, ''))

  return [] if matches.empty?

  topic = matches[0]
  return [] if topic.nil?

  rule = '{kKd}'
  color = '{Kyd}'
  title = title_option(color, topic, keys, opt)
  options = { color: color, hr: '.', border: rule }

  output.push("#{'> ' * @nest_level}#{title}".format_header(options)) unless Howzit.inclusions.include?(topic)

  if opt[:single] && Howzit.inclusions.include?(topic)
    output.push("#{'> ' * @nest_level}#{title} included above".format_header(options))
  elsif opt[:single]
    @nest_level += 1

    output.concat(topic.print_out({ single: true, header: false }))
    output.push("#{'> ' * @nest_level}...".format_header(options))
    @nest_level -= 1
  end
  Howzit.inclusions.push(topic)

  output
end

#run(nested: false) ⇒ Object

Handle run command, execute directives in topic



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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
# File 'lib/howzit/topic.rb', line 98

def run(nested: false)
  output = []

  cols = check_cols

  # Use sequential processing if we have directives with conditionals
  if @directives && @directives.any?(&:conditional?)
    return run_sequential(nested: nested, output: output, cols: cols)
  end

  # Fall back to old behavior for backward compatibility
  # Note: @set_var directives are already processed in gather_tasks for non-sequential path
  # This section is kept for backward compatibility but shouldn't be needed

  if @tasks.count.positive?
    unless @prereqs.empty?
      begin
        puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
      rescue Errno::EPIPE
        # Pipe closed, ignore
      end
      res = Prompt.yn('Have the above prerequisites been met?', default: true)
      Process.exit 1 unless res

    end

    @tasks.each do |task|
      next if (task.optional || Howzit.options[:ask]) && !ask_task(task)

      run_output, total, success = task.run

      output.concat(run_output)
      @results[:total] += total

      if success
        @results[:success] += total
      else
        Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c

        @results[:errors] += total

        break unless Howzit.options[:force]
      end

      log_task_result(task, success)
    end

    total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
    errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
    @results[:message] += if @results[:errors].zero?
                            "{bg}\u{2713} {by}Ran #{total}{x}".c
                          elsif Howzit.options[:force]
                            "{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
                          else
                            "{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
                          end
  else
    Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
  end

  output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]

  unless @postreqs.empty?
    begin
      # Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
      postreqs_content = @postreqs.join("\n\n").render_arguments
      wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
      puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
    rescue Errno::EPIPE
      # Pipe closed, ignore
    end
  end

  output
end

#title_code_block(keys) ⇒ Object



278
279
280
281
282
283
284
# File 'lib/howzit/topic.rb', line 278

def title_code_block(keys)
  if keys[:title].length.positive?
    "Block: #{keys[:title]}#{color_directive_yn(keys)}"
  else
    "Code Block#{color_directive_yn(keys)}"
  end
end

#title_option(color, topic, keys, opt) ⇒ Object



174
175
176
177
# File 'lib/howzit/topic.rb', line 174

def title_option(color, topic, keys, opt)
  option = colored_option(color, topic, keys)
  "#{opt[:single] ? 'From' : 'Include'} #{topic.title}#{option}:"
end