ruby-resty 0.1.0 → 0.2.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.
Files changed (48) hide show
  1. data/README.md +26 -8
  2. data/Rakefile +7 -0
  3. data/lib/resty/cli.rb +21 -13
  4. data/lib/resty/commands/method_command.rb +27 -12
  5. data/lib/resty/{cli_options.rb → options.rb} +20 -12
  6. data/lib/resty/override_pry_help.rb +17 -0
  7. data/lib/resty/pretty_printer.rb +59 -0
  8. data/lib/resty/repl.rb +6 -4
  9. data/lib/resty/request.rb +18 -10
  10. data/lib/resty.rb +6 -3
  11. data/lib/version.rb +1 -1
  12. data/ruby-resty.gemspec +5 -1
  13. data/spec/lib/resty/commands/method_command_spec.rb +22 -14
  14. data/spec/lib/resty/options_spec.rb +111 -0
  15. data/spec/lib/resty/pretty_printer_spec.rb +110 -0
  16. data/spec/lib/resty/request_spec.rb +69 -45
  17. data/spec/requests/commands/method_command_request_spec.rb +150 -0
  18. data/spec/server.rb +34 -0
  19. data/spec/spec_helper.rb +48 -0
  20. data/spec/vcr/method_command/delete_returns_200.yml +79 -0
  21. data/spec/vcr/method_command/delete_sends_headers.yml +46 -0
  22. data/spec/vcr/method_command/get_non_json_response_returns_200.yml +89 -0
  23. data/spec/vcr/method_command/get_non_json_response_returns_response.yml +89 -0
  24. data/spec/vcr/method_command/get_non_json_response_sends_headers.yml +89 -0
  25. data/spec/vcr/method_command/get_returns_200.yml +79 -0
  26. data/spec/vcr/method_command/get_returns_response.yml +79 -0
  27. data/spec/vcr/method_command/get_sends_headers.yml +44 -0
  28. data/spec/vcr/method_command/get_with_per_request_headers_returns_200.yml +46 -0
  29. data/spec/vcr/method_command/get_with_per_request_headers_returns_response.yml +46 -0
  30. data/spec/vcr/method_command/get_with_per_request_headers_sends_headers.yml +46 -0
  31. data/spec/vcr/method_command/get_without_per_request_headers_returns_200.yml +46 -0
  32. data/spec/vcr/method_command/get_without_per_request_headers_returns_response.yml +46 -0
  33. data/spec/vcr/method_command/get_without_per_request_headers_sends_headers.yml +46 -0
  34. data/spec/vcr/method_command/head_returns_200.yml +79 -0
  35. data/spec/vcr/method_command/head_sends_headers.yml +46 -0
  36. data/spec/vcr/method_command/options_returns_200.yml +79 -0
  37. data/spec/vcr/method_command/options_sends_headers.yml +46 -0
  38. data/spec/vcr/method_command/patch_returns_204.yml +87 -0
  39. data/spec/vcr/method_command/patch_sends_headers.yml +50 -0
  40. data/spec/vcr/method_command/post_returns_200.yml +87 -0
  41. data/spec/vcr/method_command/post_returns_created_object.yml +87 -0
  42. data/spec/vcr/method_command/post_sends_headers.yml +50 -0
  43. data/spec/vcr/method_command/put_returns_204.yml +79 -0
  44. data/spec/vcr/method_command/put_sends_headers.yml +42 -0
  45. metadata +135 -16
  46. data/lib/resty/commands/method_output.rb +0 -43
  47. data/spec/lib/resty/cli_options_spec.rb +0 -103
  48. data/spec/lib/resty/commands/method_output_spec.rb +0 -58
@@ -0,0 +1,111 @@
1
+ require 'spec_helper'
2
+
3
+ describe Resty::Options do
4
+ context "command line options" do
5
+ let(:options) { Resty::Options.new(host: "foo.com", headers: ["key:star", "type:ninja"],
6
+ username: "leeroy", password: "jenkins", alias: "nyan") }
7
+
8
+ it "returns host" do
9
+ expect(options.host).to eq("foo.com")
10
+ end
11
+
12
+ it "returns headers" do
13
+ expect(options.headers).to eq(key: "star", type: "ninja")
14
+ end
15
+
16
+ it "doesn't read config file" do
17
+ options.should have_received(:load_config_file).never
18
+ end
19
+
20
+ it "returns username" do
21
+ expect(options.username).to eq("leeroy")
22
+ end
23
+
24
+ it "returns password" do
25
+ expect(options.password).to eq("jenkins")
26
+ end
27
+
28
+ context "empty headers" do
29
+ let(:options) { Resty::Options.new({}) }
30
+
31
+ it "returns empty hash" do
32
+ expect(options.headers).to eq({})
33
+ end
34
+ end
35
+ end
36
+
37
+ context "config file" do
38
+ context "all values exist" do
39
+ before(:each) do
40
+ YAML.stubs(:load_file).returns({"nyan" => { "host" => "nyan.cat", "username" => "leeroy",
41
+ "headers" => {"header" => "value"},
42
+ "password" => "jenkins"} } )
43
+ File.stubs(:exist?).returns(true)
44
+ @options = Resty::Options.new(alias: "nyan")
45
+ end
46
+
47
+ it "returns host" do
48
+ expect(@options.host).to eq("nyan.cat")
49
+ end
50
+
51
+ it "returns headers" do
52
+ expect(@options.headers).to eq("header" => "value")
53
+ end
54
+
55
+ it "returns username" do
56
+ expect(@options.username).to eq("leeroy")
57
+ end
58
+
59
+ it "returns password" do
60
+ expect(@options.password).to eq("jenkins")
61
+ end
62
+
63
+ it "loads YAML file" do
64
+ YAML.should have_received(:load_file).with("#{Dir.home}/.ruby_resty.yml")
65
+ end
66
+ end
67
+
68
+ context "headers don't exist" do
69
+ before(:each) do
70
+ YAML.stubs(:load_file).returns({"nyan" => { "host" => "nyan.cat" } } )
71
+ File.stubs(:exist?).returns(true)
72
+ @options = Resty::Options.new(alias: "nyan")
73
+ end
74
+
75
+ it "returns host" do
76
+ expect(@options.host).to eq("nyan.cat")
77
+ end
78
+
79
+ it "returns headers" do
80
+ expect(@options.headers).to eq({})
81
+ end
82
+
83
+ it "loads YAML file" do
84
+ YAML.should have_received(:load_file).with("#{Dir.home}/.ruby_resty.yml")
85
+ end
86
+ end
87
+
88
+ context "config file doesn't exist" do
89
+ it "raises ConfigFileError" do
90
+ File.stubs(:exist?).returns(false)
91
+ expect { Resty::Options.new(alias: "nyan") }.to raise_error(Resty::ConfigFileError)
92
+ end
93
+ end
94
+
95
+ context "alias doesn't exist" do
96
+ it "raises ConfigFileError" do
97
+ YAML.stubs(:load_file).returns({"ice_cream" => {} } )
98
+ File.stubs(:exist?).returns(true)
99
+ expect { Resty::Options.new(alias: "nyan") }.to raise_error(Resty::ConfigFileError)
100
+ end
101
+ end
102
+
103
+ context "host doesn't exist" do
104
+ it "raises error" do
105
+ YAML.stubs(:load_file).returns({"nyan" => {}})
106
+ File.stubs(:exist?).returns(true)
107
+ expect { Resty::Options.new(alias: "nyan") }.to raise_error(Resty::ConfigFileError)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,110 @@
1
+ require 'spec_helper'
2
+ require 'json'
3
+
4
+ describe Resty::PrettyPrinter do
5
+ let(:request) { stub }
6
+
7
+ context "#generate" do
8
+ context "JSON response" do
9
+ let(:response) { JSON.dump({foo: "bar"}) }
10
+
11
+ before(:each) { response.stubs(:code).returns(200) }
12
+
13
+ context "non-verbose" do
14
+ let(:printer) { Resty::PrettyPrinter.new(stub(verbose?: false),
15
+ Hashie::Mash.new(response: response, request: request)) }
16
+
17
+ it "returns output" do
18
+ output = <<-eos.unindent
19
+ > Response Code: 200
20
+ {
21
+ "foo": "bar"
22
+ }
23
+ eos
24
+
25
+ # multi-line text adds an extra \n, so let's ignore it
26
+ printer.generate.should eq(output[0..-2])
27
+ end
28
+ end
29
+
30
+ context "verbose" do
31
+ let(:printer) { Resty::PrettyPrinter.new(stub(verbose?: true),
32
+ Hashie::Mash.new(response: response, request: request)) }
33
+
34
+ before(:each) do
35
+ request.stubs(:method).returns("get")
36
+ request.stubs(:url).returns("foo.com")
37
+ request.stubs(:processed_headers).returns(header: "value", header2: "value2")
38
+ response.stubs(:headers).returns(response_header: "value", response_header2: "value2")
39
+ end
40
+
41
+ it "returns verbose output" do
42
+ output = <<-eos.unindent
43
+ > GET foo.com
44
+ > header: value
45
+ > header2: value2
46
+
47
+ > Response Code: 200
48
+ > response_header: value
49
+ > response_header2: value2
50
+ {
51
+ "foo": "bar"
52
+ }
53
+ eos
54
+
55
+ # multi-line text adds an extra \n, so let's ignore it
56
+ printer.generate.should eq(output[0..-2])
57
+ end
58
+ end
59
+ end
60
+
61
+ context "non-json response" do
62
+ let(:response) { "Ender Wiggin" }
63
+
64
+ before(:each) { response.stubs(:code).returns(200) }
65
+
66
+ context "non-verbose" do
67
+ let(:printer) { Resty::PrettyPrinter.new(stub(verbose?: false),
68
+ Hashie::Mash.new(response: response, request: request)) }
69
+
70
+ it "returns output" do
71
+ output = <<-eos.unindent
72
+ > Response Code: 200
73
+ Ender Wiggin
74
+ eos
75
+
76
+ # multi-line text adds an extra \n, so let's ignore it
77
+ printer.generate.should eq(output[0..-2])
78
+ end
79
+ end
80
+
81
+ context "verbose" do
82
+ let(:printer) { Resty::PrettyPrinter.new(stub(verbose?: true),
83
+ Hashie::Mash.new(response: response, request: request)) }
84
+
85
+ before(:each) do
86
+ request.stubs(:method).returns("get")
87
+ request.stubs(:url).returns("foo.com")
88
+ request.stubs(:processed_headers).returns(header: "value", header2: "value2")
89
+ response.stubs(:headers).returns(response_header: "value", response_header2: "value2")
90
+ end
91
+
92
+ it "returns verbose output" do
93
+ output = <<-eos.unindent
94
+ > GET foo.com
95
+ > header: value
96
+ > header2: value2
97
+
98
+ > Response Code: 200
99
+ > response_header: value
100
+ > response_header2: value2
101
+ Ender Wiggin
102
+ eos
103
+
104
+ # multi-line text adds an extra \n, so let's ignore it
105
+ printer.generate.should eq(output[0..-2])
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,88 +1,112 @@
1
1
  require 'spec_helper'
2
2
 
3
3
  describe Resty::Request do
4
- let(:cli_options) { stub(host: "foo.com", verbose?: false, headers: { header: "value" }) }
4
+ let(:options) { Resty::Options.new(host: "foo.com", headers: ["header:value"]) }
5
5
 
6
6
  context "#send_request" do
7
+ let(:request) { stub(:execute) }
8
+
7
9
  before(:each) do
8
- RestClient.stubs(:send)
10
+ RestClient::Request.stubs(:new).returns(request)
9
11
  end
10
12
 
11
- context "GET" do
13
+ context "HTTP method without body" do
12
14
  let(:params) { { method: "get", path: "/api/merchants" } }
13
15
 
14
16
  before(:each) do
15
- Resty::Request.new(cli_options, params).send_request
17
+ Resty::Request.new(options, params).send_request
16
18
  end
17
19
 
18
- it "sends request" do
19
- RestClient.should have_received(:send).with("get", "foo.com/api/merchants",
20
- {header: "value"})
21
- end
22
- end
23
-
24
- context "DELETE" do
25
- let(:params) { { method: "delete", path: "/api/merchants" } }
26
-
27
- before(:each) do
28
- Resty::Request.new(cli_options, params).send_request
20
+ it "creates request" do
21
+ RestClient::Request.should have_received(:new).with(url: "foo.com/api/merchants",
22
+ method: "get",
23
+ headers: {header: "value"})
29
24
  end
30
25
 
31
26
  it "sends request" do
32
- RestClient.should have_received(:send).with("delete", "foo.com/api/merchants",
33
- {header: "value"})
27
+ request.should have_received(:execute)
34
28
  end
35
29
  end
36
30
 
37
- context "HEAD" do
38
- let(:params) { { method: "head", path: "/api/merchants" } }
31
+ context "HTTP method with body" do
32
+ let(:params) { { method: "post", path: "/api/merchants", data: {"foo" => "bar"} } }
39
33
 
40
34
  before(:each) do
41
- Resty::Request.new(cli_options, params).send_request
35
+ Resty::Request.new(options, params).send_request
36
+ end
37
+
38
+ it "creates resource" do
39
+ RestClient::Request.should have_received(:new).with(url: "foo.com/api/merchants",
40
+ method: "post",
41
+ payload: {"foo" => "bar"},
42
+ headers: {header: "value"})
42
43
  end
43
44
 
44
45
  it "sends request" do
45
- RestClient.should have_received(:send).with("head", "foo.com/api/merchants",
46
- {header: "value"})
46
+ request.should have_received(:execute)
47
47
  end
48
48
  end
49
49
 
50
- context "OPTIONS" do
51
- let(:params) { { method: "options", path: "/api/merchants" } }
50
+ context "with basic authentication" do
51
+ let(:options) { Resty::Options.new(host: "foo.com", headers: ["header:value"],
52
+ username: "leeroy", password: "jenkins") }
53
+ let(:params) { { method: "get", path: "/api/merchants" } }
52
54
 
53
55
  before(:each) do
54
- Resty::Request.new(cli_options, params).send_request
56
+ Resty::Request.new(options, params).send_request(foo: "bar")
57
+ end
58
+
59
+ it "creates request" do
60
+ RestClient::Request.should have_received(:new).with(url: "foo.com/api/merchants",
61
+ method: "get",
62
+ user: "leeroy",
63
+ password: "jenkins",
64
+ headers: {header: "value"})
55
65
  end
56
66
 
57
67
  it "sends request" do
58
- RestClient.should have_received(:send).with("options", "foo.com/api/merchants",
59
- {header: "value"})
68
+ request.should have_received(:execute)
60
69
  end
61
70
  end
62
71
 
63
- context "PUT" do
64
- let(:params) { { method: "put", path: "/api/merchants", data: {"foo" => "bar"} } }
65
-
66
- before(:each) do
67
- Resty::Request.new(cli_options, params).send_request
72
+ context "with request options" do
73
+ context "with global headers" do
74
+ let(:options) { Resty::Options.new(host: "foo.com", headers: ["header:value"]) }
75
+ let(:params) { { method: "get", path: "/api/merchants" } }
76
+
77
+ before(:each) do
78
+ Resty::Request.new(options, params).send_request(headers: { name: "cat", age: 42})
79
+ end
80
+
81
+ it "creates request" do
82
+ RestClient::Request.should have_received(:new).with(url: "foo.com/api/merchants",
83
+ method: "get",
84
+ headers: { header: "value",
85
+ name: "cat", age: 42})
86
+ end
87
+
88
+ it "sends request" do
89
+ request.should have_received(:execute)
90
+ end
68
91
  end
69
92
 
70
- it "sends request" do
71
- RestClient.should have_received(:send).with("put", "foo.com/api/merchants",
72
- {"foo" => "bar"}, {header: "value"})
73
- end
74
- end
93
+ context "without global headers" do
94
+ let(:options) { Resty::Options.new(host: "foo.com") }
95
+ let(:params) { { method: "get", path: "/api/merchants" } }
75
96
 
76
- context "POST" do
77
- let(:params) { { method: "post", path: "/api/merchants", data: {"foo" => "bar"} } }
97
+ before(:each) do
98
+ Resty::Request.new(options, params).send_request(headers: { name: "cat", age: 42})
99
+ end
78
100
 
79
- before(:each) do
80
- Resty::Request.new(cli_options, params).send_request
81
- end
101
+ it "creates request" do
102
+ RestClient::Request.should have_received(:new).with(url: "foo.com/api/merchants",
103
+ method: "get",
104
+ headers: { name: "cat", age: 42 })
105
+ end
82
106
 
83
- it "sends request" do
84
- RestClient.should have_received(:send).with("post", "foo.com/api/merchants",
85
- {"foo" => "bar"}, {header: "value"})
107
+ it "sends request" do
108
+ request.should have_received(:execute)
109
+ end
86
110
  end
87
111
  end
88
112
  end
@@ -0,0 +1,150 @@
1
+ require 'spec_helper'
2
+
3
+ describe "method_command", :vcr do
4
+ let(:options) { Resty::Options.new(host: "localhost:4567", username: "nyan", password: "cat",
5
+ headers: ["name:nyaaa", "color:green"]) }
6
+
7
+ context "GET" do
8
+ context "with per request headers" do
9
+ before(:each) do
10
+ @result = pry_eval(options, "get /api/nyan -H age:42 -H address:space")
11
+ end
12
+
13
+ it "sends headers" do
14
+ @result.request.headers.should eq(name: "nyaaa", color: "green", age: "42", address: "space")
15
+ end
16
+
17
+ it "returns 200" do
18
+ @result.response.code.should eq(200)
19
+ end
20
+
21
+ it "returns response" do
22
+ JSON.parse(@result.response).should eq("nyan" => "cat")
23
+ end
24
+ end
25
+
26
+ context "without per request headers" do
27
+ before(:each) do
28
+ @result = pry_eval(options, "get /api/nyan")
29
+ end
30
+
31
+ it "sends headers" do
32
+ @result.request.headers.should eq(name: "nyaaa", color: "green")
33
+ end
34
+
35
+ it "returns 200" do
36
+ @result.response.code.should eq(200)
37
+ end
38
+
39
+ it "returns response" do
40
+ JSON.parse(@result.response).should eq("nyan" => "cat")
41
+ end
42
+ end
43
+
44
+ context "non-json response" do
45
+ before(:each) do
46
+ @result = pry_eval(options, "get /api/nyan?format=xml")
47
+ end
48
+
49
+ it "sends headers" do
50
+ @result.request.headers.should eq(name: "nyaaa", color: "green")
51
+ end
52
+
53
+ it "returns 200" do
54
+ @result.response.code.should eq(200)
55
+ end
56
+
57
+ it "returns response" do
58
+ @result.response.should eq("<nyan>cat</nyan>")
59
+ end
60
+ end
61
+ end
62
+
63
+ context "DELETE" do
64
+ before(:each) do
65
+ @result = pry_eval(options, "delete /api/nyan")
66
+ end
67
+
68
+ it "sends headers" do
69
+ @result.request.headers.should eq(name: "nyaaa", color: "green")
70
+ end
71
+
72
+ it "returns 200" do
73
+ @result.response.code.should eq(200)
74
+ end
75
+ end
76
+
77
+ context "HEAD" do
78
+ before(:each) do
79
+ @result = pry_eval(options, "head /api/nyan")
80
+ end
81
+
82
+ it "sends headers" do
83
+ @result.request.headers.should eq(name: "nyaaa", color: "green")
84
+ end
85
+
86
+ it "returns 200" do
87
+ @result.response.code.should eq(200)
88
+ end
89
+ end
90
+
91
+ context "OPTIONS" do
92
+ before(:each) do
93
+ @result = pry_eval(options, "options /api/nyan")
94
+ end
95
+
96
+ it "sends headers" do
97
+ @result.request.headers.should eq(name: "nyaaa", color: "green")
98
+ end
99
+
100
+ it "returns 200" do
101
+ @result.response.code.should eq(200)
102
+ end
103
+ end
104
+
105
+ context "POST" do
106
+ before(:each) do
107
+ @result = pry_eval(options, "post /api/nyan {nyan: 'cat'}")
108
+ end
109
+
110
+ it "sends headers" do
111
+ @result.request.headers.should eq(name: "nyaaa", color: "green")
112
+ end
113
+
114
+ it "returns 200" do
115
+ @result.response.code.should eq(200)
116
+ end
117
+
118
+ it "returns created object" do
119
+ JSON.parse(@result.response.body).should eq("nyan" => "cat")
120
+ end
121
+ end
122
+
123
+ context "PUT" do
124
+ before(:each) do
125
+ @result = pry_eval(options, "put /api/nyan {nyan: 'cat'}")
126
+ end
127
+
128
+ it "sends headers" do
129
+ @result.request.headers.should eq(name: "nyaaa", color: "green")
130
+ end
131
+
132
+ it "returns 204" do
133
+ @result.response.code.should eq(204)
134
+ end
135
+ end
136
+
137
+ context "PATCH" do
138
+ before(:each) do
139
+ @result = pry_eval(options, "patch /api/nyan {nyan: 'cat'}")
140
+ end
141
+
142
+ it "sends headers" do
143
+ @result.request.headers.should eq(name: "nyaaa", color: "green")
144
+ end
145
+
146
+ it "returns 204" do
147
+ @result.response.code.should eq(200)
148
+ end
149
+ end
150
+ end
data/spec/server.rb ADDED
@@ -0,0 +1,34 @@
1
+ require 'sinatra'
2
+ require 'json'
3
+
4
+ use Rack::Auth::Basic do |username, password|
5
+ username == 'nyan' && password == 'cat'
6
+ end
7
+
8
+ get '/api/nyan' do
9
+ if params[:format] == "xml"
10
+ "<nyan>cat</nyan>"
11
+ else
12
+ JSON.dump({ nyan: "cat" })
13
+ end
14
+ end
15
+
16
+ delete '/api/nyan' do
17
+ end
18
+
19
+ head '/api/nyan' do
20
+ end
21
+
22
+ options '/api/nyan' do
23
+ end
24
+
25
+ put '/api/nyan' do
26
+ [204]
27
+ end
28
+
29
+ post '/api/nyan' do
30
+ JSON.dump(params)
31
+ end
32
+
33
+ patch '/api/nyan' do
34
+ end
data/spec/spec_helper.rb CHANGED
@@ -2,15 +2,63 @@ require_relative "../lib/resty"
2
2
 
