taski 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,506 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require "stringio"
5
+
6
+ module Taski
7
+ module Execution
8
+ # Tree-based progress display that shows task execution in a tree structure
9
+ # similar to Task.tree, with real-time status updates and stdout capture.
10
+ class TreeProgressDisplay
11
+ SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
12
+
13
+ # ANSI color codes (matching Task.tree)
14
+ COLORS = {
15
+ reset: "\e[0m",
16
+ task: "\e[32m", # green
17
+ section: "\e[34m", # blue
18
+ impl: "\e[33m", # yellow
19
+ tree: "\e[90m", # gray
20
+ name: "\e[1m", # bold
21
+ success: "\e[32m", # green
22
+ error: "\e[31m", # red
23
+ running: "\e[36m", # cyan
24
+ pending: "\e[90m", # gray
25
+ dim: "\e[2m" # dim
26
+ }.freeze
27
+
28
+ # Status icons
29
+ ICONS = {
30
+ pending: "⏸", # Pause for waiting
31
+ running_prefix: "", # Will use spinner
32
+ completed: "✓",
33
+ failed: "✗",
34
+ skipped: "⊘" # Prohibition sign for unselected impl candidates
35
+ }.freeze
36
+
37
+ # Shared helper methods
38
+ def self.section_class?(klass)
39
+ defined?(Taski::Section) && klass < Taski::Section
40
+ end
41
+
42
+ def self.nested_class?(child_class, parent_class)
43
+ child_name = child_class.name.to_s
44
+ parent_name = parent_class.name.to_s
45
+ child_name.start_with?("#{parent_name}::")
46
+ end
47
+
48
+ # Render a static tree structure for a task class (used by Task.tree)
49
+ # @param root_task_class [Class] The root task class
50
+ # @return [String] The rendered tree string
51
+ def self.render_static_tree(root_task_class)
52
+ renderer = StaticTreeRenderer.new
53
+ renderer.render(root_task_class)
54
+ end
55
+
56
+ # Internal renderer for static tree display (no progress tracking)
57
+ class StaticTreeRenderer
58
+ def render(root_task_class)
59
+ @task_index_map = {}
60
+ build_tree(root_task_class, "", false, Set.new)
61
+ end
62
+
63
+ private
64
+
65
+ def build_tree(task_class, prefix, is_impl, ancestors)
66
+ type_label = colored_type_label(task_class)
67
+ impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
68
+ task_number = get_task_number(task_class)
69
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
70
+
71
+ # Detect circular reference
72
+ if ancestors.include?(task_class)
73
+ circular_marker = "#{COLORS[:impl]}(circular)#{COLORS[:reset]}"
74
+ return "#{impl_prefix}#{task_number} #{name} #{type_label} #{circular_marker}\n"
75
+ end
76
+
77
+ result = "#{impl_prefix}#{task_number} #{name} #{type_label}\n"
78
+
79
+ # Register task number if not already registered
80
+ @task_index_map[task_class] = @task_index_map.size + 1 unless @task_index_map.key?(task_class)
81
+
82
+ new_ancestors = ancestors + [task_class]
83
+ dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
84
+ is_section = TreeProgressDisplay.section_class?(task_class)
85
+
86
+ dependencies.each_with_index do |dep, index|
87
+ is_last = (index == dependencies.size - 1)
88
+ is_impl_candidate = is_section && TreeProgressDisplay.nested_class?(dep, task_class)
89
+ result += render_dependency_branch(dep, prefix, is_last, is_impl_candidate, new_ancestors)
90
+ end
91
+
92
+ result
93
+ end
94
+
95
+ def render_dependency_branch(dep, prefix, is_last, is_impl, ancestors)
96
+ connector = is_last ? "└── " : "├── "
97
+ extension = is_last ? " " : "│ "
98
+ dep_tree = build_tree(dep, "#{prefix}#{extension}", is_impl, ancestors)
99
+
100
+ result = "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}"
101
+ lines = dep_tree.lines
102
+ result += lines.first
103
+ lines.drop(1).each { |line| result += line }
104
+ result
105
+ end
106
+
107
+ def get_task_number(task_class)
108
+ number = @task_index_map[task_class] || (@task_index_map.size + 1)
109
+ "#{COLORS[:tree]}[#{number}]#{COLORS[:reset]}"
110
+ end
111
+
112
+ def colored_type_label(klass)
113
+ if TreeProgressDisplay.section_class?(klass)
114
+ "#{COLORS[:section]}(Section)#{COLORS[:reset]}"
115
+ else
116
+ "#{COLORS[:task]}(Task)#{COLORS[:reset]}"
117
+ end
118
+ end
119
+ end
120
+
121
+ class TaskProgress
122
+ attr_accessor :state, :start_time, :end_time, :error, :duration
123
+ attr_accessor :is_impl_candidate
124
+
125
+ def initialize
126
+ @state = :pending
127
+ @start_time = nil
128
+ @end_time = nil
129
+ @error = nil
130
+ @duration = nil
131
+ @is_impl_candidate = false
132
+ end
133
+ end
134
+
135
+ def initialize(output: $stdout)
136
+ @output = output
137
+ @tasks = {}
138
+ @monitor = Monitor.new
139
+ @spinner_index = 0
140
+ @renderer_thread = nil
141
+ @running = false
142
+ @nest_level = 0 # Track nested executor calls
143
+ @root_task_class = nil
144
+ @tree_structure = nil
145
+ @section_impl_map = {} # Section -> selected impl class
146
+ @last_line_count = 0
147
+ end
148
+
149
+ # Set the root task to build tree structure
150
+ # Only sets root task if not already set (prevents nested executor overwrite)
151
+ # @param root_task_class [Class] The root task class
152
+ def set_root_task(root_task_class)
153
+ @monitor.synchronize do
154
+ return if @root_task_class # Don't overwrite existing root task
155
+ @root_task_class = root_task_class
156
+ build_tree_structure
157
+ end
158
+ end
159
+
160
+ # Register which impl was selected for a section
161
+ # @param section_class [Class] The section class
162
+ # @param impl_class [Class] The selected implementation class
163
+ def register_section_impl(section_class, impl_class)
164
+ @monitor.synchronize do
165
+ @section_impl_map[section_class] = impl_class
166
+ end
167
+ end
168
+
169
+ # @param task_class [Class] The task class to register
170
+ def register_task(task_class)
171
+ @monitor.synchronize do
172
+ return if @tasks.key?(task_class)
173
+ @tasks[task_class] = TaskProgress.new
174
+ end
175
+ end
176
+
177
+ # @param task_class [Class] The task class to check
178
+ # @return [Boolean] true if the task is registered
179
+ def task_registered?(task_class)
180
+ @monitor.synchronize do
181
+ @tasks.key?(task_class)
182
+ end
183
+ end
184
+
185
+ # @param task_class [Class] The task class to update
186
+ # @param state [Symbol] The new state (:pending, :running, :completed, :failed)
187
+ # @param duration [Float] Duration in milliseconds (for completed tasks)
188
+ # @param error [Exception] Error object (for failed tasks)
189
+ def update_task(task_class, state:, duration: nil, error: nil)
190
+ @monitor.synchronize do
191
+ progress = @tasks[task_class]
192
+ return unless progress
193
+
194
+ progress.state = state
195
+ progress.duration = duration if duration
196
+ progress.error = error if error
197
+
198
+ case state
199
+ when :running
200
+ progress.start_time = Time.now
201
+ when :completed, :failed
202
+ progress.end_time = Time.now
203
+ end
204
+ end
205
+ end
206
+
207
+ # @param task_class [Class] The task class
208
+ # @return [Symbol] The task state
209
+ def task_state(task_class)
210
+ @monitor.synchronize do
211
+ @tasks[task_class]&.state
212
+ end
213
+ end
214
+
215
+ def start
216
+ should_start = false
217
+ @monitor.synchronize do
218
+ @nest_level += 1
219
+ return if @nest_level > 1 # Already running from outer executor
220
+ return if @running
221
+ return unless @output.tty?
222
+
223
+ @running = true
224
+ should_start = true
225
+ end
226
+
227
+ return unless should_start
228
+
229
+ # Hide cursor (outside monitor to avoid holding lock during I/O)
230
+ @output.print "\e[?25l"
231
+ @renderer_thread = Thread.new do
232
+ loop do
233
+ break unless @running
234
+ render_live
235
+ sleep 0.1
236
+ end
237
+ end
238
+ end
239
+
240
+ def stop
241
+ should_stop = false
242
+ @monitor.synchronize do
243
+ @nest_level -= 1 if @nest_level > 0
244
+ return unless @nest_level == 0
245
+ return unless @running
246
+
247
+ @running = false
248
+ should_stop = true
249
+ end
250
+
251
+ return unless should_stop
252
+
253
+ @renderer_thread&.join
254
+ # Show cursor
255
+ @output.print "\e[?25h"
256
+ render_final
257
+ end
258
+
259
+ private
260
+
261
+ # Build tree structure from root task for display
262
+ def build_tree_structure
263
+ return unless @root_task_class
264
+
265
+ @tree_structure = build_tree_node(@root_task_class, Set.new)
266
+ register_tasks_from_tree(@tree_structure)
267
+ end
268
+
269
+ # Build a single tree node
270
+ def build_tree_node(task_class, ancestors)
271
+ return nil if ancestors.include?(task_class)
272
+
273
+ node = {
274
+ task_class: task_class,
275
+ is_section: section_class?(task_class),
276
+ children: [],
277
+ is_impl_candidate: false
278
+ }
279
+
280
+ new_ancestors = ancestors + [task_class]
281
+ dependencies = StaticAnalysis::Analyzer.analyze(task_class).to_a
282
+ is_section = section_class?(task_class)
283
+
284
+ dependencies.each do |dep|
285
+ child_node = build_tree_node(dep, new_ancestors)
286
+ if child_node
287
+ # Only mark as impl candidate if parent is Section AND
288
+ # the dependency is a nested class of that Section
289
+ child_node[:is_impl_candidate] = is_section && nested_class?(dep, task_class)
290
+ node[:children] << child_node
291
+ end
292
+ end
293
+
294
+ node
295
+ end
296
+
297
+ # Register all tasks from tree structure
298
+ def register_tasks_from_tree(node)
299
+ return unless node
300
+
301
+ task_class = node[:task_class]
302
+ register_task(task_class)
303
+
304
+ # Mark as impl candidate if applicable
305
+ if node[:is_impl_candidate]
306
+ @tasks[task_class].is_impl_candidate = true
307
+ end
308
+
309
+ node[:children].each { |child| register_tasks_from_tree(child) }
310
+ end
311
+
312
+ def render_live
313
+ lines = nil
314
+ line_count = nil
315
+
316
+ @monitor.synchronize do
317
+ @spinner_index += 1
318
+ lines = build_tree_display
319
+ line_count = @last_line_count
320
+ end
321
+
322
+ return if lines.nil? || lines.empty?
323
+
324
+ # Move cursor up to beginning of display area
325
+ if line_count && line_count > 0
326
+ @output.print "\e[#{line_count}A\r"
327
+ end
328
+
329
+ # Redraw all lines
330
+ lines.each do |line|
331
+ @output.print "\e[K#{line}\n"
332
+ end
333
+
334
+ @monitor.synchronize do
335
+ @last_line_count = lines.length
336
+ end
337
+
338
+ @output.flush
339
+ end
340
+
341
+ def render_final
342
+ @monitor.synchronize do
343
+ lines = build_tree_display
344
+ return if lines.empty?
345
+
346
+ # Clear previous animated output
347
+ if @last_line_count && @last_line_count > 0
348
+ @last_line_count.times do
349
+ @output.print "\e[1A\e[K"
350
+ end
351
+ end
352
+
353
+ # Print final state
354
+ lines.each { |line| @output.puts line }
355
+ end
356
+ end
357
+
358
+ # Build display lines from tree structure
359
+ def build_tree_display
360
+ return [] unless @tree_structure
361
+
362
+ lines = []
363
+ build_root_tree_lines(@tree_structure, "", lines)
364
+ lines
365
+ end
366
+
367
+ # Build tree lines starting from root node
368
+ # @param node [Hash] Tree node (root)
369
+ # @param prefix [String] Line prefix for tree drawing
370
+ # @param lines [Array<String>] Accumulated output lines
371
+ def build_root_tree_lines(node, prefix, lines)
372
+ task_class = node[:task_class]
373
+ progress = @tasks[task_class]
374
+
375
+ # Root node is never an impl candidate and is always selected
376
+ line = format_tree_line(task_class, progress, false, true)
377
+ lines << "#{prefix}#{line}"
378
+
379
+ render_children(node, prefix, lines, task_class, true)
380
+ end
381
+
382
+ # Render all children of a node recursively
383
+ # @param node [Hash] Tree node
384
+ # @param prefix [String] Line prefix for tree drawing
385
+ # @param lines [Array<String>] Accumulated output lines
386
+ # @param parent_task_class [Class] Parent task class (for impl selection lookup)
387
+ # @param ancestor_selected [Boolean] Whether all ancestor impl candidates were selected
388
+ def render_children(node, prefix, lines, parent_task_class, ancestor_selected)
389
+ children = node[:children]
390
+ children.each_with_index do |child, index|
391
+ is_last = (index == children.size - 1)
392
+ connector = is_last ? "└── " : "├── "
393
+ extension = is_last ? " " : "│ "
394
+
395
+ child_progress = @tasks[child[:task_class]]
396
+
397
+ # Determine child's selection status
398
+ child_is_selected = true
399
+ if child[:is_impl_candidate]
400
+ selected_impl = @section_impl_map[parent_task_class]
401
+ child_is_selected = (selected_impl == child[:task_class])
402
+ end
403
+ # Propagate ancestor selection state
404
+ child_effective_selected = ancestor_selected && child_is_selected
405
+
406
+ child_line = format_tree_line(
407
+ child[:task_class],
408
+ child_progress,
409
+ child[:is_impl_candidate],
410
+ child_effective_selected
411
+ )
412
+ lines << "#{prefix}#{COLORS[:tree]}#{connector}#{COLORS[:reset]}#{child_line}"
413
+
414
+ if child[:children].any?
415
+ render_children(child, "#{prefix}#{COLORS[:tree]}#{extension}#{COLORS[:reset]}", lines, child[:task_class], child_effective_selected)
416
+ end
417
+ end
418
+ end
419
+
420
+ def format_tree_line(task_class, progress, is_impl, is_selected)
421
+ return format_unknown_task(task_class, is_selected) unless progress
422
+
423
+ type_label = type_label_for(task_class, is_selected)
424
+ impl_prefix = is_impl ? "#{COLORS[:impl]}[impl]#{COLORS[:reset]} " : ""
425
+
426
+ # Handle unselected nodes (either impl candidates or children of unselected impl)
427
+ # Show dimmed regardless of task state since they belong to unselected branch
428
+ unless is_selected
429
+ name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
430
+ suffix = is_impl ? " #{COLORS[:dim]}(not selected)#{COLORS[:reset]}" : ""
431
+ return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{impl_prefix}#{name} #{type_label}#{suffix}"
432
+ end
433
+
434
+ status_icon = task_status_icon(progress.state, is_selected)
435
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
436
+ details = task_details(progress)
437
+
438
+ "#{status_icon} #{impl_prefix}#{name} #{type_label}#{details}"
439
+ end
440
+
441
+ def format_unknown_task(task_class, is_selected = true)
442
+ if is_selected
443
+ name = "#{COLORS[:name]}#{task_class.name}#{COLORS[:reset]}"
444
+ type_label = type_label_for(task_class, true)
445
+ "#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]} #{name} #{type_label}"
446
+ else
447
+ name = "#{COLORS[:dim]}#{task_class.name}#{COLORS[:reset]}"
448
+ type_label = type_label_for(task_class, false)
449
+ "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]} #{name} #{type_label}"
450
+ end
451
+ end
452
+
453
+ def task_status_icon(state, is_selected)
454
+ # If not selected (either direct impl candidate or child of unselected), show skipped
455
+ unless is_selected
456
+ return "#{COLORS[:dim]}#{ICONS[:skipped]}#{COLORS[:reset]}"
457
+ end
458
+
459
+ case state
460
+ when :completed
461
+ "#{COLORS[:success]}#{ICONS[:completed]}#{COLORS[:reset]}"
462
+ when :failed
463
+ "#{COLORS[:error]}#{ICONS[:failed]}#{COLORS[:reset]}"
464
+ when :running
465
+ "#{COLORS[:running]}#{spinner_char}#{COLORS[:reset]}"
466
+ else
467
+ "#{COLORS[:pending]}#{ICONS[:pending]}#{COLORS[:reset]}"
468
+ end
469
+ end
470
+
471
+ def spinner_char
472
+ SPINNER_FRAMES[@spinner_index % SPINNER_FRAMES.length]
473
+ end
474
+
475
+ def type_label_for(task_class, is_selected = true)
476
+ if section_class?(task_class)
477
+ is_selected ? "#{COLORS[:section]}(Section)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Section)#{COLORS[:reset]}"
478
+ else
479
+ is_selected ? "#{COLORS[:task]}(Task)#{COLORS[:reset]}" : "#{COLORS[:dim]}(Task)#{COLORS[:reset]}"
480
+ end
481
+ end
482
+
483
+ def task_details(progress)
484
+ case progress.state
485
+ when :completed
486
+ " #{COLORS[:success]}(#{progress.duration}ms)#{COLORS[:reset]}"
487
+ when :failed
488
+ " #{COLORS[:error]}(failed)#{COLORS[:reset]}"
489
+ when :running
490
+ elapsed = ((Time.now - progress.start_time) * 1000).round(0)
491
+ " #{COLORS[:running]}(#{elapsed}ms)#{COLORS[:reset]}"
492
+ else
493
+ ""
494
+ end
495
+ end
496
+
497
+ def section_class?(klass)
498
+ self.class.section_class?(klass)
499
+ end
500
+
501
+ def nested_class?(child_class, parent_class)
502
+ self.class.nested_class?(child_class, parent_class)
503
+ end
504
+ end
505
+ end
506
+ end
data/lib/taski/section.rb CHANGED
@@ -9,6 +9,13 @@ module Taski
9
9
  def interfaces(*interface_methods)
