Skip to content

SymbolKey: Cross-compilation symbol identity for all symbol types #280

Description

@amis92

Goal

Introduce a unified SymbolKey mechanism — a stable, serializable identifier for any ISymbol that can be resolved in a different compilation built from compatible data. Analogous to Roslyn's internal SymbolKey.

Parent epic: #279

Problem

When a roster edit creates a new WhamCompilation, all old ISymbol instances become stale. Editor code that references symbols from a previous compilation must manually re-find them, duplicating binding logic.

Current pain points:

  • AddRootEntryFromSymbol (RosterOperations.cs:195) has an explicit hack:
    // hack for referencing symbols from previous compilations, until SymbolReference is done
    var entryLocal = state.Compilation.GlobalNamespace.Catalogues
        .FirstOrDefault(x => x.Id == Entry.ContainingModule!.Id)
        ?.RootContainerEntries
        .Where(x => x.IsContainerKind(ContainerKind.Selection))
        .OfType<ISelectionEntryContainerSymbol>()
        .FirstOrDefault(x => x.Id == Entry.Id)
  • AddSelectionFromLinkOp (line 179): force lookup by string ID because ForceNode object reference is invalidated
  • AddRootEntryFromSymbol (line 222): same force-by-ID pattern

Design

Core Types

/// Lightweight, serializable identifier for an ISymbol that can be resolved across compilations.
public readonly record struct SymbolKey
{
    public SymbolKind Kind { get; init; }
    public string? SymbolId { get; init; }
    public string? ContainingModuleId { get; init; }
    public string? ContainingEntryId { get; init; }

    public static SymbolKey Create(ISymbol symbol);
    public SymbolKeyResolution Resolve(Compilation compilation);
}

public readonly struct SymbolKeyResolution
{
    public ISymbol? Symbol { get; }
    public ImmutableArray<ISymbol> CandidateSymbols { get; }
    public SymbolKeyResolutionKind Kind { get; }  // Resolved / Missing / Ambiguous
}

public enum SymbolKeyResolutionKind { Resolved, Missing, Ambiguous }

How It Works for Different Symbol Types

Catalogue entries (stable data):

Kind = ContainerEntry, SymbolId = "abc-1234", ContainingModuleId = "cat-space-marines", ContainingEntryId = null

Nested catalogue entries (child of another entry):

Kind = ContainerEntry, SymbolId = "weapon-5678", ContainingModuleId = "cat-space-marines", ContainingEntryId = "abc-1234"

Roster forces (generated unique ID per instance):

Kind = Force, SymbolId = "1234-5678-abcd", ContainingModuleId = "roster-id", ContainingEntryId = null

Roster selections (generated unique ID per instance):

Kind = Selection, SymbolId = "5678-abcd-1234", ContainingModuleId = "roster-id", ContainingEntryId = "force-id"

Resolution Strategy

  1. Find the containing module by ContainingModuleId
  2. For roster symbols → walk roster force/selection tree by ID
  3. For catalogue symbols → search catalogue scope (can use existing Binder/lookup infrastructure)

Per-Compilation Index (Performance)

/// Lazy per-compilation index for O(1) SymbolKey resolution.
internal class SymbolIndex
{
    private readonly Dictionary<(string?, string?), List<ISymbol>> _index;
    internal static SymbolIndex GetOrCreate(WhamCompilation compilation);  // CAS-protected lazy field
}

Visibility

Following Roslyn's pattern, SymbolKey should be internal initially, used by EditorServices operations. Operations store SymbolKey internally and resolve on Apply(). Can be made public later if needed.

Tasks

  • Add SymbolKey struct to WarHub.ArmouryModel.Extensions
  • Add SymbolKeyResolution and SymbolKeyResolutionKind
  • Implement SymbolKey.Create(ISymbol) — builds key from symbol hierarchy
  • Implement SymbolKey.Resolve(Compilation) — resolves in any compilation
  • Add lazy SymbolIndex to WhamCompilation for O(1) resolution
  • Refactor AddRootEntryFromSymbol to use SymbolKey
  • Refactor AddSelectionFromLinkOp and AddRootEntryFromSymbol force lookups
  • Add tests for round-trip (Create → new compilation → Resolve) for all symbol types
  • Add tests for edge cases: Missing, Ambiguous, nested entries

Edge Cases to Handle

  • Deleted entry after data change → Missing
  • Duplicate IDs in same scope → Ambiguous with candidate list
  • Nested entries sharing ID with root entries → disambiguated by ContainingEntryId
  • Link symbol vs. referenced target → same SymbolId resolves to same symbol; caller distinguishes via IEntrySymbol.IsReference

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions