jade-lang 0.5.0 → 0.7.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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +101 -1
  3. data/docs/syntax.md +4 -0
  4. data/lib/jade/ast.rb +10 -0
  5. data/lib/jade/codegen/boundary/cache.rb +1 -2
  6. data/lib/jade/codegen/boundary/specialized/list.rb +9 -4
  7. data/lib/jade/codegen/boundary/specialized/maybe.rb +15 -3
  8. data/lib/jade/codegen/boundary/specialized/record.rb +7 -3
  9. data/lib/jade/codegen/boundary/specialized.rb +7 -3
  10. data/lib/jade/codegen/boundary.rb +1 -1
  11. data/lib/jade/codegen/constructor_reference.rb +1 -1
  12. data/lib/jade/codegen/emitter.rb +3 -0
  13. data/lib/jade/codegen/error.rb +8 -0
  14. data/lib/jade/codegen/function_call.rb +6 -4
  15. data/lib/jade/codegen/function_declaration.rb +7 -6
  16. data/lib/jade/codegen.rb +2 -1
  17. data/lib/jade/formatter/helper.rb +1 -0
  18. data/lib/jade/formatter/leaves.rb +9 -0
  19. data/lib/jade/frontend/desugaring/resolved.rb +35 -0
  20. data/lib/jade/frontend/desugaring.rb +2 -1
  21. data/lib/jade/frontend/fixity_fixer.rb +1 -0
  22. data/lib/jade/frontend/semantic_analysis/constructor_reference.rb +1 -1
  23. data/lib/jade/frontend/semantic_analysis.rb +1 -1
  24. data/lib/jade/frontend/type_checking/constraints/deriving/decodable.rb +2 -14
  25. data/lib/jade/frontend/type_checking/constraints/deriving/encodable.rb +2 -2
  26. data/lib/jade/frontend/type_checking/constraints/deriving/eq.rb +10 -0
  27. data/lib/jade/frontend/type_checking/constraints/deriving/helpers.rb +10 -0
  28. data/lib/jade/frontend/type_checking/inference/function_call.rb +33 -5
  29. data/lib/jade/frontend/type_checking/inference/function_declaration.rb +2 -2
  30. data/lib/jade/frontend/type_checking.rb +44 -2
  31. data/lib/jade/interop/boundary.rb +10 -1
  32. data/lib/jade/interop/error.rb +25 -5
  33. data/lib/jade/lexer.rb +1 -0
  34. data/lib/jade/module_loader/module_name.rb +46 -0
  35. data/lib/jade/module_loader.rb +5 -0
  36. data/lib/jade/parsing.rb +6 -1
  37. data/lib/jade/runtime.rb +7 -1
  38. data/lib/jade/task.rb +1 -1
  39. data/lib/jade/version.rb +1 -1
  40. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1bbeb934a5bbbeb19cfeddeb18f59798c37380a34d514110f42e07dab206525
4
- data.tar.gz: 1f68a227a953fa57f875274f409bf93aa7269bbadfc4b3abc7634ea1d6b283c2
3
+ metadata.gz: 0e1034e93db617cde9f33aad318dc23a4bf4987f9f0243558434f14ed64feca8
4
+ data.tar.gz: f1e067ee109d4db4b7ac9a94664d2d69b92e1df10ccdbf31260958c23827e8d8
5
5
  SHA512:
