jade-lang 0.2.0 → 0.3.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -1
  3. data/lib/jade/ast/node.rb +30 -0
  4. data/lib/jade/cli/q.rb +9 -5
  5. data/lib/jade/cli.rb +3 -0
  6. data/lib/jade/codegen/emitter.rb +2 -2
  7. data/lib/jade/codegen/inlines.rb +1 -0
  8. data/lib/jade/codegen.rb +5 -1
  9. data/lib/jade/compiler.rb +17 -8
  10. data/lib/jade/formatter/helper.rb +11 -5
  11. data/lib/jade/frontend/comment_attacher.rb +13 -2
  12. data/lib/jade/frontend/semantic_analysis/error/placeholder_not_allowed.rb +22 -0
  13. data/lib/jade/frontend/semantic_analysis/error.rb +1 -0
  14. data/lib/jade/frontend/semantic_analysis/keyed_call.rb +55 -0
  15. data/lib/jade/frontend/type_checking/constraints/deriving/decodable.rb +26 -7
  16. data/lib/jade/frontend/type_checking/constraints/deriving/encodable.rb +20 -7
  17. data/lib/jade/frontend/type_checking/constraints/deriving/eq.rb +2 -1
  18. data/lib/jade/frontend/type_checking/constraints/deriving/helpers.rb +17 -0
  19. data/lib/jade/frontend/type_checking/constraints/deriving/sql_mapper.rb +130 -0
  20. data/lib/jade/frontend/type_checking/constraints/deriving.rb +2 -1
  21. data/lib/jade/frontend/type_checking/env.rb +3 -0
  22. data/lib/jade/frontend/type_checking/generalizer.rb +0 -1
  23. data/lib/jade/frontend/usage_analysis/reference_index.rb +7 -1
  24. data/lib/jade/frontend/usage_analysis.rb +78 -52
  25. data/lib/jade/module_loader.rb +27 -3
  26. data/lib/jade/parsing.rb +7 -1
  27. data/lib/jade/project.rb +100 -0
  28. data/lib/jade/source.rb +7 -3
  29. data/lib/jade/stdlib/decode.rb +18 -0
  30. data/lib/jade/type.rb +8 -5
  31. data/lib/jade/version.rb +1 -1
  32. data/lib/jade.rb +1 -0
  33. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15e51e91117f799dec4acda81faa5fd62f8f75190570cebc69d69e0c588481cb
4
- data.tar.gz: 1bd08d32f23d5b0e3f184f04d72aa1b44fd391bea3536ff2630ea93b1480f179
3
+ metadata.gz: bb2b101369a0d671ca8f2aa020e7113a49f22e700491e5d14f7e574f62ee17ec
4
+ data.tar.gz: 7e017dbe893887f9e7ac3dfcb5c434474085771f5062ee509cf8e9ee9682ddd6
5
5
  SHA512:
6
- metadata.gz: fcb1ae0703cdcd8c29c618b7aaf36d90c1f5b7eb4b95a1ee5f6dd6e4fd81d172977b3786442c1f1635e0ade323ff94a82fd95ee331a23f53459581966d08a346
7
- data.tar.gz: 4208a71d7852def7334d49142dc7aea3b4f6344941a9e66cbc9d544b8f877d84680c7206518d61aaeb35d6ecd8eec9261f7a1adf5d64fe6346020e775fadaaae
6
+ metadata.gz: 45578bee3ec52655fbba6a0cd7a053a6ad65c9981760edd86e2966fd671958a63fc08fb0120b58fda73298c584773a8e6a4c92eb71b2c11d79228bb88a905e16
7
+ data.tar.gz: 698a60f7e747a8042e32f5da24b8c6c322d25a470e3a65f0ed6dfeb61a89c67cc6394ab993115c8c42e9305e75db9b5445f7b5a42a74974832a2c5cd268fe209
data/CHANGELOG.md CHANGED
@@ -6,6 +6,79 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.1]
10
+
11
+ ### Fixed
12
+
13
+ - A module whose dependency failed to compile is no longer type checked. It
14
+ reached for bindings the broken module never produced and raised through the
15
+ compiler, naming the importer rather than the module at fault, and the
16
+ original error was never reported. Dependents now say which dependency
17
+ failed, and modules broken independently of each other are all still
18
+ reported.
19
+ - The load-path line at the top of every compiled entry module appends the
20
+ application's `lib` instead of prepending it. At position 0 it shadowed, for
21
+ the whole process, any gem sharing a name with a directory under `lib` —
22
+ `sidekiq/`, `sentry/`, `warden/`. The line exists so `uses App::Thing` can
23
+ resolve `lib/app/thing.rb`; it never needed to outrank gems.
24
+
25
+ ## [0.3.0]
26
+
27
+ ### Changed
28
+
29
+ - `jade fmt` keeps blank lines between leading comment blocks. A section
30
+ banner separated from the declaration below it stays separated, where it
31
+ used to be pulled into that declaration's doc comment. **Formatted output
32
+ differs from 0.2.0**, so a format check in CI reports diffs on the first
33
+ run after upgrading.
34
+ - `jade q` requires a `jade.json` and reports what to write when there isn't
35
+ one. It previously assumed the source root was the working directory, which
36
+ could not work for a project whose imports come from extension gems — it
37
+ failed later, inside the loader, instead.
38
+ - `Compiler::Config#source_root=` takes one root and refuses a list of more
39
+ than one. A list was accepted before but only its first entry was ever
40
+ read.
41
+
42
+ ### Added
43
+
44
+ - `jade.json` manifest, read by `Jade::Project`, giving tools that run
45
+ outside the application — the CLI, an editor's language server, a codegen
46
+ task — the source roots and extension gems that previously existed only as
47
+ Ruby run at boot. `Compiler::Config` seeds from it, and a `Jade.setup`
48
+ block still wins.
49
+ - `Encodable` and `Decodable` derive for unions whose variants take no
50
+ arguments, using the variant name in snake_case as the wire form. A union
51
+ mixing nullary and argument-carrying variants does not derive: adding an
52
+ argument to one variant would otherwise change the encoding of all of them.
53
+ - `SqlMapper` derives for unions whose variants each carry exactly one value,
54
+ naming the column after the variant. Inert unless jade-sql is loaded.
55
+ - Placeholders in keyed calls: `Point(x: _, y: n)` partially applies the way
56
+ `Point(_, n)` already did. Blanks bind in the order the fields are written.
57
+ - `Source` records the root it was loaded from, so an application module and
58
+ one shipped by an extension gem can be told apart without a name list.
59
+ - `UsageAnalysis::Reference` records the declaration each reference was made
60
+ from, including references with no source range and those inside an
61
+ `implements` block.
62
+
63
+ ### Fixed
64
+
65
+ - An interface method can be referenced as a value where the expected type
66
+ determines the instance. `Decode.field("k", Decode.decoder)` type-checked
67
+ and then failed in codegen, because the method's constraint named a
68
+ variable that appeared in nothing and so could never be bound.
69
+ - A constraint that resolves to a concrete type is discharged rather than
70
+ dropped when a binding is generalized. An interface method referenced
71
+ outside an argument position previously compiled to a bare name and failed
72
+ at runtime.
73
+ - Deriving reports which type it could not derive instead of raising through
74
+ the compiler. A struct with one un-derivable field took the whole run down
75
+ with a Ruby backtrace, which `tolerant: true` did not contain.
76
+ - `jade fmt` no longer drops comments. A comment above the first declaration
77
+ in a module attached to a node nothing renders, so a module's first
78
+ declaration could never carry a doc comment.
79
+ - A type sharing its name with its module no longer generates code that
80
+ resolves the wrong constant.
81
+
9
82
  ## [0.2.0]
10
83
 
11
84
  ### Added
@@ -42,7 +115,9 @@ Initial release.
42
115
  - `jade` CLI dispatcher: `fmt`, `lsp`, `q`.
43
116
  - Language server and headless query interface for editor/agent tooling.
44
117
 
45
- [Unreleased]: https://github.com/agustinrhcp/jade/compare/v0.2.0...HEAD
118
+ [Unreleased]: https://github.com/agustinrhcp/jade/compare/v0.3.1...HEAD
119
+ [0.3.1]: https://github.com/agustinrhcp/jade/compare/v0.3.0...v0.3.1
120
+ [0.3.0]: https://github.com/agustinrhcp/jade/compare/v0.2.0...v0.3.0
46
121
  [0.2.0]: https://github.com/agustinrhcp/jade/compare/v0.1.1...v0.2.0
47
122
  [0.1.1]: https://github.com/agustinrhcp/jade/compare/v0.1.0...v0.1.1
48
123
  [0.1.0]: https://github.com/agustinrhcp/jade/releases/tag/v0.1.0
data/lib/jade/ast/node.rb CHANGED
@@ -16,6 +16,22 @@ module Jade
16
16
  @@_next_id += 1
17
17
  end
18
18
 
19
+ # The comment block touching this node. A blank line above it means
20
+ # a section banner, which documents the region and not this node.
21
+ def doc(source)
22
+ leading_comment_groups(source)
23
+ .last
24
+ &.then { follows_directly?(it.last, self, source) ? it : nil }
25
+ &.then { it.map(&:value).join("\n") }
26
+ end
27
+
28
+ # Split wherever a blank line separates one block from the next.
29
+ def leading_comment_groups(source)
30
+ leading_comments
31
+ .slice_when { |a, b| !follows_directly?(a, b, source) }
32
+ .to_a
33
+ end
34
+
19
35
  # Deepest descendant whose range covers `offset`, or self if no child
20
36
  # matches. Returns nil if this node's range is missing or doesn't
21
37
  # cover the offset.
@@ -39,6 +55,20 @@ module Jade
39
55
  .first
40
56
  .then { [self] + (it || []) }
41
57
  end
58
+
59
+ private
60
+
61
+ def follows_directly?(first, second, source)
62
+ line_of(second.range.begin, source) -
63
+ line_of(first.range.end - 1, source) == 1
64
+ end
65
+
66
+ def line_of(pos, source)
67
+ source
68
+ .line_starts
69
+ .bsearch_index { it > pos }
70
+ .then { it ? it - 1 : source.line_starts.length - 1 }
71
+ end
42
72
  end
43
73
  end
44
74
  end
data/lib/jade/cli/q.rb CHANGED
@@ -88,18 +88,22 @@ module Jade
88
88
  class Context
89
89
  attr_reader :file, :source_root, :registry, :entry
90
90
 
91
+ # jade.json is what says where sources live and which extension gems
92
+ # ship the modules they import. Without it `Sql.Uuid` resolves
93
+ # against cwd and the load dies.
91
94
  def initialize(file)
92
- @file = file
93
- @source_root = Dir.pwd
95
+ project = Jade::Project.find!
96
+ @file = project.source_relative(file)
97
+ @source_root = project.source_root
94
98
  @registry = Jade::ModuleLoader.load(
95
- @source_root, file,
96
- cache_dir: File.join(@source_root, '.jade/cache'),
99
+ @source_root, @file,
100
+ cache_dir: project.cache_path,
97
101
  tolerant: true,
98
102
  )
99
103
  @entry = @registry
100
104
  .modules
101
105
  .each_value
102
- .find { it.source&.uri == file } || fail("no module at #{file}")
106
+ .find { it.source&.uri == @file } || fail("no module at #{@file}")
103
107
  end
104
108
 
105
109
  def path_at(line, col)
data/lib/jade/cli.rb CHANGED
@@ -26,6 +26,9 @@ module Jade
26
26
  usage($stderr)
27
27
  exit 1
28
28
  end
29
+ rescue Project::NotFound => e
30
+ warn "jade: #{e.message}"
31
+ exit 1
29
32
  end
30
33
 
31
34
  def usage(io = $stdout)
@@ -48,7 +48,7 @@ module Jade
48
48
  "Jade::Runtime.intr(#{name.inspect})"
49
49
 
50
50
  in [:struct_constructor, qualified_name, arity]
51
- "Jade::Runtime.curry(#{to_qualified(qualified_name)}.method(:[]), #{arity})"
51
+ "Jade::Runtime.curry(::#{to_qualified(qualified_name)}.method(:[]), #{arity})"
52
52
 
53
53
  in [:anon_record_constructor, keys]
54
54
  "Jade::Runtime.curry(#{data_define(keys)}.method(:[]), #{keys.size})"
@@ -68,7 +68,7 @@ module Jade
68
68
  args
69
69
  .map { "#{it}" }
70
70
  .join(',')
71
- .then { "#{to_qualified(name)}(#{it})" }
71
+ .then { "::#{to_qualified(name)}(#{it})" }
72
72
 
73
73
  in [:_]
74
74
  '_'
@@ -192,6 +192,7 @@ module Jade
192
192
  Decode.required
193
193
  Decode.sequence
194
194
  Decode.string
195
+ Decode.string_enum
195
196
  Decode.succeed
196
197
  Decode.tuple
197
198
  Decode.tuple3
data/lib/jade/codegen.rb CHANGED
@@ -393,8 +393,12 @@ module Jade
393
393
  node.is_a?(AST::ImportDeclaration) || node.is_a?(AST::InteropImportDeclaration)
394
394
  end
395
395
 
396
+ # Appended, not prepended: this exists so `uses App::Thing` can find
397
+ # `lib/app/thing.rb`, and an application's lib directory routinely holds
398
+ # directories named after the gems it depends on. At position 0 those
399
+ # shadow the real gems for the whole process.
396
400
  def load_path
397
- '$LOAD_PATH.unshift(File.expand_path("lib"))'
401
+ '$LOAD_PATH.push(File.expand_path("lib")).uniq!'
398
402
  end
399
403
  end
400
404
  end
data/lib/jade/compiler.rb CHANGED
@@ -11,7 +11,7 @@ module Jade
11
11
 
12
12
  if needs_rebuild?(target)
13
13
  ModuleLoader
14
- .load(config.source_root.first, path + '.jd', cache_dir: cache_root)
14
+ .load(config.source_root, path + '.jd', cache_dir: cache_root)
15
15
  .tap { render_diagnostics(it) }
16
16
  .then { ModuleLoader.emit(it, path: build_root) }
17
17
  end
@@ -47,22 +47,31 @@ module Jade
47
47
 
48
48
  target_mtime = File.mtime(target)
49
49
  Dir
50
- .glob(File.join(config.source_root.first, '**/*.jd'))
50
+ .glob(File.join(config.source_root, '**/*.jd'))
51
51
  .any? { |src| File.mtime(src) > target_mtime }
52
52
  end
53
53
 
54
54
  class Config
55
55
  attr_accessor :project_root, :source_root, :build_dir, :cache_dir
56
56
 
57
- def initialize
58
- @project_root = Dir.pwd
59
- @source_root = []
60
- @build_dir = ".jade/build"
61
- @cache_dir = ".jade/cache"
57
+ # Seeded from jade.json when there is one, so `Jade.setup` needs only
58
+ # what the manifest doesn't say. A setup block still wins — it runs
59
+ # after this.
60
+ def initialize(project = Project.find)
61
+ @project_root = project&.root || Dir.pwd
62
+ @source_root = project&.source_root
63
+ @build_dir = project&.build_dir || ".jade/build"
64
+ @cache_dir = project&.cache_dir || ".jade/cache"
62
65
  end
63
66
 
67
+ # One root. A list was accepted but only ever read at `.first`, so
68
+ # anything past the first was silently dropped — say so instead.
64
69
  def source_root=(root)
65
- @source_root = Array(root)
70
+ Array(root).then do
71
+ fail ArgumentError, "source_root takes one root, got #{it.size}" if it.size > 1
72
+
73
+ @source_root = it.first
74
+ end
66
75
  end
67
76
  end
68
77
  end
@@ -13,7 +13,7 @@ module Jade
13
13
  # walks delegated to their own modules.
14
14
  module Helper
15
15
  def format_node(node, indent: 0, source: nil)
16
- leading = format_leading_comments(node, indent)
16
+ leading = format_leading_comments(node, indent, source)
17
17
  trailing = format_trailing_comment(node)
18
18
 
19
19
  dispatch_for(node)
@@ -75,15 +75,21 @@ module Jade
75
75
  (INDENT * indent).length + line.length > LINE_LIMIT
76
76
  end
77
77
 
78
- def format_leading_comments(node, indent)
78
+ # The blank line between blocks is load-bearing: collapsing it turns
79
+ # a section banner into the next declaration's doc comment.
80
+ def format_leading_comments(node, indent, source = nil)
79
81
  return "" if node.leading_comments.empty?
80
82
 
81
- node.leading_comments
82
- .map { |tok| tok.value.then(&and_indent(indent)) }
83
- .join("\n")
83
+ leading_comment_blocks(node, source)
84
+ .map { |block| block.map { it.value.then(&and_indent(indent)) }.join("\n") }
85
+ .join("\n\n")
84
86
  .then { it + "\n" }
85
87
  end
86
88
 
89
+ def leading_comment_blocks(node, source)
90
+ source ? node.leading_comment_groups(source) : [node.leading_comments]
91
+ end
92
+
87
93
  def format_trailing_comment(node)
88
94
  return "" if node.trailing_comments.empty?
89
95
 
@@ -10,7 +10,9 @@ module Jade
10
10
 
11
11
  collect_nodes(ast)
12
12
  .reject { |n| n.range.nil? }
13
- .sort_by { |n| n.range.begin }
13
+ # Outermost first, so `x = f(y)` anchors on the Assign rather
14
+ # than the pattern nested inside it.
15
+ .sort_by { |n| [n.range.begin, -n.range.size] }
14
16
  .then { build_map(comments, it, source) }
15
17
  .then { reattach(ast, it) }
16
18
  end
@@ -70,7 +72,16 @@ module Jade
70
72
  end
71
73
 
72
74
  def first_node_starting_after(pos, sorted_nodes)
73
- sorted_nodes.bsearch { |n| n.range.begin >= pos }
75
+ sorted_nodes
76
+ .bsearch_index { |n| n.range.begin >= pos }
77
+ &.then { |from| (from...sorted_nodes.size).find { renders_own_comments?(sorted_nodes[it]) } }
78
+ &.then { sorted_nodes[it] }
79
+ end
80
+
81
+ # ModuleNode walks `body.expressions` directly, so a Body's own
82
+ # leading comments reach no formatter and would be lost.
83
+ def renders_own_comments?(node)
84
+ !node.is_a?(AST::Body)
74
85
  end
75
86
 
76
87
  def smallest_enclosing(pos, sorted_nodes)
@@ -0,0 +1,22 @@
1
+ module Jade
2
+ module Frontend
3
+ module SemanticAnalysis
4
+ module Error
5
+ class PlaceholderNotAllowed < Jade::Error
6
+ def initialize(entry, span, field:)
7
+ @field = field
8
+ super(entry:, span:)
9
+ end
10
+
11
+ def message
12
+ "`#{@field}: _` is only allowed when constructing a struct"
13
+ end
14
+
15
+ def label
16
+ "not allowed here"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -5,6 +5,7 @@ require 'jade/frontend/semantic_analysis/error/constant_not_callable'
5
5
  require 'jade/frontend/semantic_analysis/error/constructor_not_found'
6
6
  require 'jade/frontend/semantic_analysis/error/constructor_pattern_arity_mismatch'
7
7
  require 'jade/frontend/semantic_analysis/error/duplicate_field'
8
+ require 'jade/frontend/semantic_analysis/error/placeholder_not_allowed'
8
9
  require 'jade/frontend/semantic_analysis/error/duplicate_function_declaration'
9
10
  require 'jade/frontend/semantic_analysis/error/duplicate_record_field'
10
11
  require 'jade/frontend/semantic_analysis/error/invalid_list_rest_pattern'
@@ -16,6 +16,12 @@ module Jade
16
16
  constructor = constructor_symbol(callee_resolved, registry)
17
17
  parent = constructor && registry.lookup(constructor.parent)
18
18
 
19
+ if fields.any? { it.value.is_a?(AST::Placeholder) }
20
+ return partially_applied(
21
+ node, callee, fields, parent, constructor, registry, scope, entry, callee_r
22
+ )
23
+ end
24
+
19
25
  fields_r = analyze_in_parallel(fields, registry, scope, entry)
20
26
  fields_resolved = fields_r.node
21
27
 
@@ -34,6 +40,55 @@ module Jade
34
40
 
