Skip to content

refactor: Unify model layer and type resolution #43

@DrBarnabus

Description

@DrBarnabus

Context

This is the largest single refactoring step in the model layer and must be delivered as one atomic change — model changes, parser changes, and emitter updates are all interdependent.

Current model structure

The key part model currently has two separate sealed record types that share near-identical fields:

// PropertyKeyPart.cs
public sealed record PropertyKeyPart(
    PropertySpec Property,
    string? Format,
    ParseType ParseType,
    FormatType FormatType
) : ValueKeyPart;

// RepeatingPropertyKeyPart.cs
public sealed record RepeatingPropertyKeyPart(
    PropertySpec Property,
    char Separator,
    string? Format,
    ParseType InnerParseType,
    FormatType InnerFormatType,
    TypeRef InnerType
) : ValueKeyPart;

Both inherit from ValueKeyPart : KeyPart, which carries LengthRequired (int) and ExactLengthRequirement (bool). PropertyKeyPart uses computed values from PropertyValidation.GetFormattedLength(), while RepeatingPropertyKeyPart always hardcodes LengthRequired = 1 and ExactLengthRequirement = false — this is set explicitly in the Parser (lines 419-420). In the unified model, these values will be determined by whether CollectionSemantics is present.

ParseType and FormatType

ParseType and FormatType are separate enums whose variants currently mirror each other:

ParseType FormatType
Guid Guid
String String
Enum Enum
SpanParsable SpanFormattable

Today, the Parser always assigns these in lockstep (e.g., if ParseType = Guid then FormatType = Guid). However, this is an artifact of the current supported type set — not a fundamental constraint. A user type could implement ISpanParsable<T> without implementing ISpanFormattable (currently rejected as an error), and future work to support IParsable<T> or IFormattable independently would break the 1:1 pairing (e.g., ParseType.SpanParsable with a regular FormatType.Formattable).

For this reason, the new PropertyTypeDescriptor must keep ParseType and FormatType as separate fields — they describe independent capabilities that happen to be paired today but may diverge.

Parser duplication

The Parser has two local functions — ToPropertyKeyPart (lines 232-326, ~95 lines) and ToRepeatingPropertyKeyPart (lines 328-422, ~95 lines) — that are approximately ~60% duplicated (~55-60 shared lines per method). The shared logic includes: property validation, interface detection, PropertyTypeInfo construction, format validation, type compatibility validation, and the ParseType/FormatType determination switch. The unique parts that differ are:

  • Collection type validation: ToPropertyKeyPart rejects collection types (CollectionType != None); ToRepeatingPropertyKeyPart requires them (CollectionType == None is an error)
  • Type symbol extraction: ToPropertyKeyPart uses the property's typeSymbol directly; ToRepeatingPropertyKeyPart casts to INamedTypeSymbol and extracts TypeArguments[0] as the inner type
  • Length calculation: ToPropertyKeyPart calls PropertyValidation.GetFormattedLength() and uses the result; ToRepeatingPropertyKeyPart hardcodes LengthRequired = 1 / ExactLengthRequirement = false
  • Return type construction: Different record types with different field names (ParseType vs InnerParseType, etc.)

ISpanParsable/ISpanFormattable detection is duplicated 4 times

The interface detection logic for ISpanParsable and ISpanFormattable appears in four places:

  1. Parser ToPropertyKeyPart (lines 257-259) — uses SymbolDisplayFormat.FullyQualifiedFormat, producing "global::System.ISpanParsable<...>", matched via StartsWith
  2. Parser ToRepeatingPropertyKeyPart (lines 355-357) — same format as above
  3. PropertyAnalyzer CreateInnerTypeInfo (lines 291-293) — uses default ToDisplayString(), producing "System.ISpanParsable<...>", matched via StartsWith
  4. PropertyAnalyzer CreatePropertyTypeInfo (lines 318-320) — same format as the analyzer method above

The Parser and Analyzer use different display formats for the same logical check. The Parser uses SymbolDisplayFormat.FullyQualifiedFormat which produces the global:: prefix ("global::System.ISpanParsable"), while the Analyzer uses the default display format producing "System.ISpanParsable". Both work because they use StartsWith (to handle the generic arity), but they are inconsistent. A shared helper method created during this unification should normalize the approach.

Equality implications

ImmutableEquatableArray<KeyPart> uses SequenceEqual for comparison, which requires each KeyPart element to implement IEquatable<T>. C# record types satisfy this automatically via compiler-generated structural equality. Since CollectionSemantics will also be a record, its nullable presence on the unified PropertyKeyPart will participate correctly in structural equality. Similarly, PropertyTypeDescriptor as a record will get correct equality for free. The existing SourceGeneratorIncrementalTests verify that incremental caching still works after model changes.

Blocked by

Tasks

Unify PropertyKeyPart and RepeatingPropertyKeyPart

  1. Create a new CollectionSemantics record: record CollectionSemantics(char Separator, TypeRef InnerType)
  2. Replace PropertyKeyPart and RepeatingPropertyKeyPart with a single unified PropertyKeyPart carrying a CollectionSemantics? field (null = single property, non-null = repeating)
  3. For repeating properties, LengthRequired is always 1 and ExactLengthRequirement is always false — set by parser based on CollectionSemantics presence
  4. Verify record equality works correctly with nullable CollectionSemantics (structural comparison) — the ImmutableEquatableArray<KeyPart> collection depends on this
  5. Update all pattern matches/switch expressions throughout the Emitter that currently distinguish PropertyKeyPart vs RepeatingPropertyKeyPart (there are 20+ match sites in SourceGenerator.Emitter.cs)

Merge ParseType and FormatType into PropertyTypeDescriptor

  1. Create record PropertyTypeDescriptor(ParseType ParseType, FormatType FormatType) — these are kept as separate fields to support future independent parse/format capability (see Context)
  2. Replace the separate ParseType/FormatType fields on old PropertyKeyPart and the InnerParseType/InnerFormatType on old RepeatingPropertyKeyPart with a single TypeDescriptor property
  3. Update all Emitter switch sites that match on ParseType or FormatType to use TypeDescriptor.ParseType / TypeDescriptor.FormatType

Unify Parser type resolution

  1. Merge ToPropertyKeyPart() (lines 232-326) and ToRepeatingPropertyKeyPart() (lines 328-422) into a single method — these are ~60% duplicated (~55-60 shared lines per 95-line method). The differences are:
    • Collection type validation (single checks no collection; repeating checks collection present)
    • Type symbol extraction (single uses typeSymbol directly; repeating extracts inner type from generic TypeArguments[0])
    • Length calculation (single calls GetFormattedLength(); repeating hardcodes LengthRequired = 1 / ExactLengthRequirement = false)
    • PropertyTypeInfo construction (single uses property type name; repeating uses inner type name)
  2. Consolidate the duplicated PropertyTypeInfo construction and ISpanParsable/ISpanFormattable interface detection (currently implemented 4 times: Parser lines 257-259, Parser lines 355-357, PropertyAnalyzer lines 291-293, PropertyAnalyzer lines 318-320). Extract a shared helper method that normalizes the display format difference between Parser (FullyQualifiedFormat producing "global::System.ISpanParsable") and Analyzer (default display producing "System.ISpanParsable")

Verification

  • All snapshot tests pass (generated output identical)
  • All SourceGeneratorIncrementalTests pass (incremental caching via structural equality still works)
  • All functional tests pass (parse/format round-trips)
  • Run benchmarks to check for regressions

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions