styles 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
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