6
- metadata.gz: 87f7c7e84b193ee2dba6e989f493ca36cf4bc6a58ba7d4c38519aedf5052908263cea802e917b11555396edce72c1ae4af5ff687676f13e123d37858c9643e8f
7
- data.tar.gz: 0d2f3e20d4e1863a567310355a04bc7eb0fb73c49b629db3f0f6162ce2a009ddf821420558962e35052f0a5859d035b196483ead990659f0c73700ad97b4abfb
6
+ metadata.gz: 69fb033248f3950561b72732f70b51e2b7ed0e32aeffdab821c86a112a767e3b01dacccfda561467c312958b0f068be132421798d59551c6a0d28f3ddde06f67
7
+ data.tar.gz: 612d3fa844be6d1fa368679d1af12eb77f59a18175e84a906f29dd47405e7d6ef6002975dde83ffc291973eec3ca1cdad1c061755e89ba158a88eb369e3b8e93
data/CHANGELOG.md CHANGED
@@ -6,6 +6,104 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0]
10
+
11
+ ### Added
12
+
13
+ - **`^Ctor` — the constructor, curried.** `Decode.succeed(Spec(_, _, _, _))`
14
+ opens an applicative decode pipeline, and those underscores are coupled to
15
+ the struct's arity: add a field and every pipeline needs another one,
16
+ reported as a type mismatch at the pipeline rather than at the struct.
17
+ `^Spec` desugars to exactly the same curried lambda, with the arity read off
18
+ the resolved constructor. Works on variants as well as structs.
19
+
20
+ ### Fixed
21
+
22
+ - **A constraint on a compound type holding a free var gets its dictionary.**
23
+ A polymorphic function whose body needed something like
24
+ `Decodable(List(a))`, `Decodable(Maybe(a))`, or `Decodable({ value: a })`
25
+ crashed codegen with `undefined method 'map' for nil` — no span, no message.
26
+ The derived instance depends on `Decodable(a)`, but only *bare* var
27
+ constraints earn a dict param, so the enclosing function took none and the
28
+ dep had nothing to bind to. Those deps are now surfaced as constraints of
29
+ their own, so the function takes the dict and callers pass their witness
30
+ down. Should a dep still find no dict in scope, codegen now fails naming the
31
+ constraint instead of handing `nil` on to the pretty-printer.
32
+ - **The `Encodable` deriver keeps its free-var deps.** The mirror of the above,
33
+ and quieter: `Encodable(List(a))` with `a` free dropped the derivation
34
+ outright, and `Encode.encode(items)` compiled to Ruby referencing an
35
+ `impl_arg` that was never bound — a `NameError` at run time, no compile-time
36
+ complaint. The deriver now falls back to the constraint marker for a free
37
+ var, the way `Decodable` already did.
38
+ - **A witness reaches a callee declared later in the file.** A polymorphic
39
+ function calling a polymorphic function declared below it lost the interface
40
+ witness — the call type-checked, then died at run time missing a dictionary
41
+ argument. Moving the callee above the caller fixed it, which is what made
42
+ this look like it was about lambdas. The collecting pass walks declarations
43
+ in source order, so the callee's constraints weren't known yet; it now
44
+ repeats until the constraint sets stop growing.
45
+ - **Anonymous records of the same shape compare equal across modules.** Each
46
+ module hoisted its own class per record shape, so `{ x: 1 }` built in one
47
+ module compared false against the same record built in another — both have
48
+ type `{ x: Int }`, and it type-checks fine, so nothing warned you. A record
49
+ decoded at a port boundary compared false against either. Shapes now resolve
50
+ through the interned `Jade::Runtime.record` registry, named under
51
+ `Jade::Records` so they don't inspect as belonging to whichever module
52
+ happened to assign one first.
53
+ - **`struct Probe` in `module Probe` loads.** It type-checked clean and died at
54
+ Ruby load with `uninitialized constant Probe::Probe::Probe` — the emitter
55
+ wrote an unrooted `Probe::Probe[n]` from inside `module Probe`, while the
56
+ boundary decoder three lines below already wrote `::Probe::Probe`. Unions
57
+ escaped it because their generated constants are the variants. All three
58
+ unrooted sites are now rooted.
59
+ - **`Eq` on a variant-less union is native equality.** A union with no variants
60
+ represents an opaque native (`Decode.Value`, `List`), and the derived `Eq`
61
+ was one case branch per variant plus a `_ then false` fallthrough — with no
62
+ variants, the fallthrough was the whole function and `x == x` answered false,
63
+ no warning, no type error.
64
+
65
+ ### Changed
66
+
67
+ - Generated output moves for any code that names a constructor or builds an
68
+ anonymous record. A project that CI-checks committed artifacts will see a
69
+ diff.
70
+
71
+ ## [0.6.0]
72
+
73
+ ### Fixed
74
+
75
+ - **A `Maybe` return encodes its element.** A function returning `Maybe(T)`
76
+ handed Ruby the internal `Data` object whenever `T` was a union, or a struct
77
+ holding one — no error, just an un-encoded value across the boundary. The
78
+ specialized encoder fell back to emitting the raw element when it couldn't
79
+ specialize it, instead of declining so the generic encoder could take over;
80
+ `decode`, five lines above it, already declined correctly. `Maybe(Int)` and
81
+ `Maybe(SomeStruct)` were unaffected, which is why it stayed hidden — identity
82
+ happens to be right for primitives. Both derived and hand-written
83
+ `Encodable` instances are now applied, and a `Maybe` field of a struct
84
+ unwraps the same way a `Maybe` return does.
85
+ - **Boundary decode errors name the right side.** `DecodeError` reported
86
+ "Port returned a value that failed to decode" for every failure, including
87
+ values Ruby passed *into* an exposed function, where no port is involved —
88
+ sending you to look for a `uses` block that may not exist. Argument failures
89
+ now read "Ruby passed a value that failed to decode"; port returns are
90
+ unchanged.
91
+ - **A symbol-keyed Hash says so.** Structs cross the boundary as a Hash with
92
+ string keys, so `Person.birthday(name: "Ada", age: 40)` used to fail with
93
+ `expected String, got null` — each field independently reading `nil`, naming
94
+ neither the cause nor the fix. It is now caught where the whole hash is in
95
+ scope, listing the offending keys and the correction. Hashes mixing string
96
+ and symbol keys are left alone; that is a real shape mismatch, and the
97
+ per-field errors describe it better than a guess would.
98
+ - **A module declared under the wrong file name is now a compile error.** A
99
+ module is keyed by the name its file implies — imports resolve `Shop.Cart`
100
+ to `shop/cart.jd` and back — but nothing checked that against what the file
101
+ declared. `module Cart` in `orders.jd` compiled clean and emitted a `Cart`
102
+ module whose own type references pointed at `Orders`, so the first call
103
+ raised `NameError: uninitialized constant Cart::Internal::Orders` far from
104
+ the cause. It now fails at compile time, pointing at the declared name and
105
+ offering both the rename and the move.
106
+
9
107
  ## [0.5.0]
