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

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+