10
10
  exports(*interface_methods)
11
11
  end
12
+
13
+ # Section does not have static dependencies for execution.
14
+ # The impl method is called at runtime to determine the actual implementation.
15
+ # Static dependencies (impl candidates) are only used for tree display and circular detection.
16
+ def cached_dependencies
17
+ Set.new
18
+ end
12
19
  end
13
20
 
14
21
  def run
@@ -17,6 +24,9 @@ module Taski
17
24
  raise "Section #{self.class} does not have an implementation. Override 'impl' method."
18
25
  end
19
26
 
27
+ # Register selected impl for progress display
28
+ register_impl_selection(implementation_class)
29
+
20
30
  apply_interface_to_implementation(implementation_class)
21
31
 
22
32
  self.class.exported_methods.each do |method|
@@ -33,6 +43,13 @@ module Taski
33
43
 
34
44
  private
35
45
 
46
+ def register_impl_selection(implementation_class)
47
+ progress = Taski.progress_display
48
+ return unless progress.is_a?(Execution::TreeProgressDisplay)
49
+
50
+ progress.register_section_impl(self.class, implementation_class)
51
+ end
52
+
36
53
  # @param implementation_class [Class] The implementation task class
37
54
  def apply_interface_to_implementation(implementation_class)
38
55
  interface_methods = self.class.exported_methods
