tusktsk 2.0.1

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.
data/exe/tsk ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # TuskLang CLI Executable
5
+ # This file is installed as the 'tsk' command
6
+
7
+ require_relative '../cli/main'
8
+
9
+ # Run the CLI with the provided arguments
10
+ TuskLang::CLI.run(ARGV)
@@ -0,0 +1,621 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'pathname'
6
+ require 'fileutils'
7
+
8
+ ##
9
+ # PeanutConfig - Hierarchical configuration with binary compilation
10
+ # Part of TuskLang Ruby SDK
11
+ #
12
+ # Features:
13
+ # - CSS-like inheritance with directory hierarchy
14
+ # - Binary compilation for 85% performance boost
15
+ # - Auto-compilation on change
16
+ # - Ruby Marshal serialization for speed
17
+ # - Thread-safe caching
18
+ # - File watching with automatic cache invalidation
19
+ #
20
+ # @example Basic usage
21
+ # config = PeanutConfig.new
22
+ # settings = config.load('/path/to/project')
23
+ # host = config.get('server.host', 'localhost')
24
+ #
25
+ # @example Custom options
26
+ # config = PeanutConfig.new(auto_compile: false, watch: false)
27
+ #
28
+ class PeanutConfig
29
+ # Binary format constants
30
+ MAGIC = 'PNUT'
31
+ VERSION = 1
32
+ HEADER_SIZE = 16
33
+ CHECKSUM_SIZE = 8
34
+
35
+ # Configuration file types
36
+ CONFIG_TYPES = {
37
+ binary: 'pnt',
38
+ tsk: 'tsk',
39
+ text: 'peanuts'
40
+ }.freeze
41
+
42
+ ##
43
+ # Represents a configuration file in the hierarchy
44
+ #
45
+ ConfigFile = Struct.new(:path, :type, :mtime, keyword_init: true) do
46
+ def to_s
47
+ "#{path} (#{type}, #{mtime})"
48
+ end
49
+ end
50
+
51
+ attr_accessor :auto_compile, :watch
52
+
53
+ ##
54
+ # Initialize a new PeanutConfig instance
55
+ #
56
+ # @param auto_compile [Boolean] automatically compile text configs to binary
57
+ # @param watch [Boolean] watch config files for changes
58
+ #
59
+ def initialize(auto_compile: true, watch: true)
60
+ @cache = {}
61
+ @cache_mutex = Mutex.new
62
+ @watchers = {}
63
+ @auto_compile = auto_compile
64
+ @watch = watch
65
+ end
66
+
67
+ ##
68
+ # Find configuration files in directory hierarchy
69
+ #
70
+ # @param start_dir [String, Pathname] starting directory
71
+ # @return [Array<ConfigFile>] configuration files from root to current
72
+ #
73
+ def find_config_hierarchy(start_dir)
74
+ configs = []
75
+ current_dir = Pathname.new(start_dir).expand_path
76
+
77
+ # Walk up directory tree
78
+ until current_dir.root?
79
+ # Check for config files in priority order
80
+ binary_path = current_dir / 'peanu.pnt'
81
+ tsk_path = current_dir / 'peanu.tsk'
82
+ text_path = current_dir / 'peanu.peanuts'
83
+
84
+ if binary_path.exist?
85
+ configs << ConfigFile.new(
86
+ path: binary_path.to_s,
87
+ type: :binary,
88
+ mtime: binary_path.mtime
89
+ )
90
+ elsif tsk_path.exist?
91
+ configs << ConfigFile.new(
92
+ path: tsk_path.to_s,
93
+ type: :tsk,
94
+ mtime: tsk_path.mtime
95
+ )
96
+ elsif text_path.exist?
97
+ configs << ConfigFile.new(
98
+ path: text_path.to_s,
99
+ type: :text,
100
+ mtime: text_path.mtime
101
+ )
102
+ end
103
+
104
+ current_dir = current_dir.parent
105
+ end
106
+
107
+ # Check for global peanut.tsk
108
+ global_config = Pathname.new('peanut.tsk')
109
+ if global_config.exist?
110
+ configs.unshift(ConfigFile.new(
111
+ path: global_config.to_s,
112
+ type: :tsk,
113
+ mtime: global_config.mtime
114
+ ))
115
+ end
116
+
117
+ # Reverse to get root->current order
118
+ configs.reverse
119
+ end
120
+
121
+ ##
122
+ # Parse text-based peanut configuration
123
+ #
124
+ # @param content [String] configuration file content
125
+ # @return [Hash] parsed configuration
126
+ #
127
+ def parse_text_config(content)
128
+ config = {}
129
+ current_section = config
130
+ current_section_name = nil
131
+
132
+ content.each_line do |line|
133
+ line = line.strip
134
+
135
+ # Skip comments and empty lines
136
+ next if line.empty? || line.start_with?('#')
137
+
138
+ # Section header
139
+ if line.match?(/^\[.*\]$/)
140
+ section_name = line[1..-2]
141
+ current_section = {}
142
+ config[section_name] = current_section
143
+ current_section_name = section_name
144
+ next
145
+ end
146
+
147
+ # Key-value pair
148
+ if (match = line.match(/^([^:]+):\s*(.*)$/))
149
+ key = match[1].strip
150
+ value = match[2].strip
151
+ current_section[key] = parse_value(value)
152
+ end
153
+ end
154
+
155
+ config
156
+ end
157
+
158
+ ##
159
+ # Parse a value with type inference
160
+ #
161
+ # @param value [String] string value to parse
162
+ # @return [Object] parsed value (String, Integer, Float, Boolean, Array, nil)
163
+ #
164
+ def parse_value(value)
165
+ # Remove quotes
166
+ if (value.start_with?('"') && value.end_with?('"')) ||
167
+ (value.start_with?("'") && value.end_with?("'"))
168
+ return value[1..-2]
169
+ end
170
+
171
+ # Boolean
172
+ case value.downcase
173
+ when 'true'
174
+ return true
175
+ when 'false'
176
+ return false
177
+ when 'null'
178
+ return nil
179
+ end
180
+
181
+ # Number
182
+ if value.match?(/^\d+$/)
183
+ return Integer(value)
184
+ elsif value.match?(/^\d*\.\d+$/)
185
+ return Float(value)
186
+ end
187
+
188
+ # Array (simple comma-separated)
189
+ if value.include?(',')
190
+ return value.split(',').map { |v| parse_value(v.strip) }
191
+ end
192
+
193
+ value
194
+ end
195
+
196
+ ##
197
+ # Compile configuration to binary format
198
+ #
199
+ # @param config [Hash] configuration to compile
200
+ # @param output_path [String, Pathname] output file path
201
+ #
202
+ def compile_to_binary(config, output_path)
203
+ output_path = Pathname.new(output_path)
204
+
205
+ File.open(output_path, 'wb') do |file|
206
+ # Write header
207
+ file.write(MAGIC)
208
+ file.write([VERSION].pack('L<'))
209
+ file.write([Time.now.to_i].pack('Q<'))
210
+
211
+ # Serialize config with Marshal (Ruby's native binary format)
212
+ config_data = Marshal.dump(config)
213
+
214
+ # Create checksum
215
+ checksum = Digest::SHA256.digest(config_data)[0, CHECKSUM_SIZE]
216
+ file.write(checksum)
217
+
218
+ # Write config data
219
+ file.write(config_data)
220
+ end
221
+
222
+ # Also create intermediate .shell format (JSON)
223
+ shell_path = output_path.sub_ext('.shell')
224
+ compile_to_shell(config, shell_path)
225
+ end
226
+
227
+ ##
228
+ # Compile to intermediate shell format (70% faster than text)
229
+ #
230
+ # @param config [Hash] configuration to compile
231
+ # @param output_path [String, Pathname] output file path
232
+ #
233
+ def compile_to_shell(config, output_path)
234
+ shell_data = {
235
+ version: VERSION,
236
+ timestamp: Time.now.to_i,
237
+ data: config
238
+ }
239
+
240
+ File.write(output_path, JSON.pretty_generate(shell_data))
241
+ end
242
+
243
+ ##
244
+ # Load binary configuration
245
+ #
246
+ # @param file_path [String, Pathname] path to binary file
247
+ # @return [Hash] loaded configuration
248
+ # @raise [StandardError] if file is invalid or corrupted
249
+ #
250
+ def load_binary(file_path)
251
+ data = File.binread(file_path)
252
+
253
+ raise 'Binary file too short' if data.bytesize < HEADER_SIZE + CHECKSUM_SIZE
254
+
255
+ # Verify magic number
256
+ magic = data[0, 4]
257
+ raise 'Invalid peanut binary file' unless magic == MAGIC
258
+
259
+ # Check version
260
+ version = data[4, 4].unpack1('L<')
261
+ raise "Unsupported binary version: #{version}" if version > VERSION
262
+
263
+ # Verify checksum
264
+ stored_checksum = data[HEADER_SIZE, CHECKSUM_SIZE]
265
+ config_data = data[HEADER_SIZE + CHECKSUM_SIZE..-1]
266
+
267
+ calculated_checksum = Digest::SHA256.digest(config_data)[0, CHECKSUM_SIZE]
268
+ unless stored_checksum == calculated_checksum
269
+ raise 'Binary file corrupted (checksum mismatch)'
270
+ end
271
+
272
+ # Deserialize configuration
273
+ Marshal.load(config_data)
274
+ rescue ArgumentError, TypeError => e
275
+ raise "Failed to deserialize binary config: #{e.message}"
276
+ end
277
+
278
+ ##
279
+ # Deep merge configurations (CSS-like cascading)
280
+ #
281
+ # @param target [Hash] target configuration
282
+ # @param source [Hash] source configuration
283
+ # @return [Hash] merged configuration
284
+ #
285
+ def deep_merge(target, source)
286
+ result = target.dup
287
+
288
+ source.each do |key, value|
289
+ if result[key].is_a?(Hash) && value.is_a?(Hash)
290
+ # Merge nested hashes
291
+ result[key] = deep_merge(result[key], value)
292
+ else
293
+ # Override with source value
294
+ result[key] = value
295
+ end
296
+ end
297
+
298
+ result
299
+ end
300
+
301
+ ##
302
+ # Load configuration with inheritance
303
+ #
304
+ # @param directory [String, Pathname] directory to load from (default: current)
305
+ # @return [Hash] merged configuration
306
+ #
307
+ def load(directory = '.')
308
+ abs_dir = Pathname.new(directory).expand_path.to_s
309
+
310
+ # Check cache
311
+ @cache_mutex.synchronize do
312
+ return @cache[abs_dir].dup if @cache.key?(abs_dir)
313
+ end
314
+
315
+ hierarchy = find_config_hierarchy(directory)
316
+ merged_config = {}
317
+
318
+ # Load and merge configs from root to current
319
+ hierarchy.each do |config_file|
320
+ begin
321
+ config = case config_file.type
322
+ when :binary
323
+ load_binary(config_file.path)
324
+ when :tsk, :text
325
+ content = File.read(config_file.path)
326
+ parse_text_config(content)
327
+ else
328
+ {}
329
+ end
330
+
331
+ # Merge with CSS-like cascading
332
+ merged_config = deep_merge(merged_config, config)
333
+
334
+ # Set up file watching
335
+ watch_config(config_file.path, abs_dir) if @watch
336
+ rescue => e
337
+ warn "Error loading config file #{config_file.path}: #{e.message}"
338
+ end
339
+ end
340
+
341
+ # Cache the result
342
+ @cache_mutex.synchronize do
343
+ @cache[abs_dir] = merged_config.dup
344
+ end
345
+
346
+ # Auto-compile if enabled
347
+ auto_compile_configs(hierarchy) if @auto_compile
348
+
349
+ merged_config
350
+ end
351
+
352
+ ##
353
+ # Get configuration value by dot-separated path
354
+ #
355
+ # @param key_path [String] dot-separated key path (e.g., 'server.host')
356
+ # @param default_value [Object] default value if key not found
357
+ # @param directory [String, Pathname] directory to load from
358
+ # @return [Object] configuration value or default
359
+ #
360
+ def get(key_path, default_value = nil, directory = '.')
361
+ config = load(directory)
362
+ keys = key_path.split('.')
363
+ current = config
364
+
365
+ keys.each do |key|
366
+ if current.is_a?(Hash) && current.key?(key)
367
+ current = current[key]
368
+ else
369
+ return default_value
370
+ end
371
+ end
372
+
373
+ current
374
+ rescue => e
375
+ warn "Error getting config value #{key_path}: #{e.message}"
376
+ default_value
377
+ end
378
+
379
+ ##
380
+ # Clear configuration cache
381
+ #
382
+ def clear_cache
383
+ @cache_mutex.synchronize do
384
+ @cache.clear
385
+ end
386
+ end
387
+
388
+ ##
389
+ # Benchmark performance comparison
390
+ #
391
+ # @return [Hash] benchmark results
392
+ #
393
+ def self.benchmark
394
+ config = new
395
+ test_content = <<~CONFIG
396
+ [server]
397
+ host: "localhost"
398
+ port: 8080
399
+ workers: 4
400
+ debug: true
401
+
402
+ [database]
403
+ driver: "postgresql"
404
+ host: "db.example.com"
405
+ port: 5432
406
+ pool_size: 10
407
+
408
+ [cache]
409
+ enabled: true
410
+ ttl: 3600
411
+ backend: "redis"
412
+ CONFIG
413
+
414
+ puts "🥜 Peanut Configuration Performance Test\n"
415
+
416
+ # Test text parsing
417
+ start_time = Time.now
418
+ 1000.times { config.parse_text_config(test_content) }
419
+ text_time = Time.now - start_time
420
+ puts "Text parsing (1000 iterations): #{(text_time * 1000).round(2)}ms"
421
+
422
+ # Test binary loading
423
+ parsed = config.parse_text_config(test_content)
424
+ binary_data = Marshal.dump(parsed)
425
+
426
+ start_time = Time.now
427
+ 1000.times { Marshal.load(binary_data) }
428
+ binary_time = Time.now - start_time
429
+ puts "Binary loading (1000 iterations): #{(binary_time * 1000).round(2)}ms"
430
+
431
+ improvement = ((text_time - binary_time) / text_time * 100).round
432
+ puts "\n✨ Binary format is #{improvement}% faster than text parsing!"
433
+
434
+ {
435
+ text_time: text_time,
436
+ binary_time: binary_time,
437
+ improvement: improvement
438
+ }
439
+ end
440
+
441
+ private
442
+
443
+ ##
444
+ # Watch configuration file for changes
445
+ #
446
+ # @param file_path [String] path to watch
447
+ # @param directory [String] associated directory for cache invalidation
448
+ #
449
+ def watch_config(file_path, directory)
450
+ return if @watchers.key?(file_path)
451
+
452
+ # Simple file watching using mtime checking
453
+ # In production, you might want to use a gem like 'listen' for better performance
454
+ thread = Thread.new do
455
+ last_mtime = File.mtime(file_path)
456
+
457
+ loop do
458
+ sleep 1
459
+ begin
460
+ current_mtime = File.mtime(file_path)
461
+ if current_mtime > last_mtime
462
+ @cache_mutex.synchronize do
463
+ @cache.delete(directory)
464
+ end
465
+ puts "Configuration changed: #{file_path}"
466
+ last_mtime = current_mtime
467
+ end
468
+ rescue Errno::ENOENT
469
+ # File was deleted
470
+ @cache_mutex.synchronize do
471
+ @cache.delete(directory)
472
+ end
473
+ break
474
+ rescue => e
475
+ warn "Error watching file #{file_path}: #{e.message}"
476
+ break
477
+ end
478
+ end
479
+ end
480
+
481
+ thread.priority = -1 # Lower priority for file watching
482
+ @watchers[file_path] = thread
483
+ end
484
+
485
+ ##
486
+ # Auto-compile text configs to binary
487
+ #
488
+ # @param hierarchy [Array<ConfigFile>] configuration file hierarchy
489
+ #
490
+ def auto_compile_configs(hierarchy)
491
+ hierarchy.each do |config_file|
492
+ next unless [:text, :tsk].include?(config_file.type)
493
+
494
+ binary_path = config_file.path.gsub(/\.(peanuts|tsk)$/, '.pnt')
495
+
496
+ # Check if binary is outdated
497
+ need_compile = !File.exist?(binary_path) ||
498
+ File.mtime(binary_path) < config_file.mtime
499
+
500
+ if need_compile
501
+ begin
502
+ content = File.read(config_file.path)
503
+ config = parse_text_config(content)
504
+ compile_to_binary(config, binary_path)
505
+ puts "Compiled #{File.basename(config_file.path)} to binary format"
506
+ rescue => e
507
+ warn "Failed to compile #{config_file.path}: #{e.message}"
508
+ end
509
+ end
510
+ end
511
+ end
512
+ end
513
+
514
+ ##
515
+ # CLI functionality for standalone usage
516
+ #
517
+ class PeanutConfig::CLI
518
+ def self.run(args)
519
+ if args.empty?
520
+ show_usage
521
+ return
522
+ end
523
+
524
+ command = args[0]
525
+ config = PeanutConfig.new
526
+
527
+ case command
528
+ when 'compile'
529
+ if args.length < 2
530
+ puts 'Error: Please specify input file'
531
+ exit 1
532
+ end
533
+ compile_file(config, args[1])
534
+
535
+ when 'load'
536
+ directory = args[1] || '.'
537
+ load_config(config, directory)
538
+
539
+ when 'benchmark'
540
+ PeanutConfig.benchmark
541
+
542
+ when 'hierarchy'
543
+ directory = args[1] || '.'
544
+ show_hierarchy(config, directory)
545
+
546
+ else
547
+ puts "Unknown command: #{command}"
548
+ show_usage
549
+ exit 1
550
+ end
551
+ end
552
+
553
+ def self.show_usage
554
+ puts <<~USAGE
555
+ 🥜 PeanutConfig - TuskLang Hierarchical Configuration
556
+
557
+ Commands:
558
+ compile <file> Compile .peanuts or .tsk to binary .pnt
559
+ load [dir] Load configuration hierarchy
560
+ hierarchy [dir] Show config file hierarchy
561
+ benchmark Run performance benchmark
562
+
563
+ Example:
564
+ ruby peanut_config.rb compile config.peanuts
565
+ ruby peanut_config.rb load /path/to/project
566
+ USAGE
567
+ end
568
+
569
+ def self.compile_file(config, input_file)
570
+ unless File.exist?(input_file)
571
+ puts "Error: File #{input_file} not found"
572
+ exit 1
573
+ end
574
+
575
+ content = File.read(input_file)
576
+ parsed = config.parse_text_config(content)
577
+
578
+ # Generate output filename
579
+ output_file = case File.extname(input_file)
580
+ when '.peanuts', '.tsk'
581
+ input_file.gsub(/\.(peanuts|tsk)$/, '.pnt')
582
+ else
583
+ input_file + '.pnt'
584
+ end
585
+
586
+ config.compile_to_binary(parsed, output_file)
587
+ puts "✅ Compiled to #{output_file}"
588
+ rescue => e
589
+ puts "Error: #{e.message}"
590
+ exit 1
591
+ end
592
+
593
+ def self.load_config(config, directory)
594
+ loaded = config.load(directory)
595
+ puts JSON.pretty_generate(loaded)
596
+ rescue => e
597
+ puts "Error: #{e.message}"
598
+ exit 1
599
+ end
600
+
601
+ def self.show_hierarchy(config, directory)
602
+ hierarchy = config.find_config_hierarchy(directory)
603
+
604
+ puts "Configuration hierarchy for #{directory}:\n\n"
605
+ hierarchy.each_with_index do |config_file, index|
606
+ puts "#{index + 1}. #{config_file.path} (#{config_file.type})"
607
+ puts " Modified: #{config_file.mtime}"
608
+ puts
609
+ end
610
+
611
+ puts 'No configuration files found' if hierarchy.empty?
612
+ rescue => e
613
+ puts "Error: #{e.message}"
614
+ exit 1
615
+ end
616
+ end
617
+
618
+ # Run CLI if this file is executed directly
619
+ if __FILE__ == $PROGRAM_NAME
620
+ PeanutConfig::CLI.run(ARGV)
621
+ end