Skip to content

Commit 5e6f8ba

Browse files
apiologyclaude
andcommitted
Add | union and [...] grouping operators, matching YARD #1700
lsegal/yard#1700 proposes standardizing `|` as an explicit union operator and `[...]` as a grouping construct for YARD type tags, alongside the `&` intersection operator this branch already added for solargraph#1229. Implementing the full syntax here so Solargraph's own parser and the upstream proposal describe the same grammar, and so `(A | B) & C` - previously only buildable by translating real RBS or constructing an Intersection object directly, per the now-outdated comment on the parentheses spec - has an actual tag-string form. `|` binds looser than `&` (matching RBS's documented precedence) and, inside a fixed-arity context (`Array(...)` tuples, or a generic type's positional parameters), groups multiple types into a single slot instead of splitting into separate positional arguments - the same distinction `,` already makes there. In an implicit-union context (Array<...>/Set<...>, hash key/value lists, the top-level list itself), `|` and `,` land on the same result, since every comma-separated type in those contexts is already unioned regardless of grouping. `[...]` is the actual grouping construct - the only way to mark where a union ends when it needs to be one conjunct of an intersection (`[Foo | Bar] & Baz`). It's deliberately conservative about when it opens: only at a fresh atom (blank base, not already nested in <>/{}/()), otherwise `[`/`]` are ordinary characters - this matters for quoted string-literal types like `"[]"`, which have no concept of grouping and would otherwise crash self-typecheck against the real Dir RBS core stub. Also fixes the anonymous shorthand forms `<A>`, `(A)`, `{A=>B}` (typed before this as an empty-name UniqueType) to default their name to Array/Array/Hash respectively, per YARD #1700's third documented change - so an anonymous form now behaves exactly like its named equivalent, including for rooting. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019wfsRatLaRbxcsQ7ZVzifN
1 parent 0d5b356 commit 5e6f8ba

3 files changed

Lines changed: 260 additions & 43 deletions

File tree

lib/solargraph/complex_type.rb

Lines changed: 130 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -538,12 +538,17 @@ def parse_type_string type_string, types, key_types
538538
point_stack = 0
539539
curly_stack = 0
540540
paren_stack = 0
541+
bracket_stack = 0
541542
base = String.new
542543
subtype_string = String.new
543544
# conjuncts of an intersection type (`A & B`) seen so far in
544-
# the segment currently being parsed
545+
# the current `|`-disjunct of the segment being parsed
545546
# @type [Array<ComplexType>]
546547
conjuncts = []
548+
# disjuncts of a union type (`A | B`) seen so far in the
549+
# segment currently being parsed
550+
# @type [Array<ComplexType, ComplexType::UniqueType>]
551+
disjuncts = []
547552
# @param char [String]
548553
type_string&.each_char do |char|
549554
if char == '='
@@ -555,17 +560,8 @@ def parse_type_string type_string, types, key_types
555560
subtype_string += char
556561
elsif base.end_with?('=')
557562
raise ComplexTypeError, 'Invalid hash thing' unless key_types.nil?
558-
# types.push ComplexType.new([UniqueType.new(base[0..-2].strip)])
559-
# @sg-ignore Need to add nil check here
560-
types.push close_intersection(conjuncts, UniqueType.parse(base[0..-2].strip, subtype_string))
561-
# @todo this should either expand key_type's type
562-
# automatically or complain about not being
563-
# compatible with key_type's type in type checking
564-
key_types = types
563+
key_types = close_key_types(base, subtype_string, conjuncts, disjuncts, types)
565564
types = []
566-
conjuncts = []
567-
base.clear
568-
subtype_string.clear
569565
next
570566
else
571567
raise ComplexTypeError, "Invalid close in type #{type_string}" if point_stack.zero?
@@ -576,66 +572,168 @@ def parse_type_string type_string, types, key_types
576572
elsif char == '{'
577573
curly_stack += 1
578574
elsif char == '}'
579-
curly_stack -= 1
580-
subtype_string += char
581-
raise ComplexTypeError, "Invalid close in type #{type_string}" if curly_stack.negative?
575+
curly_stack = close_bracket(curly_stack, subtype_string, char, type_string)
582576
next
583577
elsif char == '('
584578
paren_stack += 1
585579
elsif char == ')'
586-
paren_stack -= 1
587-
subtype_string += char
588-
raise ComplexTypeError, "Invalid close in type #{type_string}" if paren_stack.negative?
580+
paren_stack = close_bracket(paren_stack, subtype_string, char, type_string)
589581
next
590-
elsif char == '&' && top_level?(point_stack, curly_stack, paren_stack)
591-
conjuncts.push ComplexType.new([UniqueType.parse(base.strip, subtype_string.strip)])
582+
elsif char == '[' &&
583+
(bracket_stack.positive? ||
584+
(base.strip.empty? && point_stack.zero? && curly_stack.zero? && paren_stack.zero?))
585+
# Only a fresh atom (blank base, not already nested in
586+
# <>/{}/()) can start a `[...]` group - matching
587+
# finish_atom's own precondition. Otherwise `[` is just an
588+
# ordinary character, e.g. part of a quoted string literal
589+
# type like `"[]"`, which has no concept of grouping.
590+
bracket_stack += 1
591+
elsif char == ']' && bracket_stack.positive?
592+
bracket_stack = close_bracket(bracket_stack, subtype_string, char, type_string)
593+
next
594+
elsif char == '&' && top_level?(point_stack, curly_stack, paren_stack, bracket_stack)
595+
conjuncts.push ComplexType.new([finish_atom(base, subtype_string)])
596+
base.clear
597+
subtype_string.clear
598+
next
599+
elsif char == '|' && top_level?(point_stack, curly_stack, paren_stack, bracket_stack)
600+
disjuncts.push close_intersection(conjuncts, finish_atom(base, subtype_string))
601+
conjuncts = []
592602
base.clear
593603
subtype_string.clear
594604
next
595-
elsif char == ',' && top_level?(point_stack, curly_stack, paren_stack)
596-
# types.push ComplexType.new([UniqueType.new(base.strip, subtype_string.strip)])
597-
types.push close_intersection(conjuncts, UniqueType.parse(base.strip, subtype_string.strip))
605+
elsif char == ',' && top_level?(point_stack, curly_stack, paren_stack, bracket_stack)
606+
disjuncts.push close_intersection(conjuncts, finish_atom(base, subtype_string))
607+
types.push close_disjunction(disjuncts)
598608
conjuncts = []
609+
disjuncts = []
599610
base.clear
600611
subtype_string.clear
601612
next
602613
end
603-
if top_level?(point_stack, curly_stack, paren_stack)
614+
if top_level?(point_stack, curly_stack, paren_stack, bracket_stack)
604615
base.concat char
605616
else
606617
subtype_string.concat char
607618
end
608619
end
609-
if point_stack != 0 || curly_stack != 0 || paren_stack != 0
620+
if point_stack != 0 || curly_stack != 0 || paren_stack != 0 || bracket_stack != 0
610621
raise ComplexTypeError,
611622
"Unclosed subtype in #{type_string}"
612623
end
613-
# types.push ComplexType.new([UniqueType.new(base, subtype_string)])
614-
types.push close_intersection(conjuncts, UniqueType.parse(base.strip, subtype_string.strip))
624+
disjuncts.push close_intersection(conjuncts, finish_atom(base, subtype_string))
625+
types.push close_disjunction(disjuncts)
615626
[types, key_types]
616627
end
617628

629+
# Decrements the stack counter for a closing `}`/`)`/`]` and
630+
# appends it to the pending subtype substring.
631+
#
632+
# @param stack [Integer]
633+
# @param subtype_string [String]
634+
# @param char [String]
635+
# @param type_string [String, nil]
636+
# @return [Integer] the decremented stack counter
637+
def close_bracket stack, subtype_string, char, type_string
638+
stack -= 1
639+
subtype_string << char
640+
raise ComplexTypeError, "Invalid close in type #{type_string}" if stack.negative?
641+
stack
642+
end
643+
644+
# Closes the key-list portion of a `Hash{K=>V}` split (the base
645+
# ending in `=` marks the boundary) and returns the types parsed
646+
# so far to be stashed as the eventual key_types, leaving
647+
# conjuncts/disjuncts/base/subtype_string cleared for the value
648+
# list that follows.
649+
#
650+
# @todo this should either expand key_type's type automatically
651+
# or complain about not being compatible with key_type's type
652+
# in type checking
653+
#
654+
# @param base [String]
655+
# @param subtype_string [String]
656+
# @param conjuncts [Array<ComplexType>]
657+
# @param disjuncts [Array<ComplexType, ComplexType::UniqueType>]
658+
# @param types [Array<ComplexType::UniqueType, ComplexType>]
659+
# @return [Array<ComplexType::UniqueType, ComplexType>] the key_types
660+
def close_key_types base, subtype_string, conjuncts, disjuncts, types
661+
# @sg-ignore Need to add nil check here
662+
disjuncts.push close_intersection(conjuncts, finish_atom(base[0..-2], subtype_string))
663+
types.push close_disjunction(disjuncts)
664+
conjuncts.clear
665+
disjuncts.clear
666+
base.clear
667+
subtype_string.clear
668+
types
669+
end
670+
618671
# @param point_stack [Integer]
619672
# @param curly_stack [Integer]
620673
# @param paren_stack [Integer]
674+
# @param bracket_stack [Integer]
621675
# @return [Boolean]
622-
def top_level? point_stack, curly_stack, paren_stack
623-
point_stack.zero? && curly_stack.zero? && paren_stack.zero?
676+
def top_level? point_stack, curly_stack, paren_stack, bracket_stack
677+
point_stack.zero? && curly_stack.zero? && paren_stack.zero? && bracket_stack.zero?
624678
end
625679

