ucl 0.1.3.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (8) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -0
  3. data/README.md +181 -0
  4. data/ext/extconf.rb +65 -2
  5. data/ext/ucl.c +125 -36
  6. data/test/test_ucl.rb +323 -0
  7. data/ucl.gemspec +17 -7
  8. metadata +68 -13
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9a2fae349a8cdd822fbe00b6aa665cbdb04116e0c3dd9d6f7e21cd60d9420b07
4
- data.tar.gz: b61d92056c4d69c0be28e3a4515fbb187a5e10a522ae181c38a6c00b51f28d21
3
+ metadata.gz: 03ae85a910a91ff58975400ecfc0fa2d71a73491c448935e083c909e74a888b7
4
+ data.tar.gz: '07085efe0e973f192f82fbe5bb0afa4afada1ea7d4f8e97e83f550c615f66322'
5
5
  SHA512:
6
- metadata.gz: b6bbdf996a6be09f56dc12d1709f35b8e0f13a22ff1f0581af11c526b74b82eca11ac352989bd2414691a86ce4025e0caffcd0f196b200a5dcff650c8fc55d61
7
- data.tar.gz: ae3d58daa278a8f97f055ec47dbd2f874d3cd953bda701217d5b09999411a2617bcb340f9c826ca2db742bf4ed3999ebee55b0794485f40c452ff6acda2aa2fd
6
+ metadata.gz: 544b300d55a2599c199464048da8206a206e688f13dc6b6eebcf7954e5adaac886686e0c5be322ee48531b213de8d888514834cfff2d87949e84885f650399ab
7
+ data.tar.gz: 7deaa1b5f34932b98b7afcbf71904aa08bd9ad3fd3a87b5f7f29ba5e66be7ec651bb7ddd6e6182e84c4d9baa867e4c7f5f490e0db5bc5e6613d295a8fb0dc239
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 Stéphane D'Alu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,181 @@
1
+ ruby-ucl
2
+ ========
3
+
4
+ Ruby bindings to the [libucl][1] library for parsing configuration files
5
+ written in the **U**niversal **C**onfiguration **L**anguage (UCL).
6
+
7
+ UCL is a configuration format inspired by [nginx][2] and JSON. It is a
8
+ superset of JSON, so any valid JSON document is also valid UCL, while
9
+ adding a more relaxed, human-friendly syntax (unquoted keys, comments,
10
+ optional commas, multipliers, macros, …).
11
+
12
+ Parsed configurations are returned as plain Ruby objects (`Hash`,
13
+ `Array`, `String`, `Integer`, `Float`, `true`/`false`, `nil`), so no
14
+ special object model has to be learned.
15
+
16
+
17
+ Installation
18
+ ------------
19
+
20
+ ~~~sh
21
+ gem install ucl
22
+ ~~~
23
+
24
+ Or add it to your `Gemfile`:
25
+
26
+ ~~~ruby
27
+ gem 'ucl'
28
+ ~~~
29
+
30
+ The extension binds to the native [libucl][1] library (version 0.8.2 or
31
+ later). At build time it is located as follows:
32
+
33
+ 1. If a system-wide libucl is found (via `pkg-config`, or under `/opt` or
34
+ `/usr/local`), it is used.
35
+ 2. Otherwise libucl is **downloaded and compiled from source** automatically
36
+ (using [mini_portile2][4] and CMake). This requires `cmake` and a C
37
+ compiler to be available.
38
+
39
+ You can force the source build regardless of any system installation:
40
+
41
+ ~~~sh
42
+ gem install ucl -- --enable-vendor-libucl
43
+ # or, with Bundler:
44
+ bundle config set build.ucl --enable-vendor-libucl
45
+ ~~~
46
+
47
+ > **Debian/Ubuntu note:** do **not** `apt install libucl-dev`. That package
48
+ > is an unrelated [UCL *data-compression* library][3] that merely shares the
49
+ > name — it does not provide `ucl_parser_new`. Since the configuration
50
+ > parser is not packaged for Debian, just install `cmake` and a compiler and
51
+ > let the gem build libucl from source. (The parser *is* packaged on Fedora,
52
+ > Homebrew and FreeBSD ports as `libucl`.)
53
+
54
+
55
+ Usage
56
+ -----
57
+
58
+ ~~~ruby
59
+ require 'ucl'
60
+
61
+ # Parse a string of UCL data
62
+ UCL.parse(File.read('foo.conf'))
63
+
64
+ # Parse a file directly (enables file-relative variables such as $FILENAME)
65
+ UCL.load_file('foo.conf')
66
+
67
+ # Pass flags explicitly (combine them with a bitwise OR)
68
+ UCL.load_file('foo.conf', UCL::KEY_SYMBOL | UCL::KEY_LOWERCASE)
69
+
70
+ # Or set the default flags applied to every subsequent call
71
+ UCL.flags = UCL::KEY_SYMBOL
72
+ UCL.parse('name = value') #=> { :name => "value" }
73
+ ~~~
74
+
75
+ Both `parse` and `load_file` accept an optional `flags` argument. When it
76
+ is omitted, the value of `UCL.flags` (default: no flag) is used.
77
+
78
+ On a malformed configuration, or if conversion of the parsed tree fails,
79
+ a `UCL::Error` is raised.
80
+
81
+ Given a configuration like:
82
+
83
+ ~~~nginx
84
+ # a sample configuration
85
+ name = example
86
+ timeout = 30s # parsed as a number of seconds
87
+ max_size = 10mb # size multipliers are understood
88
+
89
+ servers = [
90
+ { host = a.example, port = 8080 },
91
+ { host = b.example, port = 8081 },
92
+ ]
93
+
94
+ logging {
95
+ level = info
96
+ enabled = yes
97
+ }
98
+ ~~~
99
+
100
+ `UCL.load_file` returns:
101
+
102
+ ~~~ruby
103
+ {
104
+ "name" => "example",
105
+ "timeout" => 30.0,
106
+ "max_size" => 10485760,
107
+ "servers" => [ { "host" => "a.example", "port" => 8080 },
108
+ { "host" => "b.example", "port" => 8081 } ],
109
+ "logging" => { "level" => "info", "enabled" => true },
110
+ }
111
+ ~~~
112
+
113
+
114
+ Flags
115
+ -----
116
+
117
+ | Flag | Effect |
118
+ |------------------|---------------------------------------------------------------|
119
+ | `KEY_SYMBOL` | Return object keys as `Symbol` instead of `String`. |
120
+ | `KEY_LOWERCASE` | Convert all keys to lower case. |
121
+ | `NO_TIME` | Do not parse time values; keep them as strings. |
122
+ | `DISABLE_MACRO` | Disable processing of macros (e.g. `.include`). |
123
+ | `NO_FILEVARS` | Do not predefine `$FILENAME` / `$CURDIR` (affects `parse`; `load_file` still sets them from the file). |
124
+
125
+
126
+ Type mapping
127
+ ------------
128
+
129
+ | UCL type | Ruby type |
130
+ |------------|--------------------------|
131
+ | object | `Hash` |
132
+ | array | `Array` |
133
+ | string | `String` |
134
+ | integer | `Integer` |
135
+ | float | `Float` |
136
+ | time | `Float` (seconds) |
137
+ | boolean | `true` / `false` |
138
+ | null | `nil` |
139
+
140
+ Notes:
141
+
142
+ * The parser uses explicit (not implicit) arrays, so a key repeated several
143
+ times is collected into an `Array` of its values:
144
+
145
+ ~~~ruby
146
+ UCL.parse("a = 1\na = 2") #=> { "a" => [1, 2] }
147
+ UCL.parse("a = 1") #=> { "a" => 1 }
148
+ ~~~
149
+
150
+ * Integers understand size multipliers (`1k` → 1000, `1kb` → 1024,
151
+ `1mb` → 1048576), hexadecimal (`0x1f` → 31) and JSON is accepted as-is.
152
+
153
+ * Returned strings carry their verbatim bytes but are tagged with the
154
+ `ASCII-8BIT` (binary) encoding; call `String#force_encoding('UTF-8')` if
155
+ you need them as UTF-8.
156
+
157
+
158
+ Development
159
+ -----------
160
+
161
+ ~~~sh
162
+ bundle install # install development dependencies
163
+ rake compile # build the C extension into ext/
164
+ rake test # compile and run the test suite
165
+ rake clobber # remove all generated files
166
+ ~~~
167
+
168
+ `rake test` works with the development gems installed system-wide too; with
169
+ a `Gemfile.lock` present, prefix commands with `bundle exec`.
170
+
171
+
172
+ License
173
+ -------
174
+
175
+ Released under the MIT License. See [LICENSE](LICENSE).
176
+
177
+
178
+ [1]: https://github.com/vstakhov/libucl
179
+ [2]: https://nginx.org/
180
+ [3]: https://www.oberhumer.com/opensource/ucl/
181
+ [4]: https://github.com/flavorjones/mini_portile
data/ext/extconf.rb CHANGED
@@ -1,6 +1,69 @@
1
1
  require 'mkmf'
