honeybadger 2.0.0.beta.7 → 2.0.0.beta.8

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.
@@ -0,0 +1,148 @@
1
+ $:.unshift(File.expand_path('../../../../vendor/inifile/lib', __FILE__))
2
+
3
+ require 'honeybadger/cli/helpers'
4
+
5
+ module Honeybadger
6
+ module CLI
7
+ class Heroku < Thor
8
+ include Helpers
9
+
10
+ class_option :app, aliases: :'-a', type: :string, default: nil, desc: 'Specify optional Heroku APP'
11
+
12
+ desc 'install_deploy_notification', 'Install Heroku deploy notifications addon'
13
+ option :api_key, aliases: :'-k', type: :string, desc: 'Api key of your Honeybadger application'
14
+ option :environment, aliases: :'-e', type: :string, desc: 'Environment of your Heroku application (i.e. "production", "staging")'
15
+ def install_deploy_notification
16
+ app = options.has_key?('app') ? options['app'] : detect_heroku_app(false)
17
+ rails_env = options['environment'] || heroku_var('RAILS_ENV', app)
18
+ api_key = options['api_key'] || heroku_var('HONEYBADGER_API_KEY', app)
19
+
20
+ unless api_key =~ /\S/
21
+ say("Unable to detect your API key from Heroku.", :red)
22
+ say('Have you configured multiple Heroku apps? Try using --app APP', :red) unless app
23
+ exit(1)
24
+ end
25
+
26
+ unless rails_env =~ /\S/
27
+ say("Unable to detect your environment from Heroku. Use --environment ENVIRONMENT.", :red)
28
+ say('Have you configured multiple Heroku apps? Try using --app APP', :red) unless app
29
+ exit(1)
30
+ end
31
+
32
+ command = %Q(heroku addons:add deployhooks:http --url="https://api.honeybadger.io/v1/deploys?deploy[environment]=#{rails_env}&deploy[local_username]={{user}}&deploy[revision]={{head}}&api_key=#{api_key}"#{app ? " --app #{app}" : ''})
33
+
34
+ say("Running: `#{command}`")
35
+ say(`#{command}`)
36
+ end
37
+
38
+ desc 'install API_KEY', 'Install Honeybadger on Heroku using API_KEY'
39
+ def install(api_key)
40
+ say("Installing Honeybadger #{VERSION} for Heroku")
41
+
42
+ load_rails(verbose: true)
43
+
44
+ ENV['HONEYBADGER_LOGGING_LEVEL'] = '2'
45
+ ENV['HONEYBADGER_LOGGING_TTY_LEVEL'] = '0'
46
+ ENV['HONEYBADGER_LOGGING_PATH'] = 'STDOUT'
47
+ ENV['HONEYBADGER_REPORT_DATA'] = 'true'
48
+
49
+ ENV['HONEYBADGER_API_KEY'] = api_key
50
+
51
+ app = options[:app] || detect_heroku_app(false)
52
+ say("Adding config HONEYBADGER_API_KEY=#{api_key} to Heroku.", :magenta)
53
+ unless write_heroku_env({'HONEYBADGER_API_KEY' => api_key}, app)
54
+ say('Unable to update heroku config. Do you need to specify an app name?', :red)
55
+ exit(1)
56
+ end
57
+
58
+ if env = heroku_var('RAILS_ENV', app, heroku_var('RACK_ENV', app))
59
+ say('Installing deploy notification addon', :magenta)
60
+ invoke :install_deploy_notification, [], { app: app, api_key: api_key, environment: env }
61
+ else
62
+ say('Skipping deploy notification installation: we were unable to determine the environment name from your Heroku app.', :yellow)
63
+ say("To install manually, try `honeybadger heroku install_deploy_notification#{app ? " -a #{app}" : ""} -k #{api_key} --environment ENVIRONMENT`", :yellow)
64
+ end
65
+
66
+ config = Config.new(rails_framework_opts)
67
+ Honeybadger.start(config) unless load_rails_env(verbose: true)
68
+ say('Sending test notice')
69
+ unless Agent.instance && send_test(false)
70
+ say("Honeybadger is installed, but failed to send a test notice. Try `HONEYBADGER_API_KEY=#{api_key} honeybadger test`.", :red)
71
+ exit(1)
72
+ end
73
+
74
+ say("Installation complete. Happy 'badgering!", :green)
75
+ end
76
+
77
+ private
78
+
79
+ # Public: Detects the Heroku app name from GIT.
80
+ #
81
+ # prompt_on_default - If a single remote is discoverd, should we prompt the
82
+ # user before returning it?
83
+ #
84
+ # Returns the String app name if detected, otherwise nil.
85
+ def detect_heroku_app(prompt_on_default = true)
86
+ apps, git_config = {}, File.join(Dir.pwd, '.git', 'config')
87
+ if File.exist?(git_config)
88
+ require 'inifile'
89
+ ini = IniFile.load(git_config)
90
+ ini.each_section do |section|
91
+ if match = section.match(/remote \"(?<remote>.+)\"/)
92
+ url = ini[section]['url']
93
+ if url_match = url.match(/heroku\.com:(?<app>.+)\.git$/)
94
+ apps[match[:remote]] = url_match[:app]
95
+ end
96
+ end
97
+ end
98
+
99
+ if apps.size == 1
100
+ if !prompt_on_default
101
+ apps.values.first
102
+ else
103
+ say "We detected a Heroku app named #{apps.values.first}. Do you want to load the config? (y/yes or n/no)"
104
+ if STDIN.gets.chomp =~ /(y|yes)/i
105
+ apps.values.first
106
+ end
107
+ end
108
+ elsif apps.size > 1
109
+ say "We detected the following Heroku apps:"
110
+ apps.each_with_index {|a,i| say "\s\s#{i+1}. #{a[1]}" }
111
+ say "\s\s#{apps.size+1}. Use default"
112
+ say "Please select an option (1-#{apps.size+1}):"
113
+ apps.values[STDIN.gets.chomp.to_i-1]
114
+ end
115
+ end
116
+ end
117
+
118
+ def heroku_var(var, app_name, default = nil)
119
+ app = app_name ? "--app #{app_name}" : ''
120
+ result = Bundler.with_clean_env { `heroku config:get #{var} #{app} 2> /dev/null`.strip }
121
+ result.split.find(lambda { default }) {|x| x =~ /\S/ }
122
+ end
123
+
124
+ def read_heroku_env(app = nil)
125
+ cmd = ['heroku config']
126
+ cmd << "--app #{app}" if app
127
+ output = Bundler.with_clean_env { `#{cmd.join("\s")}` }
128
+ return false unless $?.to_i == 0
129
+ Hash[output.scan(/(HONEYBADGER_[^:]+):\s*(\S.*)\s*$/)]
130
+ end
131
+
132
+ def set_env_from_heroku(app = nil)
133
+ return false unless env = read_heroku_env(app)
134
+ env.each_pair do |k,v|
135
+ ENV[k] ||= v
136
+ end
137
+ end
138
+
139
+ def write_heroku_env(env, app = nil)
140
+ cmd = ["heroku config:set"]
141
+ Hash(env).each_pair {|k,v| cmd << "#{k}=#{v}" }
142
+ cmd << "--app #{app}" if app
143
+ Bundler.with_clean_env { `#{cmd.join("\s")}` }
144
+ $?.to_i == 0
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,179 @@
1
+ require 'stringio'
2
+
3
+ require 'honeybadger/cli/helpers'
4
+
5
+ module Honeybadger
6
+ module CLI
7
+ class Main < Thor
8
+ include Helpers
9
+
10
+ class HoneybadgerTestingException < RuntimeError; end
11
+
12
+ NOT_BLANK = Regexp.new('\S').freeze
13
+
14
+ desc 'deploy', 'Notify Honeybadger of deployment'
15
+ option :environment, aliases: :'-e', type: :string, desc: 'Environment of the deploy (i.e. "production", "staging")'
16
+ option :revision, aliases: :'-s', type: :string, desc: 'The revision/sha that is being deployed'
17
+ option :repository, aliases: :'-r', type: :string, desc: 'The address of your repository'
18
+ option :user, aliases: :'-u', type: :string, default: ENV['USER'] || ENV['USERNAME'], desc: 'The local user who is deploying'
19
+ option :api_key, aliases: :'-k', type: :string, desc: 'Api key of your Honeybadger application'
20
+ def deploy
21
+ load_rails(verbose: true)
22
+
23
+ payload = Hash[[:environment, :revision, :repository, :user].map {|k| [k, options[k]] }]
24
+
25
+ say('Loading configuration')
26
+ config = Config.new(rails_framework_opts)
27
+ config.update(api_key: options[:api_key]) if options[:api_key] =~ NOT_BLANK
28
+
29
+ unless (payload[:environment] ||= config[:env]) =~ NOT_BLANK
30
+ say('Unable to determine environment. (see: `honeybadger help deploy`)', :red)
31
+ exit(1)
32
+ end
33
+
34
+ unless config.valid?
35
+ say("Invalid configuration: #{config.inspect}", :red)
36
+ exit(1)
37
+ end
38
+
39
+ response = config.backend.notify(:deploys, payload)
40
+ if response.success?
41
+ say("Deploy notification for #{payload[:environment]} complete.", :green)
42
+ else
43
+ say("Deploy notification failed: #{response.code}", :red)
44
+ end
45
+ rescue => e
46
+ say("An error occurred during deploy notification: #{e}\n\t#{e.backtrace.join("\n\t")}", :red)
47
+ exit(1)
48
+ end
49
+
50
+ desc 'config', 'List configuration options'
51
+ option :default, aliases: :'-d', type: :boolean, default: true, desc: 'Output default options'
52
+ def config
53
+ load_rails
54
+ config = Config.new(rails_framework_opts)
55
+ output_config(config.to_hash(options[:default]))
56
+ end
57
+
58
+ desc 'test', 'Output test/debug information'
59
+ option :dry_run, aliases: :'-d', type: :boolean, default: false, desc: 'Skip sending data to Honeybadger'
60
+ option :file, aliases: :'-f', type: :string, default: nil, desc: 'Write the output to FILE'
61
+ def test
62
+ if options[:file]
63
+ out = StringIO.new
64
+ $stdout = out
65
+
66
+ flush = Proc.new do
67
+ $stdout = STDOUT
68
+ File.open(options[:file], 'w+') do |f|
69
+ out.rewind
70
+ out.each_line {|l| f.write(l) }
71
+ end
72
+
73
+ say("Output written to #{options[:file]}", :green)
74
+ end
75
+
76
+ Agent.at_exit(&flush)
77
+
78
+ at_exit do
79
+ # If the agent couldn't be started, the callback should happen here
80
+ # instead.
81
+ flush.() unless Agent.running?
82
+ end
83
+ end
84
+
85
+ say("Detecting framework\n\n", :bold)
86
+ load_rails(verbose: true)
87
+
88
+ ENV['HONEYBADGER_LOGGING_LEVEL'] = '0'
89
+ ENV['HONEYBADGER_LOGGING_TTY_LEVEL'] = '0'
90
+ ENV['HONEYBADGER_LOGGING_PATH'] = 'STDOUT'
91
+ ENV['HONEYBADGER_DEBUG'] = 'true'
92
+ ENV['HONEYBADGER_REPORT_DATA'] = options[:dry_run] ? 'false' : 'true'
93
+
94
+ config = Config.new(rails_framework_opts)
95
+ say("\nConfiguration\n\n", :bold)
96
+ output_config(config.to_hash)
97
+
98
+ say("\nStarting Honeybadger\n\n", :bold)
99
+ Honeybadger.start(config) unless load_rails_env(verbose: true)
100
+
101
+ say("\nSending test notice\n\n", :bold)
102
+ send_test
103
+
104
+ say("\nRunning at exit hooks\n\n", :bold)
105
+ end
106
+
107
+ desc 'install API_KEY', 'Install Honeybadger into the current directory using API_KEY'
108
+ option :test, aliases: :'-t', type: :boolean, default: nil, desc: 'Send a test error'
109
+ def install(api_key)
110
+ say("Installing Honeybadger #{VERSION}")
111
+
112
+ load_rails(verbose: true)
113
+
114
+ ENV['HONEYBADGER_LOGGING_LEVEL'] = '2'
115
+ ENV['HONEYBADGER_LOGGING_TTY_LEVEL'] = '0'
116
+ ENV['HONEYBADGER_LOGGING_PATH'] = 'STDOUT'
117
+ ENV['HONEYBADGER_REPORT_DATA'] = 'true'
118
+
119
+ config = Config.new(rails_framework_opts)
120
+ config[:api_key] = api_key
121
+
122
+ if (path = config.config_path).exist?
123
+ say("You're already on Honeybadger, so you're all set.", :yellow)
124
+ skip_test = true if options[:test].nil? # Only if it wasn't specified.
125
+ else
126
+ say("Writing configuration to: #{path}", :yellow)
127
+
128
+ begin
129
+ config.write
130
+ rescue Config::ConfigError => e
131
+ error("Error: Unable to write configuration file:\n\t#{e}")
132
+ return
133
+ rescue StandardError => e
134
+ error("Error: Unable to write configuration file:\n\t#{e.class} -- #{e.message}\n\t#{e.backtrace.join("\n\t")}")
135
+ return
136
+ end
137
+ end
138
+
139
+ if !skip_test && (options[:test].nil? || options[:test])
140
+ Honeybadger.start(config) unless load_rails_env(verbose: true)
141
+ say('Sending test notice', :yellow)
142
+ unless Agent.instance && send_test(false)
143
+ say('Honeybadger is installed, but failed to send a test notice. Try `honeybadger test`.', :red)
144
+ exit(1)
145
+ end
146
+ end
147
+
148
+ say("Installation complete. Happy 'badgering!", :green)
149
+ end
150
+
151
+ desc 'heroku SUBCOMMAND ...ARGS', 'Manage Honeybadger on Heroku'
152
+ subcommand 'heroku', Heroku
153
+
154
+ private
155
+
156
+ def output_config(nested_hash, hierarchy = [])
157
+ nested_hash.each_pair do |key, value|
158
+ if value.kind_of?(Hash)
159
+ say(tab_indent(hierarchy.size) << "#{key}:")
160
+ output_config(value, hierarchy + [key])
161
+ else
162
+ dotted_key = (hierarchy + [key]).join('.').to_sym
163
+ say(tab_indent(hierarchy.size) << "#{key}:")
164
+ indent = tab_indent(hierarchy.size+1)
165
+ say(indent + "Description: #{Config::OPTIONS[dotted_key][:description]}")
166
+ say(indent + "Default: #{Config::OPTIONS[dotted_key][:default].inspect}")
167
+ say(indent + "Current: #{value.inspect}")
168
+ end
169
+ end
170
+ end
171
+
172
+ def tab_indent(number)
173
+ ''.tap do |s|
174
+ number.times { s << "\s\s" }
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -5,6 +5,10 @@ module Honeybadger
5
5
  module Init
6
6
  module Rails
7
7
  class Railtie < ::Rails::Railtie
8
+ rake_tasks do
9
+ load 'honeybadger/tasks.rb'
10
+ end
11
+
8
12
  initializer 'honeybadger.install' do
9
13
  config = Config.new(local_config)
10
14
  if Honeybadger.start(config)
@@ -27,6 +27,9 @@ module Honeybadger
27
27
  # Internal: Empty String (used for equality comparisons and assignment)
28
28
  STRING_EMPTY = ''.freeze
29
29
 
30
+ # Internal: A Regexp which matches non-blank characters.
31
+ NOT_BLANK = /\S/.freeze
32
+
30
33
  # Internal: Matches lines beginning with ./
31
34
  RELATIVE_ROOT = Regexp.new('^\.\/').freeze
32
35
 
@@ -43,6 +46,12 @@ module Honeybadger
43
46
  class Notice
44
47
  extend Forwardable
45
48
 
49
+ # Internal: The String character used to split tag strings.
50
+ TAG_SEPERATOR = ','.freeze
51
+
52
+ # Internal: The Regexp used to strip invalid characters from individual tags.
53
+ TAG_SANITIZER = /[^\w]/.freeze
54
+
46
55
  # Public: The unique ID of this notice which can be used to reference the
47
56
  # error in Honeybadger.
48
57
  attr_reader :id
@@ -56,6 +65,9 @@ module Honeybadger
56
65
  # Public: Custom fingerprint for error, used to group similar errors together.
57
66
  attr_reader :fingerprint
58
67
 
68
+ # Public: Tags which will be applied to error.
69
+ attr_reader :tags
70
+
59
71
  # Public: The name of the class of error. (example: RuntimeError)
60
72
  attr_reader :error_class
61
73
 
@@ -143,6 +155,9 @@ module Honeybadger
143
155
  @request = OpenStruct.new(construct_request_hash(config.request, opts, @request_sanitizer, config.excluded_request_keys))
144
156
  @context = construct_context_hash(opts, @sanitizer)
145
157
 
158
+ @tags = construct_tags(opts[:tags])
159
+ @tags = construct_tags(context[:tags]) | @tags if context
160
+
146
161
  @stats = Util::Stats.all
147
162
 
148
163
  @local_variables = send_local_variables?(config) ? local_variables_from_exception(exception, config) : {}
@@ -163,7 +178,8 @@ module Honeybadger
163
178
  message: error_message,
164
179
  backtrace: backtrace,
165
180
  source: source,
166
- fingerprint: fingerprint
181
+ fingerprint: fingerprint,
182
+ tags: tags
167
183
  },
168
184
  request: {
169
185
  url: url,
@@ -370,6 +386,18 @@ module Honeybadger
370
386
  end
371
387
  end
372
388
 
389
+ def construct_tags(tags)
390
+ ret = []
391
+ Array(tags).flatten.each do |val|
392
+ val.to_s.split(TAG_SEPERATOR).each do |tag|
393
+ tag.gsub!(TAG_SANITIZER, STRING_EMPTY)
394
+ ret << tag if tag =~ NOT_BLANK
395
+ end
396
+ end
397
+
398
+ ret
399
+ end
400
+
373
401
  # Internal: Fetch local variables from first frame of backtrace.
374
402
  #
375
403
  # exception - The Exception containing the bindings stack.
@@ -0,0 +1,22 @@
1
+ namespace :honeybadger do
2
+ def warn_task_moved(old_name, new_cmd = "honeybadger help #{old_name}")
3
+ puts "This task was moved to the CLI in honeybadger 2.0. To learn more, run `#{new_cmd}`."
4
+ end
5
+
6
+ desc "Verify your gem installation by sending a test exception to the honeybadger service"
7
+ task :test do
8
+ warn_task_moved('test')
9
+ end
10
+
11
+ desc "Notify Honeybadger of a new deploy."
12
+ task :deploy do
13
+ warn_task_moved('deploy')
14
+ end
15
+
16
+ namespace :heroku do
17
+ desc "Install Heroku deploy notifications addon"
18
+ task :add_deploy_notification do
19
+ warn_task_moved('heroku:add_deploy_notification', 'honeybadger heroku help install_deploy_notification')
20
+ end
21
+ end
22
+ end
@@ -1,4 +1,4 @@
1
1
  module Honeybadger
2
2
  # Public: The current String Honeybadger version.
3
- VERSION = '2.0.0.beta.7'.freeze
3
+ VERSION = '2.0.0.beta.8'.freeze
4
4
  end
@@ -0,0 +1,628 @@
1
+ #encoding: UTF-8
2
+
3
+ # This class represents the INI file and can be used to parse, modify,
4
+ # and write INI files.
5
+ class IniFile
6
+ include Enumerable
7
+
8
+ class Error < StandardError; end
9
+ VERSION = '3.0.0'
10
+
11
+ # Public: Open an INI file and load the contents.
12
+ #
13
+ # filename - The name of the file as a String
14
+ # opts - The Hash of options (default: {})
15
+ # :comment - String containing the comment character(s)
16
+ # :parameter - String used to separate parameter and value
17
+ # :encoding - Encoding String for reading / writing
18
+ # :default - The String name of the default global section
19
+ #
20
+ # Examples
21
+ #
22
+ # IniFile.load('file.ini')
23
+ # #=> IniFile instance
24
+ #
25
+ # IniFile.load('does/not/exist.ini')
26
+ # #=> nil
27
+ #
28
+ # Returns an IniFile instance or nil if the file could not be opened.
29
+ def self.load( filename, opts = {} )
30
+ return unless File.file? filename
31
+ new(opts.merge(:filename => filename))
32
+ end
33
+
34
+ # Get and set the filename
35
+ attr_accessor :filename
36
+
37
+ # Get and set the encoding
38
+ attr_accessor :encoding
39
+
40
+ # Public: Create a new INI file from the given set of options. If :content
41
+ # is provided then it will be used to populate the INI file. If a :filename
42
+ # is provided then the contents of the file will be parsed and stored in the
43
+ # INI file. If neither the :content or :filename is provided then an empty
44
+ # INI file is created.
45
+ #
46
+ # opts - The Hash of options (default: {})
47
+ # :content - The String/Hash containing the INI contents
48
+ # :comment - String containing the comment character(s)
49
+ # :parameter - String used to separate parameter and value
50
+ # :encoding - Encoding String for reading / writing
51
+ # :default - The String name of the default global section
52
+ # :filename - The filename as a String
53
+ #
54
+ # Examples
55
+ #
56
+ # IniFile.new
57
+ # #=> an empty IniFile instance
58
+ #
59
+ # IniFile.new( :content => "[global]\nfoo=bar" )
60
+ # #=> an IniFile instance
61
+ #
62
+ # IniFile.new( :filename => 'file.ini', :encoding => 'UTF-8' )
63
+ # #=> an IniFile instance
64
+ #
65
+ # IniFile.new( :content => "[global]\nfoo=bar", :comment => '#' )
66
+ # #=> an IniFile instance
67
+ #
68
+ def initialize( opts = {} )
69
+ @comment = opts.fetch(:comment, ';#')
70
+ @param = opts.fetch(:parameter, '=')
71
+ @encoding = opts.fetch(:encoding, nil)
72
+ @default = opts.fetch(:default, 'global')
73
+ @filename = opts.fetch(:filename, nil)
74
+ content = opts.fetch(:content, nil)
75
+
76
+ @ini = Hash.new {|h,k| h[k] = Hash.new}
77
+
78
+ if content.is_a?(Hash) then merge!(content)
79
+ elsif content then parse(content)
80
+ elsif @filename then read
81
+ end
82
+ end
83
+
84
+ # Public: Write the contents of this IniFile to the file system. If left
85
+ # unspecified, the currently configured filename and encoding will be used.
86
+ # Otherwise the filename and encoding can be specified in the options hash.
87
+ #
88
+ # opts - The default options Hash
89
+ # :filename - The filename as a String
90
+ # :encoding - The encoding as a String
91
+ #
92
+ # Returns this IniFile instance.
93
+ def write( opts = {} )
94
+ filename = opts.fetch(:filename, @filename)
95
+ encoding = opts.fetch(:encoding, @encoding)
96
+ mode = encoding ? "w:#{encoding}" : "w"
97
+
98
+ File.open(filename, mode) do |f|
99
+ @ini.each do |section,hash|
100
+ f.puts "[#{section}]"
101
+ hash.each {|param,val| f.puts "#{param} #{@param} #{escape_value val}"}
102
+ f.puts
103
+ end
104
+ end
105
+
106
+ self
107
+ end
108
+ alias :save :write
109
+
110
+ # Public: Read the contents of the INI file from the file system and replace
111
+ # and set the state of this IniFile instance. If left unspecified the
112
+ # currently configured filename and encoding will be used when reading from
113
+ # the file system. Otherwise the filename and encoding can be specified in
114
+ # the options hash.
115
+ #
116
+ # opts - The default options Hash
117
+ # :filename - The filename as a String
118
+ # :encoding - The encoding as a String
119
+ #
120
+ # Returns this IniFile instance if the read was successful; nil is returned
121
+ # if the file could not be read.
122
+ def read( opts = {} )
123
+ filename = opts.fetch(:filename, @filename)
124
+ encoding = opts.fetch(:encoding, @encoding)
125
+ return unless File.file? filename
126
+
127
+ mode = encoding ? "r:#{encoding}" : "r"
128
+ File.open(filename, mode) { |fd| parse fd }
129
+ self
130
+ end
131
+ alias :restore :read
132
+
133
+ # Returns this IniFile converted to a String.
134
+ def to_s
135
+ s = []
136
+ @ini.each do |section,hash|
137
+ s << "[#{section}]"
138
+ hash.each {|param,val| s << "#{param} #{@param} #{escape_value val}"}
139
+ s << ""
140
+ end
141
+ s.join("\n")
142
+ end
143
+
144
+ # Returns this IniFile converted to a Hash.
145
+ def to_h
146
+ @ini.dup
147
+ end
148
+
149
+ # Public: Creates a copy of this inifile with the entries from the
150
+ # other_inifile merged into the copy.
151
+ #
152
+ # other - The other IniFile.
153
+ #
154
+ # Returns a new IniFile.
155
+ def merge( other )
156
+ self.dup.merge!(other)
157
+ end
158
+
159
+ # Public: Merges other_inifile into this inifile, overwriting existing
160
+ # entries. Useful for having a system inifile with user overridable settings
161
+ # elsewhere.
162
+ #
163
+ # other - The other IniFile.
164
+ #
165
+ # Returns this IniFile.
166
+ def merge!( other )
167
+ return self if other.nil?
168
+
169
+ my_keys = @ini.keys
170
+ other_keys = case other
171
+ when IniFile
172
+ other.instance_variable_get(:@ini).keys
173
+ when Hash
174
+ other.keys
175
+ else
176
+ raise Error, "cannot merge contents from '#{other.class.name}'"
177
+ end
178
+
179
+ (my_keys & other_keys).each do |key|
180
+ case other[key]
181
+ when Hash
182
+ @ini[key].merge!(other[key])
183
+ when nil
184
+ nil
185
+ else
186
+ raise Error, "cannot merge section #{key.inspect} - unsupported type: #{other[key].class.name}"
187
+ end
188
+ end
189
+
190
+ (other_keys - my_keys).each do |key|
191
+ @ini[key] = case other[key]
192
+ when Hash
193
+ other[key].dup
194
+ when nil
195
+ {}
196
+ else
197
+ raise Error, "cannot merge section #{key.inspect} - unsupported type: #{other[key].class.name}"
198
+ end
199
+ end
200
+
201
+ self
202
+ end
203
+
204
+ # Public: Yield each INI file section, parameter, and value in turn to the
205
+ # given block.
206
+ #
207
+ # block - The block that will be iterated by the each method. The block will
208
+ # be passed the current section and the parameter/value pair.
209
+ #
210
+ # Examples
211
+ #
212
+ # inifile.each do |section, parameter, value|
213
+ # puts "#{parameter} = #{value} [in section - #{section}]"
214
+ # end
215
+ #
216
+ # Returns this IniFile.
217
+ def each
218
+ return unless block_given?
219
+ @ini.each do |section,hash|
220
+ hash.each do |param,val|
221
+ yield section, param, val
222
+ end
223
+ end
224
+ self
225
+ end
226
+
227
+ # Public: Yield each section in turn to the given block.
228
+ #
229
+ # block - The block that will be iterated by the each method. The block will
230
+ # be passed the current section as a Hash.
231
+ #
232
+ # Examples
233
+ #
234
+ # inifile.each_section do |section|
235
+ # puts section.inspect
236
+ # end
237
+ #
238
+ # Returns this IniFile.
239
+ def each_section
240
+ return unless block_given?
241
+ @ini.each_key {|section| yield section}
242
+ self
243
+ end
244
+
245
+ # Public: Remove a section identified by name from the IniFile.
246
+ #
247
+ # section - The section name as a String.
248
+ #
249
+ # Returns the deleted section Hash.
250
+ def delete_section( section )
251
+ @ini.delete section.to_s
252
+ end
253
+
254
+ # Public: Get the section Hash by name. If the section does not exist, then
255
+ # it will be created.
256
+ #
257
+ # section - The section name as a String.
258
+ #
259
+ # Examples
260
+ #
261
+ # inifile['global']
262
+ # #=> global section Hash
263
+ #
264
+ # Returns the Hash of parameter/value pairs for this section.
265
+ def []( section )
266
+ return nil if section.nil?
267
+ @ini[section.to_s]
268
+ end
269
+
270
+ # Public: Set the section to a hash of parameter/value pairs.
271
+ #
272
+ # section - The section name as a String.
273
+ # value - The Hash of parameter/value pairs.
274
+ #
275
+ # Examples
276
+ #
277
+ # inifile['tenderloin'] = { 'gritty' => 'yes' }
278
+ # #=> { 'gritty' => 'yes' }
279
+ #
280
+ # Returns the value Hash.
281
+ def []=( section, value )
282
+ @ini[section.to_s] = value
283
+ end
284
+
285
+ # Public: Create a Hash containing only those INI file sections whose names
286
+ # match the given regular expression.
287
+ #
288
+ # regex - The Regexp used to match section names.
289
+ #
290
+ # Examples
291
+ #
292
+ # inifile.match(/^tree_/)
293
+ # #=> Hash of matching sections
294
+ #
295
+ # Return a Hash containing only those sections that match the given regular
296
+ # expression.
297
+ def match( regex )
298
+ @ini.dup.delete_if { |section, _| section !~ regex }
299
+ end
300
+
301
+ # Public: Check to see if the IniFile contains the section.
302
+ #
303
+ # section - The section name as a String.
304
+ #
305
+ # Returns true if the section exists in the IniFile.
306
+ def has_section?( section )
307
+ @ini.has_key? section.to_s
308
+ end
309
+
310
+ # Returns an Array of section names contained in this IniFile.
311
+ def sections
312
+ @ini.keys
313
+ end
314
+
315
+ # Public: Freeze the state of this IniFile object. Any attempts to change
316
+ # the object will raise an error.
317
+ #
318
+ # Returns this IniFile.
319
+ def freeze
320
+ super
321
+ @ini.each_value {|h| h.freeze}
322
+ @ini.freeze
323
+ self
324
+ end
325
+
326
+ # Public: Mark this IniFile as tainted -- this will traverse each section
327
+ # marking each as tainted.
328
+ #
329
+ # Returns this IniFile.
330
+ def taint
331
+ super
332
+ @ini.each_value {|h| h.taint}
333
+ @ini.taint
334
+ self
335
+ end
336
+
337
+ # Public: Produces a duplicate of this IniFile. The duplicate is independent
338
+ # of the original -- i.e. the duplicate can be modified without changing the
339
+ # original. The tainted state of the original is copied to the duplicate.
340
+ #
341
+ # Returns a new IniFile.
342
+ def dup
343
+ other = super
344
+ other.instance_variable_set(:@ini, Hash.new {|h,k| h[k] = Hash.new})
345
+ @ini.each_pair {|s,h| other[s].merge! h}
346
+ other.taint if self.tainted?
347
+ other
348
+ end
349
+
350
+ # Public: Produces a duplicate of this IniFile. The duplicate is independent
351
+ # of the original -- i.e. the duplicate can be modified without changing the
352
+ # original. The tainted state and the frozen state of the original is copied
353
+ # to the duplicate.
354
+ #
355
+ # Returns a new IniFile.
356
+ def clone
357
+ other = dup
358
+ other.freeze if self.frozen?
359
+ other
360
+ end
361
+
362
+ # Public: Compare this IniFile to some other IniFile. For two INI files to
363
+ # be equivalent, they must have the same sections with the same parameter /
364
+ # value pairs in each section.
365
+ #
366
+ # other - The other IniFile.
367
+ #
368
+ # Returns true if the INI files are equivalent and false if they differ.
369
+ def eql?( other )
370
+ return true if equal? other
371
+ return false unless other.instance_of? self.class
372
+ @ini == other.instance_variable_get(:@ini)
373
+ end
374
+ alias :== :eql?
375
+
376
+ # Escape special characters.
377
+ #
378
+ # value - The String value to escape.
379
+ #
380
+ # Returns the escaped value.
381
+ def escape_value( value )
382
+ value = value.to_s.dup
383
+ value.gsub!(%r/\\([0nrt])/, '\\\\\1')
384
+ value.gsub!(%r/\n/, '\n')
385
+ value.gsub!(%r/\r/, '\r')
386
+ value.gsub!(%r/\t/, '\t')
387
+ value.gsub!(%r/\0/, '\0')
388
+ value
389
+ end
390
+
391
+ # Parse the given content and store the information in this IniFile
392
+ # instance. All data will be cleared out and replaced with the information
393
+ # read from the content.
394
+ #
395
+ # content - A String or a file descriptor (must respond to `each_line`)
396
+ #
397
+ # Returns this IniFile.
398
+ def parse( content )
399
+ parser = Parser.new(@ini, @param, @comment, @default)
400
+ parser.parse(content)
401
+ self
402
+ end
403
+
404
+ # The IniFile::Parser has the responsibility of reading the contents of an
405
+ # .ini file and storing that information into a ruby Hash. The object being
406
+ # parsed must respond to `each_line` - this includes Strings and any IO
407
+ # object.
408
+ class Parser
409
+
410
+ attr_writer :section
411
+ attr_accessor :property
412
+ attr_accessor :value
413
+
414
+ # Create a new IniFile::Parser that can be used to parse the contents of
415
+ # an .ini file.
416
+ #
417
+ # hash - The Hash where parsed information will be stored
418
+ # param - String used to separate parameter and value
419
+ # comment - String containing the comment character(s)
420
+ # default - The String name of the default global section
421
+ #
422
+ def initialize( hash, param, comment, default )
423
+ @hash = hash
424
+ @default = default
425
+
426
+ comment = comment.to_s.empty? ? "\\z" : "\\s*(?:[#{comment}].*)?\\z"
427
+
428
+ @section_regexp = %r/\A\s*\[([^\]]+)\]#{comment}/
429
+ @ignore_regexp = %r/\A#{comment}/
430
+ @property_regexp = %r/\A(.*?)(?<!\\)#{param}(.*)\z/
431
+
432
+ @open_quote = %r/\A\s*(".*)\z/
433
+ @close_quote = %r/\A(.*(?<!\\)")#{comment}/
434
+ @full_quote = %r/\A\s*(".*(?<!\\)")#{comment}/
435
+ @trailing_slash = %r/\A(.*)(?<!\\)\\#{comment}/
436
+ @normal_value = %r/\A(.*?)#{comment}/
437
+ end
438
+
439
+ # Returns `true` if the current value starts with a leading double quote.
440
+ # Otherwise returns false.
441
+ def leading_quote?
442
+ value && value =~ %r/\A"/
443
+ end
444
+
445
+ # Given a string, attempt to parse out a value from that string. This
446
+ # value might be continued on the following line. So this method returns
447
+ # `true` if it is expecting more data.
448
+ #
449
+ # string - String to parse
450
+ #
451
+ # Returns `true` if the next line is also part of the current value.
452
+ # Returns `fase` if the string contained a complete value.
453
+ def parse_value( string )
454
+ continuation = false
455
+
456
+ # if our value starts with a double quote, then we are in a
457
+ # line continuation situation
458
+ if leading_quote?
459
+ # check for a closing quote at the end of the string
460
+ if string =~ @close_quote
461
+ value << $1
462
+
463
+ # otherwise just append the string to the value
464
+ else
465
+ value << string
466
+ continuation = true
467
+ end
468
+
469
+ # not currently processing a continuation line
470
+ else
471
+ case string
472
+ when @full_quote
473
+ self.value = $1
474
+
475
+ when @open_quote
476
+ self.value = $1
477
+ continuation = true
478
+
479
+ when @trailing_slash
480
+ self.value ? self.value << $1 : self.value = $1
481
+ continuation = true
482
+
483
+ when @normal_value
484
+ self.value ? self.value << $1 : self.value = $1
485
+
486
+ else
487
+ error
488
+ end
489
+ end
490
+
491
+ if continuation
492
+ self.value << $/ if leading_quote?
493
+ else
494
+ process_property
495
+ end
496
+
497
+ continuation
498
+ end
499
+
500
+ # Parse the ini file contents. This will clear any values currently stored
501
+ # in the ini hash.
502
+ #
503
+ # content - Any object that responds to `each_line`
504
+ #
505
+ # Returns nil.
506
+ def parse( content )
507
+ return unless content
508
+
509
+ continuation = false
510
+
511
+ @hash.clear
512
+ @line = nil
513
+ self.section = nil
514
+
515
+ content.each_line do |line|
516
+ @line = line.chomp
517
+
518
+ if continuation
519
+ continuation = parse_value @line
520
+ else
521
+ case @line
522
+ when @ignore_regexp
523
+ nil
524
+ when @section_regexp
525
+ self.section = @hash[$1]
526
+ when @property_regexp
527
+ self.property = $1.strip
528
+ error if property.empty?
529
+
530
+ continuation = parse_value $2
531
+ else
532
+ error
533
+ end
534
+ end
535
+ end
536
+
537
+ # check here if we have a dangling value ... usually means we have an
538
+ # unmatched open quote
539
+ if leading_quote?
540
+ error "Unmatched open quote"
541
+ elsif property && value
542
+ process_property
543
+ elsif value
544
+ error
545
+ end
546
+
547
+ nil
548
+ end
549
+
550
+ # Store the property/value pair in the currently active section. This
551
+ # method checks for continuation of the value to the next line.
552
+ #
553
+ # Returns nil.
554
+ def process_property
555
+ property.strip!
556
+ value.strip!
557
+
558
+ self.value = $1 if value =~ %r/\A"(.*)(?<!\\)"\z/m
559
+
560
+ section[property] = typecast(value)
561
+
562
+ self.property = nil
563
+ self.value = nil
564
+ end
565
+
566
+ # Returns the current section Hash.
567
+ def section
568
+ @section ||= @hash[@default]
569
+ end
570
+
571
+ # Raise a parse error using the given message and appending the current line
572
+ # being parsed.
573
+ #
574
+ # msg - The message String to use.
575
+ #
576
+ # Raises IniFile::Error
577
+ def error( msg = 'Could not parse line' )
578
+ raise Error, "#{msg}: #{@line.inspect}"
579
+ end
580
+
581
+ # Attempt to typecast the value string. We are looking for boolean values,
582
+ # integers, floats, and empty strings. Below is how each gets cast, but it
583
+ # is pretty logical and straightforward.
584
+ #
585
+ # "true" --> true
586
+ # "false" --> false
587
+ # "" --> nil
588
+ # "42" --> 42
589
+ # "3.14" --> 3.14
590
+ # "foo" --> "foo"
591
+ #
592
+ # Returns the typecast value.
593
+ def typecast( value )
594
+ case value
595
+ when %r/\Atrue\z/i; true
596
+ when %r/\Afalse\z/i; false
597
+ when %r/\A\s*\z/i; nil
598
+ else
599
+ Integer(value) rescue \
600
+ Float(value) rescue \
601
+ unescape_value(value)
602
+ end
603
+ end
604
+
605
+ # Unescape special characters found in the value string. This will convert
606
+ # escaped null, tab, carriage return, newline, and backslash into their
607
+ # literal equivalents.
608
+ #
609
+ # value - The String value to unescape.
610
+ #
611
+ # Returns the unescaped value.
612
+ def unescape_value( value )
613
+ value = value.to_s
614
+ value.gsub!(%r/\\[0nrt\\]/) { |char|
615
+ case char
616
+ when '\0'; "\0"
617
+ when '\n'; "\n"
618
+ when '\r'; "\r"
619
+ when '\t'; "\t"
620
+ when '\\\\'; "\\"
621
+ end
622
+ }
623
+ value
624
+ end
625
+ end
626
+
627
+ end # IniFile
628
+