35
41
  private
36
42
 
43
+ # Lowered before the fields are analyzed, so the placeholders are
44
+ # still bare AST and `Placeholder.lift` can turn the call into a
45
+ # lambda — the same route a positional partial application takes.
46
+ def partially_applied(node, callee, fields, parent, constructor, registry, scope, entry, callee_r)
47
+ errors = Validation.errors(node, fields, parent, constructor, registry, entry) +
48
+ placeholder_errors(fields, parent, entry)
49
+
50
+ return Result[node, callee_r.errors + errors, scope] if errors.any?
51
+
52
+ named, params = name_placeholders(fields)
53
+
54
+ lower(node, callee, named, parent, constructor, registry)
55
+ .then { wrap_in_lambdas(it, params, node.range) }
56
+ .then { analyze_node(it, registry, scope, entry) }
57
+ .then { Result[it.node, callee_r.errors + it.errors, scope] }
58
+ end
59
+
60
+ # Named in the order the fields were written, not the order the
61
+ # struct declares them, so `Point(y: _, x: _)` takes y first.
62
+ def name_placeholders(fields)
63
+ fields.reduce([[], []]) do |(named, params), field|
64
+ case field.value
65
+ in AST::Placeholder
66
+ Codegen::Helpers.param_synthetic_name(params.size).then do |name|
67
+ [named + [field.with(value: AST::VariableReference[name, nil])], params + [name]]
68
+ end
69
+
70
+ else
71
+ [named + [field], params]
72
+ end
73
+ end
74
+ end
75
+
76
+ def wrap_in_lambdas(call, params, range)
77
+ params
78
+ .reverse
79
+ .reduce(call) do |body, name|
80
+ AST::Lambda[[AST::Pattern::Binding[name, nil]], AST::Body[[body], nil], range]
81
+ end
82
+ end
83
+
84
+ def placeholder_errors(fields, parent, entry)
85
+ return [] if parent.is_a?(Symbol::Struct)
86
+
87
+ fields
88
+ .select { it.value.is_a?(AST::Placeholder) }
89
+ .map { Error::PlaceholderNotAllowed.new(entry.name, it.range, field: it.key) }
90
+ end
91
+
37
92
  def lower(node, callee, fields, parent, constructor, registry)
38
93
  case parent
39
94
  in Symbol::Struct
@@ -28,6 +28,9 @@ module Jade
28
28
  in Symbol::Struct
29
29
  derive_struct(constraint, resolved_sym, args, registry, lookup, entry_name)
30
30
 
31
+ in Symbol::Union if nullary?(resolved_sym, registry)
32
+ derive_nullary_union(constraint, resolved_sym, registry)
33
+
31
34
  else
32
35
  failed(constraint, entry_name)
33
36
  end
@@ -87,14 +90,32 @@ module Jade
87
90
  Type.constraint(INTERFACE, field_type, nil)
88
91
  end
89
92
 
90
- resolved_deps = field_deps.map do |dep|
91
- resolve_dep(dep, lookup, entry_name) => Ok[impl]
92
- impl
93
- end
93
+ field_deps
94
+ .map { resolve_dep(it, lookup, entry_name) }
95
+ .then { Results.sequence(it) }
96
+ .map { implementation(constraint, record_body(fields, constructor_ref), it) }
97
+ end
98
+
99
+ def derive_nullary_union(constraint, union_sym, registry)
100
+ variants(union_sym, registry)
101
+ .then { nullary_union_body(it) }
102
+ .then { Ok[implementation(constraint, it, [])] }
103
+ end
94
104
 
105
+ def nullary_union_body(variants)
106
+ [:call,
107
+ [:stdlib_fn, 'Decode.string_enum'],
108
+ [
109
+ [:list, variants.map { wire_name(it) }],
110
+ [:list, variants.map { [:call, [:struct_constructor, it.qualified_name, 0], []] }],
111
+ ],
112
+ ]
113
+ end
114
+
115
+ def record_body(fields, constructor_ref)
95
116
  seed = [:call, [:stdlib_fn, 'Decode.succeed'], [constructor_ref]]
96
117
 
97
- body = fields.each_with_index.reduce(seed) do |acc, ((field_name, _), idx)|
118
+ fields.each_with_index.reduce(seed) do |acc, ((field_name, _), idx)|
98
119
  field_decoder = [:call,
99
120
  [:stdlib_fn, 'Decode.field'],
100
121
  [
@@ -105,8 +126,6 @@ module Jade
105
126
 
106
127
  [:call, [:stdlib_fn, 'Decode.and_map'], [acc, field_decoder]]
107
128
  end
108
-
109
- Ok[implementation(constraint, body, resolved_deps)]
110
129
  end
111
130
 
112
131
  # Free-var inner constraints fall back to the constraint marker
@@ -30,6 +30,9 @@ module Jade
30
30
  in Symbol::Struct
31
31
  derive_struct(constraint, resolved_sym, args, registry, lookup, entry_name)
32
32
 
33
+ in Symbol::Union if nullary?(resolved_sym, registry)
34
+ derive_nullary_union(constraint, resolved_sym, registry)
35
+
33
36
  else
34
37
  failed(constraint, entry_name)
35
38
  end
@@ -84,18 +87,25 @@ module Jade
84
87
  end
85
88
  end
86
89
 
90
+ def derive_nullary_union(constraint, union_sym, registry)
91
+ body = variants(union_sym, registry)
92
+ .map { |v|
93
+ [
94
+ [:constructor, v.qualified_name, []],
95
+ [[:call, [:stdlib_fn, 'Encode.string'], [wire_name(v)]]],
96
+ ]
97
+ }
98
+ .then { [:case, [:var, 'v'], it] }
99
+
100
+ Ok[implementation(constraint, params: ['v'], body:, deps: [])]
101
+ end
102
+
87
103
  def derive_struct(constraint, struct_sym, type_args, registry, lookup, entry_name)
88
104
  fields = struct_fields(struct_sym, type_args, registry)
89
105
 
90
106
  field_deps = fields
91
107
  .map { |_, field_type| Type.constraint(INTERFACE, field_type, nil) }
92
108
 
93
- resolved_deps = field_deps
94
- .map do |dep|
95
- lookup.call(dep) => Ok[impl]
96
- impl
97
- end
98
-
99
109
  pair_irs = fields
100
110
  .each_with_index
101
111
  .map do |(field_name, _), idx|
@@ -118,7 +128,10 @@ module Jade
118
128
  ],
119
129
  ]
120
130
 
121
- Ok[implementation(constraint, params: ['rec'], body:, deps: resolved_deps)]
131
+ field_deps
132
+ .map { lookup.call(it) }
133
+ .then { Results.sequence(it) }
134
+ .map { implementation(constraint, params: ['rec'], body:, deps: it) }
122
135
  end
123
136
 
124
137
  def implementation(constraint, params:, body:, deps:)
@@ -34,7 +34,8 @@ module Jade
34
34
  deps = dependencies_of(impl, args)
35
35
  resolved_deps = deps.filter_map { |dep|
36
36
  next if dep.type in Type::Var
37
- lookup.call(dep) => Ok[resolved]; resolved
37
+ lookup.call(dep).on_err { return Err[it] } => Ok[resolved]
38
+ resolved
38
39
  }
39
40
 
40
41
  Symbol::Implementation.new(
@@ -6,6 +6,23 @@ module Jade
6
6
  module Helpers
7
7
  extend self
8
8
 
9
+ # `union :Int` and friends are variant-less unions standing in
10
+ # for native types, so an empty variant list is not an enum.
11
+ def nullary?(union_sym, registry)
12
+ variants(union_sym, registry)
13
+ .then { it.any? && it.all? { it.args.empty? } }
14
+ end
15
+
16
+ def variants(union_sym, registry)
17
+ union_sym.variants.map { registry.lookup(it) }
18
+ end
19
+
20
+ # The wire name a variant carries when nothing says otherwise.
21
+ # Matches what hand-written mappings already use.
22
+ def wire_name(variant)
23
+ Source.snake_case(variant.name)
24
+ end
25
+
9
26
  def struct_fields(struct_sym, type_args, registry)
10
27
  record = struct_sym.record_type
11
28
  type_param_names = struct_sym.type_params.map(&:name)