cton 0.3.0 → 0.4.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 +48 -0
- data/README.md +119 -0
- data/lib/cton/decoder.rb +49 -20
- data/lib/cton/encoder.rb +16 -2
- data/lib/cton/stats.rb +123 -0
- data/lib/cton/type_registry.rb +137 -0
- data/lib/cton/validator.rb +427 -0
- data/lib/cton/version.rb +1 -1
- data/lib/cton.rb +84 -2
- data/sig/cton.rbs +119 -6
- metadata +5 -2
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cton
|
|
4
|
+
# Result object for validation operations
|
|
5
|
+
class ValidationResult
|
|
6
|
+
attr_reader :errors
|
|
7
|
+
|
|
8
|
+
def initialize(errors = [])
|
|
9
|
+
@errors = errors.freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def valid?
|
|
13
|
+
errors.empty?
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_s
|
|
17
|
+
return "Valid CTON" if valid?
|
|
18
|
+
|
|
19
|
+
messages = errors.map(&:to_s)
|
|
20
|
+
"Invalid CTON:\n #{messages.join("\n ")}"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Represents a single validation error with location info
|
|
25
|
+
class ValidationError
|
|
26
|
+
attr_reader :message, :line, :column, :source_excerpt
|
|
27
|
+
|
|
28
|
+
def initialize(message:, line:, column:, source_excerpt: nil)
|
|
29
|
+
@message = message
|
|
30
|
+
@line = line
|
|
31
|
+
@column = column
|
|
32
|
+
@source_excerpt = source_excerpt
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s
|
|
36
|
+
loc = "line #{line}, column #{column}"
|
|
37
|
+
excerpt_str = source_excerpt ? " near '#{source_excerpt}'" : ""
|
|
38
|
+
"#{message} at #{loc}#{excerpt_str}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
{
|
|
43
|
+
message: message,
|
|
44
|
+
line: line,
|
|
45
|
+
column: column,
|
|
46
|
+
source_excerpt: source_excerpt
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Lightweight validator that checks syntax without building full AST
|
|
52
|
+
class Validator
|
|
53
|
+
def initialize
|
|
54
|
+
@errors = []
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate(cton_string)
|
|
58
|
+
@errors = []
|
|
59
|
+
@raw_string = cton_string.to_s
|
|
60
|
+
@pos = 0
|
|
61
|
+
@length = @raw_string.length
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
validate_document
|
|
65
|
+
check_trailing_content
|
|
66
|
+
rescue StopIteration
|
|
67
|
+
# Validation complete
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
ValidationResult.new(@errors)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def validate_document
|
|
76
|
+
skip_ws_and_comments
|
|
77
|
+
return if eos?
|
|
78
|
+
|
|
79
|
+
if key_ahead?
|
|
80
|
+
validate_key_value_pairs
|
|
81
|
+
else
|
|
82
|
+
validate_value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_key_value_pairs
|
|
87
|
+
loop do
|
|
88
|
+
skip_ws_and_comments
|
|
89
|
+
break if eos?
|
|
90
|
+
|
|
91
|
+
validate_key
|
|
92
|
+
validate_value_for_key
|
|
93
|
+
skip_ws_and_comments
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_key
|
|
98
|
+
skip_ws_and_comments
|
|
99
|
+
start = @pos
|
|
100
|
+
return if scan_key
|
|
101
|
+
|
|
102
|
+
add_error("Invalid key", start)
|
|
103
|
+
skip_to_recovery_point
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_value_for_key
|
|
107
|
+
skip_ws_and_comments
|
|
108
|
+
|
|
109
|
+
case peek
|
|
110
|
+
when "("
|
|
111
|
+
advance
|
|
112
|
+
validate_object_contents
|
|
113
|
+
when "["
|
|
114
|
+
advance
|
|
115
|
+
validate_array_contents
|
|
116
|
+
when "="
|
|
117
|
+
advance
|
|
118
|
+
skip_ws_and_comments
|
|
119
|
+
# After = we can have an object, array, or scalar
|
|
120
|
+
if peek == "("
|
|
121
|
+
advance
|
|
122
|
+
validate_object_contents
|
|
123
|
+
elsif peek == "["
|
|
124
|
+
advance
|
|
125
|
+
validate_array_contents
|
|
126
|
+
else
|
|
127
|
+
validate_scalar(allow_boundary: true)
|
|
128
|
+
end
|
|
129
|
+
else
|
|
130
|
+
add_error("Expected '(', '[', or '=' after key", @pos)
|
|
131
|
+
skip_to_recovery_point
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_object_contents
|
|
136
|
+
skip_ws_and_comments
|
|
137
|
+
return if consume(")")
|
|
138
|
+
|
|
139
|
+
loop do
|
|
140
|
+
if eos?
|
|
141
|
+
add_error("Unclosed object - expected ')'", @pos)
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
validate_key
|
|
146
|
+
unless consume("=")
|
|
147
|
+
add_error("Expected '=' in object", @pos)
|
|
148
|
+
skip_to_recovery_point
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
validate_value
|
|
152
|
+
|
|
153
|
+
skip_ws_and_comments
|
|
154
|
+
|
|
155
|
+
if eos?
|
|
156
|
+
add_error("Unclosed object - expected ')'", @pos)
|
|
157
|
+
return
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
break if consume(")")
|
|
161
|
+
|
|
162
|
+
unless consume(",")
|
|
163
|
+
add_error("Expected ',' or ')' in object", @pos)
|
|
164
|
+
skip_to_recovery_point
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
skip_ws_and_comments
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_array_contents
|
|
172
|
+
length_start = @pos
|
|
173
|
+
length = scan_integer
|
|
174
|
+
if length.nil?
|
|
175
|
+
add_error("Expected array length", length_start)
|
|
176
|
+
skip_to_recovery_point
|
|
177
|
+
return
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
unless consume("]")
|
|
181
|
+
add_error("Expected ']' after array length", @pos)
|
|
182
|
+
skip_to_recovery_point
|
|
183
|
+
return
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
skip_ws_and_comments
|
|
187
|
+
|
|
188
|
+
# Check for table header
|
|
189
|
+
has_header = peek == "{"
|
|
190
|
+
if has_header
|
|
191
|
+
advance
|
|
192
|
+
validate_header
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
unless consume("=")
|
|
196
|
+
add_error("Expected '=' after array declaration", @pos)
|
|
197
|
+
skip_to_recovery_point
|
|
198
|
+
return
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
return if length.zero?
|
|
202
|
+
|
|
203
|
+
if has_header
|
|
204
|
+
validate_table_rows(length)
|
|
205
|
+
else
|
|
206
|
+
validate_array_elements(length)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def validate_header
|
|
211
|
+
loop do
|
|
212
|
+
validate_key
|
|
213
|
+
skip_ws_and_comments
|
|
214
|
+
break if consume("}")
|
|
215
|
+
|
|
216
|
+
next if consume(",")
|
|
217
|
+
|
|
218
|
+
add_error("Expected ',' or '}' in header", @pos)
|
|
219
|
+
skip_to_recovery_point
|
|
220
|
+
return
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def validate_table_rows(length)
|
|
225
|
+
length.times do |i|
|
|
226
|
+
validate_scalar(allow_boundary: i == length - 1)
|
|
227
|
+
# Table cells are separated by commas, rows by semicolons
|
|
228
|
+
skip_ws_and_comments
|
|
229
|
+
next unless i < length - 1
|
|
230
|
+
|
|
231
|
+
unless consume(";") || peek == "," || eos?
|
|
232
|
+
# More permissive - allow continued parsing
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def validate_array_elements(length)
|
|
238
|
+
length.times do |i|
|
|
239
|
+
validate_value(allow_boundary: i == length - 1)
|
|
240
|
+
skip_ws_and_comments
|
|
241
|
+
if i < length - 1
|
|
242
|
+
consume(",") # Optional comma handling
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def validate_value(allow_boundary: false)
|
|
248
|
+
skip_ws_and_comments
|
|
249
|
+
|
|
250
|
+
case peek
|
|
251
|
+
when "("
|
|
252
|
+
advance
|
|
253
|
+
validate_object_contents
|
|
254
|
+
when "["
|
|
255
|
+
advance
|
|
256
|
+
validate_array_contents
|
|
257
|
+
when '"'
|
|
258
|
+
validate_string
|
|
259
|
+
else
|
|
260
|
+
validate_scalar(allow_boundary: allow_boundary)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def validate_scalar(allow_boundary: false)
|
|
265
|
+
skip_ws_and_comments
|
|
266
|
+
return validate_string if peek == '"'
|
|
267
|
+
|
|
268
|
+
start = @pos
|
|
269
|
+
scan_until_terminator
|
|
270
|
+
|
|
271
|
+
return unless @pos == start
|
|
272
|
+
|
|
273
|
+
add_error("Empty value", start)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def validate_string
|
|
277
|
+
start = @pos
|
|
278
|
+
advance # consume opening quote
|
|
279
|
+
|
|
280
|
+
loop do
|
|
281
|
+
if eos?
|
|
282
|
+
add_error("Unterminated string", start)
|
|
283
|
+
return
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
char = current
|
|
287
|
+
advance
|
|
288
|
+
|
|
289
|
+
if char == "\\"
|
|
290
|
+
if eos?
|
|
291
|
+
add_error("Invalid escape sequence", @pos - 1)
|
|
292
|
+
return
|
|
293
|
+
end
|
|
294
|
+
escaped = current
|
|
295
|
+
advance
|
|
296
|
+
unless ["n", "r", "t", '"', "\\"].include?(escaped)
|
|
297
|
+
add_error("Unsupported escape sequence '\\#{escaped}'", @pos - 2)
|
|
298
|
+
end
|
|
299
|
+
elsif char == '"'
|
|
300
|
+
return
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def check_trailing_content
|
|
306
|
+
skip_ws_and_comments
|
|
307
|
+
return if eos?
|
|
308
|
+
|
|
309
|
+
add_error("Unexpected trailing data", @pos)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Helper methods
|
|
313
|
+
|
|
314
|
+
def add_error(message, pos)
|
|
315
|
+
line, col = calculate_location(pos)
|
|
316
|
+
excerpt = extract_excerpt(pos)
|
|
317
|
+
@errors << ValidationError.new(
|
|
318
|
+
message: message,
|
|
319
|
+
line: line,
|
|
320
|
+
column: col,
|
|
321
|
+
source_excerpt: excerpt
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def calculate_location(pos)
|
|
326
|
+
consumed = @raw_string[0...pos]
|
|
327
|
+
line = consumed.count("\n") + 1
|
|
328
|
+
last_newline = consumed.rindex("\n")
|
|
329
|
+
col = last_newline ? pos - last_newline : pos + 1
|
|
330
|
+
[line, col]
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def extract_excerpt(pos, length: 20)
|
|
334
|
+
start = [pos - 5, 0].max
|
|
335
|
+
finish = [pos + length, @length].min
|
|
336
|
+
excerpt = @raw_string[start...finish]
|
|
337
|
+
excerpt = "...#{excerpt}" if start.positive?
|
|
338
|
+
excerpt = "#{excerpt}..." if finish < @length
|
|
339
|
+
excerpt.gsub(/\s+/, " ")
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def eos?
|
|
343
|
+
@pos >= @length
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def peek
|
|
347
|
+
return nil if eos?
|
|
348
|
+
|
|
349
|
+
@raw_string[@pos]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def current
|
|
353
|
+
@raw_string[@pos]
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def advance
|
|
357
|
+
@pos += 1
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def consume(char)
|
|
361
|
+
if peek == char
|
|
362
|
+
advance
|
|
363
|
+
true
|
|
364
|
+
else
|
|
365
|
+
false
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def scan_key
|
|
370
|
+
start = @pos
|
|
371
|
+
@pos += 1 while @pos < @length && @raw_string[@pos].match?(/[0-9A-Za-z_.:-]/)
|
|
372
|
+
@pos > start
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def scan_integer
|
|
376
|
+
start = @pos
|
|
377
|
+
advance if peek == "-"
|
|
378
|
+
@pos += 1 while @pos < @length && @raw_string[@pos].match?(/\d/)
|
|
379
|
+
return nil if @pos == start || (@pos == start + 1 && @raw_string[start] == "-")
|
|
380
|
+
|
|
381
|
+
@raw_string[start...@pos].to_i
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def scan_until_terminator
|
|
385
|
+
while @pos < @length
|
|
386
|
+
char = @raw_string[@pos]
|
|
387
|
+
break if [",", ";", ")", "]", "}"].include?(char) || char.match?(/\s/)
|
|
388
|
+
|
|
389
|
+
@pos += 1
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def skip_ws_and_comments
|
|
394
|
+
loop do
|
|
395
|
+
@pos += 1 while @pos < @length && @raw_string[@pos].match?(/\s/)
|
|
396
|
+
|
|
397
|
+
break unless @pos < @length && @raw_string[@pos] == "#"
|
|
398
|
+
|
|
399
|
+
@pos += 1 while @pos < @length && @raw_string[@pos] != "\n"
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def skip_to_recovery_point
|
|
404
|
+
while @pos < @length
|
|
405
|
+
char = @raw_string[@pos]
|
|
406
|
+
break if ["\n", ",", ";", ")", "]", "}"].include?(char)
|
|
407
|
+
|
|
408
|
+
@pos += 1
|
|
409
|
+
end
|
|
410
|
+
advance if @pos < @length && [",", ";"].include?(@raw_string[@pos])
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def key_ahead?
|
|
414
|
+
saved_pos = @pos
|
|
415
|
+
skip_ws_and_comments
|
|
416
|
+
|
|
417
|
+
result = false
|
|
418
|
+
if scan_key
|
|
419
|
+
skip_ws_and_comments
|
|
420
|
+
result = ["(", "[", "="].include?(peek)
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
@pos = saved_pos
|
|
424
|
+
result
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
end
|
data/lib/cton/version.rb
CHANGED
data/lib/cton.rb
CHANGED
|
@@ -4,11 +4,42 @@ require "bigdecimal"
|
|
|
4
4
|
require_relative "cton/version"
|
|
5
5
|
require_relative "cton/encoder"
|
|
6
6
|
require_relative "cton/decoder"
|
|
7
|
+
require_relative "cton/validator"
|
|
8
|
+
require_relative "cton/stats"
|
|
9
|
+
require_relative "cton/type_registry"
|
|
7
10
|
|
|
8
11
|
module Cton
|
|
9
12
|
class Error < StandardError; end
|
|
10
13
|
class EncodeError < Error; end
|
|
11
|
-
|
|
14
|
+
|
|
15
|
+
# Enhanced ParseError with structured location information
|
|
16
|
+
class ParseError < Error
|
|
17
|
+
attr_reader :line, :column, :source_excerpt, :suggestions
|
|
18
|
+
|
|
19
|
+
def initialize(message, line: nil, column: nil, source_excerpt: nil, suggestions: nil)
|
|
20
|
+
@line = line
|
|
21
|
+
@column = column
|
|
22
|
+
@source_excerpt = source_excerpt
|
|
23
|
+
@suggestions = suggestions || []
|
|
24
|
+
|
|
25
|
+
full_message = message
|
|
26
|
+
full_message = "#{full_message} at line #{line}, column #{column}" if line && column
|
|
27
|
+
full_message = "#{full_message} near '#{source_excerpt}'" if source_excerpt
|
|
28
|
+
full_message = "#{full_message}. #{@suggestions.join(". ")}" unless @suggestions.empty?
|
|
29
|
+
|
|
30
|
+
super(full_message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
message: message,
|
|
36
|
+
line: line,
|
|
37
|
+
column: column,
|
|
38
|
+
source_excerpt: source_excerpt,
|
|
39
|
+
suggestions: suggestions
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
12
43
|
|
|
13
44
|
module_function
|
|
14
45
|
|
|
@@ -29,7 +60,9 @@ module Cton
|
|
|
29
60
|
separator = options.fetch(:separator, "\n")
|
|
30
61
|
pretty = options.fetch(:pretty, false)
|
|
31
62
|
decimal_mode = options.fetch(:decimal_mode, :fast)
|
|
32
|
-
|
|
63
|
+
comments = options.fetch(:comments, nil)
|
|
64
|
+
Encoder.new(separator: separator, pretty: pretty, decimal_mode: decimal_mode, comments: comments).encode(payload,
|
|
65
|
+
io: io)
|
|
33
66
|
end
|
|
34
67
|
alias generate dump
|
|
35
68
|
|
|
@@ -37,4 +70,53 @@ module Cton
|
|
|
37
70
|
Decoder.new(symbolize_names: symbolize_names).decode(cton_string)
|
|
38
71
|
end
|
|
39
72
|
alias parse load
|
|
73
|
+
|
|
74
|
+
# Validate a CTON string without parsing
|
|
75
|
+
#
|
|
76
|
+
# @param cton_string [String] The CTON string to validate
|
|
77
|
+
# @return [ValidationResult] Result object with errors array
|
|
78
|
+
#
|
|
79
|
+
# @example
|
|
80
|
+
# result = Cton.validate("key=value")
|
|
81
|
+
# result.valid? # => true
|
|
82
|
+
#
|
|
83
|
+
# result = Cton.validate("key=(broken")
|
|
84
|
+
# result.valid? # => false
|
|
85
|
+
# result.errors.first.message # => "Expected ')' in object"
|
|
86
|
+
def validate(cton_string)
|
|
87
|
+
Validator.new.validate(cton_string)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Check if a CTON string is valid
|
|
91
|
+
#
|
|
92
|
+
# @param cton_string [String] The CTON string to check
|
|
93
|
+
# @return [Boolean] true if valid, false otherwise
|
|
94
|
+
#
|
|
95
|
+
# @example
|
|
96
|
+
# Cton.valid?("key=value") # => true
|
|
97
|
+
# Cton.valid?("key=(") # => false
|
|
98
|
+
def valid?(cton_string)
|
|
99
|
+
validate(cton_string).valid?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Get token statistics comparing CTON vs JSON
|
|
103
|
+
#
|
|
104
|
+
# @param data [Hash, Array] The data to analyze
|
|
105
|
+
# @return [Stats] Statistics object with comparison data
|
|
106
|
+
#
|
|
107
|
+
# @example
|
|
108
|
+
# stats = Cton.stats({ name: "test", values: [1, 2, 3] })
|
|
109
|
+
# puts stats.savings_percent # => 45.5
|
|
110
|
+
# puts stats.to_s
|
|
111
|
+
def stats(data)
|
|
112
|
+
Stats.new(data)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get statistics as a hash
|
|
116
|
+
#
|
|
117
|
+
# @param data [Hash, Array] The data to analyze
|
|
118
|
+
# @return [Hash] Statistics hash
|
|
119
|
+
def stats_hash(data)
|
|
120
|
+
stats(data).to_h
|
|
121
|
+
end
|
|
40
122
|
end
|