styles 0.0.1

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.
Files changed (65) hide show
  1. data/.gitignore +18 -0
  2. data/Gemfile +3 -0
  3. data/LICENSE.txt +22 -0
  4. data/README.md +725 -0
  5. data/Rakefile +9 -0
  6. data/bin/styles +11 -0
  7. data/lib/styles.rb +18 -0
  8. data/lib/styles/application.rb +190 -0
  9. data/lib/styles/colors.rb +289 -0
  10. data/lib/styles/core_ext.rb +12 -0
  11. data/lib/styles/engine.rb +73 -0
  12. data/lib/styles/line.rb +55 -0
  13. data/lib/styles/properties.rb +34 -0
  14. data/lib/styles/properties/background_color.rb +15 -0
  15. data/lib/styles/properties/base.rb +68 -0
  16. data/lib/styles/properties/border.rb +147 -0
  17. data/lib/styles/properties/color.rb +16 -0
  18. data/lib/styles/properties/display.rb +10 -0
  19. data/lib/styles/properties/font_weight.rb +13 -0
  20. data/lib/styles/properties/function.rb +7 -0
  21. data/lib/styles/properties/margin.rb +83 -0
  22. data/lib/styles/properties/match_background_color.rb +28 -0
  23. data/lib/styles/properties/match_color.rb +21 -0
  24. data/lib/styles/properties/match_font_weight.rb +23 -0
  25. data/lib/styles/properties/match_text_decoration.rb +36 -0
  26. data/lib/styles/properties/padding.rb +81 -0
  27. data/lib/styles/properties/text_align.rb +10 -0
  28. data/lib/styles/properties/text_decoration.rb +20 -0
  29. data/lib/styles/properties/width.rb +11 -0
  30. data/lib/styles/rule.rb +67 -0
  31. data/lib/styles/stylesheet.rb +103 -0
  32. data/lib/styles/sub_engines.rb +4 -0
  33. data/lib/styles/sub_engines/base.rb +16 -0
  34. data/lib/styles/sub_engines/color.rb +115 -0
  35. data/lib/styles/sub_engines/layout.rb +158 -0
  36. data/lib/styles/sub_engines/pre_processor.rb +19 -0
  37. data/lib/styles/version.rb +3 -0
  38. data/styles.gemspec +26 -0
  39. data/test/application_test.rb +92 -0
  40. data/test/colors_test.rb +162 -0
  41. data/test/engine_test.rb +59 -0
  42. data/test/integration_test.rb +136 -0
  43. data/test/line_test.rb +24 -0
  44. data/test/properties/background_color_test.rb +36 -0
  45. data/test/properties/base_test.rb +11 -0
  46. data/test/properties/border_test.rb +154 -0
  47. data/test/properties/color_test.rb +28 -0
  48. data/test/properties/display_test.rb +26 -0
  49. data/test/properties/font_weight_test.rb +24 -0
  50. data/test/properties/function_test.rb +28 -0
  51. data/test/properties/margin_test.rb +98 -0
  52. data/test/properties/match_background_color_test.rb +71 -0
  53. data/test/properties/match_color_test.rb +79 -0
  54. data/test/properties/match_font_weight_test.rb +34 -0
  55. data/test/properties/match_text_decoration_test.rb +38 -0
  56. data/test/properties/padding_test.rb +87 -0
  57. data/test/properties/text_align_test.rb +107 -0
  58. data/test/properties/text_decoration_test.rb +25 -0
  59. data/test/properties/width_test.rb +41 -0
  60. data/test/rule_test.rb +39 -0
  61. data/test/stylesheet_test.rb +245 -0
  62. data/test/sub_engines/color_test.rb +144 -0
  63. data/test/sub_engines/layout_test.rb +110 -0
  64. data/test/test_helper.rb +5 -0
  65. metadata +184 -0
@@ -0,0 +1,9 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rake/testtask'
3
+
4
+ task :default => [:test]
5
+
6
+ Rake::TestTask.new do |t|
7
+ t.libs << 'lib'
8
+ t.pattern = 'test/**/*_test.rb'
9
+ end
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ begin
4
+ require 'styles'
5
+ rescue LoadError
6
+ executable = File.symlink?(__FILE__) ? File.readlink(__FILE__) : __FILE__
7
+ $:.unshift File.join(File.dirname(executable), '..', 'lib')
8
+ require 'styles'
9
+ end
10
+
11
+ Styles::Application.new.run
@@ -0,0 +1,18 @@
1
+ module Styles
2
+
3
+ # Raised when a stylesheet cannot be loaded, either because it does not
4
+ # exist or because it is malformed.
5
+ class StylesheetLoadError < LoadError
6
+ end
7
+ end
8
+
9
+ require 'styles/version'
10
+ require 'styles/core_ext'
11
+ require 'styles/colors'
12
+ require 'styles/line'
13
+ require 'styles/engine'
14
+ require 'styles/sub_engines'
15
+ require 'styles/properties'
16
+ require 'styles/rule'
17
+ require 'styles/stylesheet'
18
+ require 'styles/application'
@@ -0,0 +1,190 @@
1
+ require 'optparse'
2
+ require 'pathname'
3
+
4
+ module Styles
5
+ class Application
6
+ STYLESHEET_CHECK_INTERVAL_SECONDS = 2
7
+
8
+ def initialize(options={})
9
+ @input_stream = options[:input_stream] || $stdin
10
+ @output_stream = options[:output_stream] || $stdout
11
+ @quiet = false
12
+ @last_reload_error_message = nil
13
+ end
14
+
15
+ def run
16
+ create_stylesheets_dir
17
+ parse_args
18
+ read_stylesheets
19
+ process
20
+ end
21
+
22
+ private
23
+
24
+ attr_accessor :last_stylesheet_check_time
25
+ attr_reader :input_stream, :output_stream
26
+
27
+ def process
28
+ ['INT', 'TERM'].each { |signal| trap(signal) { exit } }
29
+
30
+ while process_next_line
31
+ # no-op
32
+ end
33
+ end
34
+
35
+ def process_next_line
36
+ reload_stylesheets_if_outdated if check_interval_elapsed?
37
+
38
+ line = input_stream.gets
39
+ return nil unless line
40
+ result = engine.process line
41
+ output_stream.puts result if result
42
+ line
43
+ end
44
+
45
+ def engine
46
+ @engine ||= Styles::Engine.new(stylesheets)
47
+ end
48
+
49
+ def stylesheets
50
+ @stylesheets ||= []
51
+ end
52
+
53
+ def stylesheet_names
54
+ @stylesheet_names ||= []
55
+ end
56
+
57
+ def parse_args
58
+ OptionParser.new do |opts|
59
+ opts.on('--edit [NAME]', 'Edit stylesheet with given NAME (\'default\' by default)') do |name|
60
+ safe_exec which_editor, stylesheet_file(name ||= 'default')
61
+ end
62
+ opts.on('--list', 'List names of available stylesheets') do
63
+ Dir.entries(stylesheets_dir).grep(/\.rb$/).each { |ss| puts ss.sub(/\.rb$/, '') }
64
+ exit
65
+ end
66
+ opts.on('--quiet', 'Suppress stylesheet warning messages') do
67
+ @quiet = true
68
+ end
69
+ opts.on_tail('-h', '--help', 'Show this message') do
70
+ puts opts
71
+ exit
72
+ end
73
+ opts.on_tail('--version', 'Show version') do
74
+ puts "Styles version: #{Styles::VERSION}"
75
+ exit
76
+ end
77
+ end.parse!
78
+
79
+ stylesheet_names.concat ARGV.dup
80
+ end
81
+
82
+ def read_stylesheets
83
+ stylesheet_names << 'default' if stylesheet_names.empty?
84
+
85
+ stylesheet_names.each do |name|
86
+ file = stylesheet_file(name)
87
+ begin
88
+ stylesheets << ::Styles::Stylesheet.new(file)
89
+ self.last_stylesheet_check_time = Time.now
90
+ print_stylesheet_warnings
91
+ rescue ::Styles::StylesheetLoadError => e
92
+ $stderr.puts e.message
93
+ exit 1
94
+ end
95
+ end
96
+ end
97
+
98
+ def reload_stylesheets_if_outdated
99
+ before_times = stylesheets.map(&:last_updated).sort
100
+
101
+ begin
102
+ stylesheets.each &:reload_if_outdated
103
+ @last_reload_error_message = nil
104
+ rescue ::Styles::StylesheetLoadError => e
105
+ unless @last_reload_error_message == e.message
106
+ $stderr.puts e.message
107
+ @last_reload_error_message = e.message
108
+
109
+ $stderr.puts "\nFix the above errors to have output formatted correctly\n" +
110
+ "Proceeding with previously working stylesheet rules in 5 seconds..."
111
+ sleep 5
112
+ end
113
+ end
114
+ self.last_stylesheet_check_time = Time.now
115
+
116
+ after_times = stylesheets.map(&:last_updated).sort
117
+ print_stylesheet_warnings unless before_times == after_times
118
+ end
119
+
120
+ def check_interval_elapsed?
121
+ (last_stylesheet_check_time + STYLESHEET_CHECK_INTERVAL_SECONDS) <= Time.now
122
+ end
123
+
124
+ def which_editor
125
+ editor = ENV['STYLES_EDITOR'] || ENV['EDITOR']
126
+ return editor unless editor.nil?
127
+
128
+ %w[subl mate].each {|e| return e if which(e) }
129
+
130
+ '/usr/bin/vim'
131
+ end
132
+
133
+ def which(cmd)
134
+ dir = ENV['PATH'].split(':').find {|p| File.executable? File.join(p, cmd)}
135
+ Pathname.new(File.join(dir, cmd)) unless dir.nil?
136
+ end
137
+
138
+ # Properly quote and evaluate of environment variables in the cmd parameter. This
139
+ # and a few other things related to firing up an editor are borrowed or pretty much
140
+ # cribbed from Homebrew (github.com/mxcl/homebrew).
141
+ def safe_exec(cmd, *args)
142
+ exec "/bin/sh", "-i", "-c", cmd + ' "$@"', "--", *args
143
+ end
144
+
145
+ def create_stylesheets_dir
146
+ return if File.directory? stylesheets_dir
147
+ if File.exist? stylesheets_dir
148
+ # TODO: raise a custom exception that is caught and outputs something nice
149
+ raise "Not a directory: #{stylesheets_dir}"
150
+ else
151
+ Dir.mkdir stylesheets_dir
152
+ end
153
+ end
154
+
155
+ def stylesheet_file(name)
156
+ File.join(stylesheets_dir, "#{name}.rb")
157
+ end
158
+
159
+ def stylesheets_dir
160
+ @stylesheets_dir ||= ENV['STYLES_DIR'] || File.join(home_dir, '.styles')
161
+ end
162
+
163
+ def home_dir
164
+ @home_dir ||= begin
165
+ home = ENV['HOME']
166
+ home = ENV['USERPROFILE'] unless home
167
+ if !home && (ENV['HOMEDRIVE'] && ENV['HOMEPATH'])
168
+ home = File.join(ENV['HOMEDRIVE'], ENV['HOMEPATH'])
169
+ end
170
+ home = File.expand_path('~') unless home
171
+ home = 'C:/' if !home && RUBY_PLATFORM =~ /mswin|mingw/
172
+ home
173
+ end
174
+ end
175
+
176
+ def quiet?; @quiet end
177
+
178
+ def print_stylesheet_warnings
179
+ unless quiet?
180
+ stylesheets.each do |sheet|
181
+ unless sheet.unrecognized_property_names.empty?
182
+ props = sheet.unrecognized_property_names
183
+ name_list = props.map { |p| "'#{p.to_s}'" }.join(' ')
184
+ $stderr.puts "(styles) Warning: unrecognized #{props.size > 1 ? 'properties' : 'property'} #{name_list} in #{sheet.file_path}"
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,289 @@
1
+ require 'term/ansicolor'
2
+
3
+ module Styles
4
+ # Basically a wrapper around Term::ANSIColor but also adds combination foreground and background
5
+ # colors (e.g. :red_on_white). Returns nil with an invalid color specification.
6
+ class Colors
7
+ # Map CSS-style value to ANSI code name, where they are different
8
+ CSS_TO_ANSI_VALUES = {
9
+ :line_through => :strikethrough
10
+ }.freeze
11
+
12
+ FOREGROUND_COLOR_VALUES = [
13
+ :black, :red, :green, :yellow, :blue, :magenta, :cyan, :white
14
+ ].freeze
15
+
16
+ BACKGROUND_COLOR_VALUES = [
17
+ :on_black, :on_red, :on_green, :on_yellow, :on_blue, :on_magenta, :on_cyan, :on_white
18
+ ].freeze
19
+
20
+ TEXT_DECORATION_VALUES = [:underline, :strikethrough, :blink].freeze
21
+
22
+ COLOR_VALUES = (FOREGROUND_COLOR_VALUES + BACKGROUND_COLOR_VALUES).freeze
23
+ OTHER_STYLE_VALUES = [:bold, :italic, :underline, :underscore, :blink, :strikethrough]
24
+
25
+ # Only :reset is available to represent the complete absence of color and styling. There are no
26
+ # fine-grained negative codes to just remove foreground color or just remove bold. Our API
27
+ # should provide these to allow these kind of fine-grained transitions to other color states.
28
+ NEGATIVE_PSEUDO_VALUES = [
29
+ :no_fg_color, :no_bg_color, :no_bold, :no_italic, :no_text_decoration,
30
+ :no_underline, :no_blink, :no_strikethrough
31
+ ].freeze
32
+
33
+ VALID_VALUES = (::Term::ANSIColor.attributes + [:none] + CSS_TO_ANSI_VALUES.keys).freeze
34
+ VALID_VALUES_AND_PSEUDO_VALUES = (VALID_VALUES + NEGATIVE_PSEUDO_VALUES).freeze
35
+
36
+ # Retrieve color codes with the corresponding symbol. Can be basic colors like :red or
37
+ # "compound" colors specifying foreground and background colors like :red_on_blue.
38
+ #
39
+ # Any number of colors can be specified, either as multiple arguments or in an array.
40
+ def self.[](*colors)
41
+ colors.flatten!
42
+ valid_colors = []
43
+ colors.each do |color|
44
+ if is_valid_basic_value? color
45
+ valid_colors << (CSS_TO_ANSI_VALUES[color] || color)
46
+ elsif color_parts = is_compound_color?(color)
47
+ valid_colors += color_parts
48
+ end
49
+ end
50
+
51
+ unless valid_colors.empty?
52
+ valid_colors.uniq!
53
+ valid_colors.sort!
54
+ valid_colors.unshift(:reset) if valid_colors.delete(:reset)
55
+
56
+ valid_colors.inject('') { |str, color| str += ansi_color.send(color) }
57
+ end
58
+ end
59
+
60
+ class << self
61
+ alias_method :c, :[]
62
+ end
63
+
64
+ # Apply any valid colors to a string and auto-reset (if any colors applied). Does not apply
65
+ # colors to an empty string.
66
+ def self.color(string, *colors)
67
+ return string if string.nil? or string.empty?
68
+ colors.flatten!
69
+ colors.reject! { |col| col == :none || !VALID_VALUES.include?(col) }
70
+ if colors.any?
71
+ "#{colors.map { |col| c(col) }.join}#{string}#{c(:reset)}"
72
+ else
73
+ string
74
+ end
75
+ end
76
+
77
+ # Apply any valid colors to a string and auto-reset (if any colors applied). If there are any
78
+ # resets in the middle of the string, reapply the colors after them.
79
+ def self.force_color(string, *colors)
80
+ return string if string.nil? or string.empty?
81
+ colors.flatten!
82
+ colors.reject! { |col| col == :none || !VALID_VALUES.include?(col) }
83
+ if colors.any?
84
+ codes = colors.map { |col| c(col) }.join
85
+ "#{codes}#{string.gsub(c(:reset), c(:reset) + codes)}#{c(:reset)}"
86
+ else
87
+ string
88
+ end
89
+ end
90
+
91
+ def self.valid?(color)
92
+ is_valid_basic_value?(color) || is_compound_color?(color)
93
+ end
94
+
95
+ def self.is_basic_color?(color)
96
+ COLOR_VALUES.include?(color)
97
+ end
98
+
99
+ # Returns an array of colors if the given symbol represents a compound color.
100
+ # Returns nil otherwise.
101
+ def self.is_compound_color?(color)
102
+ if color.to_s =~ /(\w+)_on_(\w+)/
103
+ colors = [$1.to_sym, "on_#{$2}".to_sym]
104
+ if colors.all? { |c| COLOR_VALUES.include? c }
105
+ colors
106
+ end
107
+ end
108
+ end
109
+
110
+ # Gives a pair of color codes for transitions into and out of a colored substring in the
111
+ # middle of a possibly differently colored line.
112
+ def self.line_substring_color_transitions(line_colors, substring_colors)
113
+ line_colors, substring_colors = Array(line_colors), Array(substring_colors)
114
+
115
+ implied_substring_colors = []
116
+ line_colors.each do |line_col|
117
+ cat = category(line_col)
118
+ replaced = substring_colors.any? { |substr_col| category(substr_col) == cat}
119
+ implied_substring_colors << line_col unless replaced
120
+ end
121
+
122
+ [
123
+ color_transition(line_colors, substring_colors, false),
124
+ color_transition(substring_colors + implied_substring_colors, line_colors)
125
+ ]
126
+ end
127
+
128
+ # Produces a string of color codes to transition from one set of colors to another.
129
+ #
130
+ # If hard is true then all foregound and background colors are reset before adding the after
131
+ # colors. In other words, no colors are allowed to continue, even if not replaced.
132
+ #
133
+ # If hard is false then colors that are not explicitly replaced by new colors are not reset.
134
+ # This means that if there are foreground and background before colors and only a foreground
135
+ # after color then even though the foreground color is replaced by the new one the background
136
+ # color is allowed to continue and is not explicitly reset.
137
+ #
138
+ # Regardless of whether all colors are reset, output of unnecessary codes is avoided. This
139
+ # means, for example, that if any before colors are replaced by new colors of the same
140
+ # category (foreground, background, underline, etc.) then there will never be an explicit
141
+ # reset because that would be redundant and merely add more characters.
142
+ def self.color_transition(before_colors, after_colors, hard=true)
143
+ before_colors, after_colors = Array(before_colors), Array(after_colors)
144
+
145
+ before_categories, after_categories = categorize(before_colors), categorize(after_colors)
146
+
147
+ # Nothing to do if before and after colors are the same
148
+ return '' if before_categories == after_categories
149
+
150
+ transition = ''
151
+ should_reset = false
152
+ colors_to_apply = []
153
+
154
+ # Explicit reset is necessary if we want a hard transition and all colors in all
155
+ # categories are not replaced.
156
+ if hard
157
+ before_categories.each_pair do |cat, before_color|
158
+ next if negative?(before_color)
159
+ after_color = after_categories[cat]
160
+ if !after_color || negative?(after_color)
161
+ should_reset = true
162
+ break
163
+ end
164
+ end
165
+ end
166
+
167
+ # If soft transition then the only time we need an explicit reset is when we have a color
168
+ # in a category that is explicitly turned off with a negative value. This also applies
169
+ # to hard transitions.
170
+ unless should_reset
171
+ after_categories.each_pair do |cat, after_color|
172
+ before_color = before_categories[cat]
173
+ if before_color && negative?(after_color) && !negative?(before_color)
174
+ should_reset = true
175
+ break
176
+ end
177
+ end
178
+ end
179
+
180
+ after_categories.each_pair do |cat, after_color|
181
+ before_color = before_categories[cat]
182
+ if !negative?(after_color)
183
+ transition << c(after_color) unless before_color == after_color && !should_reset
184
+ end
185
+ end
186
+
187
+ # If we are resetting but using a soft transition then all colors execept negated ones
188
+ # need to be set again after the reset.
189
+ if should_reset && !hard
190
+ before_categories.values.each do |color|
191
+ unless negative?(color) || after_categories.values.include?(negate(color))
192
+ transition << c(color) unless after_categories.keys.include?(category(color))
193
+ end
194
+ end
195
+ end
196
+
197
+ transition.prepend(c(:reset)) if should_reset
198
+ transition
199
+ end
200
+
201
+ def self.negative?(color)
202
+ NEGATIVE_PSEUDO_VALUES.include?(color)
203
+ end
204
+
205
+ def self.uncolor(string)
206
+ ansi_color.uncolor(string)
207
+ end
208
+
209
+ private
210
+
211
+ # Exists to support color_transition - it's a bit specialized. Takes an array of colors and
212
+ # puts them in a hash, category => value. The extra things that this does is
213
+ # 1. translates the no_text_decoration negative pseudo-value into all of the negative values
214
+ # in the text_decoration category
215
+ # 2. if there is any text_decoration category value then it adds negations for the other
216
+ # text_decoration values - this is because even though they don't replace each other in
217
+ # terminals (for example: adding blink doesn't turn off existing underline) but we want
218
+ # them to in order to match CSS-style behavior so we need to add this in
219
+ #
220
+ # TODO: refactor this text_decoration stuff and probably move it elsewhere so it makes more
221
+ # sense - it could be cleaner and this probably isn't the place for it
222
+ def self.categorize(colors)
223
+ categories = {}
224
+
225
+ if categories.delete(:no_text_decoration)
226
+ TEXT_DECORATION_VALUES.each do |td|
227
+ categories[td] = negate(td)
228
+ end
229
+ end
230
+
231
+ colors.each { |color| categories[category(color)] = color }
232
+
233
+ TEXT_DECORATION_VALUES.each do |td|
234
+ if categories.values.include?(td)
235
+ (TEXT_DECORATION_VALUES - [td]).each do |other_td|
236
+ categories[other_td] = negate(other_td) unless categories.values.include?(other_td)
237
+ end
238
+ end
239
+ end
240
+
241
+ categories
242
+ end
243
+
244
+ # Get the category of a color.
245
+ #
246
+ # Foreground colors are in the :fg_color category, background colors in :bg_color. Other style
247
+ # "colors" are in their own category (:bold, :underline, etc.). Negative pseudo-values are in
248
+ # the category they negate, so :no_fg_color is in :fg_color and :no_bold is in :bold.
249
+ def self.category(color)
250
+ return nil unless VALID_VALUES_AND_PSEUDO_VALUES.include?(color)
251
+
252
+ if FOREGROUND_COLOR_VALUES.include?(color) || color == :no_fg_color
253
+ :fg_color
254
+ elsif BACKGROUND_COLOR_VALUES.include?(color) || color == :no_bg_color
255
+ :bg_color
256
+ else
257
+ color.to_s.sub(/^no_/, '').to_sym
258
+ end
259
+ end
260
+
261
+ # Get the negative pseudo-value for a color or style. Compound colors and already
262
+ # negative values cannot be negated.
263
+ def self.negate(color)
264
+ return nil unless is_valid_basic_value?(color)
265
+
266
+ if FOREGROUND_COLOR_VALUES.include?(color)
267
+ :no_fg_color
268
+ elsif BACKGROUND_COLOR_VALUES.include?(color)
269
+ :no_bg_color
270
+ else
271
+ color.to_s.prepend('no_').to_sym
272
+ end
273
+ end
274
+
275
+ # Is this a valid non-compound value? Includes colors but also stuff like :bold and
276
+ # other non-color things.
277
+ def self.is_valid_basic_value?(color)
278
+ VALID_VALUES.include?(color)
279
+ end
280
+
281
+ def self.valid_value_or_pseudo_value?(value)
282
+ VALID_VALUES_AND_PSEUDO_VALUES.include?(value)
283
+ end
284
+
285
+ def self.ansi_color
286
+ ::Term::ANSIColor
287
+ end
288
+ end
289
+ end