2
2
 
3
+ # Pinned libucl (vstakhov's Universal Configuration Language parser) used
4
+ # when the library has to be built from source. NOTE: this is *not* the
5
+ # Debian `libucl-dev` package, which is an unrelated compression library.
6
+ LIBUCL_VERSION = '0.9.4'
7
+ LIBUCL_SHA256 = '319d8ff13441f55d91cd7f3708a54bd03779733e26958c2346c5109014520aaf'
3
8
 
4
- have_library('ucl')
9
+ # Force building libucl from source, ignoring any system installation:
10
+ # gem install ucl -- --enable-vendor-libucl
11
+ # bundle config set build.ucl --enable-vendor-libucl
12
+ # UCL_VENDOR_LIBUCL=1 rake compile
13
+ force_vendor = enable_config('vendor-libucl', false) ||
14
+ ENV.key?('UCL_VENDOR_LIBUCL')
5
15
 
6
- create_makefile("ucl")
16
+ # Locate a system-wide libucl: pkg-config first, then the usual prefixes.
17
+ def system_libucl
18
+ return true if pkg_config('libucl')
19
+
20
+ find_header( 'ucl.h', '/opt/include', '/usr/local/include') &&
21
+ find_library('ucl', 'ucl_parser_new', '/opt/lib', '/usr/local/lib')
22
+ end
23
+
24
+ if !force_vendor && system_libucl
25
+ message "Using system libucl.\n"
26
+ else
27
+ message "Building libucl #{LIBUCL_VERSION} from source.\n"
28
+
29
+ # Building from source needs cmake and a working C compiler.
30
+ missing = []
31
+ missing << 'cmake' unless find_executable('cmake')
32
+ missing << 'a C compiler' unless try_compile('int main(void) { return 0; }')
33
+ unless missing.empty?
34
+ abort "\nBuilding libucl from source requires #{missing.join(' and ')}, " \
35
+ "which #{missing.length > 1 ? 'are' : 'is'} not available.\n" \
36
+ "Install the missing tool(s), or provide a system-wide libucl.\n"
37
+ end
38
+
39
+ require 'mini_portile2'
40
+
41
+ recipe = MiniPortileCMake.new('libucl', LIBUCL_VERSION)
42
+ recipe.files = [{
43
+ url: "https://github.com/vstakhov/libucl/archive/refs/tags/#{LIBUCL_VERSION}.tar.gz",
44
+ sha256: LIBUCL_SHA256,
45
+ }]
46
+ # Static, position-independent build with every optional feature disabled.
47
+ recipe.configure_options += %w[
48
+ -DCMAKE_BUILD_TYPE=Release
49
+ -DBUILD_SHARED_LIBS=OFF
50
+ -DCMAKE_POSITION_INDEPENDENT_CODE=ON
51
+ -DENABLE_URL_INCLUDE=OFF
52
+ -DENABLE_LUA=OFF
53
+ -DENABLE_UTILS=OFF
54
+ ]
55
+ recipe.cook # download, verify checksum, cmake build + install into recipe.path
56
+ recipe.activate
57
+
58
+ $INCFLAGS << " -I#{recipe.path}/include"
59
+ $LIBPATH.unshift "#{recipe.path}/lib"
60
+ have_library('m') # libucl relies on the math library
61
+
62
+ unless find_header( 'ucl.h', "#{recipe.path}/include") &&
63
+ find_library('ucl', 'ucl_parser_new', "#{recipe.path}/lib")
64
+ abort "\nlibucl was built but could not be linked " \
65
+ "(check the static archive name/dir under #{recipe.path}/lib).\n"
66
+ end
67
+ end
68
+
69
+ create_makefile('ucl')
data/ext/ucl.c CHANGED
@@ -11,13 +11,53 @@
11
11
  /**
12
12
  * Document-class: UCL
13
13
  *
14
- * UCL configuration file.
14
+ * Parser for configuration files written in the Universal Configuration
15
+ * Language (UCL), a JSON-superset format handled by the libucl library.
16
+ *
17
+ * Parsed configurations are returned as plain Ruby objects (Hash, Array,
18
+ * String, Integer, Float, true/false, nil).
19
+ *
20
+ * @example Parse a string
21
+ * UCL.parse('name = value') #=> { "name" => "value" }
22
+ *
23
+ * @example Load a file with symbol keys
24
+ * UCL.load_file('foo.conf', UCL::KEY_SYMBOL)
25
+ *
26
+ * @see https://github.com/vstakhov/libucl
15
27
  */
