diff --git a/lib/typelizer.rb b/lib/typelizer.rb index 8758be9..095dd26 100644 --- a/lib/typelizer.rb +++ b/lib/typelizer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative "typelizer/version" +require_relative "typelizer/union_type_sorter" require_relative "typelizer/property" require_relative "typelizer/model_plugins/auto" require_relative "typelizer/serializer_plugins/auto" diff --git a/lib/typelizer/interface.rb b/lib/typelizer/interface.rb index 6c44a79..6cee1ed 100644 --- a/lib/typelizer/interface.rb +++ b/lib/typelizer/interface.rb @@ -25,7 +25,7 @@ def inline? def name if inline? - Renderer.call("inline_type.ts.erb", properties: properties).strip + Renderer.call("inline_type.ts.erb", properties: properties, sort_order: config.properties_sort_order).strip else config.serializer_name_mapper.call(serializer).tr_s(":", "") end diff --git a/lib/typelizer/property.rb b/lib/typelizer/property.rb index c0e6aac..6bd3eb3 100644 --- a/lib/typelizer/property.rb +++ b/lib/typelizer/property.rb @@ -16,8 +16,16 @@ def eql?(other) fingerprint == other.fingerprint end + # Default to_s for backward compatibility (no sorting) def to_s - type_str = type_name + render(sort_order: :none) + end + + # Renders the property as a TypeScript property string + # @param sort_order [Symbol, Proc, nil] Sort order for union types (:none, :alphabetical, or Proc) + # @return [String] The property string like "name?: Type1 | Type2" + def render(sort_order: :none) + type_str = type_name(sort_order: sort_order) # Handle intersection types for traits if with_traits&.any? && type.respond_to?(:name) @@ -26,6 +34,11 @@ def to_s end type_str = "Array<#{type_str}>" if multi + + # Apply union sorting to the final type string (handles Array<...> unions too) + type_str = UnionTypeSorter.sort(type_str, sort_order) + + # Add nullable at the end (null should always be last in sorted output) type_str = "#{type_str} | null" if nullable "#{name}#{"?" if optional}: #{type_str}" @@ -33,7 +46,8 @@ def to_s def fingerprint props = to_h - props[:type] = type_name + # Always use alphabetical sorting in fingerprint for deterministic change detection + props[:type] = UnionTypeSorter.sort(type_name(sort_order: :alphabetical), :alphabetical) props.each_with_object(+"<#{self.class.name}") do |(k, v), fp| fp << " #{k}=#{v.inspect}" unless v.nil? end << ">" @@ -47,9 +61,20 @@ def enum_definition private - def type_name + # Returns the type name, optionally sorting union members + # @param sort_order [Symbol, Proc, nil] Sort order for union types + # @return [String] The type name + def type_name(sort_order: :none) + # If enum_type_name is set, use it (named enum type) return enum_type_name if enum_type_name + if enum + # Sort enum values if alphabetical sorting is requested + enum_values = enum.map { |v| v.to_s.inspect } + enum_values = enum_values.sort_by(&:downcase) if sort_order == :alphabetical + return enum_values.join(" | ") + end + type.respond_to?(:name) ? type.name : type || "unknown" end end diff --git a/lib/typelizer/templates/inheritance.ts.erb b/lib/typelizer/templates/inheritance.ts.erb index 78a617b..fed7e4e 100644 --- a/lib/typelizer/templates/inheritance.ts.erb +++ b/lib/typelizer/templates/inheritance.ts.erb @@ -1 +1,5 @@ -<%= interface.overwritten_properties.any? ? "Omit<" : "" %><%= interface.parent_interface.name %><%= "[" + interface.quote(interface.parent_interface.root_key) + "]" if interface.parent_interface.root_key %><%= interface.overwritten_properties.any? ? ", " + interface.overwritten_properties.map { |pr| interface.quote(pr.name) }.join(' | ') + ">" : ""%> +<% + omit_props = interface.overwritten_properties.map { |pr| interface.quote(pr.name) } + omit_props = omit_props.sort_by(&:downcase) if interface.config.properties_sort_order == :alphabetical +-%> +<%= interface.overwritten_properties.any? ? "Omit<" : "" %><%= interface.parent_interface.name %><%= "[" + interface.quote(interface.parent_interface.root_key) + "]" if interface.parent_interface.root_key %><%= interface.overwritten_properties.any? ? ", " + omit_props.join(' | ') + ">" : ""%> diff --git a/lib/typelizer/templates/inline_type.ts.erb b/lib/typelizer/templates/inline_type.ts.erb index a6fbc8e..1e8a8dc 100644 --- a/lib/typelizer/templates/inline_type.ts.erb +++ b/lib/typelizer/templates/inline_type.ts.erb @@ -1,5 +1,5 @@ { <%- properties.each do |property| -%> -<%= indent(property) %>; +<%= indent(property.render(sort_order: sort_order || :none)) %>; <%- end -%> } diff --git a/lib/typelizer/templates/interface.ts.erb b/lib/typelizer/templates/interface.ts.erb index ad13792..564cf75 100644 --- a/lib/typelizer/templates/interface.ts.erb +++ b/lib/typelizer/templates/interface.ts.erb @@ -9,14 +9,14 @@ render("inheritance.ts.erb", interface: interface).strip if interface.parent_int <%= " & " if interface.parent_interface %>{ <% interface.properties_to_print.each do |property| -%> <%= render("comment.ts.erb", interface: interface, property: property) -%> -<%= indent(property) %>; +<%= indent(property.render(sort_order: interface.config.properties_sort_order)) %>; <% end -%> } <% end %><% if interface.root_key %> type <%= interface.name %> = { <%= indent(interface.root_key) %>: <%= interface.name %>Data; <% interface.meta_fields&.each do |property| -%> -<%= indent(property) %>; +<%= indent(property.render(sort_order: interface.config.properties_sort_order)) %>; <% end -%> } <% end -%> @@ -24,7 +24,7 @@ type <%= interface.name %> = { type <%= trait.name %> = { <% trait.properties.each do |property| -%> -<%= indent(property) %>; +<%= indent(property.render(sort_order: interface.config.properties_sort_order)) %>; <% end -%> } <% end -%> diff --git a/lib/typelizer/union_type_sorter.rb b/lib/typelizer/union_type_sorter.rb new file mode 100644 index 0000000..318e255 --- /dev/null +++ b/lib/typelizer/union_type_sorter.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module Typelizer + # Sorts union type members within TypeScript type strings. + # Handles types like "Type3 | Type1 | Type2" -> "Type1 | Type2 | Type3" + # Also handles complex nested types like "Array" -> "Array" + module UnionTypeSorter + # Sorts union type members in a type string + # @param type_str [String] The type string potentially containing unions + # @param sort_order [Symbol, Proc, nil] The sort order (:none, :alphabetical, or Proc) + # @return [String] The type string with sorted union members + def self.sort(type_str, sort_order) + return type_str if type_str.nil? || type_str.empty? + + case sort_order + when :none, nil + type_str + when :alphabetical + sort_unions_alphabetically(type_str) + when Proc + result = sort_order.call(type_str) + result.is_a?(String) ? result : type_str + else + type_str + end + rescue => e + Typelizer.logger.warn("UnionTypeSorter error: #{e.message}, preserving original order") + type_str + end + + # Sorts union members alphabetically while preserving structure + # @param type_str [String] The type string to sort + # @return [String] The sorted type string + def self.sort_unions_alphabetically(type_str) + # Handle the string by sorting unions at each level + # We need to be careful with nested generics like Array + + result = type_str.dup + + # First, handle unions inside angle brackets (generics) + # Match content inside < > and sort unions within + result = result.gsub(/<([^<>]+)>/) do |match| + inner = Regexp.last_match(1) + sorted_inner = sort_simple_union(inner) + "<#{sorted_inner}>" + end + + # Then handle any remaining top-level unions + # But avoid sorting if the string has unbalanced brackets + if balanced_brackets?(result) + result = sort_top_level_union(result) + end + + result + end + + # Sorts a simple union string (no nested generics) + # @param union_str [String] String like "Type3 | Type1 | Type2" + # @return [String] Sorted string like "Type1 | Type2 | Type3" + def self.sort_simple_union(union_str) + return union_str unless union_str.include?("|") + + parts = split_union_members(union_str) + return union_str if parts.size <= 1 + + # Sort while preserving special cases: + # - 'null' should typically stay at the end + # - Keep the relative order of complex nested types + regular_parts, null_parts = parts.partition { |p| p.strip.downcase != "null" } + + sorted_regular = regular_parts.sort_by { |p| p.strip.downcase } + (sorted_regular + null_parts).join(" | ") + end + + # Sorts top-level union (handles cases where unions aren't inside generics) + # @param type_str [String] The type string + # @return [String] The sorted type string + def self.sort_top_level_union(type_str) + return type_str unless type_str.include?("|") + + parts = split_union_members(type_str) + return type_str if parts.size <= 1 + + # Separate null from other types + regular_parts, null_parts = parts.partition { |p| p.strip.downcase != "null" } + + sorted_regular = regular_parts.sort_by { |p| p.strip.downcase } + (sorted_regular + null_parts).join(" | ") + end + + # Splits union members while respecting nested brackets + # @param str [String] The string to split + # @return [Array] Array of union members + def self.split_union_members(str) + members = [] + current = +"" + depth = 0 + + str.each_char do |char| + case char + when "<", "(" + depth += 1 + current << char + when ">", ")" + depth -= 1 + current << char + when "|" + if depth == 0 + members << current.strip unless current.strip.empty? + current = +"" + else + current << char + end + else + current << char + end + end + + members << current.strip unless current.strip.empty? + members + end + + # Checks if brackets are balanced in the string + # @param str [String] The string to check + # @return [Boolean] True if brackets are balanced + def self.balanced_brackets?(str) + angle_depth = 0 + paren_depth = 0 + + str.each_char do |char| + case char + when "<" + angle_depth += 1 + when ">" + angle_depth -= 1 + return false if angle_depth < 0 + when "(" + paren_depth += 1 + when ")" + paren_depth -= 1 + return false if paren_depth < 0 + end + end + + angle_depth == 0 && paren_depth == 0 + end + end +end diff --git a/spec/__snapshots__/AlbaInline.ts.snap b/spec/__snapshots__/AlbaInline.ts.snap index 2ce07ca..2884ebf 100644 --- a/spec/__snapshots__/AlbaInline.ts.snap +++ b/spec/__snapshots__/AlbaInline.ts.snap @@ -1,4 +1,4 @@ -// Typelizer digest 1f9c0aa4990a456612d866b504d0a8ff +// Typelizer digest 1f176602b8eabd48a6361f57494ad27b // // DO NOT MODIFY: This file was automatically generated by Typelizer. diff --git a/spec/__snapshots__/AlbaUnionSorted.ts.snap b/spec/__snapshots__/AlbaUnionSorted.ts.snap new file mode 100644 index 0000000..e5971ff --- /dev/null +++ b/spec/__snapshots__/AlbaUnionSorted.ts.snap @@ -0,0 +1,16 @@ +// Typelizer digest ac62105fdebc3de074783f8e9c48f433 +// +// DO NOT MODIFY: This file was automatically generated by Typelizer. +import type {TypeZ, TypeA, TypeM, ZebraSection, AlphaSection, BetaSection} from '@/types' + +type AlbaUnionSorted = { + email: string; + /** Unique identifier */ + id: string; + items: Array; + sections: AlphaSection | BetaSection | ZebraSection; + status: "apple" | "banana" | "zebra"; + username: string; +} + +export default AlbaUnionSorted; diff --git a/spec/__snapshots__/index.ts.snap b/spec/__snapshots__/index.ts.snap index dc7cd8c..83ef0dd 100644 --- a/spec/__snapshots__/index.ts.snap +++ b/spec/__snapshots__/index.ts.snap @@ -1,4 +1,4 @@ -// Typelizer digest d9b18b2c19a0cbf4a2b434d272c4ee31 +// Typelizer digest 3e213a66271da1e65c0e745f00324727 // // DO NOT MODIFY: This file was automatically generated by Typelizer. export type { PostCategory, UserRole } from './Enums' @@ -26,6 +26,7 @@ export type { default as AlbaTraitsAssociations, AlbaTraitsAssociationsAssociati export type { default as AlbaTraits, AlbaTraitsBasicTrait, AlbaTraitsTimeRelatedTrait, AlbaTraitsComplexTrait, AlbaTraitsCustomAttributesTrait, AlbaTraitsWithOptionsTrait, AlbaTraitsMixedTrait, AlbaTraitsEmptyTrait } from './AlbaTraits' export type { default as AlbaTransformKeys } from './AlbaTransformKeys' export type { default as AlbaTypeShortcuts, AlbaTypeShortcutsWithMetadataTrait } from './AlbaTypeShortcuts' +export type { default as AlbaUnionSorted } from './AlbaUnionSorted' export type { default as AlbaUserAuthor } from './AlbaUserAuthor' export type { default as AlbaUserEmptyNested } from './AlbaUserEmptyNested' export type { default as AlbaUser } from './AlbaUser' diff --git a/spec/app/app/serializers/alba/union_sorted_serializer.rb b/spec/app/app/serializers/alba/union_sorted_serializer.rb new file mode 100644 index 0000000..cc07652 --- /dev/null +++ b/spec/app/app/serializers/alba/union_sorted_serializer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Alba + class UnionSortedSerializer < BaseSerializer + typelizer_config do |c| + c.properties_sort_order = :alphabetical + end + + typelize_from ::User + + # Union type with multiple types - should be sorted alphabetically + typelize sections: ["ZebraSection", "AlphaSection", "BetaSection"] + attribute :sections do |user| + [] + end + + # Union type in an array - should be sorted inside Array<> + typelize items: ["TypeZ", "TypeA", "TypeM", multi: true] + attribute :items do |user| + [] + end + + # Enum values - should be sorted alphabetically + typelize status: [:string, enum: %w[zebra apple banana]] + attribute :status do |user| + "active" + end + + # Regular properties to test property sorting still works + attributes :id, :username + + typelize email: :string + attribute :email do |user| + "test@example.com" + end + end +end diff --git a/spec/typelizer/property_spec.rb b/spec/typelizer/property_spec.rb new file mode 100644 index 0000000..0ee7e87 --- /dev/null +++ b/spec/typelizer/property_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +RSpec.describe Typelizer::Property do + describe "#to_s" do + describe "union type sorting" do + it "does not sort unions when sort_order is :none" do + prop = described_class.new(name: "field", type: "TypeZ | TypeA | TypeB") + expect(prop.render(sort_order: :none)).to eq("field: TypeZ | TypeA | TypeB") + end + + it "sorts unions alphabetically when sort_order is :alphabetical" do + prop = described_class.new(name: "field", type: "TypeZ | TypeA | TypeB") + expect(prop.render(sort_order: :alphabetical)).to eq("field: TypeA | TypeB | TypeZ") + end + + it "sorts unions in Array<> types" do + prop = described_class.new(name: "items", type: "TypeZ | TypeA | TypeB", multi: true) + result = prop.render(sort_order: :alphabetical) + expect(result).to eq("items: Array") + end + + it "keeps null at the end when nullable" do + prop = described_class.new(name: "field", type: "TypeZ | TypeA", nullable: true) + result = prop.render(sort_order: :alphabetical) + expect(result).to eq("field: TypeA | TypeZ | null") + end + + it "handles enum values with sorting" do + prop = described_class.new(name: "status", enum: %w[zebra apple banana]) + result = prop.render(sort_order: :alphabetical) + expect(result).to eq('status: "apple" | "banana" | "zebra"') + end + + it "does not sort enum values when sort_order is :none" do + prop = described_class.new(name: "status", enum: %w[zebra apple banana]) + result = prop.render(sort_order: :none) + expect(result).to eq('status: "zebra" | "apple" | "banana"') + end + + it "defaults to no sorting when sort_order not specified" do + prop = described_class.new(name: "field", type: "TypeZ | TypeA | TypeB") + expect(prop.to_s).to eq("field: TypeZ | TypeA | TypeB") + end + end + + describe "optional properties" do + it "adds ? for optional properties" do + prop = described_class.new(name: "field", type: "string", optional: true) + expect(prop.to_s).to eq("field?: string") + end + end + + describe "nullable properties" do + it "adds | null for nullable properties" do + prop = described_class.new(name: "field", type: "string", nullable: true) + expect(prop.to_s).to eq("field: string | null") + end + end + + describe "multi (array) properties" do + it "wraps type in Array<> for multi properties" do + prop = described_class.new(name: "items", type: "string", multi: true) + expect(prop.to_s).to eq("items: Array") + end + end + + describe "combined modifiers" do + it "handles optional, nullable, and multi together" do + prop = described_class.new(name: "items", type: "string", optional: true, nullable: true, multi: true) + expect(prop.to_s).to eq("items?: Array | null") + end + + it "handles union type with optional, nullable, and multi" do + prop = described_class.new(name: "items", type: "TypeZ | TypeA", optional: true, nullable: true, multi: true) + result = prop.render(sort_order: :alphabetical) + expect(result).to eq("items?: Array | null") + end + end + end + + describe "determinism" do + it "produces identical output on multiple runs with sorting" do + prop = described_class.new( + name: "sections", + type: "WebStrapiSectionsPartnerHero | WebStrapiSectionsAboutUs | WebStrapiSectionsChallenges", + multi: true + ) + + results = 10.times.map { prop.render(sort_order: :alphabetical) } + expect(results.uniq.size).to eq(1) + expect(results.first).to eq("sections: Array") + end + end +end diff --git a/spec/typelizer/union_type_sorter_spec.rb b/spec/typelizer/union_type_sorter_spec.rb new file mode 100644 index 0000000..40eb33d --- /dev/null +++ b/spec/typelizer/union_type_sorter_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +RSpec.describe Typelizer::UnionTypeSorter do + describe ".sort" do + describe "with :none" do + it "preserves original order" do + result = described_class.sort("Zebra | Apple | Banana", :none) + expect(result).to eq("Zebra | Apple | Banana") + end + end + + describe "with nil" do + it "preserves original order" do + result = described_class.sort("Zebra | Apple | Banana", nil) + expect(result).to eq("Zebra | Apple | Banana") + end + end + + describe "with :alphabetical" do + it "sorts simple union types alphabetically" do + result = described_class.sort("Zebra | Apple | Banana", :alphabetical) + expect(result).to eq("Apple | Banana | Zebra") + end + + it "sorts case-insensitively" do + result = described_class.sort("zebra | Apple | BANANA", :alphabetical) + expect(result).to eq("Apple | BANANA | zebra") + end + + it "keeps null at the end" do + result = described_class.sort("Zebra | null | Apple", :alphabetical) + expect(result).to eq("Apple | Zebra | null") + end + + it "handles unions inside Array<>" do + result = described_class.sort("Array", :alphabetical) + expect(result).to eq("Array") + end + + it "handles complex nested generics" do + result = described_class.sort("Array", :alphabetical) + expect(result).to eq("Array") + end + + it "handles empty string" do + result = described_class.sort("", :alphabetical) + expect(result).to eq("") + end + + it "handles nil" do + result = described_class.sort(nil, :alphabetical) + expect(result).to be_nil + end + + it "handles single type (no union)" do + result = described_class.sort("SingleType", :alphabetical) + expect(result).to eq("SingleType") + end + + it "handles types with numbers" do + result = described_class.sort("Type3 | Type1 | Type2", :alphabetical) + expect(result).to eq("Type1 | Type2 | Type3") + end + + it "preserves whitespace style" do + result = described_class.sort("Zebra | Apple | Banana", :alphabetical) + expect(result).to eq("Apple | Banana | Zebra") + end + end + + describe "with Proc" do + it "applies custom sorting logic" do + reverse_sort = ->(type_str) { type_str.split(" | ").reverse.join(" | ") } + result = described_class.sort("A | B | C", reverse_sort) + expect(result).to eq("C | B | A") + end + + it "falls back to original when proc returns nil" do + result = described_class.sort("A | B", ->(_) {}) + expect(result).to eq("A | B") + end + + it "falls back to original when proc returns non-string" do + result = described_class.sort("A | B", ->(_) { 123 }) + expect(result).to eq("A | B") + end + + it "falls back to original when proc raises error" do + expect(Typelizer.logger).to receive(:warn).with(/UnionTypeSorter error/) + result = described_class.sort("A | B", ->(_) { raise "boom" }) + expect(result).to eq("A | B") + end + end + + describe "with unknown sort_order" do + it "preserves original order" do + result = described_class.sort("Zebra | Apple", :unknown_strategy) + expect(result).to eq("Zebra | Apple") + end + end + end + + describe ".split_union_members" do + it "splits simple unions" do + result = described_class.split_union_members("A | B | C") + expect(result).to eq(%w[A B C]) + end + + it "respects nested angle brackets" do + result = described_class.split_union_members("Array | C") + expect(result).to eq(["Array", "C"]) + end + + it "respects nested parentheses" do + result = described_class.split_union_members("(A | B) | C") + expect(result).to eq(["(A | B)", "C"]) + end + + it "handles complex nesting" do + result = described_class.split_union_members("Map> | C | D") + expect(result).to eq(["Map>", "C", "D"]) + end + end + + describe ".balanced_brackets?" do + it "returns true for balanced brackets" do + expect(described_class.balanced_brackets?("Array")).to be true + expect(described_class.balanced_brackets?("Map")).to be true + expect(described_class.balanced_brackets?("(A | B)")).to be true + end + + it "returns false for unbalanced brackets" do + expect(described_class.balanced_brackets?("Array")).to be false + expect(described_class.balanced_brackets?("(A | B")).to be false + end + end + + describe "determinism" do + it "produces identical output on multiple runs" do + input = "WebStrapiSectionsPartnerHero | WebStrapiSectionsAboutUs | WebStrapiSectionsChallenges" + results = 10.times.map { described_class.sort(input, :alphabetical) } + expect(results.uniq.size).to eq(1) + expect(results.first).to eq("WebStrapiSectionsAboutUs | WebStrapiSectionsChallenges | WebStrapiSectionsPartnerHero") + end + + it "produces identical output for Array unions on multiple runs" do + input = "Array" + results = 10.times.map { described_class.sort(input, :alphabetical) } + expect(results.uniq.size).to eq(1) + expect(results.first).to eq("Array") + end + end +end