toon-fu 4.1.2 → 4.1.3
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/README.md +39 -3
- data/lib/toon_fu/decoder.rb +67 -0
- data/lib/toon_fu/field_list.rb +52 -0
- data/lib/toon_fu/header.rb +122 -0
- data/lib/toon_fu/lines.rb +99 -0
- data/lib/toon_fu/reader.rb +303 -0
- data/lib/toon_fu/token.rb +78 -0
- data/lib/toon_fu/tokens.rb +111 -0
- data/lib/toon_fu/version.rb +1 -1
- data/lib/toon_fu.rb +33 -3
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4b2af3b4dc45618bf09ddc862816f4bd173efc0a127d7a4554bdbc475d7be869
|
|
4
|
+
data.tar.gz: 502cc64707081cd0ba3a6b1b2577435fe0fc40716f4d7bca003ac61da65f407e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '094c5bdf3d7f30170e955878a90c688241dafd4bfbc198aeaba4797e774cca2eb7af28bae402b6cd45d206c8b48913c0b09204a1ddbdd503c2e9337e9ee79239'
|
|
7
|
+
data.tar.gz: 88b2f6001a47e7c04eb0f86711ef1b4f8d0338e291bbc356bff66640c0135ce61c1d604c852a74196de0b2a5ee4c7b06932765e1121e5bf7e054956aaf591188
|
data/README.md
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
[](https://github.com/hoblin/toon-fu/actions/workflows/spec-drift.yml)
|
|
5
5
|
[](https://rubygems.org/gems/toon-fu)
|
|
6
6
|
|
|
7
|
-
[TOON](https://toonformat.dev/) (Token-Oriented Object Notation)
|
|
7
|
+
[TOON](https://toonformat.dev/) (Token-Oriented Object Notation) codec for Ruby, versioned by the spec it implements: the gem's `MAJOR.MINOR` is the TOON spec version it speaks.
|
|
8
8
|
|
|
9
9
|
## What is this?
|
|
10
10
|
|
|
11
|
-
TOON is a compact, readable encoding of the JSON data model for LLM prompts: indentation instead of braces, quotes only where needed, and tables for arrays of uniform objects. toon-fu is written from the [specification](https://github.com/toon-format/spec) and runs the spec's reference fixtures as its conformance suite — every encode fixture of the spec version it implements passes.
|
|
11
|
+
TOON is a compact, readable encoding of the JSON data model for LLM prompts: indentation instead of braces, quotes only where needed, and tables for arrays of uniform objects. toon-fu is written from the [specification](https://github.com/toon-format/spec) and runs the spec's reference fixtures as its conformance suite — every encode and decode fixture of the spec version it implements passes.
|
|
12
12
|
|
|
13
13
|
## Why?
|
|
14
14
|
|
|
@@ -111,9 +111,45 @@ For ActiveRecord models, pass `record.as_json` (or define `as_toon`).
|
|
|
111
111
|
|
|
112
112
|
Everything else raises `ToonFu::Error` — including a `Struct` or `Data` without `as_toon`, circular references, and nesting too deep for the stack.
|
|
113
113
|
|
|
114
|
+
## Reading TOON
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
ToonFu.decode("users[2]{id,name}:\n 1,Ada\n 2,Bob")
|
|
118
|
+
# => {"users" => [{"id" => 1, "name" => "Ada"}, {"id" => 2, "name" => "Bob"}]}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
You get back plain Ruby: `Hash` with String keys, `Array`, `String`, `Integer`, `Float`, `true`, `false`, `nil`.
|
|
122
|
+
|
|
123
|
+
Quoting decides the type, and an unquoted token is a number only if the spec says it is one:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
ToonFu.decode("a: 42") # => {"a" => 42}
|
|
127
|
+
ToonFu.decode('a: "42"') # => {"a" => "42"}
|
|
128
|
+
ToonFu.decode("a: 05") # => {"a" => "05"}
|
|
129
|
+
ToonFu.decode("a: +1") # => {"a" => "+1"}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Malformed input raises `ToonFu::Error`:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
ToonFu.decode("tags[3]: a,b")
|
|
136
|
+
# ToonFu::Error: cannot decode 2 values where the header declares 3
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Options
|
|
140
|
+
|
|
141
|
+
- `strict:` — `true` (default) checks what the spec requires: declared lengths, row widths, duplicate keys, indentation. Pass `false` to read a document that bends those rules — a declared length is ignored, a duplicate key takes its last value.
|
|
142
|
+
- `indent_size:` — spaces per nesting level, default `2`.
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
ToonFu.decode(text, strict: false)
|
|
146
|
+
decoder = ToonFu::Decoder.new(indent_size: 4) # reuse for many documents
|
|
147
|
+
decoder.decode(text)
|
|
148
|
+
```
|
|
149
|
+
|
|
114
150
|
## Compared with other Ruby TOON gems
|
|
115
151
|
|
|
116
|
-
The only one that passes every spec fixture: toon-fu passes all 179 encode fixtures
|
|
152
|
+
The only one that passes every spec fixture: toon-fu passes all 179 encode and all 359 decode fixtures. The table compares encoding — the 154 encode fixtures that use default options, which every gem can run; none of the others reads TOON back.
|
|
117
153
|
|
|
118
154
|
| Gem | Spec fixtures passed | Speed vs toon-fu |
|
|
119
155
|
|---|---:|---:|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToonFu
|
|
4
|
+
# Decodes TOON with one set of options, for callers that decode many.
|
|
5
|
+
class Decoder
|
|
6
|
+
# @param strict [Boolean] whether to enforce the spec's strict-mode
|
|
7
|
+
# checks; with +false+ duplicate keys resolve last-write-wins, leading
|
|
8
|
+
# spaces that are not a multiple of indent_size are accepted, and a
|
|
9
|
+
# tab in indentation is accepted and counts as one level
|
|
10
|
+
# @param indent_size [Integer] spaces per nesting level
|
|
11
|
+
# @raise [ArgumentError] when strict is not a boolean or indent_size is
|
|
12
|
+
# not a positive Integer
|
|
13
|
+
def initialize(strict: true, indent_size: 2)
|
|
14
|
+
raise ArgumentError, "strict must be true or false, got #{strict.inspect}" unless [true, false].include?(strict)
|
|
15
|
+
raise ArgumentError, "indent_size must be a positive Integer, got #{indent_size.inspect}" unless indent_size.is_a?(Integer) && indent_size.positive?
|
|
16
|
+
|
|
17
|
+
@strict = strict
|
|
18
|
+
@indent_size = indent_size
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Decodes TOON into the JSON data model.
|
|
22
|
+
#
|
|
23
|
+
# Returns +Hash+ with String keys, +Array+, +String+, +Integer+,
|
|
24
|
+
# +Float+, +true+, +false+ or +nil+. An unquoted token is a number only
|
|
25
|
+
# when it matches the spec's own grammar, so +.5+, +1.+, +05+, +Infinity+
|
|
26
|
+
# and +1_000+ decode as strings.
|
|
27
|
+
#
|
|
28
|
+
# Out-of-range numbers: a token the grammar accepts but +Float+ cannot
|
|
29
|
+
# hold without losing its magnitude, such as +1e400+ or +1e-400+, raises
|
|
30
|
+
# rather than becoming infinity or zero. Plain digits become an exact
|
|
31
|
+
# +Integer+ of any size; a fraction or an exponent goes through +Float+
|
|
32
|
+
# and carries its 53-bit precision, even when the result is integral.
|
|
33
|
+
#
|
|
34
|
+
# Key order is the document's, except in the tabular and keyed tabular
|
|
35
|
+
# forms, where it is the header's field order at every level. Nesting
|
|
36
|
+
# deeper than 100 levels raises rather than exhausting the stack.
|
|
37
|
+
#
|
|
38
|
+
# @param text [String] UTF-8, or an encoding that transcodes to it
|
|
39
|
+
# @return [Hash, Array, String, Integer, Float, true, false, nil]
|
|
40
|
+
# @raise [Error] for text that is not a String or not valid UTF-8; for a
|
|
41
|
+
# number outside the Float range; for an invalid escape, an
|
|
42
|
+
# unterminated quoted string, or content after a closing quote; for a
|
|
43
|
+
# key without a colon; for a bare token line inside a scope; for
|
|
44
|
+
# content after a completed root form; for nesting beyond the depth
|
|
45
|
+
# limit; and, when strict, for a declared length or row width that
|
|
46
|
+
# does not match, a malformed header, a duplicate key, a blank line
|
|
47
|
+
# inside a header's scope, and indentation that is not a multiple of
|
|
48
|
+
# indent_size
|
|
49
|
+
def decode(text)
|
|
50
|
+
Reader.new(@strict, @indent_size).read(utf8(text))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def utf8(text)
|
|
56
|
+
raise Error, "cannot decode #{text.class}; pass a String" unless text.is_a?(String)
|
|
57
|
+
|
|
58
|
+
text = text.dup.force_encoding(Encoding::UTF_8) if text.encoding == Encoding::BINARY
|
|
59
|
+
text = text.encode(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
|
|
60
|
+
raise Error, "cannot decode text that is not valid UTF-8" unless text.valid_encoding?
|
|
61
|
+
|
|
62
|
+
text
|
|
63
|
+
rescue EncodingError => error
|
|
64
|
+
raise Error, "cannot decode text as UTF-8: #{error.message}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToonFu
|
|
4
|
+
class FieldList
|
|
5
|
+
def self.parse(text, delimiter)
|
|
6
|
+
new(text, delimiter).parse
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(text, delimiter)
|
|
10
|
+
@text = text
|
|
11
|
+
@delimiter = delimiter
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def parse
|
|
15
|
+
return nil if @text.empty?
|
|
16
|
+
|
|
17
|
+
entries = []
|
|
18
|
+
Tokens.split_fields(@text, @delimiter).each do |entry|
|
|
19
|
+
return nil if foreign?(entry)
|
|
20
|
+
|
|
21
|
+
name, nested = split(entry)
|
|
22
|
+
return nil if name.nil?
|
|
23
|
+
|
|
24
|
+
entries << [name, nested]
|
|
25
|
+
end
|
|
26
|
+
entries
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# A field list is split by the bracket segment's delimiter alone, so
|
|
32
|
+
# another delimiter left unquoted in an entry is a header defect (§6).
|
|
33
|
+
# Quoting protects it, which is how the encoder emits {id,"a|b"}.
|
|
34
|
+
def foreign?(entry)
|
|
35
|
+
others = Encoder::DELIMITERS - [@delimiter]
|
|
36
|
+
Tokens.index_of(entry, Tokens::UNTIL_FOREIGN.fetch(@delimiter), others.join) ? true : false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def split(entry)
|
|
40
|
+
brace = Tokens.brace_index(entry)
|
|
41
|
+
return [Tokens.key(entry), nil] if brace.nil?
|
|
42
|
+
|
|
43
|
+
name = entry[0...brace]
|
|
44
|
+
group = entry[brace..]
|
|
45
|
+
closing = Tokens.closing_brace(group)
|
|
46
|
+
return [nil, nil] unless closing == group.length - 1
|
|
47
|
+
|
|
48
|
+
nested = self.class.parse(group[1...closing], @delimiter)
|
|
49
|
+
nested.nil? ? [nil, nil] : [Tokens.key(name), nested]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToonFu
|
|
4
|
+
class Header
|
|
5
|
+
LENGTH = /\A(?:0|[1-9][0-9]*)\z/
|
|
6
|
+
DELIMITERS = {"" => ",", "\t" => "\t", "|" => "|"}.freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :key, :length, :delimiter, :fields, :inline
|
|
9
|
+
|
|
10
|
+
def self.parse(content)
|
|
11
|
+
bracket = Tokens.bracket_index(content)
|
|
12
|
+
return nil if bracket.nil?
|
|
13
|
+
|
|
14
|
+
new(content, bracket).parse
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(content, bracket)
|
|
18
|
+
@content = content
|
|
19
|
+
@bracket = bracket
|
|
20
|
+
@malformed = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A line carrying a bracket segment is either a header, or an attempt at
|
|
24
|
+
# one that §6 makes a strict-mode error; only a line without one falls
|
|
25
|
+
# through to the key-value class untouched.
|
|
26
|
+
def malformed?
|
|
27
|
+
@malformed
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse
|
|
31
|
+
@key = @bracket.zero? ? nil : @content[0...@bracket]
|
|
32
|
+
return nil unless @key.nil? || Tokens.whole_token?(@key)
|
|
33
|
+
return broken if @key&.match?(/\s\z/)
|
|
34
|
+
return broken unless (closing = @content.index("]", @bracket))
|
|
35
|
+
|
|
36
|
+
return broken unless segment(@content[(@bracket + 1)...closing])
|
|
37
|
+
return broken unless tail(closing + 1)
|
|
38
|
+
# §6: a keyed header's field list is required, and a fields-bearing
|
|
39
|
+
# header carries no inline content.
|
|
40
|
+
return broken if @keyed && !fields?
|
|
41
|
+
return broken if fields? && !@inline.strip.empty?
|
|
42
|
+
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def broken
|
|
47
|
+
@malformed = true
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def keyed?
|
|
52
|
+
@keyed
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fields?
|
|
56
|
+
!@fields.nil?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def leaves
|
|
60
|
+
@leaves ||= walk(@fields)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Names repeated across nesting levels are not duplicates (§9.3): only a
|
|
64
|
+
# repeat within one list counts.
|
|
65
|
+
def duplicate
|
|
66
|
+
return @duplicate if defined?(@duplicate)
|
|
67
|
+
|
|
68
|
+
@duplicate = repeated(@fields)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def segment(text)
|
|
74
|
+
@keyed = false
|
|
75
|
+
if text.end_with?(":")
|
|
76
|
+
@keyed = true
|
|
77
|
+
text = text[0...-1]
|
|
78
|
+
elsif (marker = text[-1]) && DELIMITERS.key?(marker) && !marker.empty?
|
|
79
|
+
@delimiter = DELIMITERS.fetch(marker)
|
|
80
|
+
text = text[0...-1]
|
|
81
|
+
if text.end_with?(":")
|
|
82
|
+
@keyed = true
|
|
83
|
+
text = text[0...-1]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
@delimiter ||= ","
|
|
87
|
+
return false unless LENGTH.match?(text)
|
|
88
|
+
|
|
89
|
+
@length = Integer(text, 10)
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def tail(index)
|
|
94
|
+
rest = @content[index..]
|
|
95
|
+
if rest.start_with?("{")
|
|
96
|
+
closing = Tokens.closing_brace(rest)
|
|
97
|
+
return false if closing.nil?
|
|
98
|
+
|
|
99
|
+
@fields = FieldList.parse(rest[1...closing], @delimiter)
|
|
100
|
+
return false if @fields.nil?
|
|
101
|
+
|
|
102
|
+
rest = rest[(closing + 1)..]
|
|
103
|
+
end
|
|
104
|
+
return false unless rest.start_with?(":")
|
|
105
|
+
|
|
106
|
+
@inline = rest[1..]
|
|
107
|
+
true
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def walk(fields)
|
|
111
|
+
fields.flat_map { |name, nested| nested ? walk(nested) : [name] }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def repeated(fields)
|
|
115
|
+
names = fields.map(&:first)
|
|
116
|
+
duplicate = names.tally.find { |_, count| count > 1 }
|
|
117
|
+
return duplicate.first if duplicate
|
|
118
|
+
|
|
119
|
+
fields.filter_map { |_, nested| nested && repeated(nested) }.first
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToonFu
|
|
4
|
+
class Line
|
|
5
|
+
ITEM = /\A-(?: |\z)/
|
|
6
|
+
|
|
7
|
+
attr_reader :content, :depth, :number, :text
|
|
8
|
+
|
|
9
|
+
attr_reader :after_blank
|
|
10
|
+
|
|
11
|
+
def initialize(content, depth, number, text = content, after_blank = false)
|
|
12
|
+
@content = content
|
|
13
|
+
@depth = depth
|
|
14
|
+
@number = number
|
|
15
|
+
@text = text
|
|
16
|
+
@after_blank = after_blank
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def item?
|
|
20
|
+
ITEM.match?(@content)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def item_content
|
|
24
|
+
@content.sub(ITEM, "")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class Lines
|
|
29
|
+
BOM = "\u{feff}"
|
|
30
|
+
COMMENT = /\A *#/
|
|
31
|
+
TRAILING_SPACES = / +\z/
|
|
32
|
+
BLANK = /\A\s*\z/
|
|
33
|
+
INDENTATION = /\A[ \t]*/
|
|
34
|
+
|
|
35
|
+
def initialize(text, strict, indent_size)
|
|
36
|
+
@strict = strict
|
|
37
|
+
@indent_size = indent_size
|
|
38
|
+
@lines = significant(text.delete_prefix(BOM))
|
|
39
|
+
@index = 0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def empty?
|
|
43
|
+
@index >= @lines.length
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def one?
|
|
47
|
+
@lines.length - @index == 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def peek
|
|
51
|
+
@lines[@index]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def next
|
|
55
|
+
line = @lines[@index]
|
|
56
|
+
@index += 1
|
|
57
|
+
line
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def number
|
|
61
|
+
peek ? peek.number : @lines.length
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def significant(text)
|
|
67
|
+
blank = false
|
|
68
|
+
text.split("\n", -1).each_with_index.filter_map do |raw, offset|
|
|
69
|
+
line = raw.delete_suffix("\r").sub(TRAILING_SPACES, "")
|
|
70
|
+
next if COMMENT.match?(line)
|
|
71
|
+
|
|
72
|
+
if BLANK.match?(line)
|
|
73
|
+
blank = true
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
built = build(line, offset + 1, blank)
|
|
78
|
+
blank = false
|
|
79
|
+
built
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build(line, number, blank)
|
|
84
|
+
Line.new(line.sub(INDENTATION, ""), depth(line, number), number, line, blank)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Non-strict mode accepts a tab as indentation (§12), where each tab
|
|
88
|
+
# counts as one level; strict mode rejects it.
|
|
89
|
+
def depth(line, number)
|
|
90
|
+
indent = line[INDENTATION]
|
|
91
|
+
raise Error, "cannot decode a line indented with a tab on line #{number}: #{line}" if @strict && indent.include?("\t")
|
|
92
|
+
|
|
93
|
+
spaces = indent.count(" ")
|
|
94
|
+
raise Error, "cannot decode an indentation of #{spaces} spaces on line #{number}, not a multiple of #{@indent_size}: #{line}" if @strict && !(spaces % @indent_size).zero?
|
|
95
|
+
|
|
96
|
+
spaces / @indent_size + indent.count("\t")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToonFu
|
|
4
|
+
class Reader
|
|
5
|
+
MAX_DEPTH = 100
|
|
6
|
+
|
|
7
|
+
def initialize(strict, indent_size)
|
|
8
|
+
@strict = strict
|
|
9
|
+
@indent_size = indent_size
|
|
10
|
+
@depth = 0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def read(text)
|
|
14
|
+
@lines = Lines.new(text, @strict, @indent_size)
|
|
15
|
+
return {} if @lines.empty?
|
|
16
|
+
|
|
17
|
+
root
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def root
|
|
23
|
+
line = @lines.peek
|
|
24
|
+
return empty_array_root if line.content == "[]"
|
|
25
|
+
|
|
26
|
+
header = usable(Header.parse(line.content), line)
|
|
27
|
+
return keyless_root(header) if header && header.key.nil?
|
|
28
|
+
return scalar_root(line) if @lines.one? && Tokens.colon_index(line.content).nil?
|
|
29
|
+
|
|
30
|
+
object(0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def keyless_root(header)
|
|
34
|
+
@lines.next
|
|
35
|
+
value = header.fields? ? table(header, 1) : array(header, 1)
|
|
36
|
+
trailing
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def empty_array_root
|
|
41
|
+
@lines.next
|
|
42
|
+
trailing
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def scalar_root(line)
|
|
47
|
+
@lines.next
|
|
48
|
+
Token.decode(Tokens.trim(line.content))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def trailing
|
|
52
|
+
return if @lines.empty?
|
|
53
|
+
|
|
54
|
+
raise Error, "cannot decode content after the root form: #{@lines.peek.text}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def object(depth, result = {})
|
|
58
|
+
while (line = @lines.peek) && line.depth >= depth
|
|
59
|
+
if line.depth > depth
|
|
60
|
+
orphan(line)
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@lines.next
|
|
65
|
+
key, value = field(line, depth)
|
|
66
|
+
store(result, key, value, line)
|
|
67
|
+
end
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def orphan(line)
|
|
72
|
+
raise Error, "cannot decode a line that belongs to no scope on line #{line.number}: #{line.text}" if @strict
|
|
73
|
+
|
|
74
|
+
@lines.next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def store(result, key, value, line)
|
|
78
|
+
raise Error, "cannot decode a duplicate key on line #{line.number}: #{key.inspect}" if @strict && result.key?(key)
|
|
79
|
+
|
|
80
|
+
result[key] = value
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def field(line, depth)
|
|
84
|
+
header = usable(Header.parse(line.content), line)
|
|
85
|
+
return [Tokens.key(header.key), header_value(header, depth)] if header&.key
|
|
86
|
+
raise Error, "cannot decode a keyless array header in object field position on line #{line.number}: #{line.text}" if header
|
|
87
|
+
|
|
88
|
+
colon = Tokens.colon_index(line.content)
|
|
89
|
+
raise Error, "cannot decode a line without a colon after its key on line #{line.number}: #{line.text}" if colon.nil?
|
|
90
|
+
|
|
91
|
+
key = Tokens.key(line.content[0...colon])
|
|
92
|
+
[key, field_value(Tokens.trim(line.content[(colon + 1)..]), depth)]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def field_value(text, depth)
|
|
96
|
+
return [] if text == "[]"
|
|
97
|
+
return nested(depth) if text.empty?
|
|
98
|
+
|
|
99
|
+
Token.decode(text)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def nested(depth)
|
|
103
|
+
line = @lines.peek
|
|
104
|
+
return {} if line.nil? || line.depth <= depth
|
|
105
|
+
|
|
106
|
+
guard(line)
|
|
107
|
+
descend { object(depth + 1) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def header_value(header, depth)
|
|
111
|
+
return table(header, depth + 1) if header.fields?
|
|
112
|
+
return [] if header.length.zero? && !items?(depth + 1)
|
|
113
|
+
|
|
114
|
+
array(header, depth + 1)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def items?(depth)
|
|
118
|
+
line = @lines.peek
|
|
119
|
+
!line.nil? && line.depth == depth
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def array(header, depth)
|
|
123
|
+
return inline(header) unless header.inline.empty?
|
|
124
|
+
|
|
125
|
+
descend { list(header, depth) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def inline(header)
|
|
129
|
+
cells = Tokens.split(header.inline, header.delimiter).map { |cell| Token.decode(Tokens.trim(cell)) }
|
|
130
|
+
count(cells.length, header, "value")
|
|
131
|
+
cells
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def list(header, depth)
|
|
135
|
+
items = []
|
|
136
|
+
while (line = @lines.peek) && line.depth == depth && line.item?
|
|
137
|
+
span(line) unless items.empty?
|
|
138
|
+
@lines.next
|
|
139
|
+
items << item(line, depth)
|
|
140
|
+
end
|
|
141
|
+
scalar_line(@lines.peek, depth)
|
|
142
|
+
count(items.length, header, "item")
|
|
143
|
+
items
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# A scalar line is valid only as a root primitive; inside a scope it is a
|
|
147
|
+
# structural error in strict and non-strict mode alike (§5.2, §14.2).
|
|
148
|
+
def scalar_line(line, depth)
|
|
149
|
+
return if line.nil? || line.depth != depth || Tokens.colon_index(line.content)
|
|
150
|
+
|
|
151
|
+
header = Header.parse(line.content)
|
|
152
|
+
return if header && !header.malformed?
|
|
153
|
+
|
|
154
|
+
raise Error, "cannot decode a bare token line inside a scope on line #{line.number}: #{line.text}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def item(line, depth)
|
|
158
|
+
rest = line.item_content
|
|
159
|
+
return {} if rest.empty?
|
|
160
|
+
return [] if rest == "[]"
|
|
161
|
+
|
|
162
|
+
header = usable(Header.parse(rest), line)
|
|
163
|
+
return item_header(header, line, depth) if header
|
|
164
|
+
return item_object(rest, depth) if Tokens.colon_index(rest)
|
|
165
|
+
|
|
166
|
+
Token.decode(Tokens.trim(rest))
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def item_header(header, line, depth)
|
|
170
|
+
return item_object(line.item_content, depth) if header.key
|
|
171
|
+
raise Error, "cannot decode a keyless fields-bearing header as a list item on line #{line.number}: #{line.text}" if header.fields?
|
|
172
|
+
|
|
173
|
+
array(header, depth + 1)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def item_object(rest, depth)
|
|
177
|
+
synthetic = Line.new(rest, depth + 1, @lines.number)
|
|
178
|
+
key, value = descend { field(synthetic, depth + 1) }
|
|
179
|
+
result = {}
|
|
180
|
+
store(result, key, value, synthetic)
|
|
181
|
+
following = @lines.peek
|
|
182
|
+
span(following) if following && following.depth == depth + 1
|
|
183
|
+
descend { object(depth + 1, result) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def table(header, depth)
|
|
187
|
+
raise Error, "cannot decode a field name repeated in one field list: #{header.duplicate.inspect}" if @strict && header.duplicate
|
|
188
|
+
|
|
189
|
+
header.keyed? ? entries(header, depth) : rows(header, depth)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def rows(header, depth)
|
|
193
|
+
collected = descend do
|
|
194
|
+
gather(depth, stop: ->(line) { key_value_line?(line, header.delimiter) }) { |line| row(header, line) }
|
|
195
|
+
end
|
|
196
|
+
count(collected.length, header, "row")
|
|
197
|
+
collected
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# §9.3: at row depth a line whose first unquoted colon precedes its first
|
|
201
|
+
# unquoted delimiter is a key-value line, and the rows end before it.
|
|
202
|
+
def key_value_line?(line, delimiter)
|
|
203
|
+
colon = Tokens.colon_index(line.content)
|
|
204
|
+
return false if colon.nil?
|
|
205
|
+
|
|
206
|
+
cell = Tokens.index_of(line.content, Tokens::UNTIL_CELL_END.fetch(delimiter), delimiter)
|
|
207
|
+
cell.nil? || colon < cell
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def entries(header, depth)
|
|
211
|
+
result = {}
|
|
212
|
+
collected = descend do
|
|
213
|
+
gather(depth) do |line|
|
|
214
|
+
colon = Tokens.colon_index(line.content)
|
|
215
|
+
if colon.nil?
|
|
216
|
+
raise Error, "cannot decode an entry row without a colon on line #{line.number}: #{line.text}" if @strict
|
|
217
|
+
|
|
218
|
+
next nil
|
|
219
|
+
end
|
|
220
|
+
[Tokens.key(line.content[0...colon]), row(header, line, line.content[(colon + 1)..]), line]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
collected.compact!
|
|
224
|
+
count(collected.length, header, "entry row")
|
|
225
|
+
collected.each { |key, value, line| store(result, key, value, line) }
|
|
226
|
+
result
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def gather(depth, stop: nil)
|
|
230
|
+
collected = []
|
|
231
|
+
while (line = @lines.peek) && line.depth == depth
|
|
232
|
+
break if stop&.call(line)
|
|
233
|
+
|
|
234
|
+
span(line) unless collected.empty?
|
|
235
|
+
@lines.next
|
|
236
|
+
collected << yield(line)
|
|
237
|
+
end
|
|
238
|
+
collected
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def span(line)
|
|
242
|
+
return unless @strict && line.after_blank
|
|
243
|
+
|
|
244
|
+
raise Error, "cannot decode a blank line inside a header span, before line #{line.number}: #{line.text}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def row(header, line, text = line.content)
|
|
248
|
+
cells = Tokens.split(text, header.delimiter)
|
|
249
|
+
cells = [] if cells.length == 1 && Tokens.trim(cells.first).empty?
|
|
250
|
+
width(cells.length, header, line)
|
|
251
|
+
shape(header.fields, cells.each)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def shape(fields, cells)
|
|
255
|
+
fields.each_with_object({}) do |(name, nested), result|
|
|
256
|
+
if nested
|
|
257
|
+
result[name] = shape(nested, cells)
|
|
258
|
+
else
|
|
259
|
+
cell = begin
|
|
260
|
+
cells.next
|
|
261
|
+
rescue StopIteration
|
|
262
|
+
next
|
|
263
|
+
end
|
|
264
|
+
result[name] = Token.decode(Tokens.trim(cell))
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def count(actual, header, noun)
|
|
270
|
+
return unless @strict && actual != header.length
|
|
271
|
+
|
|
272
|
+
raise Error, "cannot decode #{actual} #{noun}#{"s" unless actual == 1} where the header declares #{header.length}"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def width(actual, header, line)
|
|
276
|
+
return unless @strict && actual != header.leaves.length
|
|
277
|
+
|
|
278
|
+
raise Error, "cannot decode #{actual} cells on line #{line.number} where the header declares #{header.leaves.length} field#{"s" unless header.leaves.length == 1}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def usable(header, line)
|
|
282
|
+
return header unless header&.malformed?
|
|
283
|
+
raise Error, "cannot decode a malformed array header on line #{line.number}: #{line.text}" if @strict
|
|
284
|
+
|
|
285
|
+
nil
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def guard(line)
|
|
289
|
+
return unless @strict && line.depth > @depth + 1
|
|
290
|
+
|
|
291
|
+
raise Error, "cannot decode a depth jump on line #{line.number}: #{line.text}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def descend
|
|
295
|
+
@depth += 1
|
|
296
|
+
raise Error, "cannot decode nesting deeper than #{MAX_DEPTH} levels" if @depth > MAX_DEPTH
|
|
297
|
+
|
|
298
|
+
yield
|
|
299
|
+
ensure
|
|
300
|
+
@depth -= 1
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToonFu
|
|
4
|
+
class Token
|
|
5
|
+
NUMBER = /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?\z/i
|
|
6
|
+
INTEGER = /\A-?(?:0|[1-9][0-9]*)\z/
|
|
7
|
+
ESCAPE = /\\(?:(["\\nrt])|u([0-9a-fA-F]{4})|(.|\z))/m
|
|
8
|
+
UNESCAPES = {'"' => '"', "\\" => "\\", "n" => "\n", "r" => "\r", "t" => "\t"}.freeze
|
|
9
|
+
SURROGATES = (0xd800..0xdfff)
|
|
10
|
+
|
|
11
|
+
def self.key(text)
|
|
12
|
+
text.start_with?('"') ? quoted(text) : text
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.decode(text)
|
|
16
|
+
return quoted(text) if text.start_with?('"')
|
|
17
|
+
return nil if text == "null"
|
|
18
|
+
return true if text == "true"
|
|
19
|
+
return false if text == "false"
|
|
20
|
+
return number(text) if NUMBER.match?(text)
|
|
21
|
+
|
|
22
|
+
text
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.quoted(text)
|
|
26
|
+
closing = closing_quote(text)
|
|
27
|
+
raise Error, "cannot decode an unterminated quoted string: #{text}" if closing.nil?
|
|
28
|
+
raise Error, "cannot decode content after a closing quote: #{text}" unless closing == text.length - 1
|
|
29
|
+
|
|
30
|
+
unescape(text[1...closing])
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.closing_quote(text)
|
|
34
|
+
index = 1
|
|
35
|
+
while index < text.length
|
|
36
|
+
case text[index]
|
|
37
|
+
when "\\" then index += 2
|
|
38
|
+
when '"' then return index
|
|
39
|
+
else index += 1
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.unescape(body)
|
|
46
|
+
body.gsub(ESCAPE) do
|
|
47
|
+
if (literal = UNESCAPES[$1])
|
|
48
|
+
literal
|
|
49
|
+
elsif $2
|
|
50
|
+
codepoint($2.hex)
|
|
51
|
+
else
|
|
52
|
+
raise Error, "cannot decode the escape sequence \\#{$3}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.codepoint(value)
|
|
58
|
+
raise Error, "cannot decode the lone surrogate escape \\u#{format("%04x", value)}" if SURROGATES.cover?(value)
|
|
59
|
+
|
|
60
|
+
value.chr(Encoding::UTF_8)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.number(text)
|
|
64
|
+
return Integer(text, 10) if INTEGER.match?(text)
|
|
65
|
+
|
|
66
|
+
float = Float(text)
|
|
67
|
+
raise Error, "cannot decode #{text} as a Float without losing its magnitude" if float.infinite? || (float.zero? && nonzero?(text))
|
|
68
|
+
|
|
69
|
+
float.zero? ? 0.0 : float
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.nonzero?(text)
|
|
73
|
+
text.split(/e/i).first.match?(/[1-9]/)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private_class_method :quoted, :unescape, :codepoint, :number, :nonzero?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ToonFu
|
|
4
|
+
module Tokens
|
|
5
|
+
QUOTED = /"(?:[^"\\]|\\.)*"/
|
|
6
|
+
UNTIL_COLON = /[^":]+/
|
|
7
|
+
UNTIL_BRACKET = /[^":\[]+/
|
|
8
|
+
UNTIL_BRACE = /[^"{]+/
|
|
9
|
+
BRACE = /[{}]/
|
|
10
|
+
SURROUNDING_SPACES = /\A +| +\z/
|
|
11
|
+
UNTIL_CELL_END = Encoder::DELIMITERS.to_h { |d| [d, /[^"#{Regexp.escape(d)}]+/] }.freeze
|
|
12
|
+
UNTIL_FIELD_END = Encoder::DELIMITERS.to_h { |d| [d, /[^"{}#{Regexp.escape(d)}]+/] }.freeze
|
|
13
|
+
UNTIL_FOREIGN = Encoder::DELIMITERS.to_h { |d| [d, /[^"#{Regexp.escape((Encoder::DELIMITERS - [d]).join)}]+/] }.freeze
|
|
14
|
+
|
|
15
|
+
def self.index_of(text, pattern, stops)
|
|
16
|
+
scanner = StringScanner.new(text)
|
|
17
|
+
until scanner.eos?
|
|
18
|
+
next if scanner.skip(pattern)
|
|
19
|
+
return scanner.charpos if stops.include?(scanner.peek(1))
|
|
20
|
+
return nil unless scanner.skip(QUOTED)
|
|
21
|
+
end
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.colon_index(text)
|
|
26
|
+
index_of(text, UNTIL_COLON, ":")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.bracket_index(text)
|
|
30
|
+
index = index_of(text, UNTIL_BRACKET, "[:")
|
|
31
|
+
(index && text[index] == "[") ? index : nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.brace_index(text)
|
|
35
|
+
index = index_of(text, UNTIL_BRACE, "{")
|
|
36
|
+
(index && text[index] == "{") ? index : nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.closing_brace(text)
|
|
40
|
+
depth = 0
|
|
41
|
+
scanner = StringScanner.new(text)
|
|
42
|
+
until scanner.eos?
|
|
43
|
+
if scanner.skip(QUOTED)
|
|
44
|
+
next
|
|
45
|
+
elsif scanner.skip(/\{/)
|
|
46
|
+
depth += 1
|
|
47
|
+
elsif scanner.skip(/\}/)
|
|
48
|
+
depth -= 1
|
|
49
|
+
return scanner.charpos - 1 if depth.zero?
|
|
50
|
+
else
|
|
51
|
+
scanner.skip(/[^"{}]+/) || scanner.skip(/./)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.split_fields(text, delimiter)
|
|
58
|
+
entries = []
|
|
59
|
+
current = +""
|
|
60
|
+
depth = 0
|
|
61
|
+
scanner = StringScanner.new(text)
|
|
62
|
+
until scanner.eos?
|
|
63
|
+
if (quoted = scanner.scan(QUOTED))
|
|
64
|
+
current << quoted
|
|
65
|
+
elsif depth.zero? && scanner.skip(delimiter)
|
|
66
|
+
entries << current
|
|
67
|
+
current = +""
|
|
68
|
+
elsif (brace = scanner.scan(BRACE))
|
|
69
|
+
depth += (brace == "{") ? 1 : -1
|
|
70
|
+
current << brace
|
|
71
|
+
else
|
|
72
|
+
current << (scanner.scan(UNTIL_FIELD_END.fetch(delimiter)) || scanner.getch)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
entries << current
|
|
76
|
+
entries
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.split(text, delimiter)
|
|
80
|
+
cells = []
|
|
81
|
+
current = +""
|
|
82
|
+
scanner = StringScanner.new(text)
|
|
83
|
+
until scanner.eos?
|
|
84
|
+
if (quoted = scanner.scan(QUOTED))
|
|
85
|
+
current << quoted
|
|
86
|
+
elsif scanner.skip(delimiter)
|
|
87
|
+
cells << current
|
|
88
|
+
current = +""
|
|
89
|
+
else
|
|
90
|
+
current << (scanner.scan(UNTIL_CELL_END.fetch(delimiter)) || scanner.getch)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
cells << current
|
|
94
|
+
cells
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.trim(text)
|
|
98
|
+
text.gsub(SURROUNDING_SPACES, "")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.whole_token?(text)
|
|
102
|
+
return true unless text.start_with?('"')
|
|
103
|
+
|
|
104
|
+
Token.closing_quote(text) == text.length - 1
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.key(text)
|
|
108
|
+
Token.key(trim(text))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/toon_fu/version.rb
CHANGED
data/lib/toon_fu.rb
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "date"
|
|
4
|
+
require "strscan"
|
|
4
5
|
|
|
5
6
|
require_relative "toon_fu/version"
|
|
6
7
|
require_relative "toon_fu/encoder"
|
|
8
|
+
require_relative "toon_fu/decoder"
|
|
7
9
|
require_relative "toon_fu/float_literal"
|
|
8
10
|
require_relative "toon_fu/decimal_literal"
|
|
9
11
|
require_relative "toon_fu/string_literal"
|
|
10
12
|
require_relative "toon_fu/normalizer"
|
|
11
13
|
require_relative "toon_fu/fields"
|
|
12
14
|
require_relative "toon_fu/writer"
|
|
15
|
+
require_relative "toon_fu/token"
|
|
16
|
+
require_relative "toon_fu/tokens"
|
|
17
|
+
require_relative "toon_fu/field_list"
|
|
18
|
+
require_relative "toon_fu/header"
|
|
19
|
+
require_relative "toon_fu/lines"
|
|
20
|
+
require_relative "toon_fu/reader"
|
|
13
21
|
require_relative "toon_fu/encodable"
|
|
14
22
|
|
|
15
23
|
# TOON (Token-Oriented Object Notation) for Ruby.
|
|
16
24
|
module ToonFu
|
|
17
|
-
# Raised when a value has no TOON representation
|
|
25
|
+
# Raised when a value has no TOON representation, or when TOON cannot be
|
|
26
|
+
# read; see {Encoder#encode} and {Decoder#decode}.
|
|
18
27
|
class Error < StandardError; end
|
|
19
28
|
|
|
20
|
-
private_constant :FloatLiteral, :DecimalLiteral, :StringLiteral, :Normalizer, :Fields, :Writer
|
|
29
|
+
private_constant :FloatLiteral, :DecimalLiteral, :StringLiteral, :Normalizer, :Fields, :Writer, :Token, :Tokens, :FieldList, :Header, :Line, :Lines, :Reader
|
|
21
30
|
|
|
22
31
|
# Encodes a value as TOON.
|
|
23
32
|
#
|
|
@@ -43,6 +52,27 @@ module ToonFu
|
|
|
43
52
|
(options.empty? ? DEFAULT_ENCODER : Encoder.new(**options)).encode(value)
|
|
44
53
|
end
|
|
45
54
|
|
|
55
|
+
# Decodes TOON into the JSON data model.
|
|
56
|
+
#
|
|
57
|
+
# ToonFu.decode("hello") # => "hello"
|
|
58
|
+
# ToonFu.decode("\"42\"") # => "42"
|
|
59
|
+
# ToonFu.decode("") # => {}
|
|
60
|
+
# ToonFu.decode("[]") # => []
|
|
61
|
+
# ToonFu.decode("name: Ada\nadmin: true") # => {"name" => "Ada", "admin" => true}
|
|
62
|
+
# ToonFu.decode("user:\n id: 1") # => {"user" => {"id" => 1}}
|
|
63
|
+
# ToonFu.decode("tags[2]: a,b") # => {"tags" => ["a", "b"]}
|
|
64
|
+
# ToonFu.decode("[2]{id}:\n 1\n 2") # => [{"id" => 1}, {"id" => 2}]
|
|
65
|
+
# ToonFu.decode("[2:]{x}:\n a: 1\n b: 2") # => {"a" => {"x" => 1}, "b" => {"x" => 2}}
|
|
66
|
+
#
|
|
67
|
+
# @param text [String] see {Decoder#decode}
|
|
68
|
+
# @param options [Hash] see {Decoder#initialize}
|
|
69
|
+
# @return [Hash, Array, String, Integer, Float, true, false, nil]
|
|
70
|
+
# @raise [Error] see {Decoder#decode}
|
|
71
|
+
def self.decode(text, **options)
|
|
72
|
+
(options.empty? ? DEFAULT_DECODER : Decoder.new(**options)).decode(text)
|
|
73
|
+
end
|
|
74
|
+
|
|
46
75
|
DEFAULT_ENCODER = Ractor.make_shareable(Encoder.new)
|
|
47
|
-
|
|
76
|
+
DEFAULT_DECODER = Ractor.make_shareable(Decoder.new)
|
|
77
|
+
private_constant :DEFAULT_ENCODER, :DEFAULT_DECODER
|
|
48
78
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: toon-fu
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 4.1.
|
|
4
|
+
version: 4.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yevhenii Hurin
|
|
@@ -25,12 +25,19 @@ files:
|
|
|
25
25
|
- lib/toon-fu.rb
|
|
26
26
|
- lib/toon_fu.rb
|
|
27
27
|
- lib/toon_fu/decimal_literal.rb
|
|
28
|
+
- lib/toon_fu/decoder.rb
|
|
28
29
|
- lib/toon_fu/encodable.rb
|
|
29
30
|
- lib/toon_fu/encoder.rb
|
|
31
|
+
- lib/toon_fu/field_list.rb
|
|
30
32
|
- lib/toon_fu/fields.rb
|
|
31
33
|
- lib/toon_fu/float_literal.rb
|
|
34
|
+
- lib/toon_fu/header.rb
|
|
35
|
+
- lib/toon_fu/lines.rb
|
|
32
36
|
- lib/toon_fu/normalizer.rb
|
|
37
|
+
- lib/toon_fu/reader.rb
|
|
33
38
|
- lib/toon_fu/string_literal.rb
|
|
39
|
+
- lib/toon_fu/token.rb
|
|
40
|
+
- lib/toon_fu/tokens.rb
|
|
34
41
|
- lib/toon_fu/version.rb
|
|
35
42
|
- lib/toon_fu/writer.rb
|
|
36
43
|
homepage: https://github.com/hoblin/toon-fu
|