lovely_rufus 0.1.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.
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