@@ -6,8 +6,17 @@ require_relative "visitor"
6
6
  module Taski
7
7
  module StaticAnalysis
8
8
  class Analyzer
9
+ # Analyzes a task class and returns its static dependencies.
10
+ # For Task: dependencies detected from run method and called methods (SomeTask.method calls)
11
+ # For Section: impl candidates detected from impl method and called methods (constants returned)
12
+ #
13
+ # Static dependencies are used for:
14
+ # - Tree display visualization
15
+ # - Circular dependency detection
16
+ # - Task execution (for Task only; Section resolves impl at runtime)
17
+ #
9
18
  # @param task_class [Class] The task class to analyze
10
- # @return [Set<Class>] Set of task classes that are dependencies
19
+ # @return [Set<Class>] Set of task classes that are static dependencies
11
20
  def self.analyze(task_class)
12
21
  target_method = target_method_for(task_class)
13
22
  source_location = extract_method_location(task_class, target_method)
@@ -18,6 +27,8 @@ module Taski
18
27
 
19
28
  visitor = Visitor.new(task_class, target_method)
20
29
  visitor.visit(parse_result.value)
30
+ # Follow method calls to analyze dependencies in called methods
31
+ visitor.follow_method_calls
21
32
  visitor.dependencies
22
33
  end
23
34