Editor/Core/PackageReferenceDiff.cs

Utility for comparing package identifiers between a host and guest .sbproj JSON project file. It parses JSON project content, extracts validated package and editor reference identifiers, computes which host identifiers the guest lacks, and formats a short user notice.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;

namespace TeamCreate;

/// <summary>
/// Which asset-library packages the host's project lists that a guest's does not. A project names its packages in the <c>.sbproj</c>
/// (<c>PackageReferences</c> and <c>EditorReferences</c>); that file is project configuration the guest runs, so it is never transferred.
/// Only the validated package identifiers cross the wire, and the guest is told, never installed for: adding a package changes what code
/// and content the guest's machine loads, which is the person's decision. Everything here is pure and total: malformed input yields an
/// empty result instead of an exception, because a project file this library cannot read must not interfere with joining.
/// </summary>
public static class PackageReferenceDiff
{
    /// <summary>The most identifiers read from one project file, sent in one reply or named in one result.</summary>
    public const int MaxIdents = 50;

    /// <summary>The longest identifier accepted.</summary>
    public const int MaxIdentLength = 100;

    /// <summary>How many names one notice spells out; the remainder is counted.</summary>
    public const int NoticeNames = 5;

    // A project file is a few hundred bytes; anything this large is not one, and parsing it would only spend the host's frame.
    private const int MaxTextChars = 1024 * 1024;

    // Entries examined per list. The result is bounded to MaxIdents, but a hostile list of millions of invalid strings would still be walked.
    private const int MaxEntriesScanned = 2000;

    private static readonly string[] ReferenceLists = { "PackageReferences", "EditorReferences" };

    /// <summary>True when <paramref name="ident"/> is 1 to 100 characters of A-Z, a-z, 0-9, '.', '_' and '-'. Written as a loop: a regular expression's <c>$</c> would also accept a trailing line break.</summary>
    public static bool IsValidIdent( string ident )
    {
        if ( ident == null || ident.Length == 0 || ident.Length > MaxIdentLength ) return false;
        foreach ( char c in ident )
        {
            bool ok = ( c >= 'a' && c <= 'z' ) || ( c >= 'A' && c <= 'Z' ) || ( c >= '0' && c <= '9' ) || c == '.' || c == '_' || c == '-';
            if ( !ok ) return false;
        }
        return true;
    }

    /// <summary>The valid package identifiers a project file lists, deduplicated, in ordinal order, at most <see cref="MaxIdents"/>. Empty for text that is not a project file.</summary>
    public static List<string> Idents( string sbprojText )
    {
        var found = new HashSet<string>( StringComparer.Ordinal );
        if ( string.IsNullOrWhiteSpace( sbprojText ) || sbprojText.Length > MaxTextChars ) return new List<string>();
        try
        {
            using var document = JsonDocument.Parse( sbprojText, new JsonDocumentOptions { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip, MaxDepth = 16 } );
            if ( document.RootElement.ValueKind != JsonValueKind.Object ) return new List<string>();
            foreach ( string name in ReferenceLists )
            {
                if ( !document.RootElement.TryGetProperty( name, out var list ) || list.ValueKind != JsonValueKind.Array ) continue;
                int scanned = 0;
                foreach ( var item in list.EnumerateArray() )
                {
                    if ( ++scanned > MaxEntriesScanned ) break;
                    if ( item.ValueKind != JsonValueKind.String ) continue;
                    string ident = item.GetString();
                    if ( IsValidIdent( ident ) ) found.Add( ident );
                }
            }
        }
        catch ( Exception ex ) when ( ex is JsonException or InvalidOperationException or ArgumentException )
        {
            return new List<string>();
        }
        return found.OrderBy( x => x, StringComparer.Ordinal ).Take( MaxIdents ).ToList();
    }

    /// <summary>The identifiers listed by the host's project file that the guest's does not list.</summary>
    public static List<string> Missing( string hostSbprojText, string guestSbprojText ) => Missing( Idents( hostSbprojText ), guestSbprojText );

    /// <summary>
    /// The same, with the host's identifiers already extracted (what arrives on the wire). Each is validated again, because a peer wrote
    /// them. Identifiers match without regard to letter case: the package system treats them so, and reporting "Cyn.Zebra" as missing
    /// beside a listed "cyn.zebra" would be a false alarm.
    /// </summary>
    public static List<string> Missing( IEnumerable<string> hostIdents, string guestSbprojText )
    {
        var have = new HashSet<string>( Idents( guestSbprojText ), StringComparer.OrdinalIgnoreCase );
        var missing = new HashSet<string>( StringComparer.Ordinal );
        foreach ( string ident in hostIdents ?? Enumerable.Empty<string>() )
        {
            if ( IsValidIdent( ident ) && !have.Contains( ident ) ) missing.Add( ident );
            if ( missing.Count >= MaxIdents ) break;
        }
        return missing.OrderBy( x => x, StringComparer.Ordinal ).Take( MaxIdents ).ToList();
    }

    /// <summary>The one notice for the guest, or null when nothing is missing. One package gets the exact sentence; several are named (at most five) in the same sentence.</summary>
    public static string Notice( IReadOnlyList<string> missing )
    {
        if ( missing == null || missing.Count == 0 ) return null;
        const string Rest = "which your project does not list. Add {0} in the Library Manager or through Git; scenes may show {1} as missing until then.";
        if ( missing.Count == 1 ) return "The host's project uses the package \"" + missing[0] + "\" " + string.Format( Rest, "it", "it" );
        var named = missing.Take( NoticeNames ).Select( x => "\"" + x + "\"" ).ToList();
        string list = missing.Count <= NoticeNames
            ? string.Join( ", ", named.Take( named.Count - 1 ) ) + " and " + named[named.Count - 1]
            : string.Join( ", ", named ) + " and " + ( missing.Count - NoticeNames ) + " more";
        return "The host's project uses the packages " + list + " " + string.Format( Rest, "them", "them" );
    }
}