taski 0.2.2 → 0.3.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.
@@ -15,52 +15,79 @@ module Taski
15
15
  file_path, line_number = source_location
16
16
  return [] unless File.exist?(file_path)
17
17
 
18
- begin
19
- result = Prism.parse_file(file_path)
20
-
21
- unless result.success?
22
- Taski.logger.error("Parse errors in source file",
23
- file: file_path,
24
- errors: result.errors.map(&:message),
25
- method: "#{klass}##{method_name}")
26
- return []
27
- end
18
+ parse_source_file(file_path, line_number, klass, method_name)
19
+ end
28
20
 
29
- # Handle warnings if present
30
- if result.warnings.any?
31
- Taski.logger.warn("Parse warnings in source file",
32
- file: file_path,
33
- warnings: result.warnings.map(&:message),
34
- method: "#{klass}##{method_name}")
35
- end
21
+ private
36
22
 
37
- dependencies = []
38
- method_node = find_method_node(result.value, method_name, line_number)
23
+ # Parse source file and extract dependencies with proper error handling
24
+ # @param file_path [String] Path to source file
25
+ # @param line_number [Integer] Line number of method definition
26
+ # @param klass [Class] Class containing the method
27
+ # @param method_name [Symbol] Method name being analyzed
28
+ # @return [Array<Class>] Array of dependency classes
29
+ def parse_source_file(file_path, line_number, klass, method_name)
30
+ result = Prism.parse_file(file_path)
31
+ handle_parse_errors(result, file_path, klass, method_name)
32
+ extract_dependencies_from_node(result.value, line_number, klass, method_name)
33
+ rescue IOError, SystemCallError => e
34
+ Taski.logger.error("Failed to read source file",
35
+ file: file_path,
36
+ error: e.message,
37
+ method: "#{klass}##{method_name}")
38
+ []
39
+ rescue => e
40
+ Taski.logger.error("Failed to analyze method dependencies",
41
+ class: klass.name,
42
+ method: method_name,
43
+ error: e.message,
44
+ error_class: e.class.name)
45
+ []
46
+ end
39
47
 
40
- if method_node
41
- visitor = TaskDependencyVisitor.new
42
- visitor.visit(method_node)
43
- dependencies = visitor.dependencies
44
- end
48
+ # Handle parse errors and warnings from Prism parsing
49
+ # @param result [Prism::ParseResult] Parse result from Prism
50
+ # @param file_path [String] Path to source file
51
+ # @param klass [Class] Class containing the method
52
+ # @param method_name [Symbol] Method name being analyzed
53
+ # @return [Array] Empty array if errors found
54
+ # @raise [RuntimeError] If parse fails
55
+ def handle_parse_errors(result, file_path, klass, method_name)
56
+ unless result.success?
57
+ Taski.logger.error("Parse errors in source file",
58
+ file: file_path,
59
+ errors: result.errors.map(&:message),
60
+ method: "#{klass}##{method_name}")
61
+ return []
62
+ end
45
63
 
