jade-lang 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1bbeb934a5bbbeb19cfeddeb18f59798c37380a34d514110f42e07dab206525
4
- data.tar.gz: 1f68a227a953fa57f875274f409bf93aa7269bbadfc4b3abc7634ea1d6b283c2
3
+ metadata.gz: ec12f8f9ca54c6ac59b3d8d521a9ad192eb87dd5a2a5e62620f3abe00b636cee
4
+ data.tar.gz: 940c0c3e78b8dcbd013050b298508323e5c0f60f8ddcb4749ede2416b14d9e16
5
5
  SHA512:
6
- metadata.gz: 87f7c7e84b193ee2dba6e989f493ca36cf4bc6a58ba7d4c38519aedf5052908263cea802e917b11555396edce72c1ae4af5ff687676f13e123d37858c9643e8f
7
- data.tar.gz: 0d2f3e20d4e1863a567310355a04bc7eb0fb73c49b629db3f0f6162ce2a009ddf821420558962e35052f0a5859d035b196483ead990659f0c73700ad97b4abfb
6
+ metadata.gz: e2f05e1f66e7b893e547a2ccb4d31a9678705daecd35c4c806a94f65c47444ad8b69b334c14b18a3a67417963870debdc4f28530ac334c0106699ee278c1f199
7
+ data.tar.gz: 5711c4a232e7728699e89ddcf168e7dc5b1b86913e34579c7b0b1b2d3df6e069f2f4a12aeb35a800619fdb945169c142a2434f322ce582b1415c524259ab2646
data/CHANGELOG.md CHANGED
@@ -6,6 +6,42 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0]
10
+
11
+ ### Fixed
12
+
13
+ - **A `Maybe` return encodes its element.** A function returning `Maybe(T)`
14
+ handed Ruby the internal `Data` object whenever `T` was a union, or a struct
15
+ holding one — no error, just an un-encoded value across the boundary. The
16
+ specialized encoder fell back to emitting the raw element when it couldn't
17
+ specialize it, instead of declining so the generic encoder could take over;
18
+ `decode`, five lines above it, already declined correctly. `Maybe(Int)` and
19
+ `Maybe(SomeStruct)` were unaffected, which is why it stayed hidden — identity
20
+ happens to be right for primitives. Both derived and hand-written
21
+ `Encodable` instances are now applied, and a `Maybe` field of a struct
22
+ unwraps the same way a `Maybe` return does.
23
+ - **Boundary decode errors name the right side.** `DecodeError` reported
24
+ "Port returned a value that failed to decode" for every failure, including
25
+ values Ruby passed *into* an exposed function, where no port is involved —
26
+ sending you to look for a `uses` block that may not exist. Argument failures
27
+ now read "Ruby passed a value that failed to decode"; port returns are
28
+ unchanged.
29
+ - **A symbol-keyed Hash says so.** Structs cross the boundary as a Hash with
30
+ string keys, so `Person.birthday(name: "Ada", age: 40)` used to fail with
31
+ `expected String, got null` — each field independently reading `nil`, naming
32
+ neither the cause nor the fix. It is now caught where the whole hash is in
33
+ scope, listing the offending keys and the correction. Hashes mixing string
34
+ and symbol keys are left alone; that is a real shape mismatch, and the
35
+ per-field errors describe it better than a guess would.
36
+ - **A module declared under the wrong file name is now a compile error.** A
37
+ module is keyed by the name its file implies — imports resolve `Shop.Cart`
38
+ to `shop/cart.jd` and back — but nothing checked that against what the file
39
+ declared. `module Cart` in `orders.jd` compiled clean and emitted a `Cart`
40
+ module whose own type references pointed at `Orders`, so the first call
41
+ raised `NameError: uninitialized constant Cart::Internal::Orders` far from
42
+ the cause. It now fails at compile time, pointing at the declared name and
43
+ offering both the rename and the move.
44
+
9
45
  ## [0.5.0]
10
46
 
11
47
  ### Added
@@ -328,7 +364,9 @@ Initial release.
328
364
  - `jade` CLI dispatcher: `fmt`, `lsp`, `q`.
329
365
  - Language server and headless query interface for editor/agent tooling.
330
366
 
331
- [Unreleased]: https://github.com/agustinrhcp/jade/compare/v0.4.0...HEAD
367
+ [Unreleased]: https://github.com/agustinrhcp/jade/compare/v0.6.0...HEAD
368
+ [0.6.0]: https://github.com/agustinrhcp/jade/compare/v0.5.0...v0.6.0
369
+ [0.5.0]: https://github.com/agustinrhcp/jade/compare/v0.4.0...v0.5.0
332
370
  [0.4.0]: https://github.com/agustinrhcp/jade/compare/v0.3.1...v0.4.0
333
371
  [0.3.1]: https://github.com/agustinrhcp/jade/compare/v0.3.0...v0.3.1
334
372
  [0.3.0]: https://github.com/agustinrhcp/jade/compare/v0.2.0...v0.3.0
@@ -36,8 +36,7 @@ module Jade
36
36
  Boundary::Specialized.decode_expr(t, '_', registry)
37
37
  },
38
38
  encoders: cache_map(per_fn.flat_map { it[:encoders] }, 'ENC') { |t|
39
- Boundary::Specialized.identity_encoder?(t) ||
40
- Boundary::Specialized.encode_expr(t, '_', registry)
39
+ Boundary::Specialized.encode_expr(t, '_', registry)
41
40
  },
42
41
  }
43
42
  end
@@ -19,11 +19,16 @@ module Jade
19
19
  end
20
20
 
21
21
  def encode(type, value_expr, registry)
22
- inner = inner_of(type) or return nil
23
- return nil if Specialized.identity_encoder?(inner)
22
+ inner_of(type)
23
+ &.then { Specialized.encode_expr(it, '_1', registry) }
24
+ &.then { map_expr(it, value_expr) }
25
+ end
24
26
 
25
- elem = Specialized.encode_expr(inner, '_1', registry) or return nil
26
- "#{value_expr}.map { #{elem} }"
27
+ def map_expr(elem, value_expr)
28
+ case elem
29
+ in ::String then "#{value_expr}.map { #{elem} }"
30
+ in :identity then nil
31
+ end
27
32
  end
28
33
 
29
34
  def identity_encoder?(type)
@@ -17,10 +17,22 @@ module Jade
17
17
  end
18
18
 
19
19
  def encode(type, value_expr, registry)
20
- inner = inner_of(type) or return nil
21
- inner_enc = Specialized.encode_expr(inner, 'it._1', registry) || 'it._1'
20
+ inner_of(type)
21
+ &.then { element_encoder(it, registry) }
22
+ &.then { unwrap_expr(it, value_expr) }
23
+ end
24
+
25
+ def unwrap_expr(elem, value_expr)
26
+ "#{value_expr}.then { it.is_a?(::Jade::Maybe::Just) ? #{elem} : nil }"
27
+ end
22
28
 
23
- "#{value_expr}.then { it.is_a?(::Jade::Maybe::Just) ? #{inner_enc} : nil }"
29
+ # Never identity even when the element is: the Just still unwraps.
30
+ def element_encoder(inner, registry)
31
+ case Specialized.encode_expr(inner, 'it._1', registry)
32
+ in :identity then 'it._1'
33
+ in ::String => expr then expr
34
+ in nil then nil
35
+ end
24
36
  end
25
37
 
26
38
  def specializable?(type, registry, seen)
@@ -138,7 +138,7 @@ module Jade
138
138
 
139
139
  def encode_helper(struct, registry)
140
140
  struct.record_type.fields