10
108
 
11
109
  ### Added
@@ -328,7 +426,9 @@ Initial release.
328
426
  - `jade` CLI dispatcher: `fmt`, `lsp`, `q`.
329
427
  - Language server and headless query interface for editor/agent tooling.
330
428
 
331
- [Unreleased]: https://github.com/agustinrhcp/jade/compare/v0.4.0...HEAD
429
+ [Unreleased]: https://github.com/agustinrhcp/jade/compare/v0.6.0...HEAD
430
+ [0.6.0]: https://github.com/agustinrhcp/jade/compare/v0.5.0...v0.6.0
431
+ [0.5.0]: https://github.com/agustinrhcp/jade/compare/v0.4.0...v0.5.0
332
432
  [0.4.0]: https://github.com/agustinrhcp/jade/compare/v0.3.1...v0.4.0
333
433
  [0.3.1]: https://github.com/agustinrhcp/jade/compare/v0.3.0...v0.3.1
334
434
  [0.3.0]: https://github.com/agustinrhcp/jade/compare/v0.2.0...v0.3.0
data/docs/syntax.md CHANGED
@@ -266,6 +266,10 @@ Lambdas are `(params) -> { body }` — the one construct that uses braces.
266
266
  A `_` in an argument position curries the call: each `_` becomes a parameter,
267
267
  left to right, so `add(_, 5)` is a one-argument function.
268
268
 