46
- dependencies.uniq
47
- rescue IOError, SystemCallError => e
48
- Taski.logger.error("Failed to read source file",
64
+ # Handle warnings if present
65
+ if result.warnings.any?
66
+ Taski.logger.warn("Parse warnings in source file",
49
67
  file: file_path,
50
- error: e.message,
68
+ warnings: result.warnings.map(&:message),
51
69
  method: "#{klass}##{method_name}")
52
- []
53
- rescue => e
54
- Taski.logger.error("Failed to analyze method dependencies",
55
- class: klass.name,
56
- method: method_name,
57
- error: e.message,
58
- error_class: e.class.name)
59
- []
60
70
  end
61
71
  end
62
72
 
63
- private
73
+ # Extract dependencies from parsed AST node
74
+ # @param root_node [Prism::Node] Root AST node
75
+ # @param line_number [Integer] Line number of method definition
76
+ # @param klass [Class] Class containing the method
77
+ # @param method_name [Symbol] Method name being analyzed
78
+ # @return [Array<Class>] Array of unique dependency classes
79
+ def extract_dependencies_from_node(root_node, line_number, klass, method_name)
80
+ dependencies = []
81
+ method_node = find_method_node(root_node, method_name, line_number)
82
+
83
+ if method_node
84
+ visitor = TaskDependencyVisitor.new(klass)
85
+ visitor.visit(method_node)
86
+ dependencies = visitor.dependencies
87
+ end
88
+
89
+ dependencies.uniq
90
+ end
64
91
 
65
92
  def find_method_node(node, method_name, target_line)
66
93
  return nil unless node
@@ -96,9 +123,10 @@ module Taski
96
123
  class TaskDependencyVisitor < Prism::Visitor
97
124
  attr_reader :dependencies
98
125
 
99
- def initialize
126
+ def initialize(context_class = nil)
100
127
  @dependencies = []
101
128
  @constant_cache = {}
129
+ @context_class = context_class
102
130
  end
103
131
 
104
132
  def visit_constant_read_node(node)
@@ -135,14 +163,19 @@ module Taski
135
163
  return @dependencies << cached_result if cached_result # Cached positive result
136
164
 
137
165
  begin
166
+ resolved_class = nil
167
+
168
+ # 1. Try absolute reference first (existing logic)
138
169
  if Object.const_defined?(const_name)
139
- klass = Object.const_get(const_name)
140
- if klass.is_a?(Class) && klass < Taski::Task
141
- @constant_cache[const_name] = klass
142
- @dependencies << klass
143
- else
144
- @constant_cache[const_name] = false
145
- end
170
+ resolved_class = Object.const_get(const_name)
171
+ # 2. Try relative reference within namespace context
172
+ elsif @context_class
173
+ resolved_class = resolve_relative_constant(const_name)
174
+ end
175
+
176
+ if resolved_class&.is_a?(Class) && (resolved_class < Taski::Task || resolved_class < Taski::Section)
177
+ @constant_cache[const_name] = resolved_class
178
+ @dependencies << resolved_class
146
179
  else
147
180
  @constant_cache[const_name] = false
148
181
  end
@@ -151,6 +184,32 @@ module Taski
151
184
  end
152
185
  end
153
186
 
187
+ def resolve_relative_constant(const_name)
188
+ return nil unless @context_class
189
+
190
+ # Get the namespace from the context class
191
+ namespace = get_namespace_from_class(@context_class)
192
+ return nil unless namespace
193
+
194
+ # Try to resolve the constant within the namespace
195
+ full_const_name = "#{namespace}::#{const_name}"
196
+ Object.const_get(full_const_name) if Object.const_defined?(full_const_name)
197
+ rescue NameError, ArgumentError
198
+ nil
199
+ end
200
+
201
+ def get_namespace_from_class(klass)
202
+ # Extract namespace from class name (e.g., "A::AB" -> "A")
203
+ class_name = klass.name
204
+ return nil unless class_name&.include?("::")
205
+
206
+ # Split by "::" and take all but the last part
207
+ parts = class_name.split("::")
208
+ return nil if parts.length <= 1 # No namespace
209
+
210
+ parts[0..-2].join("::")
211
+ end
212
+
154
213
  def extract_constant_path(node)
155
214
  case node
156
215
  when Prism::ConstantReadNode
@@ -11,4 +11,7 @@ module Taski
11
11
 
12
12
  # Raised when task building fails during execution
13
13
  class TaskBuildError < StandardError; end
14
+
15
+ # Raised when section implementation method is missing
16
+ class SectionImplementationError < StandardError; end
14
17
  end
data/lib/taski/logger.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "logging/formatter_factory"
4
+
3
5
  module Taski
4
6
  # Enhanced logging functionality for Taski framework
5
7
  # Provides structured logging with multiple levels and context information
@@ -13,7 +15,7 @@ module Taski
13
15
  def initialize(level: :info, output: $stdout, format: :structured)
14
16
  @level = level
15
17
  @output = output
16
- @format = format
18
+ @formatter = Logging::FormatterFactory.create(format)
17
19
  @start_time = Time.now
18
20
  end
19
21
 
@@ -103,21 +105,15 @@ module Taski
103
105
 
104
106
  private
105
107
 
106
- # Core logging method
108
+ # Core logging method using Strategy Pattern
107
109
  # @param level [Symbol] Log level
108
110
  # @param message [String] Log message
109
111
  # @param context [Hash] Additional context
110
112
  def log(level, message, context)
111
113
  return unless should_log?(level)
112
114
 
113
- case @format
114
- when :simple
115
- log_simple(level, message, context)
116
- when :structured
117
- log_structured(level, message, context)
118
- when :json
119
- log_json(level, message, context)
120
- end
115
+ formatted_line = @formatter.format(level, message, context, @start_time)
116
+ @output.puts formatted_line
121
117
  end
122
118
 
123
119
  # Check if message should be logged based on current level
@@ -126,57 +122,6 @@ module Taski
126
122
  def should_log?(level)
127
123
  LEVELS[@level] <= LEVELS[level]
128
124
  end
129
-
130
- # Simple log format: [LEVEL] message
131
- def log_simple(level, message, context)
132
- @output.puts "[#{level.upcase}] #{message}"
133
- end
134
-
135
- # Structured log format with timestamp and context
136
- def log_structured(level, message, context)
137
- timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%3N")
138
- elapsed = ((Time.now - @start_time) * 1000).round(1)
139
-
140
- line = "[#{timestamp}] [#{elapsed}ms] #{level.to_s.upcase.ljust(5)} Taski: #{message}"
141
-
142
- unless context.empty?
143
- context_parts = context.map do |key, value|
144
- "#{key}=#{format_value(value)}"
145
- end
146
- line += " (#{context_parts.join(", ")})"
147
- end
148
-
149
- @output.puts line
150
- end
151
-
152
- # JSON log format for structured logging systems
153
- def log_json(level, message, context)
154
- require "json"
155
-
156
- log_entry = {
157
- timestamp: Time.now.iso8601(3),
158
- level: level.to_s,
159
- logger: "taski",
160
- message: message,
161
- elapsed_ms: ((Time.now - @start_time) * 1000).round(1)
162
- }.merge(context)
163
-
164
- @output.puts JSON.generate(log_entry)
165
- end
166
-
167
- # Format values for structured logging
168
- def format_value(value)
169
- case value
170
- when String
171
- (value.length > 50) ? "#{value[0..47]}..." : value
172
- when Array
173
- (value.size > 5) ? "[#{value[0..4].join(", ")}, ...]" : value.inspect
174
- when Hash
175
- (value.size > 3) ? "{#{value.keys[0..2].join(", ")}, ...}" : value.inspect
176
- else
177
- value.inspect
178
- end
179
- end
180
125
  end
181
126
 
182
127
  class << self
@@ -189,7 +134,7 @@ module Taski
189
134
  # Get the current progress display instance (always enabled)
190
135
  # @return [ProgressDisplay] Current progress display instance
191
136
  def progress_display
192
- @progress_display ||= ProgressDisplay.new(force_enable: ENV["TASKI_FORCE_PROGRESS"] == "1")
137
+ @progress_display ||= ProgressDisplay.new
193
138
  end
194
139
 
195
140
  # Configure the logger with new settings
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "simple_formatter"
4
+ require_relative "structured_formatter"
5
+ require_relative "json_formatter"
6
+
7
+ module Taski
8
+ module Logging
9
+ # Factory for creating log formatters
10
+ class FormatterFactory
11
+ # Create a formatter instance based on format symbol
12
+ # @param format [Symbol] Format type (:simple, :structured, :json)
13
+ # @return [FormatterInterface] Formatter instance
14
+ def self.create(format)
15
+ case format
16
+ when :simple
17
+ SimpleFormatter.new
18
+ when :structured
19
+ StructuredFormatter.new
20
+ when :json
21
+ JsonFormatter.new
22
+ else
23
+ raise ArgumentError, "Unknown format: #{format}. Valid formats: :simple, :structured, :json"
24
+ end
25
+ end
26
+
27
+ # Get list of available formats
28
+ # @return [Array<Symbol>] Available format symbols
29
+ def self.available_formats
30
+ [:simple, :structured, :json]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Logging
5
+ # Interface for log formatters
6
+ # All formatters must implement the format method
7
+ module FormatterInterface
8
+ # Format a log entry
9
+ # @param level [Symbol] Log level (:debug, :info, :warn, :error)
10
+ # @param message [String] Log message
11
+ # @param context [Hash] Additional context information
12
+ # @param start_time [Time] Logger start time for elapsed calculation
13
+ # @return [String] Formatted log line
14
+ def format(level, message, context, start_time)
15
+ raise NotImplementedError, "Subclass must implement format method"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatter_interface"
4
+
5
+ module Taski
6
+ module Logging
7
+ # JSON log formatter for structured logging systems
8
+ class JsonFormatter
9
+ include FormatterInterface
10
+
11
+ def format(level, message, context, start_time)
12
+ require "json"
13
+
14
+ log_entry = {
15
+ timestamp: Time.now.iso8601(3),
16
+ level: level.to_s,
17
+ logger: "taski",
18
+ message: message,
19
+ elapsed_ms: ((Time.now - start_time) * 1000).round(1)
20
+ }.merge(context)
21
+
22
+ JSON.generate(log_entry)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatter_interface"
4
+
5
+ module Taski
6
+ module Logging
7
+ # Simple log formatter: [LEVEL] message
8
+ class SimpleFormatter
9
+ include FormatterInterface
10
+
11
+ def format(level, message, context, start_time)
12
+ "[#{level.upcase}] #{message}"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatter_interface"
4
+
5
+ module Taski
6
+ module Logging
7
+ # Structured log formatter with timestamp and context
8
+ class StructuredFormatter
9
+ include FormatterInterface
10
+
11
+ def format(level, message, context, start_time)
12
+ timestamp = Time.now.strftime("%Y-%m-%d %H:%M:%S.%3N")
13
+ elapsed = ((Time.now - start_time) * 1000).round(1)
14
+
15
+ line = "[#{timestamp}] [#{elapsed}ms] #{level.to_s.upcase.ljust(5)} Taski: #{message}"
16
+
17
+ unless context.empty?
18
+ context_parts = context.map do |key, value|
19
+ "#{key}=#{format_value(value)}"
20
+ end
21
+ line += " (#{context_parts.join(", ")})"
22
+ end
23
+
24
+ line
25
+ end
26
+
27
+ private
28
+
29
+ # Format values for structured logging
30
+ def format_value(value)
31
+ case value
32
+ when String
33
+ (value.length > 50) ? "#{value[0..47]}..." : value
34
+ when Array
35
+ (value.size > 5) ? "[#{value[0..4].join(", ")}, ...]" : value.inspect
36
+ when Hash
37
+ (value.size > 3) ? "{#{value.keys[0..2].join(", ")}, ...}" : value.inspect
38
+ else
39
+ value.inspect
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Color constants for progress display
6
+ module DisplayColors
7
+ COLORS = {
8
+ reset: "\033[0m",
9
+ bold: "\033[1m",
10
+ dim: "\033[2m",
11
+ cyan: "\033[36m",
12
+ green: "\033[32m",
13
+ red: "\033[31m"
14
+ }.freeze
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "task_status"
4
+ require_relative "task_formatter"
5
+
6
+ module Taski
7
+ module Progress
8
+ # Manages the display state and coordinates display updates
9
+ class DisplayManager
10
+ def initialize(terminal, spinner, output_capture, include_captured_output: false)
11
+ @terminal = terminal
12
+ @spinner = spinner
13
+ @output_capture = output_capture
14
+ @include_captured_output = include_captured_output
15
+ @formatter = TaskFormatter.new
16
+ @completed_tasks = []
17
+ @current_display_lines = 0
18
+ end
19
+
20
+ def start_task_display(task_name)
21
+ clear_current_display
22
+ @output_capture.start
23
+ start_spinner_display(task_name)
24
+ end
25
+
26
+ def complete_task_display(task_name, duration:)
27
+ status = TaskStatus.new(name: task_name, duration: duration)
28
+ finish_task_display(status)
29
+ end
30
+
31
+ def fail_task_display(task_name, error:, duration:)
32
+ status = TaskStatus.new(name: task_name, duration: duration, error: error)
33
+ finish_task_display(status)
34
+ end
35
+
36
+ def clear_all_displays
37
+ @spinner.stop
38
+ @output_capture.stop
39
+ clear_current_display
40
+
41
+ # Display final summary of all completed tasks
42
+ if @completed_tasks.any?
43
+ @completed_tasks.each do |status|
44
+ @terminal.puts @formatter.format_completed_task(status)
45
+ end
46
+ @terminal.flush
47
+ end
48
+
49
+ @completed_tasks.clear
50
+ @current_display_lines = 0
51
+ end
52
+
53
+ private
54
+
55
+ def start_spinner_display(task_name)
56
+ @spinner.start(@terminal, task_name) do |spinner_char, name|
57
+ display_current_state(spinner_char, name)
58
+ end
59
+ end
60
+
61
+ def display_current_state(spinner_char, task_name)
62
+ clear_current_display
63
+
64
+ lines_count = 0
65
+
66
+ # Only display current task with spinner (no past completed tasks during execution)
67
+ @terminal.puts @formatter.format_current_task(spinner_char, task_name)
68
+ lines_count += 1
69
+
70
+ # Display output lines
71
+ @output_capture.last_lines.each do |line|
72
+ @terminal.puts @formatter.format_output_line(line)
73
+ lines_count += 1
74
+ end
75
+
76
+ @current_display_lines = lines_count
77
+ @terminal.flush
78
+ end
79
+
80
+ def finish_task_display(status)
81
+ @spinner.stop
82
+
83
+ # Capture output before stopping
84
+ captured_output = @output_capture.last_lines
85
+ @output_capture.stop
86
+ clear_current_display
87
+
88
+ # Include captured output if requested (typically for test environments)
89
+ if @include_captured_output && captured_output.any?
90
+ captured_output.each do |line|
91
+ @terminal.puts line.chomp
92
+ end
93
+ end
94
+
95
+ @completed_tasks << status
96
+ display_final_state
97
+ end
98
+
99
+ def display_final_state
100
+ # Only display the newly completed task (last one)
101
+ if @completed_tasks.any?
102
+ latest_task = @completed_tasks.last
103
+ @terminal.puts @formatter.format_completed_task(latest_task)
104
+ end
105
+ @terminal.flush
106
+ @current_display_lines = 1 # Only one line for the latest task
107
+ end
108
+
109
+ def clear_current_display
110
+ @terminal.clear_lines(@current_display_lines)
111
+ @current_display_lines = 0
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Progress
5
+ # Captures stdout and maintains last N lines like tail -f
6
+ class OutputCapture
7
+ MAX_LINES = 10
8
+ DISPLAY_LINES = 5
9
+
10
+ def initialize(main_output)
11
+ @main_output = main_output
12
+ @buffer = []
13
+ @capturing = false
14
+ @original_stdout = nil
15
+ @pipe_reader = nil
16
+ @pipe_writer = nil
17
+ @capture_thread = nil
18
+ end
19
+
20
+ def start
21
+ return if @capturing
22
+
23
+ @buffer.clear
24
+ setup_stdout_redirection
25
+ @capturing = true
26
+
27
+ start_capture_thread
28
+ end
29
+
30
+ def stop
31
+ return unless @capturing
32
+
33
+ @capturing = false
34
+
35
+ # Restore stdout
36
+ restore_stdout
37
+
38
+ # Clean up pipes and thread
39
+ cleanup_capture_thread
40
+ cleanup_pipes
41
+ end
42
+
43
+ def last_lines
44
+ @buffer.last(DISPLAY_LINES)
45
+ end
46
+
47
+ def capturing?
48
+ @capturing
49
+ end
50
+
51
+ private
52
+
53
+ def setup_stdout_redirection
54
+ @original_stdout = $stdout
55
+ @pipe_reader, @pipe_writer = IO.pipe
56
+ $stdout = @pipe_writer
57
+ end
58
+
59
+ def restore_stdout
60
+ return unless @original_stdout
61
+
62
+ $stdout = @original_stdout
63
+ @original_stdout = nil
64
+ end
65
+
66
+ def start_capture_thread
67
+ @capture_thread = Thread.new do
68
+ while (line = @pipe_reader.gets)
69
+ line = line.chomp
70
+ next if line.empty?
71
+ next if skip_line?(line)
72
+
73
+ add_line_to_buffer(line)
74
+ end
75
+ rescue IOError
76
+ # Pipe closed, normal termination
77
+ end
78
+ end
79
+
80
+ def skip_line?(line)
81
+ # Skip logger lines (they appear separately)
82
+ line.match?(/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\]/)
83
+ end
84
+
85
+ def add_line_to_buffer(line)
86
+ @buffer << line
87
+ @buffer.shift while @buffer.length > MAX_LINES
88
+ end
89
+
90
+ def cleanup_capture_thread
91
+ @capture_thread&.join(0.1)
92
+ @capture_thread = nil
93
+ end
94
+
95
+ def cleanup_pipes
96
+ [@pipe_writer, @pipe_reader].each do |pipe|
97
+ pipe&.close
98
+ rescue IOError
99
+ # Already closed, ignore
100
+ end
101
+ @pipe_writer = @pipe_reader = nil
102
+ end
103
+ end
104
+ end
105
+ end