626-
# Wraps a just-parsed unique type together with any pending
627-
# intersection conjuncts (types seen so far in this segment,
680+
# Resolves one type atom - either an ordinary named type (`base`
681+
# plus its optional `<...>`/`(...)`/`{...}` parameter substring),
682+
# or a standalone `[...]` grouping with no leading name, used to
683+
# override the default order of operations (e.g. `[Foo | Bar] &
684+
# Baz`, where `[...]` is the only way to mark where the union
685+
# ends). A bracket group's content is parsed the same way any
686+
# other parameter substring is - recursively, via
687+
# ComplexType.parse - and its result substituted directly, since
688+
# it can itself be a multi-item union (or an intersection).
689+
#
690+
# @param base [String]
691+
# @param subtype_string [String]
692+
# @return [ComplexType::UniqueType, ComplexType]
693+
def finish_atom base, subtype_string
694+
base = base.strip
695+
subtype_string = subtype_string.strip
696+
if base.empty? && subtype_string.start_with?('[')
697+
raise ComplexTypeError, "Unclosed bracket group in #{subtype_string}" unless subtype_string.end_with?(']')
698+
return ComplexType.new(ComplexType.parse(subtype_string[1..-2], partial: true))
699+
end
700+
UniqueType.parse(base, subtype_string)
701+
end
702+
703+
# Wraps a just-parsed atom together with any pending
704+
# intersection conjuncts (types seen so far in this disjunct,
628705
# separated by `&`) into a single UniqueType. Each conjunct is
629706
# a ComplexType (see UniqueType::Intersection), so the final
630707
# parsed type is promoted to a single-item ComplexType too.
631708
#
632709
# @param conjuncts [Array<ComplexType>]
633-
# @param final_type [ComplexType::UniqueType]
634-
# @return [ComplexType::UniqueType]
710+
# @param final_type [ComplexType::UniqueType, ComplexType]
711+
# @return [ComplexType::UniqueType, ComplexType]
635712
def close_intersection conjuncts, final_type
636713
return final_type if conjuncts.empty?
637714
UniqueType::Intersection.new(conjuncts + [ComplexType.new([final_type])])
638715
end
716+
717+
# Collapses the disjuncts of a union type (`A | B`) seen so far
718+
# in the segment currently being parsed into a single value to
719+
# push into the enclosing types/subtypes list - a bare type when
720+
# there was only one (the common case, `|` never used), or a
721+
# real multi-item ComplexType union otherwise. This is also
722+
# exactly what a top-level `,` in an already-implicit-union
723+
# context (Array<...>, Set<...>, hash key/value lists, the
724+
# top-level types list itself) reduces to, since each of those
725+
# contexts flattens every comma-separated type into one union
726+
# regardless of how it's grouped here - so `,` and `|` land on
727+
# the same result there, matching RBS's own tag design.
728+
#
729+
# @param disjuncts [Array<ComplexType, ComplexType::UniqueType>]
730+
# @return [ComplexType::UniqueType, ComplexType]
731+
# @sg-ignore #first is only nil for an empty array, and this is
732+
# never called with one
733+
def close_disjunction disjuncts
734+
return disjuncts.first if disjuncts.length == 1
735+
ComplexType.new(disjuncts)
736+
end
639737
end
640738

641739
VOID = ComplexType.parse('void')

lib/solargraph/complex_type/unique_type.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ class UniqueType
1313

1414
attr_reader :all_params, :subtypes, :key_types
1515

16+
# @type [Hash{String => String}]
17+
ANONYMOUS_NAME_BY_STARTING_TAG = {
18+
'{' => 'Hash',
19+
'(' => 'Array',
20+
'<' => 'Array'
21+
}.freeze
22+
1623
# Create a UniqueType with the specified name and an optional substring.
1724
# The substring is the parameter section of a parametrized type, e.g.,
1825
# for the type `Array<String>`, the name is `Array` and the substring is
@@ -24,6 +31,11 @@ class UniqueType
2431
# @return [UniqueType]
2532
def self.parse name, substring = '', make_rooted: nil
2633
raise ComplexTypeError, "Illegal prefix: #{name}" if name.start_with?(':::')
34+
# Anonymous shorthand - `<A>`, `(A)`, `{A=>B}` - omits the
35+
# leading type name, defaulting it to Array or Hash. Resolved
36+
# before the rooted/can_root_name? check below so an anonymous
37+
# `<A>` behaves exactly like the equivalent `Array<A>`.
38+
name = ANONYMOUS_NAME_BY_STARTING_TAG.fetch(substring[0]) if name.empty? && !substring.empty?
2739
if name.start_with?('::')
2840
name = name[2..]
2941
rooted = true

0 commit comments

Comments
 (0)