16
28
 
17
29
  /**
18
30
  * Document-class: UCL::Error
19
31
  *
20
- * Generic error raised by UCL.
32
+ * Raised when a configuration cannot be parsed, or when the parsed tree
33
+ * cannot be converted to Ruby objects.
34
+ */
35
+
36
+ /**
37
+ * Document-const: KEY_LOWERCASE
38
+ * Flag: convert all object keys to lower case.
39
+ */
40
+
41
+ /**
42
+ * Document-const: NO_TIME
43
+ * Flag: do not parse time values; keep them as strings.
44
+ */
45
+
46
+ /**
47
+ * Document-const: DISABLE_MACRO
48
+ * Flag: disable processing of macros (e.g. <code>.include</code>).
49
+ */
50
+
51
+ /**
52
+ * Document-const: NO_FILEVARS
53
+ * Flag: do not predefine the file variables (<code>$FILENAME</code>,
54
+ * <code>$CURDIR</code>). This affects {UCL.parse}; {UCL.load_file} still
55
+ * derives those variables from the file being loaded.
56
+ */
57
+
58
+ /**
59
+ * Document-const: KEY_SYMBOL
60
+ * Flag: return object keys as Symbol instead of String.
21
61
  */
22
62
 
23
63
 
@@ -32,13 +72,13 @@ static int ucl_allowed_c_flags = UCL_PARSER_KEY_LOWERCASE |
32
72
 
33
73
 
34
74
 
35
- VALUE
75
+ static VALUE
36
76
  _iterate_valid_ucl(ucl_object_t const *root, int flags, bool *failed)
