hansi 0.1.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,103 @@
1
+ require 'strscan'
2
+
3
+ module Hansi
4
+ class StringRenderer
5
+ ParseError ||= Class.new(SyntaxError)
6
+
7
+ def self.render(*args, **options)
8
+ input, markup = [], {}
9
+
10
+ args.each do |arg|
11
+ if arg.respond_to? :to_hash
12
+ markup.merge! arg.to_hash
13
+ else
14
+ input << arg
15
+ end
16
+ end
17
+
18
+ new(markup, **options).render(*input)
19
+ end
20
+
21
+ def initialize(markup = {}, theme: :default, escape: ?\\, tags: false, mode: Hansi.mode)
22
+ @theme = Theme[theme]
23
+ @escape = escape
24
+ @tags = tags
25
+ @markup = markup
26
+ @mode = mode
27
+ @simple = Regexp.union(@markup.keys)
28
+ reserved = @markup.keys
29
+ reserved += [?<, ?>] if @tags
30
+ reserved << @escape if @escape
31
+ @reserved = Regexp.union(reserved)
32
+ end
33
+
34
+ def render(input, *values)
35
+ scanner = StringScanner.new(input)
36
+ insert = true
37
+ stack = []
38
+ output = String.new
39
+
40
+ until scanner.eos?
41
+ if scanner.scan(@simple)
42
+ stack.last == scanner[0] ? stack.pop : stack.push(scanner[0])
43
+ insert = true
44
+ elsif @escape and scanner.scan(/#{Regexp.escape(@escape)}(.)/)
45
+ output << color_codes(stack) if insert
46
+ output << scanner[1]
47
+ insert = false
48
+ elsif @tags and scanner.scan(/<(\/)?([^>\s]+)>/)
49
+ insert = true
50
+ if scanner[1]
51
+ unexpected(scanner[2], stack.last)
52
+ stack.pop
53
+ else
54
+ stack << scanner[2]
55
+ end
56
+ else
57
+ output << color_codes(stack) if insert
58
+ output << scanner.getch
59
+ insert = false
60
+ end
61
+ end
62
+
63
+ unexpected(nil, stack.last)
64
+ output << Hansi.reset
65
+ values.any? ? output % values : output
66
+ end
67
+
68
+ def color_codes(stack)
69
+ codes = [Hansi.reset, :default, *stack].map { |element| ansi_for(element) }
70
+ codes.compact.join
71
+ end
72
+
73
+ def describe(element)
74
+ case element
75
+ when @simple then element.inspect
76
+ when nil then "end of string"
77
+ else "</#{element}>".inspect
78
+ end
79
+ end
80
+
81
+ def unexpected(element, expected)
82
+ return if element == expected
83
+ return if element == "#" and expected.start_with?("#")
84
+ return if expected.start_with?("#{element}(") and expected.end_with?(")")
85
+ raise ParseError, "unexpected #{describe(element)}, expecting #{describe(expected)}"
86
+ end
87
+
88
+ def ansi_for(input)
89
+ case input
90
+ when /^\e/ then input
91
+ when *@markup.keys then ansi_for(@markup[input])
92
+ when nil, false then nil
93
+ when AnsiCode then input.to_ansi(mode: @mode)
94
+ else ansi_for(@theme[input])
95
+ end
96
+ end
97
+
98
+ def escape(string)
99
+ return string unless @escape
100
+ string.gsub(@reserved) { |s| "#{@escape}#{s}" }
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,66 @@
1
+ module Hansi
2
+ class Theme
3
+ def self.[](key)
4
+ case key
5
+ when self then key
6
+ when Symbol then Themes.fetch(key)
7
+ when Hash then new(**key)
8
+ when Array then key.map { |k| self[k] }.inject(:merge)
9
+ else raise ArgumentError, "cannot convert %p into a %p" % [key, self]
10
+ end
11
+ end
12
+
13
+ def self.[]=(key, value)
14
+ Themes[key.to_sym] = self[value]
15
+ end
16
+
17
+ attr_reader :rules
18
+ def initialize(*inherit, **rules)
19
+ inherited = inherit.map { |t| Theme[t].rules }.inject({}, :merge)
20
+ @rules = inherited.merge(rules).freeze
21
+ end
22
+
23
+ def ==(other)
24
+ other.class == self.class and other.rules == self.rules
25
+ end
26
+
27
+ def eql?(other)
28
+ other.class.eql?(self.class) and other.rules.eql?(self.rules)
29
+ end
30
+
31
+ def hash
32
+ rules.hash
33
+ end
34
+
35
+ def [](key)
36
+ return self[rules[key]] if rules.include? key
37
+ return self[rules[key.to_sym]] if key.respond_to? :to_sym and rules.include? key.to_sym
38
+ ColorParser.parse(key)
39
+ rescue ColorParser::IllegalValue
40
+ end
41
+
42
+ def merge(other)
43
+ other_rules = self.class[other].rules
44
+ self.class.new(**rules.merge(other_rules))
45
+ end
46
+
47
+ def to_h
48
+ mapped = rules.keys.map { |key| [key, self[key]] if self[key] }
49
+ Hash[mapped.compact]
50
+ end
51
+
52
+ def to_css(&block)
53
+ mapping = rules.keys.group_by { |key| self[key] }
54
+ mapping = mapping.map { |c,k| c.to_css(*k, &block) }
55
+ mapping.compact.join(?\n)
56
+ end
57
+
58
+ def theme_name
59
+ Themes.keys.detect { |key| Themes[key] == self }
60
+ end
61
+
62
+ def inspect
63
+ "%p[%p]" % [self.class, theme_name || rules]
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,24 @@
1
+ module Hansi
2
+ Themes ||= {
3
+ default: Theme.new,
4
+ solarized: Theme.new({
5
+ base03: "#002b36",
6
+ base02: "#073642",
7
+ base01: "#586e75",
8
+ base00: "#657b83",
9
+ base0: "#839496",
10
+ base1: "#93a1a1",
11
+ base2: "#eee8d5",
12
+ base3: "#fdf6e3",
13
+ yellow: "#b58900",
14
+ orange: "#cb4b16",
15
+ red: "#dc322f",
16
+ magenta: "#d33682",
17
+ violet: "#6c71c4",
18
+ blue: "#268bd2",
19
+ cyan: "#2aa198",
20
+ green: "#859900",
21
+ default: :base00
22
+ })
23
+ }
24
+ end
@@ -0,0 +1,3 @@
1
+ module Hansi
2
+ VERSION = '0.2.1'
3
+ end
data/lib/hansi.rb ADDED
@@ -0,0 +1,53 @@
1
+ module Hansi
2
+ TRUE_COLOR = 256**3
3
+
4
+ def self.[](*args)
5
+ ColorParser.parse(*args)
6
+ end
7
+
8
+ def self.mode
9
+ @mode ||= mode_for(ENV)
10
+ end
11
+
12
+ def self.mode=(value)
13
+ @mode = value
14
+ end
15
+
16
+ def self.mode_for(env, **options)
17
+ ModeDetector.new(env, **options).mode
18
+ end
19
+
20
+ def self.render(*input, **options)
21
+ renderer_for(input.first).render(*input, **options)
22
+ end
23
+
24
+ def self.renderer_for(input)
25
+ case input
26
+ when String then StringRenderer
27
+ when Symbol, Array then SexpRenderer
28
+ when AnsiCode then ColorRenderer
29
+ else raise ArgumentError, "don't know how to render %p" % input
30
+ end
31
+ end
32
+
33
+ def self.reset
34
+ Hansi[:reset].to_ansi
35
+ end
36
+
37
+ def self.color_names
38
+ PALETTES['web'].keys
39
+ end
40
+
41
+ require 'hansi/ansi_code'
42
+ require 'hansi/color'
43
+ require 'hansi/special'
44
+
45
+ require 'hansi/color_parser'
46
+ require 'hansi/color_renderer'
47
+ require 'hansi/mode_detector'
48
+ require 'hansi/palettes'
49
+ require 'hansi/sexp_renderer'
50
+ require 'hansi/string_renderer'
51
+ require 'hansi/theme'
52
+ require 'hansi/themes'
53
+ end
@@ -0,0 +1,17 @@
1
+ describe Hansi::ColorParser do
2
+ subject(:parser) { Hansi::ColorParser }
3
+ it { should parse("#f00") .as(red: 255) }
4
+ it { should parse("00ff00") .as(green: 255) }
5
+ it { should parse("#f") .as(red: 255, green: 255, blue: 255) }
6
+ it { should parse("#f0") .as(red: 240, green: 240, blue: 240) }
7
+ it { should parse(red: 1.0) .as(red: 255) }
8
+ it { should parse(red: 300) .as(red: 255) }
9
+ it { should parse(red: -10) .as(red: 0) }
10
+ it { should parse(:rebeccapurple) .as(red: 102, green: 51, blue: 153) }
11
+ it { should parse("\e[31m") .as(red: 194, green: 54, blue: 33) }
12
+ it { should parse("\e[38;5;208m") .as(red: 255, green: 135, blue: 0) }
13
+ it { should parse("\e[38;2;255;0;0m") .as(red: 255) }
14
+
15
+ it { should_not parse("#ffff") }
16
+ it { should_not parse(Object.new) }
17
+ end
@@ -0,0 +1,6 @@
1
+ describe Hansi::ColorRenderer do
2
+ before { Hansi.mode = 16 }
3
+ subject(:renderer) { Hansi::ColorRenderer }
4
+ it { should render(Hansi[:red]).as("\e[91m") }
5
+ it { should render(Hansi[:red], "foo").as("\e[91mfoo\e[0m") }
6
+ end
@@ -0,0 +1,46 @@
1
+ describe Hansi::Color do
2
+ subject(:color) { Hansi::Color.new(255, 0, 0) }
3
+
4
+ describe :hash do
5
+ specify { color.hash.should be == Hansi["f00"].hash }
6
+ end
7
+
8
+ describe :eql? do
9
+ specify { color.should be_eql Hansi["f00"] }
10
+ end
11
+
12
+ describe :to_s do
13
+ specify { color.to_s.should be == "#ff0000" }
14
+ end
15
+
16
+ describe :to_css_rule do
17
+ specify { color.to_css_rule.should be == "color: #ff0000;" }
18
+ end
19
+
20
+ describe :to_css do
21
+ specify { color.to_css("name") .should be == ".name {\n color: #ff0000;\n}\n" }
22
+ specify { color.to_css("name", &:to_s) .should be == "name {\n color: #ff0000;\n}\n" }
23
+ end
24
+
25
+ describe :to_web_name do
26
+ specify { color.to_web_name.should be == :red }
27
+ end
28
+
29
+ describe :closest do
30
+ specify { color.closest([color, Hansi[:orange]]) .should be == color }
31
+ specify { color.closest([Hansi[:blue], Hansi[:orange]]) .should be == Hansi[:orange] }
32
+ end
33
+
34
+ describe :to_ansi do
35
+ specify { color.to_ansi(mode: 0) .should be == "" }
36
+ specify { color.to_ansi(mode: 8) .should be == "\e[31m" }
37
+ specify { color.to_ansi(mode: 16) .should be == "\e[91m" }
38
+ specify { color.to_ansi(mode: 88) .should be == "\e[38;5;9m" }
39
+ specify { color.to_ansi(mode: 256) .should be == "\e[38;5;9m" }
40
+ specify { color.to_ansi(mode: Hansi::TRUE_COLOR) .should be == "\e[38;2;255;0;0m" }
41
+
42
+ specify "unknown mode" do
43
+ expect { color.to_ansi(mode: 99) }.to raise_error(ArgumentError)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,35 @@
1
+ describe Hansi::ModeDetector do
2
+ example do
3
+ detector = Hansi::ModeDetector.new(ENV)
4
+ expect(detector) .to be_shell_out
5
+ expect(detector.io) .to be == $stdout
6
+ expect(detector.mode) .to be_an(Integer)
7
+ end
8
+
9
+ example do
10
+ detector = Hansi::ModeDetector.new({})
11
+ expect(detector) .not_to be_shell_out
12
+ expect(detector.io) .to be_nil
13
+ expect(detector.mode) .to be == 0
14
+ end
15
+
16
+ example do
17
+ detector = Hansi::ModeDetector.new({'TERM' => 'xterm'})
18
+ expect(detector.mode).to be == 16
19
+ end
20
+
21
+ example do
22
+ detector = Hansi::ModeDetector.new({'TERM' => 'footerm-256color'})
23
+ expect(detector.mode).to be == 256
24
+ end
25
+
26
+ example do
27
+ detector = Hansi::ModeDetector.new({'TERM' => 'footerm+24bit'})
28
+ expect(detector.mode).to be == Hansi::TRUE_COLOR
29
+ end
30
+
31
+ example do
32
+ detector = Hansi::ModeDetector.new({'TERM' => 'footerm+3byte'})
33
+ expect(detector.mode).to be == Hansi::TRUE_COLOR
34
+ end
35
+ end
@@ -0,0 +1,7 @@
1
+ describe Hansi::SexpRenderer do
2
+ before { Hansi.mode = 16 }
3
+ subject(:renderer) { Hansi::SexpRenderer }
4
+ it { should render([:red, "foo"]) .as("\e[0m\e[91mfoo\e[0m") }
5
+ it { should render([:red, [:blue, "b"], "c"], join: " ") .as("\e[0m\e[91m\e[34mb\e[0m \e[0m\e[91mc\e[0m") }
6
+ it { should render([:default, "foo"], theme: :solarized) .as("\e[0m\e[90mfoo\e[0m") }
7
+ end
@@ -0,0 +1,6 @@
1
+ describe Hansi::Special do
2
+ describe :to_css_rule do
3
+ example { Hansi[:bold] .to_css_rule.should be == 'font-weight: bold;' }
4
+ example { Hansi[:fraktur] .to_css_rule.should be == '/* cannot convert <Hansi::Special:"\e[20m"> to css */' }
5
+ end
6
+ end
@@ -0,0 +1,32 @@
1
+ describe Hansi::StringRenderer do
2
+ before { Hansi.mode = 16 }
3
+ subject(:renderer) { Hansi::StringRenderer }
4
+
5
+ it { should render("foo *bar*") .as("\e[0m\e[10mfoo *bar*\e[0m") }
6
+ it { should render("<foo>") .as("\e[0m\e[10m<foo>\e[0m") }
7
+ it { should render("foo *bar*", "*" => :bold) .as("\e[0m\e[10mfoo \e[0m\e[10m\e[1mbar\e[0m") }
8
+ it { should render("foo \\*bar\\*", "*" => :bold) .as("\e[0m\e[10mfoo *bar*\e[0m") }
9
+ it { should render("<bold>foo</bold>", tags: true) .as("\e[0m\e[10m\e[1mfoo\e[0m") }
10
+ it { should render("foo", mode: 256, theme: :solarized) .as("\e[0m\e[38;5;66mfoo\e[0m") }
11
+ it { should render("*%s*", "*", "*" => :bold) .as("\e[0m\e[10m\e[1m*\e[0m") }
12
+
13
+ it { should_not render("<red>", tags: true) }
14
+ it { should_not render("*", "*" => :bold) }
15
+
16
+ describe :escape do
17
+ context "special character" do
18
+ subject(:renderer) { Hansi::StringRenderer.new({"*" => :bold}) }
19
+ it { should escape("foo *bar*").as("foo \\*bar\\*") }
20
+ end
21
+
22
+ context "nothing special" do
23
+ subject(:renderer) { Hansi::StringRenderer.new }
24
+ it { should escape("<foo> *bar*").as("<foo> *bar*") }
25
+ end
26
+
27
+ context "tags" do
28
+ subject(:renderer) { Hansi::StringRenderer.new(tags: true) }
29
+ it { should escape("<foo> bar").as("\\<foo\\> bar") }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,43 @@
1
+ describe Hansi::Theme do
2
+ subject(:theme) { Hansi::Theme.new(foo: :bar, bar: :red) }
3
+
4
+ describe :[] do
5
+ specify { Hansi::Theme[:solarized][:yellow] .should be == Hansi[181, 137, 0] }
6
+ specify { Hansi::Theme[:default][:yellow] .should be == Hansi[255, 255, 0] }
7
+ specify { Hansi::Theme[Hansi::Theme[:solarized]] .should be == Hansi::Theme[:solarized] }
8
+ specify { Hansi::Theme[red: :blue] .should be == Hansi::Theme.new(red: :blue) }
9
+ specify { Hansi::Theme[[{red: :blue}, {green: :blue}]] .should be == Hansi::Theme.new(red: :blue, green: :blue) }
10
+ specify { expect { Hansi::Theme[Object.new] }.to raise_error(ArgumentError) }
11
+ end
12
+
13
+ describe :[]= do
14
+ specify do
15
+ Hansi::Theme[:foo] = :solarized
16
+ Hansi::Theme[:foo][:yellow].should be == Hansi[181, 137, 0]
17
+ end
18
+ end
19
+
20
+ describe :merge do
21
+ specify do
22
+ new_theme = theme.merge(foo: :baz)
23
+ expect(new_theme.rules).to include(foo: :baz)
24
+ end
25
+ end
26
+
27
+ describe :hash do
28
+ specify { Hansi::Theme[:default].hash.should be == Hansi::Theme[{}].hash }
29
+ end
30
+
31
+ describe :eql? do
32
+ specify { Hansi::Theme[:default].should be_eql Hansi::Theme[{}] }
33
+ end
34
+
35
+ describe :to_h do
36
+ specify { theme.to_h.should be == { foo: Hansi[:red], bar: Hansi[:red] }}
37
+ end
38
+
39
+ describe :to_css do
40
+ specify { theme.to_css .should be == ".foo, .bar {\n color: #ff0000;\n}\n" }
41
+ specify { theme.to_css { |name| "##{name}" } .should be == "#foo, #bar {\n color: #ff0000;\n}\n" }
42
+ end
43
+ end
@@ -0,0 +1,41 @@
1
+ describe Hansi do
2
+ describe :mode_for do
3
+ specify { Hansi.mode_for({}) .should be == 0 }
4
+ specify { Hansi.mode_for({'TERM' => 'footerm-256color'}) .should be == 256 }
5
+ end
6
+
7
+ describe :color_names do
8
+ specify { Hansi.color_names.should include(:red) }
9
+ specify { Hansi.color_names.should include(:rebeccapurple) }
10
+ end
11
+
12
+ describe :render do
13
+ subject(:renderer) { Hansi }
14
+
15
+ context "string renderer" do
16
+ it { should render("foo *bar*") .as("\e[0m\e[10mfoo *bar*\e[0m") }
17
+ it { should render("<foo>") .as("\e[0m\e[10m<foo>\e[0m") }
18
+ it { should render("foo *bar*", "*" => :bold) .as("\e[0m\e[10mfoo \e[0m\e[10m\e[1mbar\e[0m") }
19
+ it { should render("foo \\*bar\\*", "*" => :bold) .as("\e[0m\e[10mfoo *bar*\e[0m") }
20
+ it { should render("<bold>foo</bold>", tags: true) .as("\e[0m\e[10m\e[1mfoo\e[0m") }
21
+ it { should render("foo", mode: 256, theme: :solarized) .as("\e[0m\e[38;5;66mfoo\e[0m") }
22
+ it { should render("*%s*", "*", "*" => :bold) .as("\e[0m\e[10m\e[1m*\e[0m") }
23
+
24
+ it { should_not render("<red>", tags: true) }
25
+ it { should_not render("*", "*" => :bold) }
26
+ end
27
+
28
+ context 'sexp renderer' do
29
+ it { should render([:red, "foo"]) .as("\e[0m\e[91mfoo\e[0m") }
30
+ it { should render([:red, [:blue, "b"], "c"], join: " ") .as("\e[0m\e[91m\e[34mb\e[0m \e[0m\e[91mc\e[0m") }
31
+ it { should render([:default, "foo"], theme: :solarized) .as("\e[0m\e[90mfoo\e[0m") }
32
+ end
33
+
34
+ context 'color renderer' do
35
+ it { should render(Hansi[:red]) .as("\e[91m") }
36
+ it { should render(Hansi[:red], "foo") .as("\e[91mfoo\e[0m") }
37
+ end
38
+
39
+ it { should_not render(Object.new) }
40
+ end
41
+ end
@@ -0,0 +1,14 @@
1
+ require 'simplecov'
2
+ require 'coveralls'
3
+
4
+ SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([
5
+ SimpleCov::Formatter::HTMLFormatter,
6
+ Coveralls::SimpleCov::Formatter
7
+ ])
8
+
9
+ SimpleCov.start do
10
+ project_name 'hansi'
11
+ coverage_dir '.coverage'
12
+ add_filter "/spec/"
13
+ add_filter "/lib/hansi/mode_detector.rb"
14
+ end
@@ -0,0 +1,10 @@
1
+ require 'tool/warning_filter'
2
+ $-w = true
3
+
4
+ require 'rspec'
5
+
6
+ RSpec.configure do |config|
7
+ config.expect_with :rspec do |c|
8
+ c.syntax = [:should, :expect]
9
+ end
10
+ end
@@ -0,0 +1,28 @@
1
+ RSpec::Matchers.define :escape do |*args|
2
+ match do |renderer|
3
+ begin
4
+ @rendered = renderer.escape(*args)
5
+ rescue Exception => e
6
+ @exception = e
7
+ false
8
+ else
9
+ if @expected ||= nil
10
+ @rendered == @expected
11
+ else
12
+ !!@rendered
13
+ end
14
+ end
15
+ end
16
+
17
+ chain :as do |result|
18
+ @expected = result
19
+ end
20
+
21
+ failure_message do |renderer|
22
+ if @exception ||= nil
23
+ "expected %p to escape %p, but got exception: %p" % [renderer, args, @exception]
24
+ else
25
+ "expected %p to escape %p as %p, but got %p" % [render, args, @expected, @rendered]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ RSpec::Matchers.define :parse do |*args|
2
+ match do |parser|
3
+ begin
4
+ @parsed = parser.parse(*args)
5
+ rescue Exception => e
6
+ @exception = e
7
+ false
8
+ else
9
+ if @expected ||= nil
10
+ @parsed == @expected
11
+ else
12
+ !!@parsed
13
+ end
14
+ end
15
+ end
16
+
17
+ chain :as do |*result|
18
+ @expected = parser.parse(*result)
19
+ end
20
+
21
+ failure_message do |parser|
22
+ if @exception ||= nil
23
+ "expected %p to parse %p, but got exception: %p" % [parser, args, @exception]
24
+ else
25
+ "expected %p to parse %p as %p, but got %p" % [parser, args, @expected, @parsed]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ RSpec::Matchers.define :render do |*args, **options|
2
+ match do |renderer|
3
+ begin
4
+ @rendered = renderer.render(*args, **options)
5
+ rescue Exception => e
6
+ @exception = e
7
+ false
8
+ else
9
+ if @expected ||= nil
10
+ @rendered == @expected
11
+ else
12
+ !!@rendered
13
+ end
14
+ end
15
+ end
16
+
17
+ chain :as do |result|
18
+ @expected = result
19
+ end
20
+
21
+ failure_message do |renderer|
22
+ if @exception ||= nil
23
+ "expected %p to render %p, but got exception: %p" % [renderer, args, @exception]
24
+ else
25
+ "expected %p to render %p as %p, but got %p" % [render, args, @expected, @rendered]
26
+ end
27
+ end
28
+ end
data/spec/support.rb ADDED
@@ -0,0 +1,6 @@
1
+ require 'support/env'
2
+ require 'support/coverage'
3
+ require 'support/escape_matcher'
4
+ require 'support/parse_matcher'
5
+ require 'support/render_matcher'
6
+ require 'hansi'