kanon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,333 @@
1
+ # Kanon: the specification
2
+
3
+ What an implementation must do, in any language. The reasons behind these rules
4
+ are in [decisions.md](decisions.md); the Ruby interface is in the
5
+ [README](../README.md).
6
+
7
+ The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT,
8
+ RECOMMENDED, MAY and OPTIONAL in this document are to be interpreted as described
9
+ in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119.html). They carry that
10
+ meaning only in capitals.
11
+
12
+ ## 1. What this describes
13
+
14
+ A **configuration** is one text file. Reading it yields a **tree**: mappings,
15
+ sequences and scalars, where some scalars came from the file and some came from
16
+ outside it. The file says which ones come from outside, and under what names.
17
+
18
+ Terms used below:
19
+
20
+ - **document** — what the file parses to before anything is done with it.
21
+ - **node** — a mapping in the result tree, as the consumer sees it.
22
+ - **leaf** — a scalar position, or a declaration standing in for one.
23
+ - **path** — the keys and indices from the root of the document down to a leaf.
24
+ - **declaration** — a mapping that describes a value instead of being one.
25
+ - **source** — a lookup from a name to text or nothing. The reference
26
+ implementation reads the process environment.
27
+ - **refusal** — a named failure. The names in section 11 are normative, how an
28
+ implementation represents them is not.
29
+
30
+ ## 2. Settings
31
+
32
+ An implementation MUST take two settings from its caller and MUST NOT discover
33
+ them any other way.
34
+
35
+ **directory** — where configuration files live, `config` until set.
36
+
37
+ **environment** — which section to read. When the caller does not set it, an
38
+ implementation MUST take the first of `APP_ENV`, `RAILS_ENV`, `RACK_ENV` that is
39
+ not empty, and MUST fall back to `development` when none is.
40
+
41
+ Throughout this document, **empty** means absent, or text holding nothing once
42
+ surrounding whitespace is removed. Setting either to nothing MUST be refused, as
43
+ `EmptyDirectory` and `EmptyEnvironment`, and what is stored MUST have surrounding
44
+ whitespace removed. Both settings are process-wide, and changing either MUST empty
45
+ the cache of section 13.
46
+
47
+ ## 3. Locating the file
48
+
49
+ A configuration is addressed by name. A name MUST consist only of lowercase
50
+ letters, digits and underscores; anything else MUST be refused as `FileMissing`
51
+ without touching the filesystem, which also forbids paths.
52
+
53
+ The file is `<directory>/<name>.yml`. When no such file exists the refusal is
54
+ `FileMissing`. When it exists but cannot be opened — it is a directory, or the
55
+ process may not read it — the refusal is `InvalidSource`.
56
+
57
+ ## 4. Reading the document
58
+
59
+ The file is turned into a document in three steps, in this order.
60
+
61
+ **4.1 Template pass.** The text is run through a template pass before it is
62
+ parsed. The pass MAY build the structure of the file, not only its values, so a
63
+ conditional MAY decide whether a key exists at all. When the pass fails, the
64
+ refusal is `InvalidSource` and the message MUST carry only the kind of failure,
65
+ never the text that failed.
66
+
67
+ The template language is implementation-defined; the reference implementation
68
+ uses ERB. **This is the one part of a configuration that does not port.** A file
69
+ carrying template tags is tied to the implementation that reads it, and an
70
+ implementation MUST document which language it accepts.
71
+
72
+ **4.2 Parse.** The result of the template pass is parsed as YAML.
73
+
74
+ - Only the first document MUST be read. A `---` separator starts a second one,
75
+ and everything past it is dropped.
76
+ - Aliases and merge keys MUST be resolved, `<<: *anchor` included.
77
+ - The permitted values are text, numbers, booleans, nothing, mappings and
78
+ sequences. Anything else the parser would build — a timestamp, a
79
+ language-specific object — MUST be refused as `InvalidSource`.
80
+ - A malformed document, and a document referring to itself through an alias, MUST
81
+ be refused as `InvalidSource`. An implementation MUST NOT let a self-referring
82
+ document exhaust its stack.
83
+
84
+ **4.3 Key normalisation.** A key written as an unquoted scalar beginning with a
85
+ colon names the same key as that text without the colon, so `token:` and `:token:`
86
+ are one key. A key written in quotes names exactly its text, so `":token":` is a
87
+ third, different key. Two keys in one mapping that normalise to one name MUST be
88
+ refused as `InvalidSource`, naming both spellings.
89
+
90
+ The distinction rests on quoting, which a parser knows but a plain mapping of the
91
+ parse result no longer carries. An implementation whose parser discards it MUST
92
+ document that a quoted key beginning with a colon is not supported, rather than
93
+ quietly merging it with the unquoted spelling.
94
+
95
+ Keys that are not text at all — a number, a boolean, a sequence used as a key —
96
+ are kept as they are. They are reachable by computed access but never by a
97
+ literal one, and a variable name derived through one is refused by section 8.
98
+
99
+ ## 5. Choosing the section
100
+
101
+ If the document is a mapping and its top level holds a key equal to the
102
+ environment name, the document MUST be narrowed to the value under that key.
103
+ Otherwise the document MUST be read whole. A file with no sections is therefore
104
+ read as it stands, and nothing forces a section onto a list or a dictionary.
105
+
106
+ A narrowed section holding nothing MUST be read as an empty mapping.
107
+
108
+ ## 6. The root key
109
+
110
+ When the document is a mapping with exactly one key, and that key holds a mapping
111
+ which is not a declaration, the key MUST be removed from the data and MUST become
112
+ the first segment of every variable name derived inside it.
113
+
114
+ In every other case the single key stays in the data: over a sequence, over a
115
+ scalar, and over a declaration. It is then an ordinary path segment, so the names
116
+ derived below it are the same either way — only the reading path differs.
117
+
118
+ When the document is a sequence, the **file name** MUST become the first segment
119
+ of every variable name derived inside it, and the result MUST be wrapped in a
120
+ mapping under that same name.
121
+
122
+ ## 7. Declarations
123
+
124
+ A declaration is a mapping that describes a value. Its vocabulary is exactly four
125
+ keys:
126
+
127
+ - **`env`** — the variable name to read, in place of the derived one.
128
+ - **`default`** — what the value is when the source holds nothing.
129
+ - **`type`** — how text from the source becomes a value.
130
+ - **`optional`** — whether the value may be absent.
131
+
132
+ A mapping MUST be treated as a declaration when it holds `env`, or when it holds
133
+ at least one vocabulary key and no other key. Any other mapping is data.
134
+
135
+ A declaration holding a key outside the vocabulary MUST be refused as
136
+ `InvalidDeclaration`, naming the unwanted keys. A mapping carrying `env` is a
137
+ declaration on that key alone, so its other keys are refused rather than keeping
138
+ it as data.
139
+
140
+ `optional` permits a value to be **absent**. Only the value `true` counts, never
141
+ the text `"true"`. An empty string written as `default` is a value, not an
142
+ absence, and reaches the consumer.
143
+
144
+ ## 8. Deriving a variable name
145
+
146
+ A derived name is built from the path: the prefix from section 6, then every key
147
+ and index down to the leaf. Each segment is rendered as text and upper-cased, and
148
+ the segments are joined with a single underscore.
149
+
150
+ ```
151
+ :service: { :pool: { :size: ~ } } → SERVICE_POOL_SIZE
152
+ :service: { :queues: [ { :name: ~ } ] } → SERVICE_QUEUES_0_NAME
153
+ ```
154
+
155
+ A sequence index is a segment like any key, which means a name records a
156
+ **position**. Inserting an element renames every name below it.
157
+
158
+ A declaration holding `env` uses that name instead, whatever the path.
159
+
160
+ Every name, derived or written out, MUST match `[A-Za-z_][A-Za-z0-9_]*`; anything
161
+ else MUST be refused as `InvalidVariableName`.
162
+
163
+ Two leaves MUST NOT read one name. The second MUST be refused as
164
+ `ConflictingVariable`, naming both paths.
165
+
166
+ ## 9. Resolving a leaf
167
+
168
+ A scalar that holds a value in the file is a constant: it MUST NOT consult the
169
+ source, and its name MUST NOT appear in the report of section 14.
170
+
171
+ Every other leaf — an empty scalar, or a declaration — resolves in this order:
172
+
173
+ 1. Determine the name (section 8). A name outside the permitted shape is refused
174
+ here, before anything else about the leaf is examined.
175
+ 2. Refuse a declaration holding keys outside the vocabulary.
176
+ 3. Refuse a declaration whose `type` is not one of the five names of section 10.
177
+ A `type` key written and left empty is refused too, rather than ignored.
178
+ 4. Claim the name, refusing a second leaf reading it.
179
+ 5. Look the name up in the source. Text holding nothing counts as no value; text
180
+ of whitespace only counts as a value.
181
+ 6. Cast what was found, or the `default` when nothing was found (section 10).
182
+ 7. When the result is nothing and the leaf is not `optional`, record the name as
183
+ missing.
184
+
185
+ After the whole document has been walked, if any names were recorded missing the
186
+ read MUST be refused as `MissingValue` **naming all of them at once**. An
187
+ implementation MUST NOT stop at the first.
188
+
189
+ A leaf that is `optional` and resolves to nothing MUST appear in the tree with an
190
+ empty value rather than being dropped.
191
+
192
+ ## 10. Casting
193
+
194
+ No value is no value whatever produced it: a `~` in the file, a template tag that
195
+ expanded to nothing, and a variable that was never set are the same.
196
+
197
+ **With an explicit `type`**, one of five:
198
+
199
+ - **`string`** — rendered as text.
200
+ - **`integer`** — read in base ten, so `010` is ten and never eight, and `0x10` is
201
+ refused rather than read as sixteen.
202
+ - **`float`** — read as a number, so `1e3` is `1000.0`.
203
+ - **`boolean`** — `1`, `true`, `yes` and `on` are true; `0`, `false`, `no` and
204
+ `off` are false; letter case is ignored. Anything else is refused, naming what
205
+ is accepted.
206
+ - **`list`** — split on commas, each item trimmed, empty items dropped. A value
207
+ that leaves no items counts as nothing, so a mandatory list fails rather than
208
+ falling back to its default.
209
+
210
+ A value that does not fit its type MUST be refused as `InvalidValue`. The message
211
+ MUST carry the type, the kind of the value and its length — never the value.
212
+
213
+ **Without an explicit `type`**, the kind of `default` decides: a whole number
214
+ casts as `integer`, a number with a fraction as `float`, `true` or `false` as
215
+ `boolean`, a sequence as `list`. A `default` that is text imposes nothing, and
216
+ whatever the source held arrives unchanged.
217
+
218
+ Either way a `default` MUST be cast exactly as a value from the source is, so
219
+ `{ default: "5", type: integer }` yields the number five.
220
+
221
+ ## 11. Refusals
222
+
223
+ Every refusal below MUST be distinguishable by the consumer, and an implementation
224
+ SHOULD group them under one kind so that a single catch covers the reading of a
225
+ file.
226
+
227
+ - **`FileMissing`** — the file is absent, or the name is not a bare file name.
228
+ - **`InvalidSource`** — the file cannot be opened, its template pass failed, it
229
+ cannot be parsed, it holds a forbidden value, it refers to itself, or it holds
230
+ two keys that normalise to one.
231
+ - **`EmptyDirectory`** — the directory was set to nothing.
232
+ - **`EmptyEnvironment`** — the environment was set to nothing.
233
+ - **`MissingValue`** — mandatory values are absent, all named at once.
234
+ - **`InvalidValue`** — a value does not fit its type, or a declaration names a
235
+ type outside the five.
236
+ - **`InvalidDeclaration`** — a declaration carries a key outside the vocabulary.
237
+ - **`InvalidVariableName`** — a name, derived or written out, is not a usable
238
+ variable name.
239
+ - **`ConflictingVariable`** — two paths read one name.
240
+ - **`UnknownKey`** — a key that is not in the node, an index past the end of a
241
+ sequence, a descent into a scalar.
242
+ - **`ReservedKey`** — a key that would shadow a member of the node itself.
243
+
244
+ An implementation MUST NOT let a failure of the host language escape in place of
245
+ one of these while reading a file.
246
+
247
+ ## 12. The result
248
+
249
+ **One shape.** A consumer MUST NOT be able to tell which values came from the
250
+ file and which from the source.
251
+
252
+ **A node, never a plain mapping.** A reader MUST hand back a node offering access
253
+ by a key written out in the code, access by a key computed at runtime, a descent
254
+ by path, a test for a key, a list of its keys, and a copy as an ordinary mapping
255
+ for a third party that needs one. It MUST NOT pretend to be an ordinary mapping of
256
+ the host language.
257
+
258
+ **A key that is not there is a refusal.** Reading an absent key MUST refuse,
259
+ naming the keys that are present; returning nothing MUST NOT happen. A key
260
+ computed at runtime refuses as `UnknownKey`. A key written out in the code MAY
261
+ instead refuse the way the host language refuses an absent member, as long as the
262
+ message names the keys that are there.
263
+
264
+ **Reserved keys.** If nodes expose named members, a key clashing with one MUST be
265
+ refused as `ReservedKey` when the tree is built, rather than shadowed at read
266
+ time.
267
+
268
+ **Immutable.** The tree MUST be deeply immutable, down to the scalars inside
269
+ sequences. The copy handed out as an ordinary mapping is new and mutable, while
270
+ the scalars inside it remain the immutable ones of the tree.
271
+
272
+ **Diagnostics carry no values.** Inspection, serialisation and the refusals of
273
+ section 11 MUST carry names — of keys, of variables, of declared types — together
274
+ with kinds and lengths, and MUST NOT carry a value read from the source or held in
275
+ the file as data. A name is printed whatever produced it, a template tag included.
276
+ A refusal that wraps a lower failure MUST NOT carry that failure along.
277
+
278
+ ## 13. Caching
279
+
280
+ A configuration is read once per process and kept. Repeated reading through the
281
+ caching reader MUST hand back the same tree, not an equal one. A separate reader
282
+ that re-reads the file every time MUST leave the cache alone, and its result MUST
283
+ be compared by value.
284
+
285
+ Reading is lazy: a configuration the application never asks for MUST NOT be read.
286
+ Changing either setting of section 2 MUST empty the cache. A reader taking several
287
+ names at once MUST cache the trees, not the collection it returns, and MUST
288
+ rebuild that collection on every call.
289
+
290
+ ## 14. Reporting expected variables
291
+
292
+ An implementation MUST offer a report of the names a configuration reads and
293
+ whether each must be provided, without consulting the source.
294
+
295
+ The report MUST be produced by walking the file through the same code a read
296
+ walks, with an empty source in place of the real one.
297
+
298
+ A name is reported as required when, with an empty source, the leaf resolves to
299
+ nothing and is not `optional`.
300
+
301
+ The report walks the document but does not build the tree, so it raises every
302
+ refusal of section 11 except two: `MissingValue`, which an empty source would
303
+ raise for every name, and `ReservedKey`, which is found only while the tree is
304
+ built.
305
+
306
+ ## 15. Out of scope
307
+
308
+ An implementation MUST NOT extend beyond these.
309
+
310
+ - **Not a validator.** Casting and mandatoriness are the whole of it; format,
311
+ ranges and permitted values belong to the application.
312
+ - **Not a secret store.** Rotation, delivery and permissions belong to the
313
+ infrastructure.
314
+ - **Not hot reload.** A configuration is read once per process.
315
+ - **Not a way to express structure through the source.** Nested structure lives in
316
+ the file; a value from the source replaces a scalar, or a flat list of scalars
317
+ given as one comma separated text.
318
+ - **Nothing about environments beyond the section.** Differences between
319
+ installations belong to variables and to per-environment files, never to logic
320
+ inside the configuration.
321
+
322
+ ## 16. Conformance
323
+
324
+ An implementation conforms when it follows every MUST in sections 2 to 15.
325
+
326
+ Two implementations following this text alone MAY still disagree, because prose
327
+ cannot cover every case. Agreement between ports is established by a shared corpus
328
+ — an input, a source, and the tree or refusal that MUST result — not by a
329
+ document. Until one exists, the reference implementation is the tie-breaker.
330
+
331
+ The known edges of this format are listed in [decisions.md](decisions.md). They
332
+ are consequences of the rules above, not departures from them, and a conforming
333
+ implementation reproduces them.
data/kanon.gemspec ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/kanon/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'kanon'
7
+ spec.version = Kanon::VERSION
8
+ spec.authors = ['Maksimenko Pavel']
9
+ spec.email = ['pavel.g.maksimenko@gmail.com']
10
+
11
+ spec.summary = 'A YAML config becomes a frozen tree of nodes, and declares the environment variables it needs'
12
+ spec.description = <<~TEXT
13
+ A YAML file becomes a frozen tree of nodes with dotted access, and the values
14
+ that belong to the environment are declared in that same file. A read that
15
+ misses a mandatory value fails naming every absent variable at once, and a
16
+ typo in a key raises instead of returning nil. ERB tags keep working, so a
17
+ file that already reads the environment that way needs no rewriting. The
18
+ standard library only, with the config directory and the environment name
19
+ injected from outside.
20
+ TEXT
21
+
22
+ spec.homepage = 'https://github.com/MaksimenkoPG/kanon'
23
+ spec.license = 'MIT'
24
+ spec.required_ruby_version = '>= 2.7'
25
+
26
+ spec.metadata['homepage_uri'] = spec.homepage
27
+ spec.metadata['source_code_uri'] = spec.homepage
28
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md"
29
+ spec.metadata['rubygems_mfa_required'] = 'true'
30
+
31
+ spec.files = Dir.chdir(__dir__) do
32
+ `git ls-files -z`.split("\x0").reject do |path|
33
+ path.start_with?('spec/', 'script/', '.') || %w[Gemfile Rakefile].include?(path)
34
+ end
35
+ end
36
+ spec.require_paths = ['lib']
37
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanon
4
+ class Document
5
+ KEYWORD_SAFE_LOAD = YAML.method(:safe_load).parameters.any? { |kind, name| kind == :key && name == :aliases }
6
+
7
+ def initialize(directory, file_name, environment)
8
+ @directory = directory
9
+ @file_name = file_name
10
+ @environment = environment
11
+ end
12
+
13
+ def schema
14
+ narrowed = narrow_to_current_env(symbolized)
15
+
16
+ split_root(narrowed.nil? ? {} : narrowed)
17
+ end
18
+
19
+ private
20
+
21
+ def symbolized
22
+ deep_symbolize(parse(evaluate_erb(read_file)))
23
+ rescue SystemStackError
24
+ raise InvalidSource, "#{@file_name}.yml is nested too deeply to read or refers to itself through an alias",
25
+ cause: nil
26
+ end
27
+
28
+ def read_file
29
+ unless @file_name.to_s.match?(/\A[a-z0-9_]+\z/)
30
+ raise FileMissing,
31
+ "#{@file_name.inspect} is not a bare configuration file name"
32
+ end
33
+
34
+ path = File.join(@directory, "#{@file_name}.yml")
35
+ raise FileMissing, "No such configuration file: #{path}" unless File.exist?(path)
36
+
37
+ File.read(path)
38
+ rescue SystemCallError => e
39
+ raise InvalidSource, "#{@file_name}.yml cannot be opened: #{e.class}", cause: nil
40
+ end
41
+
42
+ # Only the error class reaches the message: the original text carries the
43
+ # value of an environment variable into logs and exception mail.
44
+ def evaluate_erb(text)
45
+ ERB.new(text).result
46
+ rescue StandardError, ScriptError => e
47
+ raise InvalidSource, "#{@file_name}.yml failed while running ERB: #{e.class}", cause: nil
48
+ end
49
+
50
+ def parse(text)
51
+ loaded = if KEYWORD_SAFE_LOAD
52
+ YAML.safe_load(text, permitted_classes: [Symbol], aliases: true)
53
+ else
54
+ YAML.safe_load(text, [Symbol], [], true)
55
+ end
56
+ loaded || {}
57
+ rescue Psych::SyntaxError => e
58
+ raise InvalidSource, "#{@file_name}.yml is not valid YAML: #{e.message}", cause: nil
59
+ rescue Psych::DisallowedClass => e
60
+ raise InvalidSource, "#{@file_name}.yml holds a YAML type the loader does not accept: #{e.message}",
61
+ cause: nil
62
+ rescue Psych::Exception => e
63
+ raise InvalidSource, "#{@file_name}.yml cannot be built into a document: #{e.message}", cause: nil
64
+ end
65
+
66
+ def narrow_to_current_env(node)
67
+ return node unless node.is_a?(Hash) && node.key?(@environment.to_sym)
68
+
69
+ node.fetch(@environment.to_sym)
70
+ end
71
+
72
+ def split_root(node)
73
+ return [[@file_name], node] if node.is_a?(Array)
74
+ return [[], node] unless node.is_a?(Hash) && node.size == 1 && node.values.first.is_a?(Hash) &&
75
+ !Kanon::EnvDeclarations.declaration?(node.values.first)
76
+
77
+ [[node.keys.first], node.values.first]
78
+ end
79
+
80
+ def deep_symbolize(node)
81
+ case node
82
+ when Hash then symbolize_keys(node)
83
+ when Array then node.map { |value| deep_symbolize(value) }
84
+ else node
85
+ end
86
+ end
87
+
88
+ def symbolize_keys(node)
89
+ seen = {}
90
+ node.each_with_object({}) do |(key, value), result|
91
+ name = key.respond_to?(:to_sym) ? key.to_sym : key
92
+ reject_twin(seen[name], key) if seen.key?(name)
93
+
94
+ seen[name] = key
95
+ result[name] = deep_symbolize(value)
96
+ end
97
+ end
98
+
99
+ def reject_twin(first, second)
100
+ raise InvalidSource, "#{@file_name}.yml reads #{first.inspect} and #{second.inspect} as one key"
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanon
4
+ class EnvDeclarations
5
+ DECLARATION_KEYS = %i[env default type optional].freeze
6
+ NAME_SEPARATOR = '_'
7
+ USABLE_NAME = /\A[A-Za-z_][A-Za-z0-9_]*\z/.freeze
8
+
9
+ private_constant :NAME_SEPARATOR, :USABLE_NAME
10
+
11
+ def self.declaration?(value)
12
+ return false unless value.is_a?(Hash)
13
+ return true if value.key?(:env)
14
+
15
+ value.keys.any? { |key| DECLARATION_KEYS.include?(key) } && (value.keys - DECLARATION_KEYS).empty?
16
+ end
17
+
18
+ def initialize(file_name, root_path = [])
19
+ @file_name = file_name
20
+ @root_path = root_path
21
+ end
22
+
23
+ def resolve(schema)
24
+ resolved = walk_with(schema, ENV)
25
+ raise MissingValue, "#{@file_name}.yml expects values from environment: #{@missing.join(', ')}" if @missing.any?
26
+
27
+ resolved
28
+ end
29
+
30
+ def expected(schema)
31
+ walk_with(schema, {})
32
+
33
+ @taken.keys.to_h { |name| [name, @missing.include?(name)] }
34
+ end
35
+
36
+ def variable_name(path)
37
+ usable((@root_path + path).map { |key| key.to_s.upcase }.join(NAME_SEPARATOR))
38
+ end
39
+
40
+ private
41
+
42
+ def walk_with(schema, source)
43
+ @source = source
44
+ @missing = []
45
+ @taken = {}
46
+
47
+ walk(schema, []) { |node, path| resolve_leaf(node, path) }
48
+ end
49
+
50
+ def walk(node, path, &leaf)
51
+ case node
52
+ when Hash
53
+ return leaf.call(node, path) if self.class.declaration?(node)
54
+
55
+ node.each_with_object({}) { |(key, value), result| result[key] = walk(value, path + [key], &leaf) }
56
+ when Array then node.each_with_index.map { |value, index| walk(value, path + [index], &leaf) }
57
+ else leaf.call(node, path)
58
+ end
59
+ end
60
+
61
+ def resolve_leaf(node, path)
62
+ node.is_a?(Hash) ? resolve_declaration(node, path) : resolve_scalar(node, path)
63
+ end
64
+
65
+ def resolve_declaration(declaration, path)
66
+ name = name_of(declaration, path)
67
+ reject_unknown_keys(declaration, name)
68
+ Typecast.reject_unknown_type(declaration[:type], name) if declaration.key?(:type)
69
+ claim(name, path)
70
+
71
+ value = value_of(name)
72
+ resolved = cast(value.nil? ? declaration[:default] : value, declaration, name)
73
+ return resolved unless resolved.nil?
74
+
75
+ @missing << name if mandatory?(declaration)
76
+ nil
77
+ end
78
+
79
+ # An empty value means "expected and not given", whatever produced the
80
+ # emptiness: a tilde in the file, an ERB tag or a missing variable.
81
+ def resolve_scalar(value, path)
82
+ return value unless value.nil?
83
+
84
+ name = variable_name(path)
85
+ claim(name, path)
86
+ resolved = value_of(name)
87
+ return resolved unless resolved.nil?
88
+
89
+ @missing << name
90
+ nil
91
+ end
92
+
93
+ def name_of(declaration, path)
94
+ declaration[:env] ? usable(declaration[:env].to_s) : variable_name(path)
95
+ end
96
+
97
+ def usable(name)
98
+ return name if USABLE_NAME.match?(name)
99
+
100
+ raise InvalidVariableName, "#{@file_name}.yml wants #{name.inspect}, which no shell can set as a variable"
101
+ end
102
+
103
+ def cast(value, declaration, name)
104
+ return nil if value.nil?
105
+ return Typecast.by_type_name(value, declaration[:type], name) if declaration.key?(:type)
106
+
107
+ Typecast.like_sample(value, declaration[:default], name)
108
+ end
109
+
110
+ # The comparison is literal: the string 'false' from YAML is truthy in Ruby
111
+ # and would otherwise switch the flag on.
112
+ def mandatory?(declaration)
113
+ declaration[:optional] != true
114
+ end
115
+
116
+ def claim(name, path)
117
+ other = @taken[name]
118
+ if other
119
+ raise ConflictingVariable,
120
+ "#{@file_name}.yml reads #{name} for both #{other.join('.')} and #{path.join('.')}"
121
+ end
122
+
123
+ @taken[name] = path
124
+ end
125
+
126
+ def value_of(name)
127
+ raw = @source[name]
128
+ raw.nil? || raw.empty? ? nil : raw
129
+ end
130
+
131
+ def reject_unknown_keys(declaration, name)
132
+ unknown = declaration.keys - DECLARATION_KEYS
133
+ return if unknown.empty?
134
+
135
+ raise InvalidDeclaration,
136
+ "#{@file_name}.yml declares #{name} with unsupported keys #{unknown.inspect}, " \
137
+ "only #{DECLARATION_KEYS.inspect} are allowed"
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kanon
4
+ class Error < StandardError; end
5
+ class FileMissing < Error; end
6
+ class InvalidSource < Error; end
7
+ class EmptyDirectory < Error; end
8
+ class EmptyEnvironment < Error; end
9
+ class InvalidDeclaration < Error; end
10
+ class InvalidVariableName < Error; end
11
+ class MissingValue < Error; end
12
+ class InvalidValue < Error; end
13
+ class ReservedKey < Error; end
14
+ class ConflictingVariable < Error; end
15
+ class UnknownKey < Error; end
16
+ end