269
+ `^Ctor` is the constructor with every field curried — `^Point` is
270
+ `Point(_, _)` without counting the fields, and stays right when the struct
271
+ gains one.
272
+
269
273
  ```jade
270
274
  module Curry exposing (add_five, add_one)
271
275
 
data/lib/jade/ast.rb CHANGED
@@ -20,6 +20,7 @@ module Jade
20
20
  define(:Bind, :pattern, :expression)
21
21
  define(:VariableReference, :name)
22
22
  define(:ConstructorReference, :name)
23
+ define(:CurriedConstructor, :name)
23
24
 
24
25
  define(:Module, :name, :exposing, :body)
25
26
  define(:Body, :expressions)
@@ -155,6 +156,15 @@ module Jade
155
156
  end
156
157
  end
157
158
 
159
+ def curried_constructor
160
+ ->(constant) do
161
+ CurriedConstructor[
162
+ constant.value,
163
+ constant.range,
164
+ ]
165
+ end
166
+ end
167
+
158
168
  def body
159
169
  ->(expressions) do
160
170
  if expressions.empty?
@@ -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)
@@ -160,7 +160,7 @@ module Jade
160
160
  in Ok[impl] then Codegen::FunctionCall.generate_impl_dispatch(impl, registry)
161
161
  in Err then nil
162
162
  end
163
- rescue NoMethodError, NoMatchingPatternError
163
+ rescue MissingDictionary, NoMethodError, NoMatchingPatternError
164
164
  # Deriver assumes derivability (type-check-time invariant); boundary
165
165
  # synthesis may probe types whose deps fail — treat as ineligible.
166
166
  nil
@@ -10,7 +10,7 @@ module Jade
10
10
  end
11
11
 
12
12
  def from_symbol(symbol)
13
- qualified = to_qualified(symbol.qualified_name)
13
+ qualified = "::#{to_qualified(symbol.qualified_name)}"
14
14
  symbol.args.empty? ? "#{qualified}[]" : "#{qualified}.method(:[])"
15
15
  end
16
16
  end
@@ -17,6 +17,9 @@ module Jade
17
17
  in [:and, expr1, expr2]
18
18
  "#{emit(expr1)} && #{emit(expr2)}"
19
19
 
20
+ in [:==, expr1, expr2]
21
+ "#{emit(expr1)} == #{emit(expr2)}"
22
+
20
23
  in Integer | TrueClass | FalseClass | Float
21
24
  ir.to_s
22
25
 
@@ -0,0 +1,8 @@
1
+ module Jade
2
+ module Codegen
3
+ # Codegen runs on a program the frontend already proved well-typed, so
4
+ # this is a bug in jade, not in the source. RuntimeError so the CLI
5
+ # reports it as a message rather than a backtrace.
6
+ MissingDictionary = Class.new(RuntimeError)
7
+ end
8
+ end
@@ -26,7 +26,7 @@ module Jade
26
26
 
27
27
  def constructor_call(callee, args, registry)
28
28
  resolve_callee_symbol(callee, registry)
29
- .then { to_qualified(it.qualified_name) }
29
+ .then { "::#{to_qualified(it.qualified_name)}" }
30
30
  .then { "#{it}[#{generate_many(args, registry)}]" }
31
31
  end
32
32
 
@@ -89,8 +89,10 @@ module Jade
89
89
  in Symbol::Implementation
90
90
  generate_impl_dispatch(dep, registry)
91
91
 
92
- in Type::Constraint(type: Type::Var)
93
- dispatch_value(dep, registry)
92
+ in Type::Constraint(interface:, type: Type::Var => var)
93
+ dispatch_value(dep, registry) ||
94
+ raise(MissingDictionary, "no dict in scope for #{interface} #{var}" \
95
+ " — this is a jade bug, please report it")
94
96
  end
95
97
  end
96
98
 
@@ -151,7 +153,7 @@ module Jade
151
153
  end
152
154
 
153
155
  def generate_keyed_variant_call(constructor, args, registry)
154
- qualified = to_qualified(constructor.qualified_name)
156
+ qualified = "::#{to_qualified(constructor.qualified_name)}"
155
157
  record_fields = constructor.args[0].fields.keys
156
158
 
157
159
  args[0] => arg
@@ -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
 
data/lib/jade/codegen.rb CHANGED
@@ -1,3 +1,4 @@
1
+ require 'jade/codegen/error'
1
2
  require 'jade/codegen/context'
2
3
  require 'jade/codegen/helpers'
3
4
  require 'jade/codegen/pretty'
@@ -91,7 +92,7 @@ module Jade
91
92
  shape_consts = collect_record_shapes(body)
92
93
  .sort
93
94
  .map { |keys|
94
- "#{record_shape_constant(keys)} = Data.define(#{keys.map { ":#{it}" }.join(', ')})"
95
+ "#{record_shape_constant(keys)} = #{record_class(keys)}"
95
96
  }
96
97
 
97
98
  boundary_cache = Boundary::Cache.collect(body, registry)
@@ -59,6 +59,7 @@ module Jade
59
59
  in AST::RecordUpdate then RecordUpdate
60
60
  in AST::VariableReference then VariableReference
61
61
  in AST::ConstructorReference then ConstructorReference
62
+ in AST::CurriedConstructor then CurriedConstructor
62
63
  in AST::CharLiteral then CharLiteral
63
64
  in AST::Literal then Literal
64
65
  end
@@ -21,6 +21,15 @@ module Jade
21
21
  end
22
22
  end
23
23
 
24
+ module CurriedConstructor
25
+ extend self
26
+ extend Helper
27
+
28
+ def format(node, indent:, source:)
29
+ "^#{node.name}".then(&and_indent(indent))
30
+ end
31
+ end
32
+
24
33
  module CharLiteral
25
34
  extend self
26
35
  extend Helper
@@ -2,6 +2,7 @@ module Jade
2
2
  module Frontend
3
3
  module Desugaring
4
4
  extend self
5
+ extend Codegen::Helpers
5
6
 
6
7
  # Runs after SemanticAnalysis (which resolves names and attaches
7
8
  # symbols). Add new post-resolution rewrites as `in` branches
@@ -13,6 +14,9 @@ module Jade
13
14
 
14
15
  def desugar_resolved(node, registry)
15
16
  case node
17
+ in AST::CurriedConstructor
18
+ curry_constructor(node, registry)
19
+
16
20
  in AST::VariableReference | AST::QualifiedAccess
17
21
  zero_arg_fn?(node.symbol, registry) ? wrap_call(node) : node
18
22
 
@@ -23,6 +27,37 @@ module Jade
23
27
 
24
28
  private
25
29
 
30
+ # `^C` is `C(_, _, …)` with the arity filled in — which needs the
31
+ # resolved symbol, so it cannot be done at parse time.
32
+ def curry_constructor(node, registry)
33
+ arity = registry.lookup(node.symbol)&.args&.length || 0
34
+ names = (0...arity).map { param_synthetic_name(it) }
35
+
36
+ AST::FunctionCall
37
+ .new(
38
+ callee: AST::ConstructorReference[node.name, node.range].with(symbol: node.symbol),
39
+ args: names.map { AST::VariableReference[it, node.range].with(symbol: Symbol.var(it, nil)) },
40
+ infix: false,
41
+ dictionaries: [],
42
+ range: node.range,
43
+ symbol: nil,
44
+ id: node.id,
45
+ leading_comments: node.leading_comments,
46
+ trailing_comments: node.trailing_comments,
47
+ dangling_comments: node.dangling_comments,
48
+ trailing_comma: false,
49
+ )
50
+ .then do |call|
51
+ names.reverse.reduce(call) do |body, name|
52
+ AST::Lambda[
53
+ [AST::Pattern::Binding[name, node.range]],
54
+ AST::Body[[body], node.range],
55
+ node.range,
56
+ ]
57
+ end
58
+ end
59
+ end
60
+
26
61
  def map_children(node)
27
62
  node
28
63
  .to_h
@@ -175,7 +175,8 @@ module Jade
175
175
  AST::TypeDeclaration | AST::ImportDeclaration |
176
176
  AST::Pattern::Literal | AST::Pattern::Binding | AST::Pattern::Wildcard |
177
177
  AST::Pattern::Record | AST::InteropImportDeclaration | AST::StructDeclaration |
178
- AST::QualifiedAccess | AST::Placeholder | AST::InterfaceDeclaration
178
+ AST::QualifiedAccess | AST::Placeholder | AST::InterfaceDeclaration |
179
+ AST::CurriedConstructor
179
180
 
180
181
  node
181
182
  end
@@ -147,6 +147,7 @@ module Jade
147
147
  in AST::VariableReference | AST::ConstructorReference | AST::TypeDeclaration |
148
148
  AST::ImportDeclaration | AST::Literal | AST::CharLiteral | AST::RecordAccessSugar | AST::InteropImportDeclaration |
149
149
  AST::StructDeclaration | AST::QualifiedAccess | AST::Placeholder | AST::InterfaceDeclaration |
150
+ AST::CurriedConstructor |
150
151
  AST::RecordUpdateSugar
151
152
 
152
153
  node
@@ -6,7 +6,7 @@ module Jade
6
6
  extend Helper
7
7
 
8
8
  def analyze(node, registry, scope, entry)
9
- node => AST::ConstructorReference(name:)
9
+ name = node.name
10
10
 
11
11
  symbol = scope.lookup(name) || resolve_private_constructor(name, registry)
12
12
 
@@ -76,7 +76,7 @@ module Jade
76
76
  in AST::CharLiteral then CharLiteral
77
77
  in AST::Assign then Assign
78
78
  in AST::VariableReference then VariableReference
79
- in AST::ConstructorReference then ConstructorReference
79
+ in AST::ConstructorReference | AST::CurriedConstructor then ConstructorReference
80
80
  in AST::Body then Body
81
81
  in AST::FunctionDeclaration then FunctionDeclaration
82
82
  in AST::FunctionCall then FunctionCall
@@ -74,7 +74,7 @@ module Jade
74
74
 
75
75
  inner_types
76
76
  .map { Type.constraint(INTERFACE, it, nil) }
77
- .map { resolve_dep(it, lookup, entry_name) }
77
+ .map { resolve_dep(it, lookup) }
78
78
  .then { Results.sequence(it) }
79
79
  .map { implementation(constraint, body, it) }
80
80
  end
@@ -99,7 +99,7 @@ module Jade
99
99
  end
100
100
 
101
101
  field_deps
102
- .map { resolve_dep(it, lookup, entry_name) }
102
+ .map { resolve_dep(it, lookup) }
103
103
  .then { Results.sequence(it) }
104
104
  .map { implementation(constraint, record_body(fields, constructor_ref), it) }
105
105
  end
@@ -131,18 +131,6 @@ module Jade
131
131
  ]
132
132
  end
133
133
 
134
- # Free-var inner constraints fall back to the constraint marker
135
- # itself — codegen later resolves it from the caller's dict env.
136
- # Without this, `Decodable(List(a))` with `a` free would bail
137
- # with UnresolvedConstraint.
138
- def resolve_dep(dep, lookup, entry_name)
139
- lookup
140
- .call(dep)
141
- .on_err(Error::UnresolvedConstraint) {
142
- dep.type.is_a?(Type::Var) ? Ok[dep] : Err[it]
143
- }
144
- end
145
-
146
134
  def implementation(constraint, body, deps)
147
135
  decoder_fn = Symbol::DerivedFunction.new(params: [], body:)
148
136
 
@@ -73,7 +73,7 @@ module Jade
73
73
 
74
74
  inner_types
75
75
  .map { Type.constraint(INTERFACE, it, nil) }
76
- .map { lookup.call(it) }
76
+ .map { resolve_dep(it, lookup) }
77
77
  .then { Results.sequence(it) }
78
78
  .map { implementation(constraint, params: [param], body:, deps: it) }
79
79
  end
@@ -111,7 +111,7 @@ module Jade
111
111
  .then { [:hash, it] }
112
112
 
113
113
  field_deps
114
- .map { lookup.call(it) }
114
+ .map { resolve_dep(it, lookup) }
115
115
  .then { Results.sequence(it) }
116
116
  .map { implementation(constraint, params: ['rec'], body:, deps: it) }
117
117
  end
@@ -22,6 +22,8 @@ module Jade
22
22
  index_map = type_vars.each_with_index.map.to_h
23
23
  variants = symbol.variants.map { registry.lookup(it) }
24
24
 
25
+ return native_eq(constraint, type_vars) if variants.empty?
26
+
25
27
  concrete = variants
26
28
  .flat_map(&:args)
27
29
  .reject { it in Symbol::Variable }
@@ -49,6 +51,14 @@ module Jade
49
51
  end
50
52
  end
51
53
 
54
+ # A variant-less union stands in for an opaque native — `Decode.Value`,
55
+ # `List` — so there is nothing to match on and equality is Ruby's.
56
+ def native_eq(constraint, type_vars)
57
+ Symbol::DerivedFunction
58
+ .new(params: ["one", "other"], body: [:==, [:var, "one"], [:var, "other"]])
59
+ .then { Ok[build_union_impl(constraint, type_vars, [], it, [])] }
60
+ end
61
+
52
62
  def build_union_impl(constraint, type_vars, concrete, eq_fn, deps)
53
63
  return implementation(constraint, { '(==)' => eq_fn }, deps:) if type_vars.empty?
54
64
 
@@ -54,6 +54,16 @@ module Jade
54
54
  )
55
55
  end
56
56
 
57
+ # A free-var inner constraint falls back to the marker itself —
58
+ # codegen resolves it from the caller's dict env.
59
+ def resolve_dep(dep, lookup)
60
+ lookup
61
+ .call(dep)
62
+ .on_err(Error::UnresolvedConstraint) {
63
+ dep.type.is_a?(Type::Var) ? Ok[dep] : Err[it]
64
+ }
65
+ end
66
+
57
67
  def resolve_field_deps(field_types, lookup, origin)
58
68
  field_types
59
69
  .map { lookup.call(Type.constraint(self::INTERFACE, it, origin)) }
@@ -57,7 +57,7 @@ module Jade
57
57
  .map { st.env.substitution.apply(it) }
58
58
 
59
59
  propagated = (callee_subst + args_subst)
60
- .select { it.type.is_a?(Type::Var) }
60
+ .flat_map { propagate(it, registry, st.env.entry_name) }
61
61
 
62
62
  # Pass 1 still needs propagation so constraints from inner calls
63
63
  # bubble up through outer constructor calls and reach the function
@@ -71,10 +71,13 @@ module Jade
71
71
  # "use the enclosing function's local dict".
72
72
  callee_errors = callee_subst
73
73
  .flat_map do |c|
74
- case c.type
75
- in Type::Var
74
+ case c
75
+ in Type::Constraint(index: :unindex) then []
76
+
77
+ in Type::Constraint(type: Type::Var)
76
78
  Constraints.attach_dictionary(c, c)
77
79
  []
80
+
78
81
  else
79
82
  Constraints.solve_at_call_site(c, registry, st.env.entry_name)
80
83
  end
@@ -87,10 +90,13 @@ module Jade
87
90
  # resolve via the enclosing function's dict_env.
88
91
  args_errors = args_subst
89
92
  .flat_map do |c|
90
- case c.type
91
- in Type::Var
93
+ case c
94
+ in Type::Constraint(index: :unindex) then []
95
+
96
+ in Type::Constraint(type: Type::Var)
92
97
  Constraints.attach_dictionary(c, c)
93
98
  []
99
+
94
100
  else
95
101
  Constraints.solve_at_call_site(c, registry, st.env.entry_name)
96
102
  end
@@ -104,6 +110,28 @@ module Jade
104
110
 
105
111
  private
106
112
 
113
+ # Only bare-var constraints earn a dict param, so a constraint that
114
+ # still holds free vars (`Decodable(List(a))`) surfaces its deps as
115
+ # markers of their own — unindexed, since they occupy no slot in
116
+ # this call's dictionary list.
117
+ def propagate(constraint, registry, entry_name)
118
+ return [constraint] if constraint.type.is_a?(Type::Var)
119
+ return [] if constraint.unbound_vars.empty?
120
+
121
+ Constraints
122
+ .resolve(constraint, registry, entry_name)
123
+ .map { var_markers(it) }
124
+ .with_default([])
125
+ end
126
+
127
+ def var_markers(dep)
128
+ case dep
129
+ in Symbol::Implementation(deps:) then deps.flat_map { var_markers(it) }
130
+ in Type::Constraint(type: Type::Var) then [dep.with(origin: nil, index: :unindex)]
131
+ else []
132
+ end
133
+ end
134
+
107
135
  def unify_callee(state, callee_result, args_acc, node, outer)
108
136
  return_type = state.fresh
109
137
  fn_type = Type.function(args_acc.types, return_type)
@@ -42,11 +42,11 @@ module Jade
42
42
  )
43
43
  end
44
44
  .then do |st|
45
- next st if st.env.bindings[symbol.qualified_name].is_a?(Scheme)
45
+ next st if st.env.bindings[symbol.qualified_name].is_a?(Scheme) && !st.skip_constraints
46
46
 
47
47
  updated_constraints = (fn_constraints + body_result.constraints)
48
48
  .map { st.env.substitution.apply(it) }
49
- .uniq
49
+ .uniq { it.type.is_a?(Type::Var) ? [it.interface, it.type] : it }
50
50
 
51
51
  # TODO: for impl function declarations, unresolved constraints here
52
52
  # (e.g. Eq(a) when the body calls == on a field of type a) should
@@ -23,8 +23,7 @@ module Jade
23
23
  def check(entry, registry)
24
24
  Loader
25
25
  .load(entry, registry)
26
- .then { check_node(entry.ast, registry, State.init(it, skip_constraints: true), Expected.infer(it.fresh)) }
27
- .then { Generalizer.generalize(it.first.env) }
26
+ .then { collect_constraints(entry, registry, it) }
28
27
  .then { check_node(entry.ast, registry, State.init(it), Expected.infer(it.fresh)) }
29
28
  .then { finalize(*it, registry) }
30
29
  .map { Canonicalize.run(entry.ast, it, registry) }
@@ -33,6 +32,49 @@ module Jade
33
32
  .and_then { PortResolution.resolve(it, registry) }
34
33
  end
35
34
 
35
+ # A collecting pass walks declarations in source order, so a call to a
36
+ # function declared later sees it before its constraints are known and
37
+ # collects nothing. Each repeat carries them one caller further back, so
38
+ # a chain of forward references needs as many rounds as it is long.
39
+ def collect_constraints(entry, registry, env, rounds = declaration_count(entry))
40
+ return env if rounds.zero?
41
+
42
+ collect_round(entry, registry, env)
43
+ .then do |collected|
44
+ next env if same_constraints?(collected, env)
45
+
46
+ collect_constraints(entry, registry, collected, rounds - 1)
47
+ end
48
+ end
49
+
50
+ def collect_round(entry, registry, env)
51
+ State
52
+ .init(env, skip_constraints: true)
53
+ .then { check_node(entry.ast, registry, it, Expected.infer(env.fresh)) }
54
+ .then { Generalizer.generalize(it.first.env) }
55
+ end
56
+
57
+ def same_constraints?(one, other)
58
+ constraint_fingerprint(one) == constraint_fingerprint(other)
59
+ end
60
+
61
+ def constraint_fingerprint(env)
62
+ env
63
+ .bindings
64
+ .map { |name, binding| [name, binding.constraints.map { [it.interface, it.type.to_s] }.sort] }
65
+ .sort
66
+ end
67
+
68
+ def declaration_count(entry)
69
+ case entry.ast
70
+ in AST::Module(body: AST::Body(expressions:)) then expressions
71
+ in AST::Body(expressions:) then expressions
72
+ else []
73
+ end
74
+ .count { it.is_a?(AST::FunctionDeclaration) }
75
+ .then { it + 1 }
76
+ end
77
+
36
78
  def finalize(state, result, registry)
37
79
  state.env => { bindings:, entry_name: }
38
80
 
@@ -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
data/lib/jade/lexer.rb CHANGED
@@ -66,6 +66,7 @@ module Jade
66
66
  '<|' => :pipe_backward,
67
67
  '<-' => :bind,
68
68
 
69
+ '^' => :caret, # `^Ctor` — the constructor, curried
69
70
  '_' => :wildcard, # also used as placeholder
70
71
  '::' => :coloncolon,
71
72
  # Ternary `cond ? a : b`. Predicate-`?` attaches via the identifier
@@ -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/parsing.rb CHANGED
@@ -336,7 +336,8 @@ module Jade
336
336
  }
337
337
 
338
338
  parser(:atom) {
339
- variable_reference | negative_literal | literal | constructor_reference |
339
+ variable_reference | negative_literal | literal |
340
+ curried_constructor | constructor_reference |
340
341
  lambda | tuple | grouping |
341
342
  record_literal | record_update_sugar | record_access_sugar | record_update
342
343
  }
@@ -509,6 +510,10 @@ module Jade
509
510
  # Should refactor to just an Constant node
510
511
  parser(:constructor_reference) { constant.map(&AST.constructor_reference) }
511
512
 
513
+ parser(:curried_constructor) {
514
+ (type(:caret) >> constant).map { AST.curried_constructor.call(it.last) }
515
+ }
516
+
512
517
  parser(:param) {
513
518
  (
514
519
  identifier >> type(:colon).skip >> type_expression
data/lib/jade/runtime.rb CHANGED
@@ -6,6 +6,8 @@ require 'jade/decode'
6
6
  require 'jade/interop/boundary'
7
7
 
8
8
  module Jade
9
+ module Records; end
10
+
9
11
  module Tuple
10
12
  Tuple2 = Data.define(:_1, :_2) do
11
13
  def to_s = "(#{[_1, _2].map(&:to_s).join(', ')})"
@@ -123,8 +125,12 @@ module Jade
123
125
  # `{a: 1, b: 2}` expression evaluated in a hot loop would call
124
126
  # `Data.define(:a, :b)` and allocate a fresh anonymous class, defeating
125
127
  # YJIT's inline cache on every subsequent property access.
128
+ # Named, so the first module to assign it doesn't lend it a name
129
+ # pointing at an unrelated module.
126
130
  def record(*keys)
127
- RECORD_CLASSES[keys] ||= Data.define(*keys)
131
+ RECORD_CLASSES[keys] ||= Data
132
+ .define(*keys)
133
+ .tap { Jade::Records.const_set(:"Record_#{keys.join('_')}", it) }
128
134
  end
129
135
 
130
136
  def register_impl(interface_name, ruby_class, functions)
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.7.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.7.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-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64
@@ -70,6 +70,7 @@ files:
70
70
  - lib/jade/codegen/constructor_reference.rb
71
71
  - lib/jade/codegen/context.rb
72
72
  - lib/jade/codegen/emitter.rb
73
+ - lib/jade/codegen/error.rb
73
74
  - lib/jade/codegen/function_call.rb
74
75
  - lib/jade/codegen/function_declaration.rb
75
76
  - lib/jade/codegen/helpers.rb
@@ -303,6 +304,7 @@ files:
303
304
  - lib/jade/module_loader/cache.rb
304
305
  - lib/jade/module_loader/dependency_graph.rb
305
306
  - lib/jade/module_loader/dependency_resolver.rb
307
+ - lib/jade/module_loader/module_name.rb
306
308
  - lib/jade/module_loader/normalize.rb
307
309
  - lib/jade/module_loader/topological_sort.rb
308
310
  - lib/jade/parsing.rb