141
- .map { |k, t| "#{k.inspect} => #{field_encode_value(t, "p.#{k}", registry)}" }
141
+ .map { |k, t| "#{k.inspect} => #{field_encode(k, t, "p.#{k}", registry)}" }
142
142
  .then { "{ #{it.join(', ')} }" }
143
143
  .then { Pretty.block("def self.#{encode_helper_name(struct)}(p)", it) }
144
144
  end
@@ -148,8 +148,12 @@ module Jade
148
148
  fail("non-specializable field type for #{key}: #{type}")
149
149
  end
150
150
 
151
- def field_encode_value(type, accessor, registry)
152
- Specialized.encode_expr(type, accessor, registry) || accessor
151
+ def field_encode(key, type, accessor, registry)
152
+ case Specialized.encode_expr(type, accessor, registry)
153
+ in :identity then accessor
154
+ in ::String => expr then expr
155
+ in nil then fail("non-encodable field type for #{key}: #{type}")
156
+ end
153
157
  end
154
158
 
155
159
  def snake(name)
@@ -28,10 +28,14 @@ module Jade
28
28
  Record.decode(type, input, registry)
29
29
  end
30
30
 
31
- # Ruby expression that encodes `value_expr` to the wire form, or
32
- # `nil` if the encoder is identity (caller skips the wrap) or the
33
- # type isn't specializable (caller falls back to the cache).
31
+ # How to encode `value_expr` to the wire form:
32
+ #
33
+ # String inline expression
34
+ # :identity the value is already wire-shaped; emit it unchanged
35
+ # nil not specializable; fall back to the cached encoder
34
36
  def encode_expr(type, value_expr, registry)
37
+ return :identity if identity_encoder?(type)
38
+
35
39
  Record.encode(type, value_expr, registry) ||
36
40
  List.encode(type, value_expr, registry) ||
37
41
  Maybe.encode(type, value_expr, registry)
@@ -80,14 +80,15 @@ module Jade
80
80
  end
81
81
 
82
82
  def encode_return(return_type, call_expr, registry)
83
- if Codegen::Boundary::Specialized.identity_encoder?(return_type)
84
- call_expr
85
- elsif (expr = Codegen::Boundary::Specialized.encode_expr(return_type, call_expr, registry))
86
- expr
87
- else
83
+ case Codegen::Boundary::Specialized.encode_expr(return_type, call_expr, registry)
84
+ in :identity then call_expr
85
+
86
+ in ::String => expr then expr
87
+
88
+ in nil
88
89
  Codegen::Boundary::Cache
89
90
  .encoder_for(return_type, registry)
90
- .then { |encoder| "#{encoder}.call(#{call_expr})" }
91
+ .then { "#{it}.call(#{call_expr})" }
91
92
  end
92
93
  end
93
94
 
@@ -50,12 +50,21 @@ module Jade
50
50
 
51
51
  def hash(label, v)
52
52
  case v
53
- when ::Hash then v
53
+ when ::Hash then string_keyed!(label, v)
54
54
  when ::Data then v.to_h.transform_keys(&:to_s)
55
55
  else type_error!(label, v)
56
56
  end
57
57
  end
58
58
 
59
+ # Mixed keys are a real shape mismatch; the per-field errors say more
60
+ # than a guess about intent would.
61
+ def string_keyed!(label, v)
62
+ return v if v.empty? || v.keys.any?(::String)
63
+
64
+ v.keys.grep(::Symbol)
65
+ .then { it.empty? ? v : raise(Jade::Interop::SymbolKeys.new(label, it)) }
66
+ end
67
+
59
68
  def type_error!(label, v)
60
69
  raise Jade::Interop::DecodeError.new(
61
70
  Jade::Decode::WrongType[label, Jade::Decode.type_name(v)],
@@ -40,12 +40,31 @@ module Jade
40
40
  end
41
41
  end
42
42
 
43
+ # Caught where the whole hash is in scope; downstream each field just
44
+ # reads nil and blames its own type.
45
+ class SymbolKeys < Error
46
+ def initialize(label, keys)
47
+ super(
48
+ "#{label} expects a Hash with string keys, got symbol keys " \
49
+ "(#{keys.take(4).map(&:inspect).join(', ')}). " \
50
+ "Values cross the boundary as wire data — " \
51
+ "try #{keys.first.to_s.inspect} rather than #{keys.first.inspect}."
52
+ )
53
+ end
54
+ end
55
+
43
56
  class DecodeError < Error
44
- attr_reader :decode_error, :value
57
+ attr_reader :decode_error, :value, :source
58
+
59
+ SUBJECTS = {
60
+ argument: 'Ruby passed a value that failed to decode',
61
+ port_return: 'Port returned a value that failed to decode',
62
+ }.freeze
45
63
 
46
- def initialize(decode_error, value)
64
+ def initialize(decode_error, value, source: :argument)
47
65
  @decode_error = decode_error
48
66
  @value = value
67
+ @source = source
49
68
  super(format(decode_error, value))
50
69
  end
51
70
 
@@ -54,16 +73,17 @@ module Jade
54
73
  def format(error, value)
55
74
  path, leaf = unwind(error)
56
75
  location = path.empty? ? "value" : path.join
76
+ subject = SUBJECTS.fetch(@source)
57
77
 
58
78
  case leaf
59
79
  in Jade::Decode::WrongType[expected, got]
60
- "Port returned a value that failed to decode at #{location}: expected #{expected}, got #{got} (#{value.inspect})"
80
+ "#{subject} at #{location}: expected #{expected}, got #{got} (#{value.inspect})"
61
81
 
62
82
  in Jade::Decode::MissingField[key]
63
- "Port returned a value that failed to decode at #{location}: missing field `#{key}` (#{value.inspect})"
83
+ "#{subject} at #{location}: missing field `#{key}` (#{value.inspect})"
64
84
 
65
85
  in Jade::Decode::Custom[msg]
66
- "Port returned a value that failed to decode at #{location}: #{msg} (#{value.inspect})"
86
+ "#{subject} at #{location}: #{msg} (#{value.inspect})"
67
87
 
68
88
  in Jade::Decode::Multiple[errors]
69
89
  errors
@@ -0,0 +1,46 @@
1
+ module Jade
2
+ module ModuleLoader
3
+ # A module is keyed by the name its file implies — imports resolve
4
+ # `Shop.Cart` to `shop/cart.jd` and back. The wrapper takes the declared
5
+ # name while every type reference inside takes the file's, so a mismatch
6
+ # emits Ruby referring to a constant that was never defined.
7
+ module ModuleName
8
+ extend self
9
+
10
+ def check(ast, source)
11
+ return Ok[ast] unless ast.is_a?(AST::Module)
12
+
13
+ source.to_module_name.then do |expected|
14
+ ast.name == expected ? Ok[ast] : Err[mismatch(ast, source, expected)]
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def mismatch(ast, source, expected)
21
+ Diagnostics::List
22
+ .empty
23
+ .error(
24
+ "Module is declared as `#{ast.name}` but `#{source.uri}` defines `#{expected}`.",
25
+ source:,
26
+ span: name_span(ast),
27
+ label: "expected `#{expected}`",
28
+ )
29
+ .help("Rename it to `#{expected}`, or move the file to `#{path_for(ast.name)}`.")
30
+ end
31
+
32
+ # `module` is skipped during parsing, so the node starts at the name.
33
+ def name_span(ast)
34
+ ast.range.begin...(ast.range.begin + ast.name.length)
35
+ end
36
+
37
+ def path_for(module_name)
38
+ module_name
39
+ .split('.')
40
+ .map { Source.snake_case(it) }
41
+ .join('/')
42
+ .then { "#{it}.jd" }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -9,6 +9,7 @@ require 'jade/codegen'
9
9
  require 'jade/module_loader/cache'
10
10
  require 'jade/module_loader/dependency_resolver'
11
11
  require 'jade/module_loader/dependency_graph'
12
+ require 'jade/module_loader/module_name'
12
13
  require 'jade/module_loader/normalize'
13
14
  require 'jade/module_loader/topological_sort'
14
15
  require 'jade/diagnostics'
@@ -132,6 +133,10 @@ module Jade
132
133
  .then { raise CompilationError.new(it) }
133
134
  end => Ok([raw_ast, comments])
134
135
 
136
+ ModuleName
137
+ .check(raw_ast, source)
138
+ .on_err { raise CompilationError.new(it) }
139
+
135
140
  Frontend::CommentAttacher
136
141
  .attach(raw_ast, comments, source)
137
142
  .then { Registry.entry(source.to_module_name).with(ast: it, source:, entry:) }
data/lib/jade/task.rb CHANGED
@@ -93,7 +93,7 @@ module Jade
93
93
 
94
94
  def decode(decoder, value)
95
95
  Jade::Decode::Runner.run!(decoder, value) do |error|
96
- fail Jade::Interop::DecodeError.new(error, value)
96
+ fail Jade::Interop::DecodeError.new(error, value, source: :port_return)
97
97
  end
98
98
  end
99
99
  end
data/lib/jade/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Jade
2
- VERSION = '0.5.0'
2
+ VERSION = '0.6.0'
3
3
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jade-lang
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Agustin Cornu
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-11 00:00:00.000000000 Z
10
+ date: 2026-08-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -303,6 +303,7 @@ files:
303
303
  - lib/jade/module_loader/cache.rb
304
304
  - lib/jade/module_loader/dependency_graph.rb
305
305
  - lib/jade/module_loader/dependency_resolver.rb
306
+ - lib/jade/module_loader/module_name.rb
306
307
  - lib/jade/module_loader/normalize.rb
307
308
  - lib/jade/module_loader/topological_sort.rb
308
309
  - lib/jade/parsing.rb