statifier 0.0.1 → 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 +4 -4
- data/CHANGELOG.md +35 -0
- data/{LICENSE.txt → LICENSE} +1 -1
- data/README.md +601 -16
- data/lib/statifier/accepts.rb +167 -0
- data/lib/statifier/basichttp/decode.rb +142 -0
- data/lib/statifier/basichttp/encoding.rb +224 -0
- data/lib/statifier/basichttp/net_http_transport.rb +157 -0
- data/lib/statifier/basichttp/outbound.rb +87 -0
- data/lib/statifier/basichttp/processor.rb +170 -0
- data/lib/statifier/basichttp/request.rb +94 -0
- data/lib/statifier/basichttp/response.rb +34 -0
- data/lib/statifier/basichttp.rb +47 -0
- data/lib/statifier/compiler/content.rb +131 -0
- data/lib/statifier/compiler/error.rb +30 -0
- data/lib/statifier/compiler/expressions.rb +64 -0
- data/lib/statifier/compiler/numbering.rb +145 -0
- data/lib/statifier/compiler/parts.rb +144 -0
- data/lib/statifier/compiler/walk.rb +61 -0
- data/lib/statifier/compiler.rb +82 -0
- data/lib/statifier/datamodel/data_binding.rb +114 -0
- data/lib/statifier/datamodel/delay.rb +54 -0
- data/lib/statifier/datamodel/location.rb +145 -0
- data/lib/statifier/datamodel/program.rb +82 -0
- data/lib/statifier/datamodel/reason_value.rb +42 -0
- data/lib/statifier/datamodel/reasons.rb +88 -0
- data/lib/statifier/datamodel/stores.rb +169 -0
- data/lib/statifier/datamodel/system_variables.rb +69 -0
- data/lib/statifier/datamodel.rb +120 -0
- data/lib/statifier/document/data.rb +36 -0
- data/lib/statifier/document/executable.rb +69 -0
- data/lib/statifier/document/invoke.rb +17 -0
- data/lib/statifier/document/states.rb +40 -0
- data/lib/statifier/document.rb +51 -0
- data/lib/statifier/driver/calls.rb +183 -0
- data/lib/statifier/driver/checks.rb +110 -0
- data/lib/statifier/driver/delivery.rb +45 -0
- data/lib/statifier/driver/invocations.rb +129 -0
- data/lib/statifier/driver/mailbox.rb +120 -0
- data/lib/statifier/driver/routing.rb +193 -0
- data/lib/statifier/driver/session_id.rb +40 -0
- data/lib/statifier/driver/work.rb +196 -0
- data/lib/statifier/driver.rb +205 -0
- data/lib/statifier/effect.rb +208 -0
- data/lib/statifier/event.rb +67 -0
- data/lib/statifier/frozen.rb +22 -0
- data/lib/statifier/interpreter/blocks.rb +142 -0
- data/lib/statifier/interpreter/content.rb +228 -0
- data/lib/statifier/interpreter/entering.rb +141 -0
- data/lib/statifier/interpreter/exit_entry.rb +174 -0
- data/lib/statifier/interpreter/foreach.rb +83 -0
- data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
- data/lib/statifier/interpreter/invoke/passes.rb +143 -0
- data/lib/statifier/interpreter/invoke.rb +145 -0
- data/lib/statifier/interpreter/loop.rb +170 -0
- data/lib/statifier/interpreter/machine_state.rb +152 -0
- data/lib/statifier/interpreter/macrostep.rb +185 -0
- data/lib/statifier/interpreter/name_match.rb +43 -0
- data/lib/statifier/interpreter/selection.rb +221 -0
- data/lib/statifier/interpreter/send.rb +223 -0
- data/lib/statifier/interpreter/step.rb +138 -0
- data/lib/statifier/interpreter/stop.rb +66 -0
- data/lib/statifier/interpreter/targets.rb +107 -0
- data/lib/statifier/interpreter.rb +57 -0
- data/lib/statifier/lowering/branches.rb +84 -0
- data/lib/statifier/lowering/data.rb +102 -0
- data/lib/statifier/lowering/error.rb +102 -0
- data/lib/statifier/lowering/executable.rb +106 -0
- data/lib/statifier/lowering/placement.rb +65 -0
- data/lib/statifier/lowering/reading.rb +53 -0
- data/lib/statifier/lowering/states.rb +111 -0
- data/lib/statifier/lowering.rb +129 -0
- data/lib/statifier/machine/content.rb +81 -0
- data/lib/statifier/machine/data.rb +42 -0
- data/lib/statifier/machine/states.rb +56 -0
- data/lib/statifier/machine/values.rb +37 -0
- data/lib/statifier/machine.rb +103 -0
- data/lib/statifier/position/rebuild.rb +84 -0
- data/lib/statifier/position/shape.rb +73 -0
- data/lib/statifier/position/text.rb +43 -0
- data/lib/statifier/position.rb +206 -0
- data/lib/statifier/validator/attributes.rb +35 -0
- data/lib/statifier/validator/context.rb +74 -0
- data/lib/statifier/validator/data.rb +136 -0
- data/lib/statifier/validator/error.rb +90 -0
- data/lib/statifier/validator/executable.rb +139 -0
- data/lib/statifier/validator/history.rb +81 -0
- data/lib/statifier/validator/messages.rb +142 -0
- data/lib/statifier/validator/states.rb +161 -0
- data/lib/statifier/validator.rb +94 -0
- data/lib/statifier/version.rb +4 -1
- data/lib/statifier/xml/character_data.rb +142 -0
- data/lib/statifier/xml/cursor.rb +131 -0
- data/lib/statifier/xml/markup.rb +153 -0
- data/lib/statifier/xml/nodes.rb +50 -0
- data/lib/statifier/xml/parser.rb +121 -0
- data/lib/statifier/xml/scope.rb +46 -0
- data/lib/statifier/xml/tags.rb +112 -0
- data/lib/statifier/xml.rb +85 -0
- data/lib/statifier.rb +386 -2
- metadata +129 -20
- data/.gitignore +0 -8
- data/CODE_OF_CONDUCT.md +0 -74
- data/Gemfile +0 -11
- data/Gemfile.lock +0 -33
- data/Rakefile +0 -10
- data/bin/console +0 -8
- data/bin/setup +0 -6
- data/statifier.gemspec +0 -34
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "document"
|
|
4
|
+
require_relative "lowering"
|
|
5
|
+
require_relative "validator/error"
|
|
6
|
+
require_relative "validator/context"
|
|
7
|
+
require_relative "validator/states"
|
|
8
|
+
require_relative "validator/history"
|
|
9
|
+
require_relative "validator/data"
|
|
10
|
+
require_relative "validator/executable"
|
|
11
|
+
require_relative "validator/attributes"
|
|
12
|
+
|
|
13
|
+
module Statifier
|
|
14
|
+
# The typed chart in, the chart or every error it carries out: the third
|
|
15
|
+
# step of reading a chart, a port of the reference's validator, and the
|
|
16
|
+
# gate in front of the compiler. A chart with an error is not compiled.
|
|
17
|
+
#
|
|
18
|
+
# Lowering keeps what was written, including shapes the specification
|
|
19
|
+
# forbids; the validator holds that chart to the rules lowering leaves to
|
|
20
|
+
# it. It also answers warnings, findings that do not gate compilation.
|
|
21
|
+
# Four contracts, the reference's own:
|
|
22
|
+
#
|
|
23
|
+
# - Collect-all. Every check runs over the whole chart on every call; a
|
|
24
|
+
# chart that breaks five rules answers five errors.
|
|
25
|
+
# - Document order. The errors, and separately the warnings, are sorted by
|
|
26
|
+
# where they start; findings at one offset keep the order the checks
|
|
27
|
+
# ran in.
|
|
28
|
+
# - Never a partial result. The chart is answered only when there is no
|
|
29
|
+
# error, and it is the caller's own chart, unchanged.
|
|
30
|
+
# - Both channels always. The warnings are answered whether or not an
|
|
31
|
+
# error is.
|
|
32
|
+
#
|
|
33
|
+
# No check here parses an expression: a `cond` or an `expr` is checked
|
|
34
|
+
# when the chart is compiled, as the reference checks it there.
|
|
35
|
+
#
|
|
36
|
+
# @api private
|
|
37
|
+
module Validator
|
|
38
|
+
# The checks that gate compilation, in the reference's order: it decides
|
|
39
|
+
# the order of errors at one offset.
|
|
40
|
+
CHECKS = [
|
|
41
|
+
[States, :ids], [States, :targets], [States, :initial_targets], [States, :initial_elements],
|
|
42
|
+
[History, :histories], [History, :finals], [History, :final_parents], [History, :default_entries],
|
|
43
|
+
[DataChecks, :donedata], [DataChecks, :contents], [DataChecks, :params], [Enums, :boilerplate],
|
|
44
|
+
[Enums, :enums], [DataChecks, :data], [Executable, :assigns], [Executable, :ifs], [Executable, :scripts],
|
|
45
|
+
[Executable, :invokes], [Executable, :sends_check], [Executable, :cancels], [Attributes, :duplicates]
|
|
46
|
+
].freeze
|
|
47
|
+
|
|
48
|
+
# The checks whose findings are warnings.
|
|
49
|
+
WARNING_CHECKS = [[Executable, :finalize_warnings]].freeze
|
|
50
|
+
|
|
51
|
+
# What {Validator.validate} answers: the chart as `value` when no check
|
|
52
|
+
# found an error (nil otherwise), every {Error} as `errors` and every
|
|
53
|
+
# {Warning} as `warnings`, each frozen and in document order.
|
|
54
|
+
Result = ::Data.define(:value, :errors, :warnings) do
|
|
55
|
+
# @return [Boolean] true when the chart may be compiled
|
|
56
|
+
def ok? = errors.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The encodings whose strings are read from their bytes.
|
|
60
|
+
READ_AS_BYTES = [Encoding::BINARY, Encoding::US_ASCII].freeze
|
|
61
|
+
|
|
62
|
+
# Holds a lowered chart to every rule that gates compilation.
|
|
63
|
+
#
|
|
64
|
+
# @param document [Document] the chart {Lowering.lower} answered
|
|
65
|
+
# @param source [String] the document it was read from: the enumerated
|
|
66
|
+
# attributes and the attributes of each tag are read back out of it
|
|
67
|
+
# @param invoke_content_markup [Boolean] accept a root that declares no
|
|
68
|
+
# namespace, as inline invoke content markup is placed in the SCXML
|
|
69
|
+
# namespace; a root declaring another namespace is still refused
|
|
70
|
+
# @return [Result] the chart, or its errors, and its warnings
|
|
71
|
+
def self.validate(document, source, invoke_content_markup: false)
|
|
72
|
+
context = Index.build(document, utf8(source), invoke_content_markup)
|
|
73
|
+
errors = run(CHECKS, document, context)
|
|
74
|
+
Result.new(value: errors.empty? ? document : nil, errors:, warnings: run(WARNING_CHECKS, document, context))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Every finding of `checks`, sorted by start offset, the checks' order
|
|
78
|
+
# kept at one offset.
|
|
79
|
+
def self.run(checks, document, context)
|
|
80
|
+
findings = checks.flat_map { |owner, check| owner.public_send(check, document, context) }
|
|
81
|
+
findings.each_with_index.sort_by { |finding, index| [finding.location.start_offset, index] }.map(&:first).freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The source as the reader read it, so that its byte offsets slice it.
|
|
85
|
+
def self.utf8(source)
|
|
86
|
+
return source if source.encoding == Encoding::UTF_8
|
|
87
|
+
return source.dup.force_encoding(Encoding::UTF_8) if READ_AS_BYTES.include?(source.encoding)
|
|
88
|
+
|
|
89
|
+
source.encode(Encoding::UTF_8)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private_class_method :run, :utf8
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/statifier/version.rb
CHANGED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module XML
|
|
5
|
+
# Reading the text of a document: character data, CDATA sections,
|
|
6
|
+
# attribute values and the references inside them.
|
|
7
|
+
#
|
|
8
|
+
# Character data folds line breaks per XML 1.0 section 2.11: a literal
|
|
9
|
+
# CR LF pair and a literal lone CR each become one LF, inside a CDATA
|
|
10
|
+
# section too, while a CR decoded from `
` stays a CR. An attribute
|
|
11
|
+
# value is normalized per section 3.3.3: a literal TAB, LF or CR becomes a
|
|
12
|
+
# space, a literal CR LF pair becomes one space, a character reference
|
|
13
|
+
# keeps its decoded character, and nothing is trimmed or collapsed. Both
|
|
14
|
+
# read the raw source, so a CR followed by a comment is a lone CR.
|
|
15
|
+
#
|
|
16
|
+
# The five predefined entities and decimal and hex character references
|
|
17
|
+
# expand in text and in attribute values. Any other entity reference is
|
|
18
|
+
# refused in a document with no document type declaration, where it can
|
|
19
|
+
# name no declared entity. In a document with one, a reference to any
|
|
20
|
+
# other name is kept as its literal text (`&name;`), as the reference
|
|
21
|
+
# keeps it: the declarations are not read, so nothing expands it.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
module CharacterData
|
|
25
|
+
PREDEFINED = { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => '"', "apos" => "'" }.freeze
|
|
26
|
+
|
|
27
|
+
# A reference body runs to the first `;` and never spans whitespace,
|
|
28
|
+
# markup or a quote; one that does means the `&` began no reference.
|
|
29
|
+
REFERENCE = /&([^;\x20\t\r\n<&"']+);/
|
|
30
|
+
HEX = /\A#x([0-9A-Fa-f]+)\z/
|
|
31
|
+
DECIMAL = /\A#([0-9]+)\z/
|
|
32
|
+
|
|
33
|
+
# The characters XML 1.0's Char production leaves out that a valid
|
|
34
|
+
# UTF-8 string can still hold (a surrogate cannot be valid UTF-8).
|
|
35
|
+
EXCLUDED = "\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\uFFFE\\uFFFF"
|
|
36
|
+
INVALID = /[#{EXCLUDED}]/
|
|
37
|
+
TEXT_RUN = /[^<&\r\]#{EXCLUDED}]+/
|
|
38
|
+
CDATA_RUN = /[^\]\r#{EXCLUDED}]+/
|
|
39
|
+
VALUE_RUNS = {
|
|
40
|
+
'"' => /[^"<&\t\n\r#{EXCLUDED}]+/,
|
|
41
|
+
"'" => /[^'<&\t\n\r#{EXCLUDED}]+/
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Reads character data up to the next `<` or the end into `buffer`.
|
|
47
|
+
def read_character_data(buffer)
|
|
48
|
+
buffer << (@cursor.scan(TEXT_RUN) || text_special) until @cursor.eos? || @cursor.at?("<")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def text_special
|
|
52
|
+
return read_reference if @cursor.at?("&")
|
|
53
|
+
return line_break("\n") if @cursor.at?("\r")
|
|
54
|
+
|
|
55
|
+
@cursor.refuse(:cdata_end_in_text, "']]>' is not allowed in character data") if @cursor.at?("]]>")
|
|
56
|
+
return @cursor.getch if @cursor.at?("]")
|
|
57
|
+
|
|
58
|
+
refuse_character
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Reads `<![CDATA[ ... ]]>` into `buffer`: the content verbatim except
|
|
62
|
+
# that line breaks fold.
|
|
63
|
+
def read_cdata(buffer)
|
|
64
|
+
@cursor.skip("<![CDATA[")
|
|
65
|
+
buffer << (@cursor.scan(CDATA_RUN) || cdata_special) until @cursor.skip("]]>")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def cdata_special
|
|
69
|
+
@cursor.refuse_at_end(:unterminated_cdata, "a CDATA section is never closed") if @cursor.eos?
|
|
70
|
+
return line_break("\n") if @cursor.at?("\r")
|
|
71
|
+
return @cursor.getch if @cursor.at?("]")
|
|
72
|
+
|
|
73
|
+
refuse_character
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Reads an attribute value up to its closing `quote`, which it leaves
|
|
77
|
+
# unread, and answers it normalized.
|
|
78
|
+
def read_value(quote)
|
|
79
|
+
value = +""
|
|
80
|
+
value << (@cursor.scan(VALUE_RUNS.fetch(quote)) || value_special) until @cursor.at?(quote)
|
|
81
|
+
value.freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def value_special
|
|
85
|
+
refuse_unexpected_end if @cursor.eos?
|
|
86
|
+
@cursor.refuse(:lt_in_attribute_value, "'<' is not allowed in an attribute value") if @cursor.at?("<")
|
|
87
|
+
return read_reference if @cursor.at?("&")
|
|
88
|
+
return line_break(" ") if @cursor.at?("\r")
|
|
89
|
+
return @cursor.getch && " " if @cursor.at?(/[\t\n]/)
|
|
90
|
+
|
|
91
|
+
refuse_character
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Consumes a CR and the LF after it, if any, and answers `replacement`.
|
|
95
|
+
def line_break(replacement)
|
|
96
|
+
@cursor.skip(/\r\n?/)
|
|
97
|
+
replacement
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Reads `&name;`, `&#digits;` or `&#xhex;` and answers the character it
|
|
101
|
+
# stands for.
|
|
102
|
+
def read_reference
|
|
103
|
+
at = @cursor.mark
|
|
104
|
+
@cursor.refuse(:malformed_reference, "'&' does not start a reference", at) unless @cursor.skip(REFERENCE)
|
|
105
|
+
body = @cursor[1]
|
|
106
|
+
return character_reference(body, at) if body.start_with?("#")
|
|
107
|
+
|
|
108
|
+
PREDEFINED.fetch(body) { entity_reference(body, at) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def entity_reference(body, at)
|
|
112
|
+
return "&#{body};" if @doctype && body.match?(Tags::WHOLE_NAME)
|
|
113
|
+
|
|
114
|
+
@cursor.refuse(:unknown_entity, "&#{body}; is not a predefined entity", at)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def character_reference(body, at)
|
|
118
|
+
digits = body[HEX, 1]
|
|
119
|
+
code = digits ? digits.to_i(16) : body[DECIMAL, 1]&.to_i
|
|
120
|
+
return code.chr(Encoding::UTF_8) if code && xml_char?(code)
|
|
121
|
+
|
|
122
|
+
@cursor.refuse(:invalid_character_reference, "&#{body}; is not a valid character reference", at)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# XML 1.0's Char production.
|
|
126
|
+
def xml_char?(code)
|
|
127
|
+
[0x9, 0xA, 0xD].include?(code) || (0x20..0xD7FF).cover?(code) ||
|
|
128
|
+
(0xE000..0xFFFD).cover?(code) || (0x10000..0x10FFFF).cover?(code)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def refuse_character
|
|
132
|
+
code = @cursor.scan(INVALID).ord
|
|
133
|
+
@cursor.move_to(@cursor.offset - code.chr(Encoding::UTF_8).bytesize)
|
|
134
|
+
@cursor.refuse(:invalid_character, format("the character U+%04X is not allowed in XML", code))
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def refuse_unexpected_end
|
|
138
|
+
@cursor.refuse(:unexpected_end, "the document ends inside a tag")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module XML
|
|
5
|
+
# How a refusal leaves the reader: raised inside one parse and rescued by
|
|
6
|
+
# {XML.parse}, which answers it as a failed {Result}. It never reaches a
|
|
7
|
+
# caller.
|
|
8
|
+
#
|
|
9
|
+
# @api private
|
|
10
|
+
class Refused < StandardError
|
|
11
|
+
# @return [ParseError] the refusal
|
|
12
|
+
attr_reader :error
|
|
13
|
+
|
|
14
|
+
# @param error [ParseError] the refusal to carry
|
|
15
|
+
def initialize(error)
|
|
16
|
+
super(error.message)
|
|
17
|
+
@error = error
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The read position in one document: a byte scanner, and the line and
|
|
22
|
+
# column of the last position asked for, counted forward from there so
|
|
23
|
+
# that the whole document is counted once.
|
|
24
|
+
#
|
|
25
|
+
# @api private
|
|
26
|
+
class Cursor
|
|
27
|
+
# A position: the byte offset and the line and column it sits at.
|
|
28
|
+
Mark = Data.define(:offset, :line, :column)
|
|
29
|
+
|
|
30
|
+
WHITESPACE = /[\x20\t\r\n]+/
|
|
31
|
+
|
|
32
|
+
# @return [String] the document, as UTF-8
|
|
33
|
+
attr_reader :text
|
|
34
|
+
|
|
35
|
+
# @param text [String] the document, valid UTF-8
|
|
36
|
+
def initialize(text)
|
|
37
|
+
@text = text
|
|
38
|
+
@scanner = StringScanner.new(text)
|
|
39
|
+
@known = Mark.new(offset: 0, line: 1, column: 1)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Integer] the byte offset of the read position
|
|
43
|
+
def offset = @scanner.pos
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] whether the whole document has been read
|
|
46
|
+
def eos? = @scanner.eos?
|
|
47
|
+
|
|
48
|
+
# @param pattern [String, Regexp] what to look for at the read position
|
|
49
|
+
# @return [Boolean] whether it is there; nothing is consumed
|
|
50
|
+
def at?(pattern) = !@scanner.match?(pattern).nil?
|
|
51
|
+
|
|
52
|
+
# @param pattern [String, Regexp] what to consume
|
|
53
|
+
# @return [String, nil] the text consumed, or nil when it is not there
|
|
54
|
+
def scan(pattern) = @scanner.scan(pattern)
|
|
55
|
+
|
|
56
|
+
# @param pattern [String, Regexp] what to consume
|
|
57
|
+
# @return [Integer, nil] the bytes consumed, or nil when it is not there
|
|
58
|
+
def skip(pattern) = @scanner.skip(pattern)
|
|
59
|
+
|
|
60
|
+
# @param pattern [Regexp] what to consume up to and through
|
|
61
|
+
# @return [Integer, nil] the bytes consumed, or nil, consuming nothing,
|
|
62
|
+
# when it does not occur
|
|
63
|
+
def skip_until(pattern) = @scanner.skip_until(pattern)
|
|
64
|
+
|
|
65
|
+
# @param group [Integer] a group of the last match
|
|
66
|
+
# @return [String, nil] its text
|
|
67
|
+
def [](group) = @scanner[group]
|
|
68
|
+
|
|
69
|
+
# @return [String] the next character, consumed
|
|
70
|
+
def getch = @scanner.getch
|
|
71
|
+
|
|
72
|
+
# @return [Integer, nil] the bytes of whitespace consumed, or nil when
|
|
73
|
+
# there is none
|
|
74
|
+
def skip_whitespace = @scanner.skip(WHITESPACE)
|
|
75
|
+
|
|
76
|
+
# Moves the read position.
|
|
77
|
+
#
|
|
78
|
+
# @param offset [Integer] the byte offset to read from next
|
|
79
|
+
# @return [void]
|
|
80
|
+
def move_to(offset)
|
|
81
|
+
@scanner.pos = offset
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Mark] the read position with its line and column
|
|
85
|
+
def mark
|
|
86
|
+
pos = @scanner.pos
|
|
87
|
+
@known = Mark.new(offset: 0, line: 1, column: 1) if pos < @known.offset
|
|
88
|
+
@known = count_to(pos) if pos > @known.offset
|
|
89
|
+
@known
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# @param from [Mark] where the span starts
|
|
93
|
+
# @param to [Mark] where it ends, exclusive; the read position when omitted
|
|
94
|
+
# @return [Location]
|
|
95
|
+
def span(from, to = mark)
|
|
96
|
+
Location.new(start_line: from.line, start_column: from.column, start_offset: from.offset,
|
|
97
|
+
end_line: to.line, end_column: to.column, end_offset: to.offset)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Refuses the document.
|
|
101
|
+
#
|
|
102
|
+
# @param reason [Symbol] the reason token
|
|
103
|
+
# @param message [String] what is wrong, for a person
|
|
104
|
+
# @param at [Mark] where; the read position when omitted
|
|
105
|
+
# @raise [Refused] always
|
|
106
|
+
def refuse(reason, message, at = mark)
|
|
107
|
+
raise Refused, ParseError.new(reason:, message: "#{message} (line #{at.line}, column #{at.column})",
|
|
108
|
+
location: span(at, at))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Refuses the document at its end.
|
|
112
|
+
#
|
|
113
|
+
# @param (see #refuse)
|
|
114
|
+
# @raise [Refused] always
|
|
115
|
+
def refuse_at_end(reason, message)
|
|
116
|
+
@scanner.terminate
|
|
117
|
+
refuse(reason, message)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def count_to(pos)
|
|
123
|
+
chunk = @text.byteslice(@known.offset, pos - @known.offset)
|
|
124
|
+
newline = chunk.rindex("\n")
|
|
125
|
+
return Mark.new(offset: pos, line: @known.line, column: @known.column + chunk.length) unless newline
|
|
126
|
+
|
|
127
|
+
Mark.new(offset: pos, line: @known.line + chunk.count("\n"), column: chunk.length - newline)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module XML
|
|
5
|
+
# The constructs that produce no node: comments, processing
|
|
6
|
+
# instructions, the XML declaration and the document type declaration.
|
|
7
|
+
#
|
|
8
|
+
# @api private
|
|
9
|
+
module Markup
|
|
10
|
+
S = "[\\x20\\t\\r\\n]"
|
|
11
|
+
EQ = "#{S}*=#{S}*".freeze
|
|
12
|
+
|
|
13
|
+
# What follows `<?xml` in an XML declaration (XML 1.0 production 23).
|
|
14
|
+
# Group 1 or 2 is the declared encoding.
|
|
15
|
+
DECLARATION = /
|
|
16
|
+
#{S}+version#{EQ}(?:"1\.[0-9]+"|'1\.[0-9]+')
|
|
17
|
+
(?:#{S}+encoding#{EQ}(?:"([A-Za-z][A-Za-z0-9._-]*)"|'([A-Za-z][A-Za-z0-9._-]*)'))?
|
|
18
|
+
(?:#{S}+standalone#{EQ}(?:"(?:yes|no)"|'(?:yes|no)'))?
|
|
19
|
+
#{S}*\?>
|
|
20
|
+
/x
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Skips whitespace, comments and processing instructions outside the
|
|
25
|
+
# root element; before it (`prolog`) also one document type
|
|
26
|
+
# declaration. Stops at the first other character.
|
|
27
|
+
def skip_misc(prolog:)
|
|
28
|
+
doctype = prolog
|
|
29
|
+
loop do
|
|
30
|
+
@cursor.skip_whitespace
|
|
31
|
+
next skip_comment if @cursor.at?("<!--")
|
|
32
|
+
next skip_processing_instruction if @cursor.at?("<?")
|
|
33
|
+
return unless doctype && @cursor.at?("<!DOCTYPE")
|
|
34
|
+
|
|
35
|
+
skip_doctype
|
|
36
|
+
doctype = false
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Skips `<!-- ... -->`, refusing `--` inside it as XML 1.0 section 2.5
|
|
41
|
+
# does.
|
|
42
|
+
def skip_comment
|
|
43
|
+
@cursor.skip("<!--")
|
|
44
|
+
@cursor.refuse_at_end(:unterminated_comment, "a comment is never closed") unless @cursor.skip_until(/--/)
|
|
45
|
+
return if @cursor.skip(">")
|
|
46
|
+
|
|
47
|
+
@cursor.move_to(@cursor.offset - 2)
|
|
48
|
+
@cursor.refuse(:double_hyphen_in_comment, "'--' is not allowed inside a comment")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Skips `<? ... ?>`. The target `xml` is the XML declaration, read only
|
|
52
|
+
# at the very start of the document; any other spelling of it is
|
|
53
|
+
# reserved.
|
|
54
|
+
def skip_processing_instruction
|
|
55
|
+
start = @cursor.mark
|
|
56
|
+
@cursor.skip("<?")
|
|
57
|
+
target = read_name
|
|
58
|
+
return read_declaration(start) if target == "xml" && start.offset.zero?
|
|
59
|
+
|
|
60
|
+
refuse_reserved(target, start) if target.casecmp?("xml")
|
|
61
|
+
return if @cursor.skip_until(/\?>/)
|
|
62
|
+
|
|
63
|
+
@cursor.refuse_at_end(:unterminated_processing_instruction, "a processing instruction is never closed")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def read_declaration(start)
|
|
67
|
+
@cursor.refuse(:malformed_xml_declaration, "the XML declaration is malformed", start) \
|
|
68
|
+
unless @cursor.skip(DECLARATION)
|
|
69
|
+
encoding = @cursor[1] || @cursor[2]
|
|
70
|
+
return if encoding.nil? || encoding.casecmp?("UTF-8")
|
|
71
|
+
|
|
72
|
+
@cursor.refuse(:unsupported_encoding, "the document declares the encoding #{encoding}; only UTF-8 is read",
|
|
73
|
+
start)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def refuse_reserved(target, start)
|
|
77
|
+
if target == "xml"
|
|
78
|
+
@cursor.refuse(:misplaced_xml_declaration,
|
|
79
|
+
"the XML declaration is allowed only at the start of the document", start)
|
|
80
|
+
end
|
|
81
|
+
@cursor.refuse(:reserved_target, "the processing instruction target #{target} is reserved", start)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Skips `<!DOCTYPE ... >` as the reference reads one: the declaration
|
|
85
|
+
# ends at the `>` that brings the count of open angle brackets,
|
|
86
|
+
# `<!DOCTYPE` included, to zero. The reference also finds the end a
|
|
87
|
+
# second way, at the first `>` outside square brackets, and reads the
|
|
88
|
+
# document only when the two ends agree or nothing between them is
|
|
89
|
+
# markup. Neither reading treats a quote specially, and the
|
|
90
|
+
# declarations inside are not read: only the predefined entities
|
|
91
|
+
# expand.
|
|
92
|
+
def skip_doctype
|
|
93
|
+
start = @cursor.mark
|
|
94
|
+
@cursor.skip("<!DOCTYPE")
|
|
95
|
+
angle, bracket = DoctypeEnds.new(@cursor.text, @cursor.offset).ends
|
|
96
|
+
unless angle && bracket
|
|
97
|
+
@cursor.refuse_at_end(:unterminated_doctype, "a document type declaration is never closed")
|
|
98
|
+
end
|
|
99
|
+
low, high = [angle, bracket].minmax
|
|
100
|
+
refuse_ambiguous_doctype(start) if @cursor.text.byteslice(low, high - low).include?("<")
|
|
101
|
+
@cursor.move_to(angle)
|
|
102
|
+
@doctype = true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def refuse_ambiguous_doctype(start)
|
|
106
|
+
@cursor.refuse(:ambiguous_doctype,
|
|
107
|
+
"the document type declaration has two different ends with markup between them", start)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The two ends of a document type declaration, read from just after
|
|
112
|
+
# `<!DOCTYPE`: the byte offset after the `>` that balances the angle
|
|
113
|
+
# brackets, and after the first `>` outside square brackets (a `]`
|
|
114
|
+
# never taking the depth below zero). Either is nil when the document
|
|
115
|
+
# ends first.
|
|
116
|
+
#
|
|
117
|
+
# @api private
|
|
118
|
+
class DoctypeEnds
|
|
119
|
+
# @param text [String] the document
|
|
120
|
+
# @param offset [Integer] the byte offset just after `<!DOCTYPE`
|
|
121
|
+
def initialize(text, offset)
|
|
122
|
+
@scanner = StringScanner.new(text)
|
|
123
|
+
@scanner.pos = offset
|
|
124
|
+
@angles = 1
|
|
125
|
+
@depth = 0
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Array(Integer, Integer)] the angle-bracket end and the
|
|
129
|
+
# square-bracket end, each nil when the document ends first
|
|
130
|
+
def ends
|
|
131
|
+
step(@scanner.matched) while (@angle.nil? || @bracket.nil?) && @scanner.skip_until(/[<>\[\]]/)
|
|
132
|
+
[@angle, @bracket]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def step(char)
|
|
138
|
+
case char
|
|
139
|
+
when "<" then @angles += 1
|
|
140
|
+
when "[" then @depth += 1
|
|
141
|
+
when "]" then @depth = [@depth - 1, 0].max
|
|
142
|
+
else close
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def close
|
|
147
|
+
@bracket ||= @scanner.pos if @depth.zero?
|
|
148
|
+
@angle ||= @scanner.pos if @angles == 1
|
|
149
|
+
@angles -= 1
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Statifier
|
|
4
|
+
module XML
|
|
5
|
+
# A source span: 1-based lines and columns, 0-based byte offsets into the
|
|
6
|
+
# document read as UTF-8, and an exclusive end. Columns count code
|
|
7
|
+
# points, and only a line feed starts a new line. An element's span runs
|
|
8
|
+
# from the `<` of its start tag to the byte after the `>` of its end tag
|
|
9
|
+
# (the tag itself when it closes itself), so its start line and column
|
|
10
|
+
# are those of its `<`.
|
|
11
|
+
Location = Data.define(:start_line, :start_column, :start_offset, :end_line, :end_column, :end_offset) do
|
|
12
|
+
# The raw text this span covers.
|
|
13
|
+
#
|
|
14
|
+
# @param source [String] the UTF-8 document the span was read from
|
|
15
|
+
# @return [String] the bytes from the start offset up to the end offset
|
|
16
|
+
def slice(source) = source.byteslice(start_offset, end_offset - start_offset)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# One attribute, in document order. `value` is normalized (XML 1.0
|
|
20
|
+
# section 3.3.3) and its references expanded; `location` covers
|
|
21
|
+
# `name="value"` with its quotes, and `value_location` only the raw text
|
|
22
|
+
# inside the quotes.
|
|
23
|
+
Attribute = Data.define(:name, :value, :location, :value_location)
|
|
24
|
+
|
|
25
|
+
# An element. `name` is the qualified name as written, prefix included;
|
|
26
|
+
# `namespace` is the URI that name resolves to under the declarations in
|
|
27
|
+
# scope (the empty string when an empty declaration is in scope for it),
|
|
28
|
+
# or nil when none is. `attributes` keeps document order and every
|
|
29
|
+
# duplicate; `children` interleaves elements and text runs.
|
|
30
|
+
Element = Data.define(:name, :namespace, :attributes, :children, :location)
|
|
31
|
+
|
|
32
|
+
# A run of character data between two tags, coalesced across any
|
|
33
|
+
# comment, processing instruction or CDATA section inside it. `value` is
|
|
34
|
+
# expanded and line-break folded (XML 1.0 section 2.11); `location`
|
|
35
|
+
# covers the raw run.
|
|
36
|
+
Text = Data.define(:value, :location)
|
|
37
|
+
|
|
38
|
+
# Why a document was refused: a reason symbol, a message for a person
|
|
39
|
+
# that ends with the line and column, and the zero-width location where
|
|
40
|
+
# reading stopped.
|
|
41
|
+
ParseError = Data.define(:reason, :message, :location)
|
|
42
|
+
|
|
43
|
+
# What {XML.parse} answers: the root element as `value`, or the refusal
|
|
44
|
+
# as `error`.
|
|
45
|
+
Result = Data.define(:value, :error) do
|
|
46
|
+
# @return [Boolean] true when the document was read
|
|
47
|
+
def ok? = error.nil?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|