lovely_rufus 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,67 @@
1
+ Lovely Rufus
2
+ ============
3
+
4
+ Lovely Rufus is an executable and a Ruby library for wrapping paragraphs of text in the spirit of [Par](http://www.nicemice.net/par/).
5
+
6
+
7
+
8
+ Usage
9
+ -----
10
+
11
+ Lovely Rufus can be used from the command-line by piping text through the `lovely-rufus` executable:
12
+
13
+ $ echo 'The Ballyshannon foundered off the coast of Cariboo, And down in fathoms many went the captain and the crew;' | lovely-rufus
14
+ The Ballyshannon foundered off the coast of Cariboo,
15
+ And down in fathoms many went the captain and the crew;
16
+
17
+ Lovely Rufus can also be used from Ruby code through the `TextWrapper` class:
18
+
19
+ $ irb
20
+ >> require 'lovely_rufus'
21
+ >> text = 'The Ballyshannon foundered off the coast of Cariboo, And down in fathoms many went the captain and the crew;'
22
+ >> puts LovelyRufus::TextWrapper.wrap text
23
+ The Ballyshannon foundered off the coast of Cariboo,
24
+ And down in fathoms many went the captain and the crew;
25
+
26
+ Note that `TextWrapper.wrap` can take optional desired width:
27
+
28
+ $ irb
29
+ >> require 'lovely_rufus'
30
+ >> text = 'The Ballyshannon foundered off the coast of Cariboo, And down in fathoms many went the captain and the crew;'
31
+ >> puts LovelyRufus::TextWrapper.wrap text, width: 15
32
+ The
33
+ Ballyshannon
34
+ foundered off
35
+ the coast of
36
+ Cariboo, And
37
+ down in fathoms
38
+ many went the
39
+ captain and
40
+ the crew;
41
+
42
+
43
+
44
+ Features
45
+ --------
46
+
47
+ Currently, Lovely Rufus sports the following features:
48
+
49
+ * paragraphs are wrapped to the specified width,
50
+ * one-letter words are not left at ends of lines,
51
+ * email quotes (`>`) are handled properly and normalised (`> > >` → `>>>`),
52
+ * email-quoted paragraph breaks are cleared,
53
+ * code comments (starting with `#` and `//`) are handled properly,
54
+ * multiple paragraphs are wrapped independently.
55
+
56
+
57
+
58
+ Name and history
59
+ ----------------
60
+
61
+ Lovely Rufus was created as a [Ruby Mendicant University](http://blog.majesticseacreature.com/tag/rubymendicant) project and is named after [a certain _Love Actually_ character](http://en.wikipedia.org/wiki/Love_Actually#Rufus) who’s [exceptionally good at wrapping](http://www.youtube.com/watch?v=W6E1wPwOaE4).
62
+
63
+
64
+
65
+ ---
66
+
67
+ © MMX-MMXIV Piotr Szotkowski <chastell@chastell.net>, licensed under AGPL-3.0 (see LICENCE)
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ require 'rake/testtask'
2
+ require 'reek/rake/task'
3
+ require 'rubocop/rake_task'
4
+
5
+ task default: %i[spec rubocop reek]
6
+
7
+ Rake::TestTask.new :spec do |task|
8
+ task.test_files = FileList['spec/**/*_spec.rb']
9
+ task.warning = true
10
+ end
11
+
12
+ Reek::Rake::Task.new do |task|
13
+ task.config_files = 'config/reek.yml'
14
+ task.fail_on_error = false
15
+ task.reek_opts = '--quiet'
16
+ end
17
+
18
+ Rubocop::RakeTask.new
data/bin/lovely-rufus ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/lovely_rufus'
4
+ LovelyRufus::CLIWrapper.new.run
data/config/reek.yml ADDED
@@ -0,0 +1,14 @@
1
+ IrresponsibleModule:
2
+ enabled: false
3
+
4
+ NestedIterators:
5
+ max_allowed_nesting: 2
6
+
7
+ UncommunicativeVariableName:
8
+ exclude:
9
+ - LovelyRufus::HangoutWrapper#hangout_line
10
+
11
+ UnusedParameters:
12
+ exclude:
13
+ - LovelyRufus::Layer#call
14
+ - LovelyRufus::OneLetterGluer#call
@@ -0,0 +1,17 @@
1
+ module LovelyRufus class BasicWrapper < Layer
2
+ def call wrap
3
+ @wrap = wrap
4
+ wrapped = chopped.gsub(/(.{1,#{wrap.width}})( |$\n?)/, "\\1\n")
5
+ next_layer.call Wrap[wrapped, width: wrap.width]
6
+ end
7
+
8
+ attr_reader :wrap
9
+ private :wrap
10
+
11
+ private
12
+
13
+ def chopped
14
+ words = wrap.text.split
15
+ words.map { |word| word.gsub(/(.{1,#{wrap.width}})/, '\\1 ') }.join.chop
16
+ end
17
+ end end
@@ -0,0 +1,30 @@
1
+ require 'optparse'
2
+
3
+ module LovelyRufus class CLIWrapper
4
+ def initialize args = ARGV, text_wrapper: TextWrapper
5
+ @settings = Settings.new args
6
+ @text_wrapper = text_wrapper
7
+ end
8
+
9
+ def run stream = $stdin
10
+ puts text_wrapper.wrap stream.read, width: settings.width
11
+ end
12
+
13
+ attr_reader :settings, :text_wrapper
14
+ private :settings, :text_wrapper
15
+
16
+ private
17
+
18
+ class Settings
19
+ attr_reader :width
20
+
21
+ def initialize args
22
+ @width = 72
23
+ OptionParser.new do |opts|
24
+ opts.on '-w', '--width WIDTH', Integer, 'Wrapping width' do |width|
25
+ @width = width
26
+ end
27
+ end.parse! args
28
+ end
29
+ end
30
+ end end
@@ -0,0 +1,32 @@
1
+ module LovelyRufus class HangoutWrapper < Layer
2
+ def call wrap
3
+ @wrap = wrap
4
+ final = hangout_line ? rewrapped : wrap.text
5
+ next_layer.call Wrap[final, width: wrap.width]
6
+ end
7
+
8
+ attr_reader :wrap
9
+ private :wrap
10
+
11
+ private
12
+
13
+ def hangout_line
14
+ lines.each_cons 2 do |a, b|
15
+ return a if a[/\p{space}/] and a.rindex(/\p{space}/) >= b.size
16
+ unless b == lines.last
17
+ return b if b[/\p{space}/] and b.rindex(/\p{space}/) >= a.size
18
+ end
19
+ end
20
+ end
21
+
22
+ def lines
23
+ @lines ||= wrap.text.lines.map(&:chomp)
24
+ end
25
+
26
+ def rewrapped
27
+ hangout_line << NBSP
28
+ unfolded = lines.join(' ').gsub("#{NBSP} ", NBSP)
29
+ wrapped = BasicWrapper.new.call(Wrap[unfolded, width: wrap.width]).text
30
+ HangoutWrapper.new.call(Wrap[wrapped, width: wrap.width]).text
31
+ end
32
+ end end
@@ -0,0 +1,12 @@
1
+ module LovelyRufus class Layer
2
+ def initialize next_layer = -> wrap { wrap }
3
+ @next_layer = next_layer
4
+ end
5
+
6
+ def call opts = {}
7
+ fail 'Layer subclasses must define #call'
8
+ end
9
+
10
+ attr_reader :next_layer
11
+ private :next_layer
12
+ end end
@@ -0,0 +1,7 @@
1
+ module LovelyRufus class OneLetterGluer < Layer
2
+ def call wrap
3
+ pattern = /(?<=\p{space})(&|\p{letter})\p{space}/
4
+ text = wrap.text.gsub pattern, "\\1\\2#{NBSP}"
5
+ next_layer.call Wrap[text, width: wrap.width]
6
+ end
7
+ end end
@@ -0,0 +1,27 @@
1
+ module LovelyRufus class QuoteStripper < Layer
2
+ def call wrap
3
+ @wrap = wrap
4
+ wrapped = next_layer.call stripped_wrap
5
+ quoted = wrapped.text.lines.map { |line| fixed_quote + line }.join
6
+ Wrap[quoted, width: wrapped.width + fixed_quote.size]
7
+ end
8
+
9
+ attr_reader :wrap
10
+ private :wrap
11
+
12
+ private
13
+
14
+ def fixed_quote
15
+ quote.size > 0 ? quote.delete(' ') + ' ' : ''
16
+ end
17
+
18
+ def quote
19
+ starts = wrap.text.lines.map { |line| line[/^#{QUOTES}/] }.uniq
20
+ starts.size == 1 ? starts.first || '' : ''
21
+ end
22
+
23
+ def stripped_wrap
24
+ stripped_text = wrap.text.lines.map { |line| line[quote.size..-1] }.join
25
+ Wrap[stripped_text, width: wrap.width - fixed_quote.size]
26
+ end
27
+ end end
@@ -0,0 +1,29 @@
1
+ module LovelyRufus class TextWrapper
2
+ def self.wrap text, width: 72
3
+ new(Wrap[text, width: width]).call
4
+ end
5
+
6
+ def initialize wrap
7
+ @wrap = wrap
8
+ end
9
+
10
+ def call
11
+ paras.map do |para|
12
+ chain.call(Wrap[para, width: wrap.width]).text.tr NBSP, ' '
13
+ end.join "\n"
14
+ end
15
+
16
+ attr_reader :wrap
17
+ private :wrap
18
+
19
+ private
20
+
21
+ def chain
22
+ layers = [QuoteStripper, OneLetterGluer, BasicWrapper, HangoutWrapper]
23
+ layers.reverse.reduce(-> wrap { wrap }) { |inner, outer| outer.new inner }
24
+ end
25
+
26
+ def paras
27
+ wrap.text.split(/\n#{QUOTES}?\n/).reject { |para| para[/^#{QUOTES}?$/] }
28
+ end
29
+ end end
@@ -0,0 +1,10 @@
1
+ module LovelyRufus
2
+ Wrap = Struct.new :text, :width do
3
+ class << self
4
+ undef []
5
+ def [] text = '', width: 72
6
+ new text, width
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,13 @@
1
+ require_relative 'lovely_rufus/layer'
2
+ require_relative 'lovely_rufus/basic_wrapper'
3
+ require_relative 'lovely_rufus/cli_wrapper'
4
+ require_relative 'lovely_rufus/hangout_wrapper'
5
+ require_relative 'lovely_rufus/one_letter_gluer'
6
+ require_relative 'lovely_rufus/quote_stripper'
7
+ require_relative 'lovely_rufus/text_wrapper'
8
+ require_relative 'lovely_rufus/wrap'
9
+
10
+ module LovelyRufus
11
+ NBSP = "\u00A0"
12
+ QUOTES = %r{[>/#][>/# ]*}
13
+ end
@@ -0,0 +1,20 @@
1
+ Gem::Specification.new do |gem|
2
+ gem.author = 'Piotr Szotkowski'
3
+ gem.description = 'An executable and a Ruby library for wrapping paragraphs of text.'
4
+ gem.email = 'chastell@chastell.net'
5
+ gem.homepage = 'http://github.com/chastell/lovely_rufus'
6
+ gem.license = 'AGPL-3.0'
7
+ gem.name = 'lovely_rufus'
8
+ gem.summary = 'lovely_rufus: text wrapper'
9
+ gem.version = '0.1.0'
10
+
11
+ gem.files = `git ls-files -z`.split "\0"
12
+ gem.executables = gem.files.grep(%r{^bin/}).map { |path| File.basename path }
13
+ gem.test_files = gem.files.grep %r{^spec/.*\.rb$}
14
+
15
+ gem.add_development_dependency 'bogus', '~> 0.1.4'
16
+ gem.add_development_dependency 'minitest', '~> 5.0'
17
+ gem.add_development_dependency 'minitest-focus', '~> 1.1'
18
+ gem.add_development_dependency 'reek', '~> 1.3'
19
+ gem.add_development_dependency 'rubocop', '~> 0.18.0'
20
+ end
@@ -0,0 +1,43 @@
1
+ require_relative '../spec_helper'
2
+
3
+ module LovelyRufus describe BasicWrapper do
4
+ describe '#call' do
5
+ it 'wraps text to the given width' do
6
+ text = 'I go crazy when I hear a cymbal and a hi-hat ' \
7
+ 'with a souped-up tempo'
8
+ wrap = <<-end.dedent
9
+ I go crazy when I hear
10
+ a cymbal and a hi-hat
11
+ with a souped-up tempo
12
+ end
13
+ bw = BasicWrapper.new
14
+ bw.call(Wrap[text, width: 22]).must_equal Wrap[wrap, width: 22]
15
+ end
16
+
17
+ it 'never extends past the given width, chopping words if necessary' do
18
+ text = 'I’m killing your brain like a poisonous mushroom'
19
+ wrap = <<-end.dedent
20
+ I’m
21
+ killi
22
+ ng
23
+ your
24
+ brain
25
+ like
26
+ a
27
+ poiso
28
+ nous
29
+ mushr
30
+ oom
31
+ end
32
+ bw = BasicWrapper.new
33
+ bw.call(Wrap[text, width: 5]).must_equal Wrap[wrap, width: 5]
34
+ end
35
+
36
+ it 'passes the fixed text to the next layer and returns its outcome' do
37
+ final = fake :wrap
38
+ layer = fake :layer
39
+ mock(layer).call(Wrap["I\nO\nU\n", width: 2]) { final }
40
+ BasicWrapper.new(layer).call(Wrap['I O U', width: 2]).must_equal final
41
+ end
42
+ end
43
+ end end
@@ -0,0 +1,27 @@
1
+ require_relative '../spec_helper'
2
+
3
+ module LovelyRufus describe CLIWrapper do
4
+ describe '#run' do
5
+ let(:text) { "all right: stop, collaborate and listen\n" }
6
+ let(:text_wrapper) { fake :text_wrapper, as: :class }
7
+
8
+ it 'reads the passed stream to TextWrapper and prints the results' do
9
+ stub(text_wrapper).wrap(text, width: 72) { text }
10
+ lambda do
11
+ CLIWrapper.new(text_wrapper: text_wrapper).run StringIO.new text
12
+ end.must_output text
13
+ end
14
+
15
+ it 'accepts the desired width and passes it to TextWrapper' do
16
+ wrap = <<-end.dedent
17
+ all right: stop,
18
+ collaborate and listen
19
+ end
20
+ stub(text_wrapper).wrap(text, width: 22) { wrap }
21
+ lambda do
22
+ stream = StringIO.new text
23
+ CLIWrapper.new(%w[--width 22], text_wrapper: text_wrapper).run stream
24
+ end.must_output wrap
25
+ end
26
+ end
27
+ end end
@@ -0,0 +1,25 @@
1
+ require_relative '../spec_helper'
2
+
3
+ module LovelyRufus describe HangoutWrapper do
4
+ describe '#call' do
5
+ it 'removes hangouts from the text' do
6
+ text = <<-end.dedent
7
+ I go crazy when I hear a cymbal and
8
+ a hi-hat with a souped-up tempo
9
+ end
10
+ wrap = <<-end.dedent
11
+ I go crazy when I hear a cymbal
12
+ and a hi-hat with a souped-up tempo
13
+ end
14
+ hw = HangoutWrapper.new
15
+ hw.call(Wrap[text, width: 35]).must_equal Wrap[wrap, width: 35]
16
+ end
17
+
18
+ it 'passes the fixed text to the next layer and returns its outcome' do
19
+ final = fake :wrap
20
+ layer = fake :layer
21
+ mock(layer).call(any Wrap) { final }
22
+ HangoutWrapper.new(layer).call(Wrap["I O\nU", width: 4]).must_equal final
23
+ end
24
+ end
25
+ end end
@@ -0,0 +1,26 @@
1
+ require_relative '../spec_helper'
2
+
3
+ module LovelyRufus describe OneLetterGluer do
4
+ describe '#call' do
5
+ it 'replaces spaces after one-letter words with non-break spaces' do
6
+ text = 'I go crazy when I hear a cymbal and a hi-hat'
7
+ glue = 'I go crazy when I hear a cymbal and a hi-hat'
8
+ olg = OneLetterGluer.new
9
+ olg.call(Wrap[text, width: 42]).must_equal Wrap[glue, width: 42]
10
+ end
11
+
12
+ it 'glues subsequent one-letter words' do
13
+ text = 'one-letter words in English: a, I & o'
14
+ glue = 'one-letter words in English: a, I & o'
15
+ olg = OneLetterGluer.new
16
+ olg.call(Wrap[text, width: 42]).must_equal Wrap[glue, width: 42]
17
+ end
18
+
19
+ it 'passes the fixed text to the next layer and returns its outcome' do
20
+ final = fake :wrap
21
+ layer = fake :layer
22
+ mock(layer).call(Wrap['I O U', width: 69]) { final }
23
+ OneLetterGluer.new(layer).call(Wrap['I O U', width: 69]).must_equal final
24
+ end
25
+ end
26
+ end end
@@ -0,0 +1,113 @@
1
+ require_relative '../spec_helper'
2
+
3
+ module LovelyRufus describe QuoteStripper do
4
+ describe '#call' do
5
+ it 'strips quotes and adjusts width before calling the next layer' do
6
+ quoted = <<-end.dedent
7
+ > to the extreme I rock a mic like a vandal
8
+ > light up a stage and wax a chump like a candle
9
+ end
10
+ unquoted = <<-end.dedent
11
+ to the extreme I rock a mic like a vandal
12
+ light up a stage and wax a chump like a candle
13
+ end
14
+ layer = fake :layer, call: Wrap[unquoted, width: 70]
15
+ QuoteStripper.new(layer).call Wrap[quoted, width: 72]
16
+ layer.must_have_received :call, [Wrap[unquoted, width: 70]]
17
+ end
18
+
19
+ it 'adds quotes back in (and adjusts width) before returning' do
20
+ quoted = <<-end.dedent
21
+ > take heed, ’cause I’m a lyrical poet
22
+ > Miami’s on the scene just in case you didn’t know it
23
+ end
24
+ wrap = Wrap[quoted, width: 72]
25
+ QuoteStripper.new.call(wrap).must_equal wrap
26
+ end
27
+
28
+ it 'does not touch non-quoted texts' do
29
+ plain = <<-end.dedent
30
+ my town, that created all the bass sound
31
+ enough to shake and kick holes in the ground
32
+ end
33
+ wrap = Wrap[plain, width: 72]
34
+ QuoteStripper.new.call(wrap).must_equal wrap
35
+ end
36
+
37
+ it 'does not alter text contents' do
38
+ wrap = Wrap['> foo > bar']
39
+ QuoteStripper.new.call(wrap).must_equal wrap
40
+ end
41
+
42
+ it 'strips multilevel quotes' do
43
+ quoted = <<-end.dedent
44
+ >> ’cause my style’s like a chemical spill
45
+ >> feasible rhymes that you can vision and feel
46
+ end
47
+ unquoted = <<-end.dedent
48
+ ’cause my style’s like a chemical spill
49
+ feasible rhymes that you can vision and feel
50
+ end
51
+ layer = fake :layer, call: Wrap[unquoted, width: 69]
52
+ QuoteStripper.new(layer).call Wrap[quoted, width: 72]
53
+ layer.must_have_received :call, [Wrap[unquoted, width: 69]]
54
+ end
55
+
56
+ it 'strips broken quotes properly' do
57
+ quoted = <<-end.dedent
58
+ > > >conducted and formed this is a hell of a concept
59
+ > > >we make it hype and you want to step with this
60
+ end
61
+ unquoted = <<-end.dedent
62
+ conducted and formed this is a hell of a concept
63
+ we make it hype and you want to step with this
64
+ end
65
+ layer = fake :layer, call: Wrap[unquoted, width: 68]
66
+ QuoteStripper.new(layer).call Wrap[quoted, width: 72]
67
+ layer.must_have_received :call, [Wrap[unquoted, width: 68]]
68
+ end
69
+
70
+ it 'fixes broken quotes when adding them back in' do
71
+ quoted = <<-end.dedent
72
+ > > >Shay plays on the fade,
73
+ > > >slice like a ninja
74
+ > > >cut like a razor blade
75
+ end
76
+ fixed = <<-end.dedent
77
+ >>> Shay plays on the fade,
78
+ >>> slice like a ninja
79
+ >>> cut like a razor blade
80
+ end
81
+ wrap = Wrap[quoted, width: 72]
82
+ QuoteStripper.new.call(wrap).must_equal Wrap[fixed, width: 72]
83
+ end
84
+
85
+ it 'strips // code comments' do
86
+ quoted = <<-end.dedent
87
+ // so fast other DJs say ‘damn!’
88
+ // if my rhyme was a drug I’d sell it by the gram
89
+ end
90
+ unquoted = <<-end.dedent
91
+ so fast other DJs say ‘damn!’
92
+ if my rhyme was a drug I’d sell it by the gram
93
+ end
94
+ layer = fake :layer, call: Wrap[unquoted, width: 69]
95
+ QuoteStripper.new(layer).call Wrap[quoted, width: 72]
96
+ layer.must_have_received :call, [Wrap[unquoted, width: 69]]
97
+ end
98
+
99
+ it 'strips # code comments' do
100
+ quoted = <<-end.dedent
101
+ # keep my composure when it’s time to get loose
102
+ # magnetized by the mic while I kick my juice
103
+ end
104
+ unquoted = <<-end.dedent
105
+ keep my composure when it’s time to get loose
106
+ magnetized by the mic while I kick my juice
107
+ end
108
+ layer = fake :layer, call: Wrap[unquoted, width: 70]
109
+ QuoteStripper.new(layer).call Wrap[quoted, width: 72]
110
+ layer.must_have_received :call, [Wrap[unquoted, width: 70]]
111
+ end
112
+ end
113
+ end end
@@ -0,0 +1,40 @@
1
+ require_relative '../spec_helper'
2
+
3
+ module LovelyRufus describe TextWrapper do
4
+ describe '.wrap' do
5
+ it 'wraps the passed String to 72 characters by default' do
6
+ short = 'all right: stop, collaborate and listen'
7
+ long = short + ' – Ice is back with a brand new invention'
8
+ wrap = <<-end.dedent
9
+ all right: stop, collaborate and listen
10
+ – Ice is back with a brand new invention
11
+ end
12
+ TextWrapper.wrap(short).must_equal "#{short}\n"
13
+ TextWrapper.wrap(long).must_equal wrap
14
+ end
15
+
16
+ it 'wraps the passed String to the given number of characters' do
17
+ input = 'something grabs a hold of me tightly; ' \
18
+ 'flow like a harpoon – daily and nightly'
19
+ TextWrapper.wrap(input, width: 40).must_equal <<-end.dedent
20
+ something grabs a hold of me tightly;
21
+ flow like a harpoon – daily and nightly
22
+ end
23
+ TextWrapper.wrap(input, width: 21).must_equal <<-end.dedent
24
+ something grabs
25
+ a hold of me tightly;
26
+ flow like a harpoon
27
+ – daily and nightly
28
+ end
29
+ end
30
+
31
+ it 'supports all the example use-cases' do
32
+ path = File.expand_path 'text_wrapper_spec.yml', __dir__
33
+ YAML.load_file(path).each do |spec|
34
+ width = spec.fetch('width') { 72 }
35
+ wrap = "#{spec['output']}\n"
36
+ TextWrapper.wrap(spec['input'], width: width).must_equal wrap
37
+ end
38
+ end
39
+ end
40
+ end end