Skip to content

Commit 1272184

Browse files
committed
Merge issue-1699-union-grouping: combine "&" and "[A | B]" into one PR
Combines intersection type support ("A & B", from lsegal#1700) with grouped- union support ("[A | B]", from lsegal#1702) into a single PR, per review feedback, and fixes an integration bug the merge surfaced: the grouping syntax's "|" handler pushed its type directly instead of routing through finish_intersection, which would have silently dropped any pending "&" conjuncts when a group boundary was hit (e.g. in "[Foo & Bar | Baz]"). Also fixes a real English-rendering ambiguity in the combined output: GroupType wraps its whole member list in one pair of parens, but IntersectionType (and a multi-method DuckType, "#foo & #bar") render as a bare "X and Y" with no punctuation of their own. Sitting next to a sibling in the group's "or"-joined list, that read ambiguously ("a Foo and a Bar or a Baz" doesn't show which operator binds tighter) even though the parse itself was correct. GroupType now adds defensive parens around exactly those two cases. Documents operator precedence explicitly: "&" always binds tighter than whichever separator surrounds it ("," at the top level, "|" inside "[...]"); "|" is only valid inside "[...]"; "," is never valid inside "[...]". Adds end-to-end specs combining both operators, including the precedence and disambiguation cases above.
2 parents c23ebf8 + d77dc18 commit 1272184

4 files changed

Lines changed: 237 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# main
22

3-
- Add support for intersection types (`Foo & Bar`) in tag type lists (closes #1644)
3+
- Add support for intersection (`Foo & Bar`) and grouped-union
4+
(`[Foo | Bar]`) syntax in tag type lists (closes #1644, #1699); document
5+
the existing anonymous `<A>`, `(A)`, and `{A=>B}` shorthand forms
46
- Fix duplicate "View source" links after client-side navigation in default HTML template
57

68
# [0.9.45] - July 14th, 2026

docs/Tags.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@ a list of parametrized types can occur in any order inside of a type. An array
182182
specified as `Array<String, Fixnum>` can contain any amount of Strings or Fixnums,
183183
in any order. When the order matters, use "order-dependent lists", described below.
184184

185+
The type name before `<...>` can be omitted, in which case it defaults to
186+
`Array`: `<String, Fixnum>` means the same thing as `Array<String, Fixnum>`.
187+
185188
#### Duck-Types
186189

187190
Duck-types are allowed in type specifier lists, and are identified by method
@@ -238,13 +241,55 @@ Keys in the hash-specific syntax are commonly [literal values](#Literals) such
238241
as symbols (`:key`) or strings (`'key'`, `"key"`), but any type listed in the
239242
[type conventions](#Type_List_Conventions) is allowed.
240243

244+
The type name before `{...}` can be omitted, in which case it defaults to
245+
`Hash`: `{K=>V}` means the same thing as `Hash{K=>V}`.
246+
241247
#### Order-Dependent Lists
242248

243249
An order dependent list is a set of types surrounded by "()" and separated by
244250
commas. This list must contain exactly those types in exactly the order specified.
245251
For instance, an Array containing a String, Fixnum and Hash in that order (and
246252
having exactly those 3 elements) would be listed as: `Array(String, Fixnum, Hash)`.
247253

254+
The type name before `(...)` can be omitted, in which case it defaults to
255+
`Array`: `(String, Fixnum, Hash)` means the same thing as
256+
`Array(String, Fixnum, Hash)`.
257+
258+
#### Grouped Unions
259+
260+
A comma inside an order-dependent list already means "next slot," so there's no
261+
way to write "a slot that can be either of two types" using a comma there - and
262+
the same problem applies to any other position where `,` already has a
263+
different meaning. Wrapping a `|`-separated list in square brackets (`[...]`)
264+
groups it into a single union type that can be used anywhere a type is
265+
expected, including as one slot of an order-dependent list:
266+
`Array([Integer | String], Symbol)` describes a 2-element Array whose first
267+
element is an `Integer` or a `String`, followed by a `Symbol`.
268+
269+
`[...]` is dedicated entirely to this grouping; unlike `<...>`, `(...)`, and
270+
`{...}`, it never takes a preceding type name and is never itself a
271+
collection - `[Integer | String]` alone just means "an Integer or a String",
272+
identical in meaning to the plain top-level list `Integer, String`, just
273+
usable in more places. `|` is only meaningful inside `[...]`, and a plain `,`
274+
is not allowed inside `[...]` - a top-level type list already uses `,` for
275+
the same "either of these" meaning, so there is no need for two spellings of
276+
it in the same position.
277+
278+
#### Operator Precedence
279+
280+
`&`, `,`, and `|` can all appear in the same type, and each means something
281+
different depending on where it's used, so here's how they combine:
282+
283+
* `&` always binds tighter than whichever separator surrounds it - `,` at
284+
the top level, or `|` inside `[...]`. `Foo & Bar, Baz` means
285+
`(Foo & Bar), Baz`, and `[Foo & Bar | Baz]` means `[(Foo & Bar) | Baz]`,
286+
not `Foo & (Bar, Baz)` or `Foo & [Bar | Baz]`.
287+
* `|` only has meaning inside `[...]` - anywhere else, it's not valid syntax.
288+
* `,` is not valid inside `[...]` - use `|` there instead.
289+
* `[...]` can nest inside itself (`[[Foo | Bar] | Baz]`), and can be used
290+
as one conjunct of an intersection in either order
291+
(`[Foo | Bar] & Baz`, `Baz & [Foo | Bar]`).
292+
248293
#### Literals
249294

250295
Some literals are accepted by virtue of being Ruby literals, but also by YARD

lib/yard/tags/types_explainer.rb

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,37 @@ def to_s(singular = true)
8282
end
8383
end
8484

85+
# @private
86+
class GroupType < Type
87+
attr_accessor :types
88+
89+
def initialize(types)
90+
@types = types
91+
end
92+
93+
def to_s(singular = true)
94+
"(" + list_join(types.map {|t| disambiguate(t, singular) }) + ")"
95+
end
96+
97+
private
98+
99+
# {IntersectionType} and a multi-method {DuckType} (`#foo & #bar`)
100+
# both render as a bare "X and Y", with no punctuation of their own
101+
# to mark where they end. Sitting next to a sibling in this group's
102+
# own "or"-joined list, that reads ambiguously (e.g. "a Foo and a
103+
# Bar or a Baz" doesn't show whether the "and" or the "or" binds
104+
# tighter) even though the parse itself is unambiguous. Wrap those
105+
# two cases in their own parens so the group's members are visually
106+
# self-delimiting; every other {Type} already is (a bare name, or
107+
# something with its own bracketing like {GroupType} itself).
108+
def disambiguate(type, singular)
109+
rendered = type.to_s(singular)
110+
needs_parens = type.is_a?(IntersectionType) ||
111+
(type.is_a?(DuckType) && type.name.include?('&'))
112+
needs_parens ? "(#{rendered})" : rendered
113+
end
114+
end
115+
85116
# @private
86117
class CollectionType < Type
87118
attr_accessor :types
@@ -167,10 +198,13 @@ class Parser
167198
:collection_end => />/,
168199
:fixed_collection_start => /\(/,
169200
:fixed_collection_end => /\)/,
201+
:group_start => /\[/,
202+
:group_end => /\]/,
170203
:type_name => /#{ISEP}#{METHODNAMEMATCH}|#{NAMESPACEMATCH}|#{LITERALMATCH}|\w+/,
171204
:symbol => /:#{METHODNAMEMATCH}/,
172205
:type_next => /[,]/,
173206
:intersect => /&/,
207+
:union_sep => /\|/,
174208
:whitespace => /\s+/,
175209
:hash_collection_start => /\{/,
176210
:hash_collection_value => /=>/,
@@ -195,7 +229,26 @@ def parse(until_tokens: [:parse_end])
195229

196230
private
197231

198-
def parse_until(until_tokens)
232+
# @param allow_pipe [Boolean] whether a bare `|` is a legal separator
233+
# in this scope. Only true directly inside `[...]`, YARD's grouping
234+
# syntax: it lets a union be nested as a single type wherever a
235+
# type is expected, including inside constructs where `,` already
236+
# has a different meaning (an order-dependent list's positional
237+
# slots). `,` is not allowed inside `[...]` (and `|` is not allowed
238+
# anywhere else) - the two are never legal in the same scope, so
239+
# there is nothing to disambiguate between them.
240+
# @param allow_comma [Boolean] whether a bare `,` is a legal separator
241+
# in this scope. False only directly inside `[...]`.
242+
#
243+
# `&` (intersection) is handled orthogonally to both of the above via
244+
# `intersection_conjuncts`/{#finish_intersection} - it is legal
245+
# anywhere a type is expected (top-level, inside `<...>`/`(...)`,
246+
# and inside `[...]` alongside `|`), and always binds tighter than
247+
# whichever separator (`,` or `|`) is active in the current scope:
248+
# `A & B, C` is `(A & B), C`, and `[A & B | C]` is `[(A & B) | C]`.
249+
# @return [Array(Array<Type>, Symbol)] the parsed types and the
250+
# token that ended the list
251+
def parse_until(until_tokens, allow_pipe: false, allow_comma: true)
199252
current_parsed_types = []
200253
type = nil
201254
name = nil
@@ -221,17 +274,31 @@ def parse_until(until_tokens)
221274
name = nil
222275
type = nil
223276
when :type_next
277+
raise SyntaxError, "',' is not allowed inside '[...]' groups" unless allow_comma
224278
raise SyntaxError, "expecting name, got '#{token}' at #{@scanner.pos}" if name.nil?
225279
type = create_type(name) unless type
226280
current_parsed_types << finish_intersection(intersection_conjuncts, type)
227281
intersection_conjuncts = []
228282
name = nil
229283
type = nil
284+
when :union_sep
285+
raise SyntaxError, "'|' is only allowed inside '[...]' groups" unless allow_pipe
286+
raise SyntaxError, "expecting name, got '|' at #{@scanner.pos}" if name.nil?
287+
type = create_type(name) unless type
288+
current_parsed_types << finish_intersection(intersection_conjuncts, type)
289+
intersection_conjuncts = []
290+
name = nil
291+
type = nil
230292
when :fixed_collection_start, :collection_start
231293
name ||= "Array"
232294
klass = token_type == :collection_start ? CollectionType : FixedCollectionType
233295
nested_types, = parse_until([:fixed_collection_end, :collection_end, :parse_end])
234296
type = klass.new(name, nested_types)
297+
when :group_start
298+
raise SyntaxError, "'[' cannot follow a type name" if name
299+
nested_types, = parse_until([:group_end, :parse_end], allow_pipe: true, allow_comma: false)
300+
type = GroupType.new(nested_types)
301+
name = "Group"
235302
when :hash_collection_start
236303
name ||= "Hash"
237304
type = parse_hash_collection(name)

spec/tags/types_explainer_spec.rb

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,37 @@ def type(name)
8585
end
8686
end
8787

88+
describe YARD::Tags::TypesExplainer::GroupType, '#to_s' do
89+
it "works for two types" do
90+
group = described_class.new([type("Foo"), type("Bar")])
91+
expect(group.to_s).to eq "(a Foo or a Bar)"
92+
expect(group.to_s(false)).to eq "(Foos or Bars)"
93+
end
94+
95+
it "works for more than two types" do
96+
group = described_class.new([type("Foo"), type("Bar"), type("Baz")])
97+
expect(group.to_s).to eq "(a Foo, a Bar or a Baz)"
98+
end
99+
100+
it "adds defensive parens around an IntersectionType member" do
101+
intersection = YARD::Tags::TypesExplainer::IntersectionType.new([type("Foo"), type("Bar")])
102+
group = described_class.new([intersection, type("Baz")])
103+
expect(group.to_s).to eq "((a Foo and a Bar) or a Baz)"
104+
end
105+
106+
it "adds defensive parens around a multi-method DuckType member" do
107+
duck = YARD::Tags::TypesExplainer::DuckType.new("#foo & #bar")
108+
group = described_class.new([duck, type("Baz")])
109+
expect(group.to_s).to eq "((an object that responds to #foo and #bar) or a Baz)"
110+
end
111+
112+
it "does not add extra parens around a single-method DuckType member" do
113+
duck = YARD::Tags::TypesExplainer::DuckType.new("#foo")
114+
group = described_class.new([duck, type("Baz")])
115+
expect(group.to_s).to eq "(an object that responds to #foo or a Baz)"
116+
end
117+
end
118+
88119
describe YARD::Tags::TypesExplainer::LiteralType, '#to_s' do
89120
it "works for literal values" do
90121
[':symbol', "'5'"].each do |name|
@@ -216,6 +247,36 @@ def parse_fail(types)
216247
expect(type.first.name).to eq "Array"
217248
end
218249

250+
it "parses a grouped union inside square brackets as a GroupType" do
251+
type = parse("[String | Symbol]")
252+
expect(type.first).to be_a(YARD::Tags::TypesExplainer::GroupType)
253+
expect(type.first.types.map(&:name)).to eq ["String", "Symbol"]
254+
end
255+
256+
it "allows a grouped union as a fixed-tuple slot" do
257+
type = parse("Array([String | Symbol], Integer)")
258+
expect(type.first).to be_a(YARD::Tags::TypesExplainer::FixedCollectionType)
259+
expect(type.first.types.first).to be_a(YARD::Tags::TypesExplainer::GroupType)
260+
expect(type.first.types.first.types.map(&:name)).to eq ["String", "Symbol"]
261+
expect(type.first.types.last.name).to eq "Integer"
262+
end
263+
264+
it "does not allow '|' outside of square brackets" do
265+
parse_fail "String | Symbol"
266+
end
267+
268+
it "does not allow '|' inside a collection type" do
269+
parse_fail "Array<String | Symbol>"
270+
end
271+
272+
it "does not allow ',' inside square brackets" do
273+
parse_fail "[String, Symbol]"
274+
end
275+
276+
it "does not allow '[' to follow a type name" do
277+
parse_fail "Foo[String]"
278+
end
279+
219280
it "allows a hash collection type without a name" do
220281
type = parse("{K=>V}")
221282
expect(type.first.name).to eq "Hash"
@@ -306,5 +367,65 @@ def parse_fail(types)
306367
expect(explain).to eq expected.delete("\n").squeeze(' ')
307368
end
308369
end
370+
371+
it "parses grouped unions (`[A | B]`)" do
372+
expect = {
373+
# standalone, redundant with a plain top-level union, but legal
374+
"[Integer | String]" => "(an Integer or a String)",
375+
# the motivating case: a union in a fixed-tuple slot, which `,`
376+
# can't express there since it already means "next slot"
377+
"Array([Integer | String], Symbol)" =>
378+
"an Array containing ((an Integer or a String) followed by a Symbol)",
379+
"Hash{String => [Integer | Symbol]}" =>
380+
"a Hash with keys made of (Strings) and values of ((Integers or Symbols))"
381+
}
382+
expect.each do |input, expected|
383+
explain = YARD::Tags::TypesExplainer.explain(input)
384+
expect(explain).to eq expected.delete("\n").squeeze(' ')
385+
end
386+
end
387+
388+
it "composes `&` and `[A | B]` together, with `&` binding tighter than `|`" do
389+
expect = {
390+
# a group used as one conjunct of an intersection
391+
"[Integer | String] & Comparable" => "(an Integer or a String) and a Comparable",
392+
"Comparable & [Integer | String]" => "a Comparable and (an Integer or a String)",
393+
# `&` binds tighter than `|` *inside* a group too, matching how it
394+
# already binds tighter than `,` everywhere else: `A & B | C` inside
395+
# `[...]` groups parses as `(A & B) | C`, not `A & (B | C)`. The
396+
# `&`-joined conjunct has no punctuation of its own to mark where it
397+
# ends, so the group adds defensive parens around it (only) to keep
398+
# the English rendering unambiguous, matching the real parse tree.
399+
"[Foo & Bar | Baz]" => "((a Foo and a Bar) or a Baz)",
400+
"[Foo | Bar & Baz]" => "(a Foo or (a Bar and a Baz))",
401+
# duck-types inside a group are NOT collapsed the way `&`-joined ones
402+
# are - grouping is a real alternative ("either responds to #foo, or
403+
# responds to #bar"), not the same method-list convention. But a
404+
# multi-method duck-type (already `&`-collapsed before the `|` is
405+
# seen) gets the same defensive-parens treatment as `&` above.
406+
"[#foo | #bar]" => "(an object that responds to #foo or an object that responds to #bar)",
407+
"[#foo & #bar | #baz]" =>
408+
"((an object that responds to #foo and #bar) or an object that responds to #baz)",
409+
# a group nested inside another group's slot - GroupType always
410+
# parenthesizes itself, so no extra disambiguation is needed here
411+
"Array([[Integer | String] | Symbol], Number)" =>
412+
"an Array containing (((an Integer or a String) or a Symbol) followed by a Number)"
413+
}
414+
expect.each do |input, expected|
415+
explain = YARD::Tags::TypesExplainer.explain(input)
416+
expect(explain).to eq expected.delete("\n").squeeze(' ')
417+
end
418+
end
419+
420+
it "does not allow ',' or bare '|' to leak across the '&'/'[...]' boundary" do
421+
# '&' never needs '[...]' - it's legal (and binds tightest) everywhere
422+
expect(YARD::Tags::TypesExplainer.explain("Array<Foo & Bar, Baz>")).to eq(
423+
"an Array of (Foos and Bars or Bazs)"
424+
)
425+
# but a bare '|' still requires '[...]' even next to a valid '&' usage
426+
expect(YARD::Tags::TypesExplainer.explain("Foo & Bar | Baz")).to be_nil
427+
# and ',' still can't cross into a '[...]' group just because '&' is nearby
428+
expect(YARD::Tags::TypesExplainer.explain("[Foo & Bar, Baz]")).to be_nil
429+
end
309430
end
310431
end

0 commit comments

Comments
 (0)