Editor/Core/UnityYaml.cs

A YAML parser for Unity text-serialized files (.asset, .prefab, .unity, .meta). It tokenizes indented lines and parses mappings, sequences, quoted/flow scalars, and Unity document headers into YamlNode trees and UnityYamlObject records.

File Access
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

/// <summary>A reference to another Unity object: {fileID: N, guid: G, type: T}.</summary>
public readonly record struct UnityReference( long FileId, string Guid )
{
	// Unity writes all-zero GUIDs for built-in or missing references.
	public bool HasGuid => !string.IsNullOrEmpty( Guid ) && Guid.Any( c => c != '0' );
}

/// <summary>A node of Unity's YAML subset: a scalar, an ordered mapping (duplicate keys kept) or a sequence.</summary>
public sealed class YamlNode
{
	static readonly IReadOnlyList<KeyValuePair<string, YamlNode>> NoPairs = Array.Empty<KeyValuePair<string, YamlNode>>();
	static readonly IReadOnlyList<YamlNode> NoItems = Array.Empty<YamlNode>();
	YamlNode() { }
	internal static YamlNode Scalar( string value ) => new() { Value = value };
	internal static YamlNode Map( List<KeyValuePair<string, YamlNode>> pairs ) => new() { Pairs = pairs };
	internal static YamlNode List( List<YamlNode> items ) => new() { Items = items };

	/// <summary>Scalar text; null for mappings and sequences.</summary>
	public string Value { get; private init; }
	public IReadOnlyList<KeyValuePair<string, YamlNode>> Pairs { get; private init; } = NoPairs;
	public IReadOnlyList<YamlNode> Items { get; private init; } = NoItems;
	public bool IsList => Items != NoItems;

	/// <summary>The first value with this key, or null.</summary>
	public YamlNode this[string key]
	{
		get
		{
			foreach ( var pair in Pairs ) if ( pair.Key == key ) return pair.Value;
			return null;
		}
	}
	/// <summary>Every value with this key, in order. Unity 4 repeats keys such as "data".</summary>
	public IEnumerable<YamlNode> All( string key ) => Pairs.Where( p => p.Key == key ).Select( p => p.Value );
	/// <summary>The first value with this key anywhere below this node, in document order.</summary>
	public YamlNode Find( string key )
	{
		foreach ( var pair in Pairs )
		{
			if ( pair.Key == key ) return pair.Value;
			if ( pair.Value.Find( key ) is YamlNode nested ) return nested;
		}
		foreach ( var item in Items ) if ( item.Find( key ) is YamlNode nested ) return nested;
		return null;
	}
	public double? Number => double.TryParse( Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var number ) && double.IsFinite( number ) ? number : null;
	public long? Integer => long.TryParse( Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number ) ? number : null;
	public UnityReference Reference => new( this["fileID"]?.Integer ?? 0, this["guid"]?.Value ?? "" );
}

/// <summary>One object document: "--- !u!ClassId &amp;FileId [stripped]" followed by "Type: body".</summary>
public sealed record UnityYamlObject( int ClassId, long FileId, bool Stripped, string Type, YamlNode Body );

/// <summary>Reads Unity's text serialization (assets, prefabs, scenes and .meta files) into nodes.</summary>
public static class UnityYaml
{
	static readonly Regex Header = new( @"^---\s+!u!(?<class>-?[0-9]+)\s+&(?<id>-?[0-9]+)(?<stripped>\s+stripped)?" );
	readonly record struct Line( int Indent, string Content );