37
77
  {
38
- ucl_object_iter_t it = ucl_object_iterate_new(NULL);
78
+ ucl_object_iter_t it = NULL; /* only allocated for objects/arrays */
39
79
  const ucl_object_t *obj = NULL;
40
80
 
41
- VALUE val;
81
+ VALUE val = Qnil;
42
82
 
43
83
  switch (root->type) {
44
84
  case UCL_INT:
@@ -64,10 +104,12 @@ _iterate_valid_ucl(ucl_object_t const *root, int flags, bool *failed)
64
104
  val = rb_float_new(ucl_object_todouble(root));
65
105
  break;
66
106
 
67
- case UCL_OBJECT:
107
+ case UCL_OBJECT: {
108
+ bool iterated = false;
68
109
  val = rb_hash_new();
69
- it = ucl_object_iterate_reset(it, root);
110
+ it = ucl_object_iterate_new(root);
70
111
  while ((obj = ucl_object_iterate_safe(it, !true))) {
112
+ iterated = true;
71
113
  size_t keylen;
72
114
  const char *key = ucl_object_keyl(obj, &keylen);
73
115
  VALUE v_key = rb_str_new(key, keylen);
@@ -75,17 +117,25 @@ _iterate_valid_ucl(ucl_object_t const *root, int flags, bool *failed)
75
117
  v_key = rb_to_symbol(v_key);
76
118
  rb_hash_aset(val, v_key, _iterate_valid_ucl(obj, flags, failed));
77
119
  }
78
- *failed = ucl_object_iter_chk_excpn(it);
120
+ /* An empty object has a NULL hash that the safe iterator reports as an
121
+ * exception (EINVAL); ignore that and only flag a genuine error that
122
+ * occurs while iterating. Accumulate so a nested failure deeper in the
123
+ * tree is never cleared by a successful parent iteration. */
124
+ if (iterated && ucl_object_iter_chk_excpn(it)) *failed = true;
79
125
  break;
80
-
81
- case UCL_ARRAY:
126
+ }
127
+
128
+ case UCL_ARRAY: {
129
+ bool iterated = false;
82
130
  val = rb_ary_new();
83
- it = ucl_object_iterate_reset(it, root);
131
+ it = ucl_object_iterate_new(root);
84
132
  while ((obj = ucl_object_iterate_safe(it, !true))) {
133
+ iterated = true;
85
134
  rb_ary_push(val, _iterate_valid_ucl(obj, flags, failed));
86
135
  }
87
- *failed = ucl_object_iter_chk_excpn(it);
136
+ if (iterated && ucl_object_iter_chk_excpn(it)) *failed = true;
88
137
  break;
138
+ }
89
139
 
90
140
  case UCL_USERDATA:
91
141
  val = rb_str_new(root->value.sv, root->len);
@@ -96,14 +146,20 @@ _iterate_valid_ucl(ucl_object_t const *root, int flags, bool *failed)
96
146
  break;
97
147
 
98
148
  default:
99
- rb_bug("unhandled type (%d)", root->type);
100
-
149
+ rb_raise(eUCLError, "unhandled UCL type (%d)", root->type);
150
+
101
151
  }
102
152
 
103
- ucl_object_iterate_free(it);
153
+ if (it != NULL) ucl_object_iterate_free(it);
104
154
  return val;
105
155
  }
106
156
 
157
+ /**
158
+ * Default flags applied by {UCL.parse} and {UCL.load_file} when none are
159
+ * given explicitly.
160
+ *
161
+ * @return [Integer] the current default flags (0 by default)
162
+ */
107
163
  static VALUE
108
164
  ucl_s_get_flags(VALUE klass)
109
165
  {
@@ -111,6 +167,17 @@ ucl_s_get_flags(VALUE klass)
111
167
  }
112
168
 
113
169
 
170
+ /**
171
+ * Set the default flags applied by {UCL.parse} and {UCL.load_file} when
172
+ * none are given explicitly.
173
+ *
174
+ * @param val [Integer] flags, combined with a bitwise OR
175
+ *
176
+ * @example
177
+ * UCL.flags = UCL::KEY_SYMBOL | UCL::KEY_LOWERCASE
178
+ *
179
+ * @return [Integer] the flags that were set
180
+ */
114
181
  static VALUE
115
182
  ucl_s_set_flags(VALUE klass, VALUE val)