3
3
  require 'mocha/api'
4
4
  require 'bourne'
5
+ require 'vcr'
5
6
 
7
+ require 'active_support/inflector'
6
8
  require 'pry/test/helper'
7
9
 
8
10
  RSpec.configure do |c|
9
11
  c.mock_with :mocha
12
+ c.treat_symbols_as_metadata_keys_with_true_values = true
13
+
14
+ c.around(:each, :vcr) do |example|
15
+ name = example.metadata[:full_description].split(/\s+/, 2).join("/").underscore.gsub(/[^\w\/]+/, "_")
16
+ options = {}
17
+ options[:record] = example.metadata[:record] if example.metadata[:record]
18
+ VCR.use_cassette(name, options) { example.call }
19
+ end
10
20
 
11
21
  include PryTestHelpers
12
22
  end
13
23
 
24
+ VCR.configure do |c|
25
+ c.cassette_library_dir = 'spec/vcr'
26
+ c.hook_into :webmock
27
+
28
+ c.default_cassette_options = {
29
+ :record => :once,
30
+ :decode_compressed_response => true,
31
+ # Because psych is binary encoding response headers marked with ASCII-8BIT
32
+ # https://groups.google.com/forum/?fromgroups#!topic/vcr-ruby/2sKrJa86ktU
33
+ # And syck's output is much easier to read
34
+ :serialize_with => :syck,
35
+ }
36
+
37
+ # Pretty print your json so it's not all on one line
38
+ # From discussion here: https://github.com/myronmarston/vcr/pull/147
39
+ # https://gist.github.com/26edfe7669cc7b85e164
40
+ c.before_record do |i|
41
+ type = Array(i.response.headers['Content-Type']).join(',').split(';').first
42
+ code = i.response.status.code
43
+
44
+ if type =~ /[\/+]json$/ or 'text/javascript' == type
45
+ begin
46
+ data = JSON.parse i.response.body
47
+ rescue
48
+ if code != 404
49
+ puts
50
+ warn "VCR: JSON parse error for Content-type #{type}"
51
+ warn "Your unparseable json is: " + i.response.body.inspect
52
+ puts
53
+ end
54
+ else
55
+ i.response.body = JSON.pretty_generate data
56
+ i.response.update_content_length_header
57
+ end
58
+ end
59
+ end
60
+ end
61
+
14
62
  class String
15
63
  def unindent
16
64
  gsub(/^#{scan(/^\s*/).min_by{|l|l.length}}/, "")