fasti 1.0.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.
- checksums.yaml +7 -0
- data/.mcp.json +19 -0
- data/.rspec +3 -0
- data/.rubocop.yml +82 -0
- data/.rubocop_todo.yml +89 -0
- data/.serena/project.yml +68 -0
- data/.simplecov +31 -0
- data/.yardopts +9 -0
- data/AGENTS.md +60 -0
- data/CHANGELOG.md +25 -0
- data/CLAUDE.md +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +416 -0
- data/RELEASING.md +202 -0
- data/Rakefile +34 -0
- data/TODO.md +11 -0
- data/benchmark/holiday_cache_benchmark.rb +111 -0
- data/benchmark/memory_benchmark.rb +86 -0
- data/docs/agents/git-pr.md +298 -0
- data/docs/agents/languages.md +388 -0
- data/docs/agents/rubocop.md +55 -0
- data/docs/plans/positional-arguments.md +303 -0
- data/docs/plans/structured-config.md +232 -0
- data/examples/config.rb +80 -0
- data/exe/fasti +6 -0
- data/lib/fasti/calendar.rb +292 -0
- data/lib/fasti/calendar_transition.rb +266 -0
- data/lib/fasti/cli.rb +550 -0
- data/lib/fasti/config/schema.rb +36 -0
- data/lib/fasti/config/types.rb +66 -0
- data/lib/fasti/config.rb +125 -0
- data/lib/fasti/error.rb +6 -0
- data/lib/fasti/formatter.rb +234 -0
- data/lib/fasti/style_parser.rb +211 -0
- data/lib/fasti/version.rb +6 -0
- data/lib/fasti.rb +21 -0
- data/mise.toml +5 -0
- data/sig/fasti.rbs +4 -0
- metadata +181 -0
data/lib/fasti/config.rb
ADDED
@@ -0,0 +1,125 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "dry-configurable"
|
4
|
+
|
5
|
+
module Fasti
|
6
|
+
# Configuration management using dry-configurable
|
7
|
+
#
|
8
|
+
# Provides a clean interface for managing fasti configuration
|
9
|
+
# with type safety and validation.
|
10
|
+
#
|
11
|
+
# @example Basic usage
|
12
|
+
# Fasti.configure do |config|
|
13
|
+
# config.format = :quarter
|
14
|
+
# config.country = :jp
|
15
|
+
# end
|
16
|
+
#
|
17
|
+
# @example With styling
|
18
|
+
# Fasti.configure do |config|
|
19
|
+
# config.style = {
|
20
|
+
# sunday: { foreground: :red, bold: true },
|
21
|
+
# holiday: { background: :yellow }
|
22
|
+
# }
|
23
|
+
# end
|
24
|
+
class Config
|
25
|
+
extend Dry::Configurable
|
26
|
+
|
27
|
+
# Calendar display format
|
28
|
+
setting :format, default: :month, constructor: Types::Format
|
29
|
+
|
30
|
+
# Week start day
|
31
|
+
setting :start_of_week, default: :sunday, constructor: Types::StartOfWeek
|
32
|
+
|
33
|
+
# Country code for holiday detection
|
34
|
+
setting :country, default: :us, constructor: Types::Country
|
35
|
+
|
36
|
+
# Style configuration
|
37
|
+
# Accepts a hash mapping style targets to their attributes
|
38
|
+
# @param value [Hash<Symbol|String, Hash>] Style configuration hash
|
39
|
+
# @return [Hash<Symbol, Hash>] Validated and normalized style hash
|
40
|
+
setting :style, default: nil, constructor: ->(value) do
|
41
|
+
case value
|
42
|
+
when nil
|
43
|
+
nil
|
44
|
+
when Hash
|
45
|
+
validate_and_normalize_style(value)
|
46
|
+
else
|
47
|
+
raise ArgumentError, "Style must be nil or Hash, got #{value.class}"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Validates and normalizes a style configuration hash
|
52
|
+
#
|
53
|
+
# @param style_hash [Hash] Raw style configuration
|
54
|
+
# @return [Hash<Symbol, Hash>] Validated and normalized style hash
|
55
|
+
# @raise [ArgumentError] If validation fails
|
56
|
+
def self.validate_and_normalize_style(style_hash)
|
57
|
+
validated_style = {}
|
58
|
+
|
59
|
+
style_hash.each do |target, attributes|
|
60
|
+
# Validate and convert target
|
61
|
+
target_sym = Types::StyleTarget.call(target)
|
62
|
+
|
63
|
+
# Validate attributes structure
|
64
|
+
unless attributes.is_a?(Hash)
|
65
|
+
raise ArgumentError, "Style attributes for #{target} must be a Hash, got #{attributes.class}"
|
66
|
+
end
|
67
|
+
|
68
|
+
# Validate individual attributes using schema
|
69
|
+
result = Schema::StyleAttribute.call(attributes)
|
70
|
+
if result.success?
|
71
|
+
validated_style[target_sym] = TIntMe[**result.to_h]
|
72
|
+
else
|
73
|
+
errors = result.errors.to_h.map {|key, messages|
|
74
|
+
"#{key}: #{Array(messages).join(", ")}"
|
75
|
+
}.join("; ")
|
76
|
+
raise ArgumentError, "Invalid style attributes for #{target}: #{errors}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
validated_style
|
81
|
+
end
|
82
|
+
|
83
|
+
# Reset configuration to defaults
|
84
|
+
def self.reset!
|
85
|
+
configure do |config|
|
86
|
+
config.format = :month
|
87
|
+
config.start_of_week = :sunday
|
88
|
+
config.country = :us
|
89
|
+
config.style = nil
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Load configuration from a Ruby file
|
94
|
+
#
|
95
|
+
# @param file_path [String] Path to configuration file
|
96
|
+
# @return [Hash] Configuration hash loaded from file
|
97
|
+
# @raise [ConfigError] If file has syntax errors
|
98
|
+
def self.load_from_file(file_path)
|
99
|
+
return {} unless File.exist?(file_path)
|
100
|
+
|
101
|
+
begin
|
102
|
+
# Execute the configuration file in the context of Fasti
|
103
|
+
instance_eval(File.read(file_path), file_path)
|
104
|
+
config.to_h
|
105
|
+
rescue SyntaxError => e
|
106
|
+
raise ConfigError, "Invalid Ruby syntax in #{file_path}: #{e.message}"
|
107
|
+
rescue => e
|
108
|
+
raise ConfigError, "Error loading configuration from #{file_path}: #{e.message}"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
# Configuration error
|
114
|
+
class ConfigError < StandardError; end
|
115
|
+
|
116
|
+
# Convenience method for configuration
|
117
|
+
def self.configure(&)
|
118
|
+
Config.configure(&)
|
119
|
+
end
|
120
|
+
|
121
|
+
# Access current configuration
|
122
|
+
def self.config
|
123
|
+
Config.config
|
124
|
+
end
|
125
|
+
end
|
data/lib/fasti/error.rb
ADDED
@@ -0,0 +1,234 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fasti
|
4
|
+
# Handles calendar formatting and display with color-coded output.
|
5
|
+
#
|
6
|
+
# This class provides various calendar display formats (month, quarter, year)
|
7
|
+
# with ANSI color coding for holidays, weekends, and the current date.
|
8
|
+
# Holiday detection is handled by the Calendar class using the holidays gem.
|
9
|
+
#
|
10
|
+
# ## Styling
|
11
|
+
# - **Holidays**: Bold text
|
12
|
+
# - **Sundays**: Bold text
|
13
|
+
# - **Today**: Inverted background/text colors (combined with above styles)
|
14
|
+
#
|
15
|
+
# @example Basic month formatting
|
16
|
+
# formatter = Formatter.new
|
17
|
+
# calendar = Calendar.new(2024, 6, country: :jp)
|
18
|
+
# puts formatter.format_month(calendar)
|
19
|
+
#
|
20
|
+
# @example Year view
|
21
|
+
# formatter = Formatter.new
|
22
|
+
# puts formatter.format_year(2024, start_of_week: :sunday, country: :jp)
|
23
|
+
class Formatter
|
24
|
+
# Creates a new formatter instance.
|
25
|
+
#
|
26
|
+
# @param styles [Hash<Symbol, Style>] Styles for different day types
|
27
|
+
# Keys can be :sunday, :monday, ..., :saturday, :holiday, :today
|
28
|
+
# @example
|
29
|
+
# Formatter.new(styles: config.style)
|
30
|
+
def initialize(styles: {})
|
31
|
+
@styles = styles
|
32
|
+
@style_cache = {}
|
33
|
+
end
|
34
|
+
|
35
|
+
# Formats a single month calendar with headers and color coding.
|
36
|
+
#
|
37
|
+
# Displays the month/year header, day abbreviations, and calendar grid
|
38
|
+
# with appropriate color coding for holidays, weekends, and today.
|
39
|
+
#
|
40
|
+
# @param calendar [Calendar] The calendar to format
|
41
|
+
# @return [String] Formatted calendar string with ANSI color codes
|
42
|
+
#
|
43
|
+
# @example
|
44
|
+
# calendar = Calendar.new(2024, 6, country: :jp)
|
45
|
+
# formatter.format_month(calendar)
|
46
|
+
# # Output:
|
47
|
+
# # June 2024
|
48
|
+
# #
|
49
|
+
# # Su Mo Tu We Th Fr Sa
|
50
|
+
# # 1
|
51
|
+
# # 2 3 4 5 6 7 8
|
52
|
+
# # ...
|
53
|
+
def format_month(calendar)
|
54
|
+
output = []
|
55
|
+
|
56
|
+
# Month/Year header - centered
|
57
|
+
header = calendar.month_year_header
|
58
|
+
output << header.center(20)
|
59
|
+
output << ""
|
60
|
+
|
61
|
+
# Day headers
|
62
|
+
output << calendar.day_headers.join(" ")
|
63
|
+
|
64
|
+
# Calendar grid
|
65
|
+
calendar.calendar_grid.each do |week|
|
66
|
+
week_str = week.map {|day|
|
67
|
+
format_day(day, calendar)
|
68
|
+
}.join(" ")
|
69
|
+
output << week_str
|
70
|
+
end
|
71
|
+
|
72
|
+
output.join("\n")
|
73
|
+
end
|
74
|
+
|
75
|
+
# Formats three calendars side-by-side in a quarter view.
|
76
|
+
#
|
77
|
+
# Displays three months horizontally with aligned headers and grids.
|
78
|
+
# Typically used for showing current month with adjacent months.
|
79
|
+
#
|
80
|
+
# @param calendars [Array<Calendar>] Array of exactly 3 calendars to display
|
81
|
+
# @return [String] Formatted quarter view string
|
82
|
+
# @raise [ArgumentError] If not exactly 3 calendars provided
|
83
|
+
#
|
84
|
+
# @example Quarter view
|
85
|
+
# calendars = [
|
86
|
+
# Calendar.new(2024, 5, country: :jp),
|
87
|
+
# Calendar.new(2024, 6, country: :jp),
|
88
|
+
# Calendar.new(2024, 7, country: :jp)
|
89
|
+
# ]
|
90
|
+
# formatter.format_quarter(calendars)
|
91
|
+
def format_quarter(calendars)
|
92
|
+
raise ArgumentError, "Expected 3 calendars for quarter view" unless calendars.length == 3
|
93
|
+
|
94
|
+
output = []
|
95
|
+
|
96
|
+
# Headers for all three months
|
97
|
+
headers = calendars.map {|cal| cal.month_year_header.center(20) }
|
98
|
+
output << headers.join(" ")
|
99
|
+
output << ""
|
100
|
+
|
101
|
+
# Day headers for all three months
|
102
|
+
day_headers = calendars.map {|cal| cal.day_headers.join(" ") }
|
103
|
+
output << day_headers.join(" ")
|
104
|
+
|
105
|
+
# Calendar grids side by side
|
106
|
+
max_rows = calendars.map {|cal| cal.calendar_grid.length }.max
|
107
|
+
|
108
|
+
(0...max_rows).each do |row_index|
|
109
|
+
row_parts = calendars.map {|cal|
|
110
|
+
if row_index < cal.calendar_grid.length
|
111
|
+
week = cal.calendar_grid[row_index]
|
112
|
+
week.map {|day| format_day(day, cal) }.join(" ")
|
113
|
+
else
|
114
|
+
" " * 20 # Empty space for shorter months
|
115
|
+
end
|
116
|
+
}
|
117
|
+
output << row_parts.join(" ")
|
118
|
+
end
|
119
|
+
|
120
|
+
output.join("\n")
|
121
|
+
end
|
122
|
+
|
123
|
+
# Formats a complete year view with all 12 months in quarters.
|
124
|
+
#
|
125
|
+
# Displays the full year as 4 quarters, each containing 3 months
|
126
|
+
# side-by-side. Each quarter is separated by blank lines.
|
127
|
+
#
|
128
|
+
# @param year [Integer] The year to display
|
129
|
+
# @param start_of_week [Symbol] Week start preference (:sunday or :monday)
|
130
|
+
# @param country [String] Country code for holiday context
|
131
|
+
# @return [String] Formatted year view string
|
132
|
+
#
|
133
|
+
# @example Full year display
|
134
|
+
# formatter.format_year(2024, start_of_week: :sunday, country: :jp)
|
135
|
+
# # Displays all 12 months in 4 rows of 3 months each
|
136
|
+
def format_year(year, country:, start_of_week: :sunday)
|
137
|
+
output = []
|
138
|
+
|
139
|
+
# Year header
|
140
|
+
output << year.to_s.center(64)
|
141
|
+
output << ""
|
142
|
+
|
143
|
+
# Process 4 quarters (3 months each)
|
144
|
+
quarters = []
|
145
|
+
(1..12).each_slice(3) do |months|
|
146
|
+
calendars = months.map {|month| Calendar.new(year, month, start_of_week:, country:) }
|
147
|
+
quarters << format_quarter(calendars)
|
148
|
+
end
|
149
|
+
|
150
|
+
output << quarters.join("\n\n")
|
151
|
+
output.join("\n")
|
152
|
+
end
|
153
|
+
|
154
|
+
# Formats a single day with appropriate color coding.
|
155
|
+
#
|
156
|
+
# Applies ANSI styling based on the day's characteristics:
|
157
|
+
# - Today: Inverted colors (combined with other formatting)
|
158
|
+
# - Holidays: Bold text
|
159
|
+
# - Sundays: Bold text
|
160
|
+
# - Regular days: No special formatting
|
161
|
+
#
|
162
|
+
# @param day [Integer, nil] Day of the month (1-31) or nil for empty cells
|
163
|
+
# @param calendar [Calendar] Calendar context for date conversion
|
164
|
+
# @return [String] Formatted day string with ANSI codes, right-aligned to 2 characters
|
165
|
+
#
|
166
|
+
# @example
|
167
|
+
# format_day(15, calendar) #=> "15" (regular day)
|
168
|
+
# format_day(1, calendar) #=> styled " 1" with bold text (if Sunday/holiday)
|
169
|
+
# format_day(nil, calendar) #=> " " (empty cell)
|
170
|
+
# Determines applicable style targets for a given day
|
171
|
+
#
|
172
|
+
# @param day [Integer] Day of month
|
173
|
+
# @param calendar [Calendar] Calendar instance
|
174
|
+
# @return [Array<Symbol>] Sorted array of style target symbols
|
175
|
+
private def determine_style_targets(day, calendar)
|
176
|
+
return [] unless day
|
177
|
+
|
178
|
+
date = calendar.to_date(day)
|
179
|
+
return [] unless date
|
180
|
+
|
181
|
+
targets = []
|
182
|
+
|
183
|
+
# Add weekday target
|
184
|
+
weekday_key = %i[sunday monday tuesday wednesday thursday friday saturday][date.wday]
|
185
|
+
targets << weekday_key if @styles.key?(weekday_key)
|
186
|
+
|
187
|
+
# Add holiday target
|
188
|
+
targets << :holiday if calendar.holiday?(day) && @styles.key?(:holiday)
|
189
|
+
|
190
|
+
# Add today target
|
191
|
+
targets << :today if date == Date.today && @styles.key?(:today)
|
192
|
+
|
193
|
+
targets.sort
|
194
|
+
end
|
195
|
+
|
196
|
+
# Gets or creates a composed style for the given targets
|
197
|
+
#
|
198
|
+
# @param targets [Array<Symbol>] Sorted array of style target symbols
|
199
|
+
# @return [TIntMe::Style, nil] Composed style object or nil if no styling needed
|
200
|
+
private def get_composed_style(targets)
|
201
|
+
return nil if targets.empty?
|
202
|
+
|
203
|
+
cache_key = targets.dup.freeze
|
204
|
+
return @style_cache[cache_key] if @style_cache.key?(cache_key)
|
205
|
+
|
206
|
+
# Compose styles in order using reduce without initial value
|
207
|
+
# This avoids unnecessary composition with DEFAULT_STYLE
|
208
|
+
composed_style = targets.map {|target| @styles[target] }.reduce(&:>>)
|
209
|
+
|
210
|
+
@style_cache[cache_key] = composed_style
|
211
|
+
composed_style
|
212
|
+
end
|
213
|
+
|
214
|
+
private def format_day(day, calendar)
|
215
|
+
return " " unless day
|
216
|
+
|
217
|
+
date = calendar.to_date(day)
|
218
|
+
|
219
|
+
# Handle calendar transition gaps - date might be nil
|
220
|
+
unless date
|
221
|
+
# For gap days, return empty space to show the gap visually
|
222
|
+
return " "
|
223
|
+
end
|
224
|
+
|
225
|
+
day_str = day.to_s.rjust(2)
|
226
|
+
|
227
|
+
# Determine applicable style targets and get composed style from cache
|
228
|
+
targets = determine_style_targets(day, calendar)
|
229
|
+
style = get_composed_style(targets)
|
230
|
+
|
231
|
+
style ? style.call(day_str) : day_str
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
@@ -0,0 +1,211 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Fasti
|
4
|
+
# Parses style definition strings into Style objects for calendar formatting.
|
5
|
+
#
|
6
|
+
# This class handles the parsing of style definition strings in the format:
|
7
|
+
# "target:attribute=value,attribute,no-attribute target:attribute=value"
|
8
|
+
#
|
9
|
+
# Supported targets:
|
10
|
+
# - Weekdays: sunday, monday, tuesday, wednesday, thursday, friday, saturday
|
11
|
+
# - Special days: holiday, today
|
12
|
+
#
|
13
|
+
# Supported attributes:
|
14
|
+
# - Colors: foreground=color, background=color
|
15
|
+
# - Text effects: bold, italic, underline, underline=double, overline, blink, inverse, hide, faint
|
16
|
+
# - Negation: no-bold, no-italic, etc.
|
17
|
+
#
|
18
|
+
# @example Basic usage
|
19
|
+
# parser = StyleParser.new
|
20
|
+
# styles = parser.parse("sunday:bold,foreground=red holiday:bold today:inverse")
|
21
|
+
# styles[:sunday] #=> Style.new(bold: true, foreground: :red)
|
22
|
+
#
|
23
|
+
# @example With negation
|
24
|
+
# styles = parser.parse("sunday:foreground=red,no-bold saturday:no-italic")
|
25
|
+
# styles[:sunday] #=> Style.new(foreground: :red, bold: false)
|
26
|
+
class StyleParser
|
27
|
+
# Valid target names that can be used in style definitions
|
28
|
+
VALID_TARGETS = %w[sunday monday tuesday wednesday thursday friday saturday holiday today].freeze
|
29
|
+
private_constant :VALID_TARGETS
|
30
|
+
|
31
|
+
# Valid color names for foreground and background
|
32
|
+
VALID_COLORS = %w[red green blue yellow magenta cyan white black default].freeze
|
33
|
+
private_constant :VALID_COLORS
|
34
|
+
|
35
|
+
# Valid boolean attributes that can be enabled/disabled
|
36
|
+
BOOLEAN_ATTRIBUTES = %w[bold faint italic overline blink inverse conceal].freeze
|
37
|
+
private_constant :BOOLEAN_ATTRIBUTES
|
38
|
+
|
39
|
+
# Special attributes that can have specific values
|
40
|
+
SPECIAL_ATTRIBUTES = {"underline" => [true, false, :double]}.freeze
|
41
|
+
private_constant :SPECIAL_ATTRIBUTES
|
42
|
+
|
43
|
+
# Parses a style definition string into a hash of Style objects.
|
44
|
+
#
|
45
|
+
# @param style_string [String] Style definition string
|
46
|
+
# @return [Hash<Symbol, Style>] Hash mapping target symbols to Style objects
|
47
|
+
# @raise [ArgumentError] If the style string contains invalid syntax or values
|
48
|
+
#
|
49
|
+
# @example
|
50
|
+
# parse("sunday:bold,foreground=red holiday:bold today:inverse")
|
51
|
+
# #=> { sunday: Style.new(...), holiday: Style.new(...), today: Style.new(...) }
|
52
|
+
def parse(style_string)
|
53
|
+
return {} if style_string.nil? || style_string.strip.empty?
|
54
|
+
|
55
|
+
style_string.strip.split(/\s+/).each_with_object({}) do |entry, styles|
|
56
|
+
target, attributes_hash = parse_entry(entry)
|
57
|
+
styles[target] = create_style(attributes_hash)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Parses a single style entry in the format "target:attributes"
|
62
|
+
#
|
63
|
+
# @param entry [String] Single style entry
|
64
|
+
# @return [Array<Symbol, Hash>] Target symbol and attributes hash
|
65
|
+
# @raise [ArgumentError] If entry format is invalid
|
66
|
+
private def parse_entry(entry)
|
67
|
+
# Entry should not contain whitespace since it comes from split(/\s+/)
|
68
|
+
if entry.match?(/\s/)
|
69
|
+
raise ArgumentError, "Style entry should not contain whitespace: '#{entry}'"
|
70
|
+
end
|
71
|
+
|
72
|
+
parts = entry.split(":", 2)
|
73
|
+
raise ArgumentError, "Invalid style entry format: '#{entry}'" if parts.length != 2
|
74
|
+
|
75
|
+
target = parts[0]
|
76
|
+
attributes_str = parts[1]
|
77
|
+
|
78
|
+
raise ArgumentError, "Invalid target: '#{target}'" unless VALID_TARGETS.include?(target)
|
79
|
+
|
80
|
+
attributes = parse_attributes(attributes_str)
|
81
|
+
[target.to_sym, attributes]
|
82
|
+
end
|
83
|
+
|
84
|
+
# Parses attribute string into a hash of attribute names and values
|
85
|
+
#
|
86
|
+
# @param attributes_str [String] Comma-separated attribute string
|
87
|
+
# @return [Hash<String, Object>] Hash of attribute names to values
|
88
|
+
private def parse_attributes(attributes_str)
|
89
|
+
attributes_str.split(",").each_with_object({}) do |attr, attributes|
|
90
|
+
attr = attr.strip
|
91
|
+
next if attr.empty?
|
92
|
+
|
93
|
+
case attr
|
94
|
+
when /\A(?!no-)(.+)=(.+)\z/
|
95
|
+
# Handle key=value format
|
96
|
+
key = $1
|
97
|
+
value = $2
|
98
|
+
|
99
|
+
# Reject keys or values with whitespace
|
100
|
+
if key != key.strip || value != value.strip
|
101
|
+
raise ArgumentError, "Attribute keys and values cannot contain leading or trailing spaces: '#{attr}'"
|
102
|
+
end
|
103
|
+
|
104
|
+
attributes[key] = parse_attribute_value(key, value)
|
105
|
+
when /\Ano-(.+)\z/
|
106
|
+
# Handle no- prefix (negation)
|
107
|
+
attribute_name = $1
|
108
|
+
validate_boolean_attribute(attribute_name)
|
109
|
+
attributes[attribute_name] = false
|
110
|
+
else
|
111
|
+
# Handle simple boolean attributes (bold, italic, etc.)
|
112
|
+
validate_boolean_attribute(attr)
|
113
|
+
attributes[attr] = true
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Parses an attribute value based on the attribute key
|
119
|
+
#
|
120
|
+
# @param key [String] Attribute name
|
121
|
+
# @param value [String] Attribute value
|
122
|
+
# @return [Object] Parsed value (Symbol, Boolean, etc.)
|
123
|
+
# @raise [ArgumentError] If the key or value is invalid
|
124
|
+
private def parse_attribute_value(key, value)
|
125
|
+
case key
|
126
|
+
when "foreground", "background"
|
127
|
+
parse_color_value(value)
|
128
|
+
when "underline"
|
129
|
+
parse_underline_value(value)
|
130
|
+
when *BOOLEAN_ATTRIBUTES
|
131
|
+
raise ArgumentError, "Boolean attributes should not use '=' syntax. Use '#{key}' or 'no-#{key}' instead"
|
132
|
+
else
|
133
|
+
raise ArgumentError, "Unknown attribute: '#{key}'"
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
# Parses a color value (color name or hex code)
|
138
|
+
#
|
139
|
+
# @param value [String] Color value
|
140
|
+
# @return [Symbol, String] Color as symbol or hex string
|
141
|
+
# @raise [ArgumentError] If color is invalid
|
142
|
+
private def parse_color_value(value)
|
143
|
+
# Check if it's a hex color (with or without #)
|
144
|
+
if value.match?(/\A#?\h{3}(?:\h{3})?\z/)
|
145
|
+
parse_hex_color(value)
|
146
|
+
elsif VALID_COLORS.include?(value)
|
147
|
+
value.to_sym
|
148
|
+
else
|
149
|
+
raise ArgumentError, "Invalid color: '#{value}'. Valid colors: #{VALID_COLORS.join(", ")}, or hex colors " \
|
150
|
+
"(#RGB, #RRGGBB, RGB, RRGGBB)"
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
# Parses and normalizes hex color values
|
155
|
+
#
|
156
|
+
# @param value [String] Hex color value (with or without #, 3 or 6 digits)
|
157
|
+
# @return [String] Normalized 6-digit hex color with #
|
158
|
+
# @raise [ArgumentError] If hex color format is invalid
|
159
|
+
private def parse_hex_color(value)
|
160
|
+
# Remove # if present
|
161
|
+
hex_part = value.start_with?("#") ? value[1..] : value
|
162
|
+
|
163
|
+
case hex_part.length
|
164
|
+
when 3
|
165
|
+
# Expand 3-digit to 6-digit (F00 -> FF0000)
|
166
|
+
expanded = hex_part.chars.map {|c| c + c }.join
|
167
|
+
"##{expanded.upcase}"
|
168
|
+
when 6
|
169
|
+
"##{hex_part.upcase}"
|
170
|
+
else
|
171
|
+
raise ArgumentError, "Invalid hex color: '#{value}'. Use 3-digit (#RGB) or 6-digit (#RRGGBB) format"
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Parses underline attribute value
|
176
|
+
#
|
177
|
+
# @param value [String] Underline value
|
178
|
+
# @return [Symbol] Parsed underline value
|
179
|
+
# @raise [ArgumentError] If underline value is invalid
|
180
|
+
private def parse_underline_value(value)
|
181
|
+
case value
|
182
|
+
when "double"
|
183
|
+
:double
|
184
|
+
else
|
185
|
+
raise ArgumentError, "Invalid underline value: '#{value}'. Valid values: double"
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
# Validates that an attribute is a valid boolean attribute
|
190
|
+
#
|
191
|
+
# @param attribute [String] Attribute name to validate
|
192
|
+
# @raise [ArgumentError] If attribute is not a valid boolean attribute
|
193
|
+
private def validate_boolean_attribute(attribute)
|
194
|
+
valid_attributes = BOOLEAN_ATTRIBUTES + ["underline"]
|
195
|
+
return if valid_attributes.include?(attribute)
|
196
|
+
|
197
|
+
raise ArgumentError,
|
198
|
+
"Invalid boolean attribute: '#{attribute}'. Valid attributes: #{valid_attributes.join(", ")}"
|
199
|
+
end
|
200
|
+
|
201
|
+
# Creates a Style object from parsed attributes
|
202
|
+
#
|
203
|
+
# @param attributes [Hash<String, Object>] Hash of attribute names to values
|
204
|
+
# @return [Style] New Style object
|
205
|
+
private def create_style(attributes)
|
206
|
+
style_params = attributes.transform_keys(&:to_sym)
|
207
|
+
|
208
|
+
TIntMe[**style_params]
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
data/lib/fasti.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "tint_me"
|
4
|
+
require "zeitwerk"
|
5
|
+
|
6
|
+
# Manually require version for gemspec compatibility (ignored by Zeitwerk)
|
7
|
+
require_relative "fasti/version"
|
8
|
+
|
9
|
+
# Fasti - Flexible calendar application with multi-country holiday support
|
10
|
+
#
|
11
|
+
# Main namespace containing all Fasti components including configuration,
|
12
|
+
# calendar logic, formatting, and CLI interface.
|
13
|
+
module Fasti
|
14
|
+
# Setup Zeitwerk autoloader
|
15
|
+
loader = Zeitwerk::Loader.for_gem
|
16
|
+
# VERSION constant doesn't follow Zeitwerk naming conventions, so ignore it
|
17
|
+
loader.ignore("#{__dir__}/fasti/version.rb")
|
18
|
+
# CLI acronym inflection - cli.rb defines CLI constant, not Cli
|
19
|
+
loader.inflector.inflect("cli" => "CLI")
|
20
|
+
loader.setup
|
21
|
+
end
|
data/mise.toml
ADDED
data/sig/fasti.rbs
ADDED