wcl 0.2.3.alpha1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3e5c8aad8cfd3f0c1dff7cf957a5dd20704f18eaad80e6b37286d2157e08bb6e
4
+ data.tar.gz: fa13a51a38fdbe98ec55d102eafdfbd33dc4cb8e3ed7823a9f19a1a77ac38ab7
5
+ SHA512:
6
+ metadata.gz: de89bae27e663707e68fd03d8bbcfda250018c6c047f37d3b4c231b67564805d8a1fa48e86cc2173c6f6a53b025e27ce76a74aa89824cf4533f7430ddaae4396
7
+ data.tar.gz: b0ba5a0d6944c07e6c6760ba07a8ea78eac4d111d96eb67b585bab064b7f40b8522bae737d5333aea0659f7988c37b9ee32660a9b7c10d5699fc854cd0c04f0c
@@ -0,0 +1,59 @@
1
+ require "json"
2
+
3
+ module Wcl
4
+ # Thread-local callback bridge for custom functions invoked from WASM.
5
+ module Callback
6
+ module_function
7
+
8
+ def set_functions(fn_hash)
9
+ Thread.current[:wcl_functions] = fn_hash
10
+ end
11
+
12
+ def clear_functions
13
+ Thread.current[:wcl_functions] = nil
14
+ end
15
+
16
+ def invoke(name, args_json)
17
+ functions = Thread.current[:wcl_functions]
18
+ return [false, "callback not found: #{name}"] if functions.nil? || !functions.key?(name)
19
+
20
+ begin
21
+ args = JSON.parse(args_json)
22
+ ruby_args = args.map { |a| json_to_ruby(a) }
23
+ result = functions[name].call(ruby_args)
24
+ result_json = JSON.generate(ruby_to_json(result))
25
+ [true, result_json]
26
+ rescue => e
27
+ [false, e.message]
28
+ end
29
+ end
30
+
31
+ def json_to_ruby(val)
32
+ case val
33
+ when nil, true, false, Integer, Float, String
34
+ val
35
+ when Array
36
+ val.map { |v| json_to_ruby(v) }
37
+ when Hash
38
+ val.transform_values { |v| json_to_ruby(v) }
39
+ else
40
+ val
41
+ end
42
+ end
43
+
44
+ def ruby_to_json(val)
45
+ case val
46
+ when nil, true, false, Integer, Float, String
47
+ val
48
+ when Array
49
+ val.map { |v| ruby_to_json(v) }
50
+ when Hash
51
+ val.transform_values { |v| ruby_to_json(v) }
52
+ when Set
53
+ { "__type" => "set", "items" => val.map { |v| ruby_to_json(v) } }
54
+ else
55
+ val
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,78 @@
1
+ require "json"
2
+
3
+ module Wcl
4
+ # JSON-to-Ruby value conversion for WCL WASM binding.
5
+ module Convert
6
+ module_function
7
+
8
+ def json_to_ruby(val)
9
+ case val
10
+ when nil, true, false, String
11
+ val
12
+ when Integer
13
+ val
14
+ when Float
15
+ val == val.to_i && !val.is_a?(Float) ? val.to_i : val
16
+ when Array
17
+ val.map { |v| json_to_ruby(v) }
18
+ when Hash
19
+ # Check for set encoding
20
+ if val["__type"] == "set" && val.key?("items")
21
+ items = val["items"].map { |v| json_to_ruby(v) }
22
+ begin
23
+ Set.new(items)
24
+ rescue
25
+ items
26
+ end
27
+ # Check for block ref encoding
28
+ elsif val.key?("kind") && (val.key?("attributes") || val.key?("children") || val.key?("decorators"))
29
+ json_to_block_ref(val)
30
+ else
31
+ val.transform_values { |v| json_to_ruby(v) }
32
+ end
33
+ else
34
+ val
35
+ end
36
+ end
37
+
38
+ def json_to_values(json_str)
39
+ data = JSON.parse(json_str)
40
+ data.transform_values { |v| json_to_ruby(v) }
41
+ end
42
+
43
+ def json_to_blocks(json_str)
44
+ data = JSON.parse(json_str)
45
+ data.map { |b| json_to_block_ref(b) }
46
+ end
47
+
48
+ def json_to_diagnostics(json_str)
49
+ data = JSON.parse(json_str)
50
+ data.map do |d|
51
+ Diagnostic.new(
52
+ severity: d["severity"],
53
+ message: d["message"],
54
+ code: d["code"]
55
+ )
56
+ end
57
+ end
58
+
59
+ def json_to_block_ref(obj)
60
+ attrs = (obj["attributes"] || {}).transform_values { |v| json_to_ruby(v) }
61
+ children = (obj["children"] || []).map { |c| json_to_block_ref(c) }
62
+ decorators = (obj["decorators"] || []).map do |d|
63
+ Decorator.new(
64
+ name: d["name"],
65
+ args: (d["args"] || {}).transform_values { |v| json_to_ruby(v) }
66
+ )
67
+ end
68
+
69
+ BlockRef.new(
70
+ kind: obj["kind"],
71
+ id: obj["id"],
72
+ attributes: attrs,
73
+ children: children,
74
+ decorators: decorators
75
+ )
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,80 @@
1
+ require "json"
2
+
3
+ module Wcl
4
+ # A parsed and evaluated WCL document.
5
+ class Document
6
+ def initialize(handle)
7
+ @handle = handle
8
+ @closed = false
9
+ @values = nil
10
+ @diagnostics = nil
11
+
12
+ ObjectSpace.define_finalizer(self, self.class._invoke_release(handle))
13
+ end
14
+
15
+ def self._invoke_release(handle)
16
+ proc { WasmRuntime.get.document_free(handle) rescue nil }
17
+ end
18
+
19
+ def values
20
+ raise "Document is closed" if @closed
21
+
22
+ @values ||= Convert.json_to_values(WasmRuntime.get.document_values(@handle))
23
+ end
24
+
25
+ def has_errors?
26
+ raise "Document is closed" if @closed
27
+
28
+ WasmRuntime.get.document_has_errors(@handle)
29
+ end
30
+
31
+ def errors
32
+ diagnostics.select(&:error?)
33
+ end
34
+
35
+ def diagnostics
36
+ raise "Document is closed" if @closed
37
+
38
+ @diagnostics ||= Convert.json_to_diagnostics(WasmRuntime.get.document_diagnostics(@handle))
39
+ end
40
+
41
+ def query(query_str)
42
+ raise "Document is closed" if @closed
43
+
44
+ json_str = WasmRuntime.get.document_query(@handle, query_str)
45
+ result = JSON.parse(json_str)
46
+ raise ValueError, result["error"] if result.key?("error")
47
+
48
+ Convert.json_to_ruby(result["ok"])
49
+ end
50
+
51
+ def blocks
52
+ raise "Document is closed" if @closed
53
+
54
+ json_str = WasmRuntime.get.document_blocks(@handle)
55
+ Convert.json_to_blocks(json_str)
56
+ end
57
+
58
+ def blocks_of_type(kind)
59
+ raise "Document is closed" if @closed
60
+
61
+ json_str = WasmRuntime.get.document_blocks_of_type(@handle, kind)
62
+ Convert.json_to_blocks(json_str)
63
+ end
64
+
65
+ def close
66
+ return if @closed
67
+
68
+ @closed = true
69
+ WasmRuntime.get.document_free(@handle)
70
+ ObjectSpace.undefine_finalizer(self)
71
+ end
72
+
73
+ def to_h
74
+ { values: values, has_errors: has_errors?, diagnostics: diagnostics.map(&:inspect) }
75
+ end
76
+ end
77
+
78
+ # Error raised for invalid queries.
79
+ class ValueError < StandardError; end
80
+ end
data/lib/wcl/types.rb ADDED
@@ -0,0 +1,92 @@
1
+ module Wcl
2
+ # A reference to a WCL block with its attributes.
3
+ class BlockRef
4
+ attr_reader :kind, :id, :attributes, :children, :decorators
5
+
6
+ def initialize(kind:, id: nil, attributes: {}, children: [], decorators: [])
7
+ @kind = kind
8
+ @id = id
9
+ @attributes = attributes
10
+ @children = children
11
+ @decorators = decorators
12
+ end
13
+
14
+ def get(key)
15
+ @attributes[key]
16
+ end
17
+
18
+ def [](key)
19
+ @attributes[key]
20
+ end
21
+
22
+ def has_decorator?(name)
23
+ @decorators.any? { |d| d.name == name }
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ kind: @kind,
29
+ id: @id,
30
+ attributes: @attributes,
31
+ children: @children.map(&:to_h),
32
+ decorators: @decorators.map(&:to_h)
33
+ }
34
+ end
35
+
36
+ def inspect
37
+ if @id
38
+ "#<Wcl::BlockRef(#{@kind} #{@id})>"
39
+ else
40
+ "#<Wcl::BlockRef(#{@kind})>"
41
+ end
42
+ end
43
+ alias_method :to_s, :inspect
44
+ end
45
+
46
+ # A WCL decorator with name and arguments.
47
+ class Decorator
48
+ attr_reader :name, :args
49
+
50
+ def initialize(name:, args: {})
51
+ @name = name
52
+ @args = args
53
+ end
54
+
55
+ def to_h
56
+ { name: @name, args: @args }
57
+ end
58
+
59
+ def inspect
60
+ "#<Wcl::Decorator(@#{@name})>"
61
+ end
62
+ alias_method :to_s, :inspect
63
+ end
64
+
65
+ # A WCL diagnostic (error, warning, etc.).
66
+ class Diagnostic
67
+ attr_reader :severity, :message, :code
68
+
69
+ def initialize(severity:, message:, code: nil)
70
+ @severity = severity
71
+ @message = message
72
+ @code = code
73
+ end
74
+
75
+ def error?
76
+ @severity == "error"
77
+ end
78
+
79
+ def warning?
80
+ @severity == "warning"
81
+ end
82
+
83
+ def inspect
84
+ if @code
85
+ "#<Wcl::Diagnostic(#{@severity}: [#{@code}] #{@message})>"
86
+ else
87
+ "#<Wcl::Diagnostic(#{@severity}: #{@message})>"
88
+ end
89
+ end
90
+ alias_method :to_s, :inspect
91
+ end
92
+ end
@@ -0,0 +1,3 @@
1
+ module Wcl
2
+ VERSION = "0.2.3.alpha1"
3
+ end
@@ -0,0 +1,188 @@
1
+ require "wasmtime"
2
+
3
+ module Wcl
4
+ # Singleton WASM runtime that loads and manages the wcl_wasm module.
5
+ class WasmRuntime
6
+ @instance = nil
7
+ @init_mutex = Mutex.new
8
+
9
+ def self.get
10
+ return @instance if @instance
11
+
12
+ @init_mutex.synchronize do
13
+ @instance ||= new
14
+ end
15
+ @instance
16
+ end
17
+
18
+ def initialize
19
+ @mutex = Mutex.new
20
+ @engine = Wasmtime::Engine.new
21
+
22
+ wasm_path = File.join(__dir__, "wcl_wasm.wasm")
23
+ @module = Wasmtime::Module.from_file(@engine, wasm_path)
24
+
25
+ @linker = Wasmtime::Linker.new(@engine)
26
+ Wasmtime::WASI::P1.add_to_linker_sync(@linker)
27
+ define_host_functions
28
+
29
+ @store = Wasmtime::Store.new(@engine, wasi_p1_config: Wasmtime::WasiConfig.new)
30
+ @wasm_instance = @linker.instantiate(@store, @module)
31
+
32
+ # Cache exported functions
33
+ @alloc = @wasm_instance.export("wcl_wasm_alloc").to_func
34
+ @dealloc = @wasm_instance.export("wcl_wasm_dealloc").to_func
35
+ @string_free = @wasm_instance.export("wcl_wasm_string_free").to_func
36
+ @parse_fn = @wasm_instance.export("wcl_wasm_parse").to_func
37
+ @parse_with_functions_fn = @wasm_instance.export("wcl_wasm_parse_with_functions").to_func
38
+ @doc_free = @wasm_instance.export("wcl_wasm_document_free").to_func
39
+ @doc_values = @wasm_instance.export("wcl_wasm_document_values").to_func
40
+ @doc_has_errors = @wasm_instance.export("wcl_wasm_document_has_errors").to_func
41
+ @doc_diagnostics = @wasm_instance.export("wcl_wasm_document_diagnostics").to_func
42
+ @doc_query = @wasm_instance.export("wcl_wasm_document_query").to_func
43
+ @doc_blocks = @wasm_instance.export("wcl_wasm_document_blocks").to_func
44
+ @doc_blocks_of_type = @wasm_instance.export("wcl_wasm_document_blocks_of_type").to_func
45
+ @memory = @wasm_instance.export("memory").to_memory
46
+ end
47
+
48
+ # -- Public API (all synchronized) ------------------------------------
49
+
50
+ def parse(source, options_json)
51
+ @mutex.synchronize do
52
+ src_ptr = write_string(source)
53
+ opts_ptr = write_string(options_json)
54
+ begin
55
+ @parse_fn.call(src_ptr, opts_ptr)
56
+ ensure
57
+ dealloc_string(src_ptr, source) if src_ptr != 0
58
+ dealloc_string(opts_ptr, options_json) if opts_ptr != 0 && options_json
59
+ end
60
+ end
61
+ end
62
+
63
+ def parse_with_functions(source, options_json, func_names_json)
64
+ @mutex.synchronize do
65
+ src_ptr = write_string(source)
66
+ opts_ptr = write_string(options_json)
67
+ names_ptr = write_string(func_names_json)
68
+ begin
69
+ @parse_with_functions_fn.call(src_ptr, opts_ptr, names_ptr)
70
+ ensure
71
+ dealloc_string(src_ptr, source) if src_ptr != 0
72
+ dealloc_string(opts_ptr, options_json) if opts_ptr != 0 && options_json
73
+ dealloc_string(names_ptr, func_names_json) if names_ptr != 0
74
+ end
75
+ end
76
+ end
77
+
78
+ def document_free(handle)
79
+ @mutex.synchronize { @doc_free.call(handle) }
80
+ end
81
+
82
+ def document_values(handle)
83
+ @mutex.synchronize do
84
+ ptr = @doc_values.call(handle)
85
+ consume_string(ptr)
86
+ end
87
+ end
88
+
89
+ def document_has_errors(handle)
90
+ @mutex.synchronize { @doc_has_errors.call(handle) != 0 }
91
+ end
92
+
93
+ def document_diagnostics(handle)
94
+ @mutex.synchronize do
95
+ ptr = @doc_diagnostics.call(handle)
96
+ consume_string(ptr)
97
+ end
98
+ end
99
+
100
+ def document_query(handle, query)
101
+ @mutex.synchronize do
102
+ q_ptr = write_string(query)
103
+ begin
104
+ ptr = @doc_query.call(handle, q_ptr)
105
+ consume_string(ptr)
106
+ ensure
107
+ dealloc_string(q_ptr, query) if q_ptr != 0
108
+ end
109
+ end
110
+ end
111
+
112
+ def document_blocks(handle)
113
+ @mutex.synchronize do
114
+ ptr = @doc_blocks.call(handle)
115
+ consume_string(ptr)
116
+ end
117
+ end
118
+
119
+ def document_blocks_of_type(handle, kind)
120
+ @mutex.synchronize do
121
+ k_ptr = write_string(kind)
122
+ begin
123
+ ptr = @doc_blocks_of_type.call(handle, k_ptr)
124
+ consume_string(ptr)
125
+ ensure
126
+ dealloc_string(k_ptr, kind) if k_ptr != 0
127
+ end
128
+ end
129
+ end
130
+
131
+ private
132
+
133
+ def define_host_functions
134
+ @linker.func_new(
135
+ "env", "host_call_function",
136
+ [:i32, :i32, :i32, :i32, :i32, :i32], [:i32]
137
+ ) do |caller, name_ptr, name_len, args_ptr, args_len, result_ptr_out, result_len_out|
138
+ memory = caller.export("memory").to_memory
139
+
140
+ name = memory.read(name_ptr, name_len).force_encoding("UTF-8")
141
+ args_json = memory.read(args_ptr, args_len).force_encoding("UTF-8")
142
+
143
+ success, result_json = Callback.invoke(name, args_json)
144
+
145
+ if result_json
146
+ result_bytes = result_json.encode("UTF-8")
147
+ alloc_fn = caller.export("wcl_wasm_alloc").to_func
148
+ ptr = alloc_fn.call(result_bytes.bytesize)
149
+
150
+ memory.write(ptr, result_bytes)
151
+ memory.write(result_ptr_out, [ptr].pack("V"))
152
+ memory.write(result_len_out, [result_bytes.bytesize].pack("V"))
153
+ end
154
+
155
+ success ? 0 : -1
156
+ end
157
+ end
158
+
159
+ def write_string(str)
160
+ return 0 if str.nil?
161
+
162
+ encoded = str.encode("UTF-8")
163
+ ptr = @alloc.call(encoded.bytesize + 1)
164
+ @memory.write(ptr, encoded + "\0")
165
+ ptr
166
+ end
167
+
168
+ def read_c_string(ptr)
169
+ return "" if ptr == 0
170
+
171
+ data = @memory.read(ptr, @memory.data_size - ptr)
172
+ null_idx = data.index("\0") || data.size
173
+ data[0, null_idx].force_encoding("UTF-8")
174
+ end
175
+
176
+ def consume_string(ptr)
177
+ return "" if ptr == 0
178
+
179
+ s = read_c_string(ptr)
180
+ @string_free.call(ptr)
181
+ s
182
+ end
183
+
184
+ def dealloc_string(ptr, original)
185
+ @dealloc.call(ptr, original.encode("UTF-8").bytesize + 1)
186
+ end
187
+ end
188
+ end
Binary file
data/lib/wcl.rb ADDED
@@ -0,0 +1,53 @@
1
+ require "json"
2
+ require_relative "wcl/version"
3
+ require_relative "wcl/types"
4
+ require_relative "wcl/callback"
5
+ require_relative "wcl/convert"
6
+ require_relative "wcl/wasm_runtime"
7
+ require_relative "wcl/document"
8
+
9
+ module Wcl
10
+ module_function
11
+
12
+ # Parse a WCL source string and return a Document.
13
+ def parse(source, root_dir: nil, allow_imports: nil, max_import_depth: nil,
14
+ max_macro_depth: nil, max_loop_depth: nil, max_iterations: nil,
15
+ functions: nil, variables: nil)
16
+ options = {}
17
+ options["rootDir"] = root_dir.to_s if root_dir
18
+ options["allowImports"] = allow_imports unless allow_imports.nil?
19
+ options["maxImportDepth"] = max_import_depth if max_import_depth
20
+ options["maxMacroDepth"] = max_macro_depth if max_macro_depth
21
+ options["maxLoopDepth"] = max_loop_depth if max_loop_depth
22
+ options["maxIterations"] = max_iterations if max_iterations
23
+ options["variables"] = variables if variables
24
+
25
+ options_json = options.empty? ? nil : JSON.generate(options)
26
+ runtime = WasmRuntime.get
27
+
28
+ if functions && !functions.empty?
29
+ Callback.set_functions(functions)
30
+ begin
31
+ func_names_json = JSON.generate(functions.keys)
32
+ handle = runtime.parse_with_functions(source, options_json, func_names_json)
33
+ ensure
34
+ Callback.clear_functions
35
+ end
36
+ else
37
+ handle = runtime.parse(source, options_json)
38
+ end
39
+
40
+ Document.new(handle)
41
+ end
42
+
43
+ # Parse a WCL file and return a Document.
44
+ def parse_file(path, **kwargs)
45
+ path = path.to_s
46
+ source = File.read(path)
47
+ rescue Errno::ENOENT, Errno::EACCES => e
48
+ raise IOError, "#{path}: #{e.message}"
49
+ else
50
+ kwargs[:root_dir] ||= File.dirname(File.expand_path(path))
51
+ parse(source, **kwargs)
52
+ end
53
+ end
metadata ADDED
@@ -0,0 +1,65 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: wcl
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.3.alpha1
5
+ platform: ruby
6
+ authors:
7
+ - Wil Taylor
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: wasmtime
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '42'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '42'
27
+ description: Ruby bindings for WCL, powered by a WASM module and the wasmtime runtime.
28
+ Provides the full 11-phase parsing pipeline with native Ruby types.
29
+ email:
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - lib/wcl.rb
35
+ - lib/wcl/callback.rb
36
+ - lib/wcl/convert.rb
37
+ - lib/wcl/document.rb
38
+ - lib/wcl/types.rb
39
+ - lib/wcl/version.rb
40
+ - lib/wcl/wasm_runtime.rb
41
+ - lib/wcl/wcl_wasm.wasm
42
+ homepage: https://github.com/wiltaylor/wcl
43
+ licenses:
44
+ - MIT
45
+ metadata: {}
46
+ post_install_message:
47
+ rdoc_options: []
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '3.1'
55
+ required_rubygems_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '0'
60
+ requirements: []
61
+ rubygems_version: 3.5.22
62
+ signing_key:
63
+ specification_version: 4
64
+ summary: WCL (Wil's Configuration Language) Ruby bindings
65
+ test_files: []