lens_core 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: afb85b28397a4ddf5ce37a849ed0c86ca3a8cb5a0d31bf1d827f57d398135ac6
4
+ data.tar.gz: 4d617c41ea6d3bae184a0a18f9beb589a9cfdc78f0d80e89fbd6ecc480f7250d
5
+ SHA512:
6
+ metadata.gz: 60c91a54b74fb9c0c3fae2c14c98b753a950377834752576b452f824db7cc0746c6cf0a9778be3eab3a1a698c677ff644d90b66c61d53c4dd73fa7028210c032
7
+ data.tar.gz: 6f3b1d77bc94d6995cb5e47528b1e5b6931b5fc91026e485e1b33b02f78c54e3a3d68e77a9c58a3d64210d77342b4c4f0a5bca48dd188148a1a701f7219a187d
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ gem "json_schemer"
8
+ gem "rake"
9
+ gem "rspec"
10
+ gem "rubocop"
11
+ gem "rubocop-performance"
12
+ gem "rubocop-rake"
13
+ gem "rubocop-rspec"
data/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ BSD 2-Clause License
2
+
3
+ Copyright (c) 2026, Ribose
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
17
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
18
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
19
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
20
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
21
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
22
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
23
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
24
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
25
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.adoc ADDED
@@ -0,0 +1,62 @@
1
+ = LensCore
2
+
3
+ LensCore is a small, dependency-free model for conformance diagnostics: one
4
+ report shape that any format checker can emit, with machine-readable (JSON,
5
+ YAML) and human-readable output rendered from the same model.
6
+
7
+ Each diagnostic records:
8
+
9
+ * what is wrong (`kind`);
10
+ * how serious it is (`severity`);
11
+ * where it is: line and column for text formats, byte offset and record for
12
+ binary ones;
13
+ * which rule of which specification or profile it breaks.
14
+
15
+ == Usage
16
+
17
+ [source,ruby]
18
+ ----
19
+ require "lens_core"
20
+
21
+ report = LensCore::Report.new(
22
+ producer: LensCore::Producer.new(name: "my-checker", version: "1.0.0"),
23
+ profile: LensCore::Profile.new(id: "my-profile"),
24
+ valid: false,
25
+ diagnostics: [
26
+ LensCore::Diagnostic.new(
27
+ severity: "error", kind: "bad_checksum", requirement_id: "REC-001",
28
+ message: "Record checksum does not match.",
29
+ location: LensCore::Location.binary(byte_offset: 0, record_index: 0)
30
+ )
31
+ ]
32
+ )
33
+
34
+ puts LensCore::Renderers::Json.render(report)
35
+ puts LensCore::Renderers::Human.render(report)
36
+
37
+ report = LensCore::Parsers::Json.parse(File.binread("report.json"))
38
+ ----
39
+
40
+ Parsers and renderers are `LensCore::Parsers::Json`, `Parsers::Yaml` and
41
+ `LensCore::Renderers::Json`, `Renderers::Yaml`, `Renderers::Human`. Failures
42
+ are subclasses of `LensCore::Error`.
43
+
44
+ == Schema
45
+
46
+ The format is documented in link:docs/diagnostics-v1.adoc[docs/diagnostics-v1.adoc]
47
+ and specified by `schemas/lens-diagnostics-v1.json` (also at
48
+ `LensCore::Schema::PATH`). The gem version is `LensCore::VERSION`; the schema
49
+ version is `LensCore::SCHEMA_VERSION` (`1`).
50
+
51
+ == Status
52
+
53
+ The v1 contract is a ready-to-tag candidate: the schema, model, renderers and
54
+ the golden corpus (`spec/fixtures/v1/golden`) are pinned by SHA-256 in
55
+ `spec/fixtures/v1/golden/manifest.yml`. Nothing is tagged or published yet.
56
+ Adopt it by pinning an exact commit, never a branch; a host application that
57
+ needs only LensCore is shown in `spec/fixtures/host_app`.
58
+
59
+ == License
60
+
61
+ The gem is available as open source under the terms of the BSD 2-Clause
62
+ License.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,237 @@
1
+ = LensCore diagnostics, schema version 1
2
+ :toc:
3
+
4
+ This document is the prose half of the diagnostics wire format. The machine
5
+ half is `schemas/lens-diagnostics-v1.json` (JSON Schema 2020-12). The Ruby
6
+ model in `lib/lens_core/` implements both. Where they differ, the difference
7
+ is listed under <<beyond-schema>>.
8
+
9
+ The gem version (`LensCore::VERSION`) and the schema version
10
+ (`LensCore::SCHEMA_VERSION`, also `LensCore::Schema.version`) are separate
11
+ constants. A host that embeds LensCore should report both.
12
+
13
+ == Document shape
14
+
15
+ A report is one JSON (or YAML) object. Members appear in the order shown.
16
+
17
+ [cols="2,1,4",options="header"]
18
+ |===
19
+ | Member | Required | Meaning
20
+
21
+ | `schema_version` | yes | The integer `1`. Written without a fraction.
22
+ | `producer` | yes | The tool that made the report: `name`, `version`
23
+ (non-empty strings).
24
+ | `profile` | yes | The rule set checked against: `id` (non-empty string) and
25
+ `citations` (array, may be empty).
26
+ | `timestamp` | no | When the check ran, if the caller says so. RFC 3339 with an
27
+ explicit offset. See <<timestamps>>.
28
+ | `valid` | yes | Boolean verdict, stated by the producer.
29
+ | `summary` | yes | `errors`, `warnings`, `infos`: integers derived from
30
+ `diagnostics`.
31
+ | `diagnostics` | yes | Array of diagnostics, in the producer's order (may be
32
+ empty).
33
+ |===
34
+
35
+ === Diagnostic
36
+
37
+ [cols="2,1,4",options="header"]
38
+ |===
39
+ | Member | Required | Meaning
40
+
41
+ | `severity` | yes | `error`, `warning` or `info`.
42
+ | `kind` | yes | Non-empty string naming the sort of finding. Open
43
+ vocabulary: see <<compatibility>>.
44
+ | `requirement_id` | yes | Non-empty string identifying the requirement, stable
45
+ across releases of the producer.
46
+ | `message` | yes | Non-empty human-readable text. May contain newlines.
47
+ | `citations` | no | Array of citations; `[]` when absent.
48
+ | `remediation` | no | Non-empty text saying how to fix it.
49
+ | `context` | no | JSON object of supporting values; `{}` when absent.
50
+ | `location` | no | A location. Absent when the finding has no place.
51
+ |===
52
+
53
+ === Citation
54
+
55
+ `uri` (required): an absolute URI, that is a scheme followed by `:` and
56
+ non-whitespace text (`https://...`, `urn:...`). `label` (optional): a
57
+ non-empty human name for the cited text.
58
+
59
+ === Location
60
+
61
+ `type` is required and is `xml` or `binary`. The other members are
62
+ coordinates, all optional; a coordinate that is unknown is left out.
63
+
64
+ [cols="1,2,4",options="header"]
65
+ |===
66
+ | Type | Member | Meaning
67
+
68
+ | `xml` | `line` | Integer >= 1.
69
+ | `xml` | `column` | Integer >= 1.
70
+ | `xml` | `path` | Non-empty string, e.g. an element path.
71
+ | `binary` | `byte_offset` | Integer >= 0.
72
+ | `binary` | `byte_length` | Integer >= 0.
73
+ | `binary` | `record_index` | Integer >= 0, counting from zero.
74
+ | `binary` | `record_type` | Non-empty string.
75
+ |===
76
+
77
+ A coordinate that belongs to the other type (`line` on a `binary` location) is
78
+ a schema violation. The two types are format-neutral examples; neither implies
79
+ that any particular lens uses this gem.
80
+
81
+ [[compatibility]]
82
+ == Compatibility
83
+
84
+ * A document whose `schema_version` is any integer other than the one this gem
85
+ implements is rejected with `LensCore::UnsupportedVersionError`. A
86
+ `schema_version` that is not an integer is a `LensCore::SchemaError`.
87
+ * Within version 1, producers may add to a report. Consumers must accept:
88
+ - new `kind` values;
89
+ - additional (unknown) members on any object: the report, `producer`,
90
+ `profile`, a diagnostic, a citation, a location.
91
+ * The Ruby model keeps unknown members as _extensions_ and writes them back
92
+ after the standard members (see <<ordering>>). The one exception is
93
+ `summary`: its counts are derived, so any other member inside it is ignored
94
+ and not written back.
95
+ * Removing a member, changing a member's meaning or type, or adding a new
96
+ `severity` or location `type` is a new schema version.
97
+ * Integers must be written as integers (`3`, not `3.0` or `3e0`). JSON Schema
98
+ validators treat `3.0` as an integer; LensCore does not.
99
+
100
+ [[omission]]
101
+ == Omission and null
102
+
103
+ * An optional member that has no value is omitted. `null` is never a valid
104
+ value for a standard member; a document that contains one is a
105
+ `SchemaError`. (Extension members and values inside `context` may be `null`
106
+ like any JSON value.)
107
+ * `citations` (on a diagnostic) and `context` read as `[]` and `{}` when
108
+ absent. The renderers always write them, so canonical output has them.
109
+ * `timestamp`, `remediation` and `location` are written only when present.
110
+ * Location coordinates are never defaulted. A zero is written only when the
111
+ coordinate is really zero.
112
+
113
+ [[ordering]]
114
+ == Ordering and canonical form
115
+
116
+ * `diagnostics` keep the order given by the producer. Nothing sorts them.
117
+ * Standard members appear in the order of the tables above: report members as
118
+ `schema_version`, `producer`, `profile`, `timestamp`, `valid`, `summary`,
119
+ `diagnostics`; diagnostic members as `severity`, `kind`, `requirement_id`,
120
+ `message`, `citations`, `remediation`, `context`, `location`.
121
+ * Extension members follow the standard members, sorted lexically by bytes.
122
+ * Keys inside `context` and inside extension values are sorted lexically, at
123
+ every depth.
124
+ * `summary` is computed from the diagnostics: one count per severity.
125
+ * Canonical JSON is `JSON.pretty_generate` output (two-space indent) plus one
126
+ final newline. Parsing canonical JSON and rendering it again returns the same
127
+ bytes. Non-canonical JSON (other key order, other spacing) parses to the same
128
+ model and renders in canonical form.
129
+ * YAML carries the same content with the same key order, rendered with
130
+ `YAML.dump` and unbounded line width.
131
+
132
+ [[timestamps]]
133
+ == Timestamps
134
+
135
+ LensCore never reads the clock. A `timestamp` appears only when the caller
136
+ supplies one, as a `Time` or an RFC 3339 string with an explicit offset
137
+ (`Z` or `+hh:mm`). It is rendered in UTC with a `Z` suffix, with sub-second
138
+ digits only when there are some (`2026-10-23T10:00:00Z`,
139
+ `2026-10-23T10:00:00.25Z`). A string without an offset is rejected rather
140
+ than guessed at.
141
+
142
+ In YAML, quote the timestamp (the renderer does). An unquoted RFC 3339
143
+ timestamp is also accepted, in the `timestamp` member only.
144
+
145
+ == Validity
146
+
147
+ `valid` is the producer's verdict, because policy (do warnings fail a
148
+ document?) belongs to the producer. One rule is fixed: `valid` cannot be
149
+ `true` when the report contains an `error`.
150
+
151
+ == Text and encoding
152
+
153
+ * Documents are UTF-8. The parsers accept a String tagged UTF-8, US-ASCII or
154
+ binary and read its bytes as UTF-8; a String in any other encoding is
155
+ rejected, not transcoded.
156
+ * A byte order mark is rejected.
157
+ * Every string in a report, including object keys, must be valid UTF-8.
158
+
159
+ == Renderers
160
+
161
+ JSON::
162
+ `LensCore::Renderers::Json.render(report)`: canonical JSON.
163
+ YAML::
164
+ `LensCore::Renderers::Yaml.render(report)`: canonical YAML.
165
+ Human::
166
+ `LensCore::Renderers::Human.render(report)`: plain text with no colour or
167
+ wrapping. A header line (producer, profile, the timestamp if present), a
168
+ `VALID`/`INVALID` line with the three counts, then one block per diagnostic
169
+ in order, showing severity, requirement ID, kind, location (present
170
+ coordinates only), message, remediation, citations and context. It is meant
171
+ for reading, not parsing.
172
+
173
+ All three show every diagnostic of every severity.
174
+
175
+ == Parsers and failures
176
+
177
+ `LensCore::Parsers::Json.parse(text)` and `LensCore::Parsers::Yaml.parse(text)`
178
+ return a `LensCore::Report` or raise a subclass of `LensCore::Error`:
179
+
180
+ [cols="2,4",options="header"]
181
+ |===
182
+ | Error class | Raised when
183
+
184
+ | `LensCore::ParseError` | The text is not well-formed JSON or YAML, is not a
185
+ String, or starts with a byte order mark.
186
+ | `LensCore::UnsafeYamlError` | The YAML uses an object tag, a symbol, or an
187
+ alias. Anchor declarations that nothing aliases are harmless and accepted.
188
+ | `LensCore::InvalidEncodingError` | The input, or a string inside it, is not
189
+ valid UTF-8, or the input is a String in another encoding.
190
+ | `LensCore::UnsupportedVersionError` | `schema_version` is an integer other
191
+ than 1.
192
+ | `LensCore::SchemaError` | The document is well-formed but breaks the schema.
193
+ `error.path` is the list of keys and indexes to the offending member;
194
+ `error.message` renders it, e.g. `diagnostics[1].location.line: must be >= 1`.
195
+ |===
196
+
197
+ The same `SchemaError` and `InvalidEncodingError` are raised when a value
198
+ object is built directly in Ruby with bad arguments.
199
+
200
+ [[beyond-schema]]
201
+ == Rules beyond JSON Schema
202
+
203
+ JSON Schema validation alone accepts documents that LensCore rejects:
204
+
205
+ * `summary` counts must equal the number of diagnostics of each severity.
206
+ JSON Schema cannot count array items by property.
207
+ * Integer-valued numbers written with a fraction (`3.0`).
208
+ * Unsafe YAML constructs and invalid UTF-8, which are rejected before a
209
+ schema applies.
210
+
211
+ The schema does enforce that `valid: true` goes with `summary.errors` equal to
212
+ zero.
213
+
214
+ == Golden corpus
215
+
216
+ `spec/fixtures/v1/golden` holds canonical v1 reports that every release must
217
+ parse and render byte for byte, and that validate against the schema.
218
+ `manifest.yml` records, for each file, its SHA-256, producer and version,
219
+ schema version, source fixture and the LensCore contract commit used to render
220
+ it; SVG exports also record the exact svg_conform commit. The schema's own
221
+ SHA-256 is recorded too. Goldens omit timestamps.
222
+
223
+ * `svg_conform/` holds three real exports: an SVG 1.1 CSS grammar error, an
224
+ SVG 2 secure-static external reference, and a four-channel report (error,
225
+ validity error, warning, info).
226
+ * `examples/` holds format-neutral examples of the two location forms. The
227
+ binary example is a contract example only; it is not evidence of any PNG,
228
+ EMF or PostScript implementation.
229
+
230
+ `ruby -Ilib script/refresh_svg_goldens PATH_TO_SVG_CONFORM` regenerates the
231
+ three SVG files and their hashes. It refuses a checkout whose HEAD is not the
232
+ commit named in the manifest, canonicalizes through LensCore, and writes only
233
+ inside this repository.
234
+
235
+ `spec/fixtures/host_app` is a minimal application whose bundle contains only
236
+ LensCore. `bundle exec ruby read_reports.rb DIRECTORY` reads every report in a
237
+ directory and prints profile, counts and locations.
data/lens_core.gemspec ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/lens_core/version"
4
+
5
+ all_files_in_git = Dir.chdir(File.expand_path(__dir__)) do
6
+ `git ls-files -z`.split("\x0")
7
+ end
8
+
9
+ Gem::Specification.new do |spec|
10
+ spec.name = "lens_core"
11
+ spec.version = LensCore::VERSION
12
+ spec.authors = ["Ribose"]
13
+ spec.email = ["open.source@ribose.com"]
14
+
15
+ spec.summary = "Format-neutral conformance diagnostics model and renderers."
16
+ spec.description = "One diagnostics report shape (schema version 1) " \
17
+ "that any format checker can emit, with JSON and " \
18
+ "YAML parsers and JSON, YAML and human-readable " \
19
+ "renderers. No runtime dependencies."
20
+ spec.homepage = "https://github.com/claricle/lens_core"
21
+ spec.license = "BSD-2-Clause"
22
+ spec.required_ruby_version = Gem::Requirement.new(">= 3.1.0")
23
+
24
+ spec.metadata["homepage_uri"] = spec.homepage
25
+ spec.metadata["source_code_uri"] = spec.homepage
26
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
27
+ spec.metadata["rubygems_mfa_required"] = "true"
28
+
29
+ spec.files = all_files_in_git
30
+ .reject { |f| f.match(%r{\A(?:(?:test|spec|features|bin|script)/|\.)}) }
31
+ spec.require_paths = ["lib"]
32
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LensCore
4
+ # A pointer to the text a requirement comes from.
5
+ class Citation < Node
6
+ MEMBERS = %w[uri label].freeze
7
+
8
+ attr_reader :uri, :label
9
+
10
+ def self.from_h(hash)
11
+ known, extra = split(hash)
12
+ new(uri: known["uri"], label: known["label"], extensions: extra)
13
+ end
14
+
15
+ # Wire array -> frozen array of Citation; errors carry the array index.
16
+ def self.list_from_h(list, name = "citations")
17
+ Validation.array(list, name).each_with_index.map do |item, i|
18
+ from_h(item)
19
+ rescue SchemaError => e
20
+ raise e.within(i).within(name)
21
+ end.freeze
22
+ end
23
+
24
+ # Ruby array -> frozen array, every item a Citation.
25
+ def self.check_list(list, name = "citations")
26
+ Validation.array(list, name).each_with_index.map do |item, i|
27
+ unless item.is_a?(self)
28
+ Validation.fail_at("must be a LensCore::Citation",
29
+ [name, i])
30
+ end
31
+ item
32
+ end.freeze
33
+ end
34
+
35
+ # @param uri [String] absolute URI (required)
36
+ # @param label [String, nil] human-readable name
37
+ def initialize(uri:, label: nil, extensions: {})
38
+ @uri = Validation.absolute_uri(uri, "uri")
39
+ @label = label.nil? ? nil : Validation.string(label, "label")
40
+ super(extensions: extensions)
41
+ freeze
42
+ end
43
+
44
+ private
45
+
46
+ def standard_members
47
+ { "uri" => uri, "label" => label }
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LensCore
4
+ # One finding. `kind` is an open vocabulary: producers may introduce new
5
+ # values, consumers must tolerate ones they do not know.
6
+ class Diagnostic < Node
7
+ SEVERITIES = %w[error warning info].freeze
8
+ MEMBERS = %w[severity kind requirement_id message citations remediation
9
+ context location].freeze
10
+
11
+ attr_reader(*MEMBERS.map(&:to_sym))
12
+
13
+ def self.from_h(hash)
14
+ known, extra = split(hash)
15
+ new(
16
+ severity: known["severity"], kind: known["kind"],
17
+ requirement_id: known["requirement_id"], message: known["message"],
18
+ remediation: known["remediation"], context: known.fetch("context", {}),
19
+ citations: Citation.list_from_h(known.fetch("citations", [])),
20
+ location: location_from(known["location"]), extensions: extra
21
+ )
22
+ end
23
+
24
+ def self.location_from(value)
25
+ return nil if value.nil?
26
+
27
+ Location.from_h(value)
28
+ rescue SchemaError => e
29
+ raise e.within("location")
30
+ end
31
+ private_class_method :location_from
32
+
33
+ def initialize(severity:, kind:, requirement_id:, message:, citations: [],
34
+ remediation: nil, context: {}, location: nil,
35
+ extensions: {})
36
+ @severity = Validation.one_of(severity, SEVERITIES, "severity")
37
+ @kind = Validation.string(kind, "kind")
38
+ @requirement_id = Validation.string(requirement_id, "requirement_id")
39
+ @message = Validation.string(message, "message")
40
+ @citations = Citation.check_list(citations)
41
+ @remediation = Validation.optional_string(remediation, "remediation")
42
+ @context = Validation.context(context)
43
+ @location = check_location(location)
44
+ super(extensions: extensions)
45
+ freeze
46
+ end
47
+
48
+ SEVERITIES.each do |name|
49
+ define_method(:"#{name}?") { severity == name }
50
+ end
51
+
52
+ private
53
+
54
+ def check_location(location)
55
+ return location if location.nil? || location.is_a?(Location)
56
+
57
+ Validation.fail_at("must be a LensCore::Location", "location")
58
+ end
59
+
60
+ def standard_members
61
+ MEMBERS.to_h { |name| [name, public_send(name)] }.merge(
62
+ "citations" => citations.map(&:to_h), "location" => location&.to_h,
63
+ )
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LensCore
4
+ # Base class of every error LensCore raises for bad input.
5
+ class Error < StandardError; end
6
+
7
+ # The text is not well-formed JSON or YAML (or is not a document at all).
8
+ class ParseError < Error; end
9
+
10
+ # The YAML uses a feature that is not safe to load: an object tag, a symbol,
11
+ # or an alias.
12
+ class UnsafeYamlError < Error; end
13
+
14
+ # The text, or a string inside it, is not valid UTF-8.
15
+ class InvalidEncodingError < Error; end
16
+
17
+ # The document declares a schema version other than LensCore::SCHEMA_VERSION.
18
+ class UnsupportedVersionError < Error; end
19
+
20
+ # The document is well-formed but breaks the schema. `path` names the
21
+ # offending member, e.g. "diagnostics[2].location.line".
22
+ class SchemaError < Error
23
+ attr_reader :detail, :path
24
+
25
+ def initialize(detail, path = [])
26
+ @detail = detail
27
+ @path = path.freeze
28
+ super(path.empty? ? detail : "#{format_path}: #{detail}")
29
+ end
30
+
31
+ # The same error, located one level further out.
32
+ def within(segment)
33
+ self.class.new(detail, [segment, *path])
34
+ end
35
+
36
+ private
37
+
38
+ def format_path
39
+ path.each_with_index.map do |seg, i|
40
+ if seg.is_a?(Integer)
41
+ "[#{seg}]"
42
+ else
43
+ (i.zero? ? seg.to_s : ".#{seg}")
44
+ end
45
+ end.join
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LensCore
4
+ # Where a diagnostic applies. `type` says which coordinates are meaningful;
5
+ # a coordinate that is not known is nil and is omitted from output, never
6
+ # replaced by a placeholder.
7
+ #
8
+ # xml line, column (1-based), path
9
+ # binary byte_offset, byte_length, record_index (0-based), record_type
10
+ class Location < Node
11
+ TYPES = %w[xml binary].freeze
12
+ FIELDS = {
13
+ "xml" => %w[line column path],
14
+ "binary" => %w[byte_offset byte_length record_index record_type],
15
+ }.freeze
16
+ MEMBERS = (["type"] + FIELDS.values.flatten).freeze
17
+ MINIMUMS = {
18
+ "line" => 1, "column" => 1,
19
+ "byte_offset" => 0, "byte_length" => 0, "record_index" => 0
20
+ }.freeze
21
+
22
+ attr_reader :type, :coordinates
23
+
24
+ def self.from_h(hash)
25
+ known, extra = split(hash)
26
+ type = Validation.one_of(known["type"], TYPES, "type")
27
+ new(type: type, **known.except("type").transform_keys(&:to_sym),
28
+ extensions: extra)
29
+ end
30
+
31
+ def self.xml(line: nil, column: nil, path: nil, extensions: {})
32
+ new(type: "xml", line: line, column: column, path: path,
33
+ extensions: extensions)
34
+ end
35
+
36
+ def self.binary(byte_offset: nil, byte_length: nil, record_index: nil,
37
+ record_type: nil, extensions: {})
38
+ new(type: "binary", byte_offset: byte_offset, byte_length: byte_length,
39
+ record_index: record_index, record_type: record_type,
40
+ extensions: extensions)
41
+ end
42
+
43
+ def initialize(type:, extensions: {}, **fields)
44
+ @type = Validation.one_of(type, TYPES, "type")
45
+ @coordinates = coordinates_from(fields)
46
+ super(extensions: extensions)
47
+ freeze
48
+ end
49
+
50
+ FIELDS.values.flatten.each do |name|
51
+ define_method(name) { coordinates[name] }
52
+ end
53
+
54
+ private
55
+
56
+ def coordinates_from(fields)
57
+ applicable = FIELDS.fetch(type)
58
+ reject_stray(fields, applicable)
59
+ applicable.to_h { |name| [name, check(name, fields[name.to_sym])] }
60
+ .compact.freeze
61
+ end
62
+
63
+ def reject_stray(fields, applicable)
64
+ stray = fields.keys.map(&:to_s) - applicable
65
+ return if stray.empty?
66
+
67
+ Validation.fail_at("is not a #{type} coordinate", stray.first)
68
+ end
69
+
70
+ def check(name, value)
71
+ return nil if value.nil?
72
+ return Validation.string(value, name) unless MINIMUMS.key?(name)
73
+
74
+ Validation.integer(value, name, min: MINIMUMS.fetch(name))
75
+ end
76
+
77
+ def standard_members
78
+ { "type" => type }.merge(coordinates)
79
+ end
80
+ end
81
+ end