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.
- checksums.yaml +4 -4
- data/lib/honeybadger.rb +5 -0
- data/lib/honeybadger/cli.rb +6 -372
- data/lib/honeybadger/cli/helpers.rb +158 -0
- data/lib/honeybadger/cli/heroku.rb +148 -0
- data/lib/honeybadger/cli/main.rb +179 -0
- data/lib/honeybadger/init/rails.rb +4 -0
- data/lib/honeybadger/notice.rb +29 -1
- data/lib/honeybadger/tasks.rb +22 -0
- data/lib/honeybadger/version.rb +1 -1
- data/vendor/inifile/lib/inifile.rb +628 -0
- metadata +15 -3
@@ -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
|
data/lib/honeybadger/notice.rb
CHANGED
@@ -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
|
data/lib/honeybadger/version.rb
CHANGED
@@ -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
|
+
|