	/// <summary>Object documents in file order. A file without document headers (such as .meta) yields one object with no type.</summary>
	public static IReadOnlyList<UnityYamlObject> Parse( string text )
	{
		var objects = new List<UnityYamlObject>();
		var lines = new List<Line>();
		(int Class, long Id, bool Stripped)? header = null;
		void Flush()
		{
			if ( header == null && lines.Count == 0 ) return;
			var root = new Reader( lines ).Document();
			// Object documents hold a single "TypeName:" key whose value is the object body.
			if ( header is { } h ) objects.Add( new( h.Class, h.Id, h.Stripped, root.Pairs.FirstOrDefault().Key, root.Pairs.FirstOrDefault().Value ?? root ) );
			else objects.Add( new( 0, 0, false, null, root ) );
			lines.Clear();
		}
		foreach ( var raw in text.Split( '\n' ) )
		{
			var line = raw.TrimEnd( '\r' );
			if ( line.StartsWith( "---", StringComparison.Ordinal ) )
			{
				Flush();
				var match = Header.Match( line );
				header = match.Success && int.TryParse( match.Groups["class"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var classId ) &&
					long.TryParse( match.Groups["id"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var fileId )
					? (classId, fileId, match.Groups["stripped"].Success) : (0, 0, false);
				continue;
			}
			if ( line.StartsWith( '%' ) && header == null && lines.Count == 0 ) continue; // %YAML / %TAG directives
			var indent = 0;
			while ( indent < line.Length && line[indent] == ' ' ) indent++;
			if ( indent == line.Length ) continue;
			lines.Add( new( indent, line[indent..].TrimEnd() ) );
		}
		Flush();
		return objects;
	}

	/// <summary>Bodies of objects with this type name, including a headerless root that contains it as a key.</summary>
	public static IEnumerable<YamlNode> Objects( IEnumerable<UnityYamlObject> objects, string type ) =>
		objects.Select( o => o.Type == type ? o.Body : o.Type == null ? o.Body[type] : null ).Where( b => b != null );

	/// <summary>The root mapping of a single-document file such as a .meta.</summary>
	public static YamlNode Document( string text ) => Parse( text ).FirstOrDefault()?.Body;

	sealed class Reader( List<Line> source )
	{
		readonly Line[] lines = source.ToArray();
		int i;

		public YamlNode Document() => i < lines.Length ? Block( lines[i].Indent ) : YamlNode.Map( new() );

		static bool IsItem( Line line ) => line.Content == "-" || line.Content.StartsWith( "- ", StringComparison.Ordinal );
		// Unity's documents nest a few dozen levels; a deeper (malformed or crafted) file stops with an error instead of
		// overflowing the stack.
		int depth;
		YamlNode Block( int indent )
		{
			if ( ++depth > MaxDepth ) throw new InvalidDataException( "The YAML document is nested too deeply." );
			try { return IsItem( lines[i] ) ? Sequence( indent ) : Mapping( indent ); }
			finally { depth--; }
		}

		YamlNode Mapping( int indent )
		{
			var pairs = new List<KeyValuePair<string, YamlNode>>();
			while ( i < lines.Length && lines[i].Indent >= indent )
			{
				var line = lines[i];
				// Tolerate malformed lines rather than failing the whole file.
				if ( line.Indent > indent || IsItem( line ) || SplitKey( line.Content ) is not { } split ) { i++; continue; }
				pairs.Add( new( split.Key, AfterKey( split.Value, indent ) ) );
			}
			return YamlNode.Map( pairs );
		}

		YamlNode Sequence( int indent )
		{
			var items = new List<YamlNode>();
			while ( i < lines.Length && lines[i].Indent == indent && IsItem( lines[i] ) )
			{
				var rest = lines[i].Content[1..];
				var content = rest.TrimStart();
				if ( content.Length == 0 )
				{
					i++;
					items.Add( i < lines.Length && lines[i].Indent > indent ? Block( lines[i].Indent ) : YamlNode.Scalar( "" ) );
					continue;
				}
				// "- key: value" starts a mapping (or "- - x" a sequence) at the column after the dash.
				var column = indent + 1 + rest.Length - content.Length;
				var line = new Line( column, content );
				if ( IsItem( line ) || SplitKey( content ) != null ) { lines[i] = line; items.Add( Block( column ) ); }
				else { i++; items.Add( Inline( content, indent ) ); }
			}
			return YamlNode.List( items );
		}

		YamlNode AfterKey( string rest, int indent )
		{
			i++;
			if ( rest.Length > 0 ) return Inline( rest, indent );
			if ( i < lines.Length && lines[i].Indent > indent ) return Block( lines[i].Indent );
			// Unity writes a key's sequence items at the key's own indentation.
			if ( i < lines.Length && lines[i].Indent == indent && IsItem( lines[i] ) ) return Sequence( indent );
			return YamlNode.Scalar( "" );
		}

		/// <summary>A value starting on an already consumed line; continuation lines are more indented than the owner.</summary>
		YamlNode Inline( string text, int indent )
		{
			string More() => i < lines.Length && lines[i].Indent > indent ? lines[i++].Content : null;
			switch ( text[0] )
			{
				case '{' or '[':
				{
					var flow = new StringBuilder( text );
					var scan = new FlowScan();
					scan.Feed( text );
					while ( !scan.Balanced && More() is string next ) { flow.Append( ' ' ).Append( next ); scan.Feed( " " + next ); }
					var position = 0;
					return Flow( flow.ToString(), ref position );
				}
				case '"' or '\'':
				{
					var quoted = new StringBuilder( text );
					var scan = new QuoteScan( text[0] );
					scan.Feed( text, 1 );
					while ( !scan.Closed && More() is string next ) { quoted.Append( '\n' ).Append( next ); scan.Feed( "\n" + next, 0 ); }
					var end = 0;
					return YamlNode.Scalar( Quoted( quoted.ToString(), ref end ) );
				}
				case '|' or '>':
					var block = new List<string>();
					while ( More() is string next ) block.Add( next );
					return YamlNode.Scalar( string.Join( text[0] == '|' ? "\n" : " ", block ) );
				default:
					var plain = new StringBuilder( text );
					while ( More() is string next ) plain.Append( ' ' ).Append( next );
					return YamlNode.Scalar( plain.ToString() );
			}
		}
	}

	/// <summary>"key: value" or "key:" outside quotes and flow collections; null for any other line.</summary>
	static (string Key, string Value)? SplitKey( string content )
	{
		if ( content[0] is '{' or '[' ) return null;
		if ( content[0] is '"' or '\'' )
		{
			var end = 0;
			var key = Quoted( content, ref end );
			if ( end < content.Length && content[end] == ':' && (end + 1 == content.Length || content[end + 1] == ' ') ) return (key, content[(end + 1)..].Trim());
			return null;
		}
		var colon = content.IndexOf( ": ", StringComparison.Ordinal );
		if ( colon > 0 ) return (content[..colon], content[(colon + 2)..].Trim());
		if ( content.EndsWith( ':' ) && content.Length > 1 ) return (content[..^1], "");
		return null;
	}

	const int MaxDepth = 512;

	/// <summary>Whether a flow collection's brackets are closed, fed one piece at a time.</summary>
	sealed class FlowScan
	{
		int depth;
		char quote;
		bool escaped;
		public bool Balanced => depth <= 0 && quote == '\0';
		public void Feed( string text )
		{
			foreach ( var c in text )
			{
				if ( escaped ) { escaped = false; continue; }
				if ( quote != '\0' ) { if ( c == '\\' && quote == '"' ) escaped = true; else if ( c == quote ) quote = '\0'; }
				else if ( c is '"' or '\'' ) quote = c;
				else if ( c is '{' or '[' ) depth++;
				else if ( c is '}' or ']' ) depth--;
			}
		}
	}

	/// <summary>Whether a quoted scalar is closed, fed one piece at a time.</summary>
	sealed class QuoteScan( char quote )
	{
		bool escaped, pendingQuote;
		public bool Closed { get; private set; }
		public void Feed( string text, int start )
		{
			for ( var p = start; p < text.Length && !Closed; p++ )
			{
				var c = text[p];
				if ( escaped ) { escaped = false; continue; }
				// A single quote doubled inside single quotes is a literal quote.
				if ( pendingQuote ) { pendingQuote = false; if ( c == '\'' ) continue; Closed = true; return; }
				if ( quote == '"' && c == '\\' ) escaped = true;
				else if ( c == quote ) { if ( quote == '\'' ) pendingQuote = true; else Closed = true; }
			}
			// Pieces are lines, so a doubled quote never spans two: a quote ending this piece closes the scalar.
			if ( pendingQuote ) Closed = true;
		}
	}

	/// <summary>Reads a quoted scalar starting at position, folding line breaks and decoding escapes.</summary>
	static string Quoted( string text, ref int position )
	{
		var quote = text[position++];
		var result = new StringBuilder();
		while ( position < text.Length )
		{
			var c = text[position++];
			if ( c == quote )
			{
				if ( quote == '\'' && position < text.Length && text[position] == '\'' ) { result.Append( '\'' ); position++; continue; }
				break;
			}
			if ( c == '\n' )
			{
				// A folded line break becomes a space; continuation indentation was already removed.
				while ( result.Length > 0 && result[^1] == ' ' ) result.Length--;
				result.Append( ' ' );
				continue;
			}
			if ( c != '\\' || quote != '"' || position >= text.Length ) { result.Append( c ); continue; }
			var e = text[position++];
			switch ( e )
			{
				case 'n': result.Append( '\n' ); break;
				case 't': result.Append( '\t' ); break;
				case 'r': result.Append( '\r' ); break;
				case '0': result.Append( '\0' ); break;
				case '\n': break; // escaped line break: join without a space
				case 'x' or 'u' or 'U':
					var length = e == 'x' ? 2 : e == 'u' ? 4 : 8;
					if ( position + length <= text.Length && int.TryParse( text.AsSpan( position, length ), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var code ) )
					{
						if ( code is >= 0 and < 0xD800 or > 0xDFFF and <= 0x10FFFF ) result.Append( char.ConvertFromUtf32( code ) );
						else result.Append( '\uFFFD' );
						position += length;
					}
					break;
				default: result.Append( e ); break; // \\ \" \/ and others
			}
		}
		return result.ToString();
	}

	static YamlNode Flow( string text, ref int p, int depth = 0 )
	{
		if ( depth > MaxDepth ) throw new InvalidDataException( "The YAML document is nested too deeply." );
		void Skip( ref int at ) { while ( at < text.Length && text[at] == ' ' ) at++; }
		Skip( ref p );
		if ( p >= text.Length ) return YamlNode.Scalar( "" );
		if ( text[p] == '{' )
		{
			p++;
			var pairs = new List<KeyValuePair<string, YamlNode>>();
			while ( true )
			{
				Skip( ref p );
				if ( p >= text.Length ) break;
				if ( text[p] == '}' ) { p++; break; }
				if ( text[p] == ',' ) { p++; continue; }
				string key;
				if ( text[p] is '"' or '\'' ) key = Quoted( text, ref p );
				else
				{
					var start = p;
					while ( p < text.Length && !(text[p] == ':' && (p + 1 == text.Length || text[p + 1] is ' ' or ',' or '}')) && text[p] is not (',' or '}') ) p++;
					key = text[start..p].Trim();
				}
				Skip( ref p );
				var value = YamlNode.Scalar( "" );
				if ( p < text.Length && text[p] == ':' ) { p++; value = Flow( text, ref p, depth + 1 ); }
				pairs.Add( new( key, value ) );
			}
			return YamlNode.Map( pairs );
		}
		if ( text[p] == '[' )
		{
			p++;
			var items = new List<YamlNode>();
			while ( true )
			{
				Skip( ref p );
				if ( p >= text.Length ) break;
				// A stray '}' also ends the list (malformed text must not loop forever).
				if ( text[p] is ']' or '}' ) { p++; break; }
				if ( text[p] == ',' ) { p++; continue; }
				var before = p;
				items.Add( Flow( text, ref p, depth + 1 ) );
				if ( p == before ) p++;
			}
			return YamlNode.List( items );
		}
		if ( text[p] is '"' or '\'' ) return YamlNode.Scalar( Quoted( text, ref p ) );
		var begin = p;
		while ( p < text.Length && text[p] is not (',' or '}' or ']') ) p++;
		return YamlNode.Scalar( text[begin..p].Trim() );
	}
}