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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/{LICENSE.txt → LICENSE} +1 -1
  4. data/README.md +601 -16
  5. data/lib/statifier/accepts.rb +167 -0
  6. data/lib/statifier/basichttp/decode.rb +142 -0
  7. data/lib/statifier/basichttp/encoding.rb +224 -0
  8. data/lib/statifier/basichttp/net_http_transport.rb +157 -0
  9. data/lib/statifier/basichttp/outbound.rb +87 -0
  10. data/lib/statifier/basichttp/processor.rb +170 -0
  11. data/lib/statifier/basichttp/request.rb +94 -0
  12. data/lib/statifier/basichttp/response.rb +34 -0
  13. data/lib/statifier/basichttp.rb +47 -0
  14. data/lib/statifier/compiler/content.rb +131 -0
  15. data/lib/statifier/compiler/error.rb +30 -0
  16. data/lib/statifier/compiler/expressions.rb +64 -0
  17. data/lib/statifier/compiler/numbering.rb +145 -0
  18. data/lib/statifier/compiler/parts.rb +144 -0
  19. data/lib/statifier/compiler/walk.rb +61 -0
  20. data/lib/statifier/compiler.rb +82 -0
  21. data/lib/statifier/datamodel/data_binding.rb +114 -0
  22. data/lib/statifier/datamodel/delay.rb +54 -0
  23. data/lib/statifier/datamodel/location.rb +145 -0
  24. data/lib/statifier/datamodel/program.rb +82 -0
  25. data/lib/statifier/datamodel/reason_value.rb +42 -0
  26. data/lib/statifier/datamodel/reasons.rb +88 -0
  27. data/lib/statifier/datamodel/stores.rb +169 -0
  28. data/lib/statifier/datamodel/system_variables.rb +69 -0
  29. data/lib/statifier/datamodel.rb +120 -0
  30. data/lib/statifier/document/data.rb +36 -0
  31. data/lib/statifier/document/executable.rb +69 -0
  32. data/lib/statifier/document/invoke.rb +17 -0
  33. data/lib/statifier/document/states.rb +40 -0
  34. data/lib/statifier/document.rb +51 -0
  35. data/lib/statifier/driver/calls.rb +183 -0
  36. data/lib/statifier/driver/checks.rb +110 -0
  37. data/lib/statifier/driver/delivery.rb +45 -0
  38. data/lib/statifier/driver/invocations.rb +129 -0
  39. data/lib/statifier/driver/mailbox.rb +120 -0
  40. data/lib/statifier/driver/routing.rb +193 -0
  41. data/lib/statifier/driver/session_id.rb +40 -0
  42. data/lib/statifier/driver/work.rb +196 -0
  43. data/lib/statifier/driver.rb +205 -0
  44. data/lib/statifier/effect.rb +208 -0
  45. data/lib/statifier/event.rb +67 -0
  46. data/lib/statifier/frozen.rb +22 -0
  47. data/lib/statifier/interpreter/blocks.rb +142 -0
  48. data/lib/statifier/interpreter/content.rb +228 -0
  49. data/lib/statifier/interpreter/entering.rb +141 -0
  50. data/lib/statifier/interpreter/exit_entry.rb +174 -0
  51. data/lib/statifier/interpreter/foreach.rb +83 -0
  52. data/lib/statifier/interpreter/invoke/arguments.rb +52 -0
  53. data/lib/statifier/interpreter/invoke/passes.rb +143 -0
  54. data/lib/statifier/interpreter/invoke.rb +145 -0
  55. data/lib/statifier/interpreter/loop.rb +170 -0
  56. data/lib/statifier/interpreter/machine_state.rb +152 -0
  57. data/lib/statifier/interpreter/macrostep.rb +185 -0
  58. data/lib/statifier/interpreter/name_match.rb +43 -0
  59. data/lib/statifier/interpreter/selection.rb +221 -0
  60. data/lib/statifier/interpreter/send.rb +223 -0
  61. data/lib/statifier/interpreter/step.rb +138 -0
  62. data/lib/statifier/interpreter/stop.rb +66 -0
  63. data/lib/statifier/interpreter/targets.rb +107 -0
  64. data/lib/statifier/interpreter.rb +57 -0
  65. data/lib/statifier/lowering/branches.rb +84 -0
  66. data/lib/statifier/lowering/data.rb +102 -0
  67. data/lib/statifier/lowering/error.rb +102 -0
  68. data/lib/statifier/lowering/executable.rb +106 -0
  69. data/lib/statifier/lowering/placement.rb +65 -0
  70. data/lib/statifier/lowering/reading.rb +53 -0
  71. data/lib/statifier/lowering/states.rb +111 -0
  72. data/lib/statifier/lowering.rb +129 -0
  73. data/lib/statifier/machine/content.rb +81 -0
  74. data/lib/statifier/machine/data.rb +42 -0
  75. data/lib/statifier/machine/states.rb +56 -0
  76. data/lib/statifier/machine/values.rb +37 -0
  77. data/lib/statifier/machine.rb +103 -0
  78. data/lib/statifier/position/rebuild.rb +84 -0
  79. data/lib/statifier/position/shape.rb +73 -0
  80. data/lib/statifier/position/text.rb +43 -0
  81. data/lib/statifier/position.rb +206 -0
  82. data/lib/statifier/validator/attributes.rb +35 -0
  83. data/lib/statifier/validator/context.rb +74 -0
  84. data/lib/statifier/validator/data.rb +136 -0
  85. data/lib/statifier/validator/error.rb +90 -0
  86. data/lib/statifier/validator/executable.rb +139 -0
  87. data/lib/statifier/validator/history.rb +81 -0
  88. data/lib/statifier/validator/messages.rb +142 -0
  89. data/lib/statifier/validator/states.rb +161 -0
  90. data/lib/statifier/validator.rb +94 -0
  91. data/lib/statifier/version.rb +4 -1
  92. data/lib/statifier/xml/character_data.rb +142 -0
  93. data/lib/statifier/xml/cursor.rb +131 -0
  94. data/lib/statifier/xml/markup.rb +153 -0
  95. data/lib/statifier/xml/nodes.rb +50 -0
  96. data/lib/statifier/xml/parser.rb +121 -0
  97. data/lib/statifier/xml/scope.rb +46 -0
  98. data/lib/statifier/xml/tags.rb +112 -0
  99. data/lib/statifier/xml.rb +85 -0
  100. data/lib/statifier.rb +386 -2
  101. metadata +129 -20
  102. data/.gitignore +0 -8
  103. data/CODE_OF_CONDUCT.md +0 -74
  104. data/Gemfile +0 -11
  105. data/Gemfile.lock +0 -33
  106. data/Rakefile +0 -10
  107. data/bin/console +0 -8
  108. data/bin/setup +0 -6
  109. 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
@@ -1,3 +1,6 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Statifier
2
- VERSION = "0.0.1"
4
+ # The gem's version.
5
+ VERSION = "0.1.0"
3
6
  end
@@ -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