116
183
  {
@@ -121,12 +188,19 @@ ucl_s_set_flags(VALUE klass, VALUE val)
121
188
 
122
189
 
123
190
  /**
124
- * Parse a configuration file
191
+ * Parse a UCL configuration from a string.
192
+ *
193
+ * @param data [String] the UCL configuration to parse
194
+ * @param flags [Integer] parsing flags combined with a bitwise OR;
195
+ * defaults to {UCL.flags} when omitted
125
196
  *
126
- * @param data [String]
127
- * @param flags [Integer]
197
+ * @example
198
+ * UCL.parse('name = value') #=> { "name" => "value" }
199
+ * UCL.parse('name = value', UCL::KEY_SYMBOL) #=> { :name => "value" }
200
+ *
201
+ * @raise [UCL::Error] if the configuration is malformed
128
202
  *
129
- * @return configuration file as ruby objects.
203
+ * @return [Hash, Array, Object] the configuration as Ruby objects
130
204
  */
131
205
  static VALUE
132
206
  ucl_s_parse(int argc, VALUE *argv, VALUE klass)
@@ -142,23 +216,27 @@ ucl_s_parse(int argc, VALUE *argv, VALUE klass)
142
216
 
143
217
  struct ucl_parser *parser =
144
218
  ucl_parser_new(c_flags | UCL_PARSER_NO_IMPLICIT_ARRAYS);
219
+ if (parser == NULL)
220
+ rb_raise(eUCLError, "failed to allocate UCL parser");
145
221
 
146
222
  ucl_parser_add_chunk(parser,
147
223
  (unsigned char *)RSTRING_PTR(data),
148
224
  RSTRING_LEN(data));
149
-
225
+
150
226
  if (ucl_parser_get_error(parser)) {
151
- const char *errormsg = ucl_parser_get_error(parser);
152
- if (parser != NULL) { ucl_parser_free(parser); }
153
- rb_raise(eUCLError, "%s", errormsg);
227
+ /* Copy the message into the exception before freeing the parser:
228
+ * ucl_parser_get_error() points into memory owned by the parser. */
229
+ VALUE err = rb_exc_new2(eUCLError, ucl_parser_get_error(parser));
230
+ ucl_parser_free(parser);
231
+ rb_exc_raise(err);
154
232
  }
155
233
 
156
234
  bool failed = false;
157
235
  ucl_object_t *root = ucl_parser_get_object(parser);
158
236
  VALUE res = _iterate_valid_ucl(root, FIX2INT(flags), &failed);
159
237
 
160
- if (parser != NULL) { ucl_parser_free(parser); }
161
- if (root != NULL) { ucl_object_unref(root); }
238
+ ucl_parser_free(parser);
239
+ if (root != NULL) { ucl_object_unref(root); }
162
240
 
163
241
  if (failed) {
164
242
  rb_raise(eUCLError, "failed to iterate over ucl object");
@@ -169,15 +247,22 @@ ucl_s_parse(int argc, VALUE *argv, VALUE klass)
169
247
 
170
248
 
171
249
  /**
172
- * Load configuration file
250
+ * Load and parse a UCL configuration from a file.
173
251
  *
174
- * @param file [String]
175
- * @param flags [Integer]
252
+ * Unlike {UCL.parse}, this defines the file variables ($FILENAME,
253
+ * $CURDIR) from the loaded file, so they can be referenced from within
254
+ * the configuration.
255
+ *
256
+ * @param file [String] path to the configuration file
257
+ * @param flags [Integer] parsing flags combined with a bitwise OR;
258
+ * defaults to {UCL.flags} when omitted
176
259
  *
177
260
  * @example
178
261
  * UCL.load_file('foo.conf', UCL::KEY_SYMBOL)
179
262
  *
180
- * @return configuration file as ruby objects.
263
+ * @raise [UCL::Error] if the file cannot be read or is malformed
264
+ *
265
+ * @return [Hash, Array, Object] the configuration as Ruby objects
181
266
  */
182
267
  static VALUE
183
268
  ucl_s_load_file(int argc, VALUE *argv, VALUE klass)
@@ -194,23 +279,27 @@ ucl_s_load_file(int argc, VALUE *argv, VALUE klass)
194
279
 
195
280
  struct ucl_parser *parser =
196
281
  ucl_parser_new(c_flags | UCL_PARSER_NO_IMPLICIT_ARRAYS);
282
+ if (parser == NULL)
283
+ rb_raise(eUCLError, "failed to allocate UCL parser");
197
284
 
198
285
  ucl_parser_add_file(parser, c_file);
199
286
  ucl_parser_set_filevars(parser, c_file, false);
200
-
287
+
201
288
  if (ucl_parser_get_error(parser)) {
202
- const char *errormsg = ucl_parser_get_error(parser);
203
- if (parser != NULL) { ucl_parser_free(parser); }
204
- rb_raise(eUCLError, "%s", errormsg);
289
+ /* Copy the message into the exception before freeing the parser:
290
+ * ucl_parser_get_error() points into memory owned by the parser. */
291
+ VALUE err = rb_exc_new2(eUCLError, ucl_parser_get_error(parser));
292
+ ucl_parser_free(parser);
293
+ rb_exc_raise(err);
205
294
  }
206
295
 
207
296
  bool failed = false;
208
297
  ucl_object_t *root = ucl_parser_get_object(parser);
209
298
  VALUE res = _iterate_valid_ucl(root, FIX2INT(flags), &failed);
210
299
 
211
- if (parser != NULL) { ucl_parser_free(parser); }
212
- if (root != NULL) { ucl_object_unref(root); }
213
-
300
+ ucl_parser_free(parser);
301
+ if (root != NULL) { ucl_object_unref(root); }
302
+
214
303
  if (failed) {
215
304
  rb_raise(eUCLError, "failed to iterate over ucl object");
216
305
  }
data/test/test_ucl.rb ADDED
@@ -0,0 +1,323 @@
1
+ require 'minitest/autorun'
2
+ require 'tempfile'
3
+
4
+ # Load the compiled extension. By default it is looked up in ../ext (where
5
+ # `rake compile` builds it); UCL_EXT_DIR can point elsewhere.
6
+ libdir = ENV['UCL_EXT_DIR'] || File.expand_path('../ext', __dir__)
7
+ $LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir)
8
+ require 'ucl'
9
+
10
+ class TestUCL < Minitest::Test
11
+ def setup
12
+ # UCL.flags is global state; keep tests independent.
13
+ UCL.flags = 0
14
+ end
15
+
16
+ # ---- scalar types -------------------------------------------------------
17
+
18
+ def test_integer
19
+ assert_equal({ 'n' => 42 }, UCL.parse('n = 42'))
20
+ end
21
+
22
+ def test_negative_integer
23
+ assert_equal({ 'n' => -5 }, UCL.parse('n = -5'))
24
+ end
25
+
26
+ def test_large_integer
27
+ assert_equal({ 'n' => 9_999_999_999_999 }, UCL.parse('n = 9999999999999'))
28
+ end
29
+
30
+ def test_max_int64
31
+ assert_equal({ 'n' => 9_223_372_036_854_775_807 },
32
+ UCL.parse('n = 9223372036854775807'))
33
+ end
34
+
35
+ def test_hexadecimal_integer
36
+ assert_equal({ 'h' => 31 }, UCL.parse('h = 0x1f'))
37
+ end
38
+
39
+ def test_float
40
+ assert_equal({ 'f' => 1.5 }, UCL.parse('f = 1.5'))
41
+ end
42
+
43
+ def test_float_with_exponent
44
+ assert_equal({ 'f' => 1500.0 }, UCL.parse('f = 1.5e3'))
45
+ end
46
+
47
+ def test_string
48
+ assert_equal({ 's' => 'hello' }, UCL.parse('s = hello'))
49
+ end
50
+
51
+ def test_quoted_string
52
+ assert_equal({ 's' => 'hello world' }, UCL.parse('s = "hello world"'))
53
+ end
54
+
55
+ def test_string_keeps_utf8_bytes
56
+ # Strings are returned with ASCII-8BIT encoding, but the bytes are the
57
+ # verbatim (UTF-8) content of the configuration.
58
+ s = UCL.parse('s = "héllo"')['s']
59
+ assert_equal 'héllo'.b, s.b
60
+ end
61
+
62
+ def test_booleans
63
+ assert_equal({ 't' => true, 'f' => false }, UCL.parse("t = true\nf = false"))
64
+ end
65
+
66
+ def test_boolean_word_variants
67
+ assert_equal({ 'a' => true, 'b' => true, 'c' => false, 'd' => false },
68
+ UCL.parse("a = yes\nb = on\nc = off\nd = no"))
69
+ end
70
+
71
+ def test_null
72
+ assert_equal({ 'z' => nil }, UCL.parse('z = null'))
73
+ end
74
+
75
+ # ---- size multipliers ---------------------------------------------------
76
+
77
+ def test_si_multiplier
78
+ assert_equal({ 's' => 1000 }, UCL.parse('s = 1k'))
79
+ end
80
+
81
+ def test_binary_multipliers
82
+ assert_equal({ 'a' => 1024, 'b' => 1_048_576 },
83
+ UCL.parse("a = 1kb\nb = 1mb"))
84
+ end
85
+
86
+ # ---- containers ---------------------------------------------------------
87
+
88
+ def test_array
89
+ assert_equal({ 'a' => [1, 2, 3] }, UCL.parse('a = [1, 2, 3]'))
90
+ end
91
+
92
+ def test_mixed_type_array
93
+ assert_equal({ 'a' => [1, 'two', true, nil] },
94
+ UCL.parse('a = [1, "two", true, null]'))
95
+ end
96
+
97
+ def test_nested_arrays
98
+ assert_equal({ 'a' => [[1, 2], [3, 4]] }, UCL.parse('a = [[1,2],[3,4]]'))
99
+ end
100
+
101
+ def test_array_of_objects
102
+ assert_equal({ 'a' => [{ 'x' => 1 }, { 'y' => 2 }] },
103
+ UCL.parse('a = [ {x=1}, {y=2} ]'))
104
+ end
105
+
106
+ def test_nested_object
107
+ assert_equal({ 'o' => { 'x' => 1 } }, UCL.parse('o { x = 1 }'))
108
+ end
109
+
110
+ def test_deeply_nested
111
+ assert_equal({ 'a' => { 'b' => { 'c' => { 'd' => 1 } } } },
112
+ UCL.parse('a { b { c { d = 1 } } }'))
113
+ end
114
+
115
+ def test_dotted_key_is_kept_flat
116
+ assert_equal({ 'a.b.c' => 1 }, UCL.parse('a.b.c = 1'))
117
+ end
118
+
119
+ # ---- JSON compatibility -------------------------------------------------
120
+
121
+ def test_parses_json
122
+ assert_equal({ 'a' => 1, 'b' => [2, 3] }, UCL.parse('{"a": 1, "b": [2,3]}'))
123
+ end
124
+
125
+ # ---- comments -----------------------------------------------------------
126
+
127
+ def test_comments_are_ignored
128
+ assert_equal({ 'x' => 1, 'y' => 2 },
129
+ UCL.parse("# header\nx = 1 # inline\ny = 2"))
130
+ end
131
+
132
+ # ---- duplicate keys become an explicit array ----------------------------
133
+
134
+ def test_duplicate_keys_make_array
135
+ assert_equal({ 'k' => [1, 2, 3] }, UCL.parse("k = 1\nk = 2\nk = 3"))
136
+ end
137
+
138
+ def test_single_key_stays_scalar
139
+ assert_equal({ 'k' => 1 }, UCL.parse('k = 1'))
140
+ end
141
+
142
+ # ---- empty objects (regression: used to raise) --------------------------
143
+
144
+ def test_empty_input_returns_empty_hash
145
+ assert_equal({}, UCL.parse(''))
146
+ end
147
+
148
+ def test_whitespace_only_returns_empty_hash
149
+ assert_equal({}, UCL.parse(" \n "))
150
+ end
151
+
152
+ def test_comment_only_returns_empty_hash
153
+ assert_equal({}, UCL.parse("# nothing here\n"))
154
+ end
155
+
156
+ def test_empty_object
157
+ assert_equal({ 'e' => {} }, UCL.parse('e {}'))
158
+ end
159
+
160
+ def test_nested_empty_object
161
+ assert_equal({ 'a' => { 'b' => {} } }, UCL.parse('a { b {} }'))
162
+ end
163
+
164
+ def test_empty_object_then_more_keys
165
+ assert_equal({ 'a' => {}, 'b' => 2 }, UCL.parse("a {}\nb = 2"))
166
+ end
167
+
168
+ def test_empty_array
169
+ assert_equal({ 'a' => [] }, UCL.parse('a = []'))
170
+ end
171
+
172
+ # ---- time ---------------------------------------------------------------
173
+
174
+ def test_time_is_parsed_to_float
175
+ assert_equal({ 't' => 10.0 }, UCL.parse('t = 10s'))
176
+ end
177
+
178
+ def test_no_time_keeps_string
179
+ assert_equal({ 't' => '10s' }, UCL.parse('t = 10s', UCL::NO_TIME))
180
+ end
181
+
182
+ # ---- key flags ----------------------------------------------------------
183
+
184
+ def test_key_symbol
185
+ assert_equal({ name: 'value' }, UCL.parse('name = value', UCL::KEY_SYMBOL))
186
+ end
187
+
188
+ def test_key_symbol_recurses_into_nested_objects
189
+ assert_equal({ o: { x: 1 } }, UCL.parse('o { x = 1 }', UCL::KEY_SYMBOL))
190
+ end
191
+
192
+ def test_key_lowercase
193
+ assert_equal({ 'foo' => 1 }, UCL.parse('FOO = 1', UCL::KEY_LOWERCASE))
194
+ end
195
+
196
+ def test_combined_flags
197
+ assert_equal({ foo: 1 },
198
+ UCL.parse('FOO = 1', UCL::KEY_SYMBOL | UCL::KEY_LOWERCASE))
199
+ end
200
+
201
+ # ---- macros -------------------------------------------------------------
202
+
203
+ def test_disable_macro_does_not_process_include
204
+ # DISABLE_MACRO stops macros from being executed, so the referenced file
205
+ # is never read. libucl versions differ in how they treat the macro line:
206
+ # older ones ignore it (parsing only the remaining keys), newer ones reject
207
+ # it as an invalid key. Both outcomes are acceptable here.
208
+ config = %(.include "/no/such/file"\nx = 1)
209
+ begin
210
+ assert_equal({ 'x' => 1 }, UCL.parse(config, UCL::DISABLE_MACRO))
211
+ rescue UCL::Error => e
212
+ refute_empty e.message
213
+ end
214
+ end
215
+
216
+ def test_include_of_missing_file_raises_without_flag
217
+ assert_raises(UCL::Error) { UCL.parse(%(.include "/no/such"\nx = 1)) }
218
+ end
219
+
220
+ # ---- file variables -----------------------------------------------------
221
+
222
+ def test_no_filevars_leaves_variable_unexpanded
223
+ assert_equal({ 'd' => '$CURDIR' },
224
+ UCL.parse('d = $CURDIR', UCL::NO_FILEVARS))
225
+ end
226
+
227
+ def test_parse_expands_curdir_by_default
228
+ refute_equal '$CURDIR', UCL.parse('d = $CURDIR')['d']
229
+ end
230
+
231
+ # ---- default flags ------------------------------------------------------
232
+
233
+ def test_flags_default_to_zero
234
+ assert_equal 0, UCL.flags
235
+ end
236
+
237
+ def test_flags_are_used_as_default
238
+ UCL.flags = UCL::KEY_SYMBOL
239
+ assert_equal({ name: 'value' }, UCL.parse('name = value'))
240
+ end
241
+
242
+ def test_explicit_flags_override_default
243
+ UCL.flags = UCL::KEY_SYMBOL
244
+ assert_equal({ 'name' => 'value' }, UCL.parse('name = value', 0))
245
+ end
246
+
247
+ # ---- result object ------------------------------------------------------
248
+
249
+ def test_result_is_mutable
250
+ result = UCL.parse('a = 1')
251
+ result['b'] = 2
252
+ assert_equal({ 'a' => 1, 'b' => 2 }, result)
253
+ end
254
+
255
+ # ---- load_file ----------------------------------------------------------
256
+
257
+ def test_load_file
258
+ with_conf("list = [1, 2, 3]\nname = test\n") do |path|
259
+ assert_equal({ 'list' => [1, 2, 3], 'name' => 'test' },
260
+ UCL.load_file(path))
261
+ end
262
+ end
263
+
264
+ def test_load_file_sets_filename_variable
265
+ with_conf('f = "$FILENAME"' + "\n") do |path|
266
+ assert_equal({ 'f' => File.realpath(path) }, UCL.load_file(path))
267
+ end
268
+ end
269
+
270
+ def test_load_file_with_flags
271
+ with_conf("Name = value\n") do |path|
272
+ assert_equal({ name: 'value' },
273
+ UCL.load_file(path, UCL::KEY_SYMBOL | UCL::KEY_LOWERCASE))
274
+ end
275
+ end
276
+
277
+ def test_load_missing_file_raises
278
+ assert_raises(UCL::Error) { UCL.load_file('/no/such/file.conf') }
279
+ end
280
+
281
+ # ---- errors -------------------------------------------------------------
282
+
283
+ def test_malformed_raises_ucl_error
284
+ assert_raises(UCL::Error) { UCL.parse('a = [') }
285
+ end
286
+
287
+ def test_error_message_is_present
288
+ err = assert_raises(UCL::Error) { UCL.parse('a = [') }
289
+ refute_empty err.message
290
+ end
291
+
292
+ def test_error_is_a_standard_error
293
+ assert_operator UCL::Error, :<, StandardError
294
+ end
295
+
296
+ def test_repeated_malformed_parses_do_not_crash
297
+ 100.times do
298
+ assert_raises(UCL::Error) { UCL.parse('} bad {') }
299
+ end
300
+ end
301
+
302
+ def test_parse_rejects_non_string
303
+ assert_raises(TypeError) { UCL.parse(42) }
304
+ end
305
+
306
+ # ---- constants ----------------------------------------------------------
307
+
308
+ def test_constants_defined
309
+ %i[KEY_SYMBOL KEY_LOWERCASE NO_TIME DISABLE_MACRO NO_FILEVARS].each do |c|
310
+ assert UCL.const_defined?(c), "UCL::#{c} should be defined"
311
+ end
312
+ end
313
+
314
+ private
315
+
316
+ def with_conf(content)
317
+ Tempfile.create(['ucl', '.conf']) do |f|
318
+ f.write(content)
319
+ f.flush
320
+ yield f.path
321
+ end
322
+ end
323
+ end
data/ucl.gemspec CHANGED
@@ -1,12 +1,12 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = 'ucl'
3
- s.version = '0.1.3.1'
4
- s.summary = " Universal configuration library parser"
3
+ s.version = '0.1.4'
4
+ s.summary = 'Universal Configuration Language (UCL) parser'
5
5
  s.description = <<~EOF
6
-
7
- Read configuration file in UCL format (binding to the libucl).
8
-
9
- EOF
6
+ Parse configuration files written in the Universal Configuration
7
+ Language (UCL), a human-friendly JSON superset. Native bindings to
8
+ the libucl library; results are returned as plain Ruby objects.
9
+ EOF
10
10
 
11
11
  s.homepage = 'https://github.com/sdalu/ruby-ucl'
12
12
  s.license = 'MIT'
@@ -15,5 +15,15 @@ Gem::Specification.new do |s|
15
15
  s.email = [ 'sdalu@sdalu.com' ]
16
16
 
17
17
  s.extensions = [ 'ext/extconf.rb' ]
18
- s.files = %w[ ucl.gemspec ] + Dir['ext/**/*.{c,h,rb}']
18
+ s.files = %w[ ucl.gemspec README.md LICENSE ] +
19
+ Dir['ext/**/*.{c,h,rb}'] +
20
+ Dir['test/**/*.rb']
21
+
22
+ # Used at build time to download and compile libucl from source when no
23
+ # system-wide installation is found (requires cmake and a C compiler).
24
+ s.add_dependency 'mini_portile2', '~> 2.8'
25
+
26
+ s.add_development_dependency 'rake'
27
+ s.add_development_dependency 'minitest', '~> 5.0'
28
+ s.add_development_dependency 'yard'
19
29
  end
metadata CHANGED
@@ -1,19 +1,74 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ucl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3.1
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stéphane D'Alu
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2023-02-02 00:00:00.000000000 Z
12
- dependencies: []
13
- description: |2+
14
-
15
- Read configuration file in UCL format (binding to the libucl).
16
-
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: mini_portile2
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.8'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: yard
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: |
69
+ Parse configuration files written in the Universal Configuration
70
+ Language (UCL), a human-friendly JSON superset. Native bindings to
71
+ the libucl library; results are returned as plain Ruby objects.
17
72
  email:
18
73
  - sdalu@sdalu.com
19
74
  executables: []
@@ -21,14 +76,16 @@ extensions:
21
76
  - ext/extconf.rb
22
77
  extra_rdoc_files: []
23
78
  files:
79
+ - LICENSE
80
+ - README.md
24
81
  - ext/extconf.rb
25
82
  - ext/ucl.c
83
+ - test/test_ucl.rb
26
84
  - ucl.gemspec
27
85
  homepage: https://github.com/sdalu/ruby-ucl
28
86
  licenses:
29
87
  - MIT
30
88
  metadata: {}
31
- post_install_message:
32
89
  rdoc_options: []
33
90
  require_paths:
34
91
  - lib
@@ -43,9 +100,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
43
100
  - !ruby/object:Gem::Version
44
101
  version: '0'
45
102
  requirements: []
46
- rubygems_version: 3.3.26
47
- signing_key:
103
+ rubygems_version: 4.0.9
48
104
  specification_version: 4
49
- summary: Universal configuration library parser
105
+ summary: Universal Configuration Language (UCL) parser
50
106
  test_files: []
51
- ...