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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cton
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
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
- class ParseError < Error; end
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
- Encoder.new(separator: separator, pretty: pretty, decimal_mode: decimal_mode).encode(payload, io: io)
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