Editor/Prism/Text/TextDocument.cs

Text editor document model. Stores lines, encoding, BOM and line-ending style; supports loading/saving files, text/Get/Replace/Insert/Delete, position/range mapping and change notifications.

File Access
using Editor.Prism.Core;
using System.IO;
using System.Text;

namespace Editor.Prism.Text;

/// <summary>How a file terminates its lines. Detected on load and preserved on save.</summary>
public enum LineEndingStyle
{
	/// <summary>Unix: <c>\n</c>.</summary>
	Lf,

	/// <summary>Windows: <c>\r\n</c>. The Prism house style, and what every s&amp;box shader ships with.</summary>
	CrLf,

	/// <summary>Classic Mac: <c>\r</c>.</summary>
	Cr
}

/// <summary>
/// One applied edit. Everything that tracks a position — carets, diagnostics, find matches, folds —
/// maps forward through this, which is why edits never desynchronise the editor.
/// </summary>
public readonly record struct TextChange(
	TextRange Removed, string RemovedText, string InsertedText, TextPosition InsertedEnd, int Version )
{
	/// <summary>First line touched by the edit.</summary>
	public int StartLine => Removed.Start.Line;

	/// <summary>How many lines the document grew (or shrank, when negative).</summary>
	public int LineDelta => InsertedEnd.Line - Removed.End.Line;

	/// <summary>Whether the edit stayed inside a single line, so only that line needs re-lexing structurally.</summary>
	public bool IsSingleLine => Removed.Start.Line == Removed.End.Line && InsertedEnd.Line == Removed.Start.Line;

	/// <summary>
	/// Moves a position recorded before this edit to where it belongs afterwards. Positions inside the
	/// removed range collapse onto the end of the inserted text.
	/// </summary>
	public TextPosition Map( TextPosition position )
	{
		var start = Removed.Start;
		var end = Removed.End;

		if ( position <= start )
			return position;

		if ( position < end )
			return InsertedEnd;

		var lineDelta = InsertedEnd.Line - end.Line;

		if ( position.Line == end.Line )
			return new TextPosition( position.Line + lineDelta, InsertedEnd.Column + (position.Column - end.Column) );

		return new TextPosition( position.Line + lineDelta, position.Column );
	}

	/// <summary>Maps both ends of a range.</summary>
	public TextRange Map( TextRange range ) => new( Map( range.Start ), Map( range.End ) );
}

/// <summary>
/// The text model: a list of lines plus everything needed to write the file back exactly as it was
/// found. Line endings, byte-order mark and encoding are detected on load and preserved on save —
/// Prism never reformats a user's <c>.hlsl</c> behind their back.
/// <para>
/// All mutation goes through <see cref="Replace(TextRange, string)"/>, which returns the
/// <see cref="TextChange"/> every observer maps its positions through.
/// </para>
/// </summary>
public sealed class TextDocument
{
	readonly List<string> _lines = new();
	readonly List<bool> _lineDirty = new();

	/// <summary>Creates an empty document.</summary>
	public TextDocument() : this( string.Empty )
	{
	}

	/// <summary>Creates a document from text, detecting its line ending style.</summary>
	public TextDocument( string text )
	{
		LineEnding = DetectLineEnding( text );
		Encoding = new UTF8Encoding( false );
		SetLinesFrom( text ?? string.Empty );
	}

	/// <summary>Raised after every applied edit, including undo and redo.</summary>
	public event Action<TextDocument, TextChange> Changed;

	/// <summary>Absolute path this document was loaded from, or null for an in-memory buffer.</summary>
	public string FilePath { get; set; }

	/// <summary>Display name used by tabs and diagnostics. Falls back to the file name.</summary>
	public string Title
	{
		get
		{
			if ( !string.IsNullOrEmpty( _title ) )
				return _title;

			if ( !string.IsNullOrEmpty( FilePath ) )
				return Path.GetFileName( FilePath );

			return "untitled";
		}
		set => _title = value;
	}

	string _title;

	/// <summary>The line terminator written on save.</summary>
	public LineEndingStyle LineEnding { get; set; } = LineEndingStyle.CrLf;

	/// <summary>Whether the file began with a byte-order mark, which is re-emitted on save.</summary>
	public bool HasByteOrderMark { get; set; }

	/// <summary>The encoding the file was read with, and the one it is written back with.</summary>
	public Encoding Encoding { get; set; } = new UTF8Encoding( false );

	/// <summary>Monotonic counter bumped by every edit. Caches key off this.</summary>
	public int Version { get; private set; }

	/// <summary>The version that was last written to disk.</summary>
	public int SavedVersion { get; private set; }

	/// <summary>Whether the buffer differs from what was last loaded or saved.</summary>
	public bool IsModified => Version != SavedVersion;

	/// <summary>Why the last <see cref="Load"/> failed, or null.</summary>
	public string LoadError { get; private set; }

	/// <summary>Why the last <see cref="Save"/> failed, or null.</summary>
	public string SaveError { get; private set; }

	/// <summary>Number of lines. Always at least one.</summary>
	public int LineCount => _lines.Count;

	/// <summary>Every line, without terminators.</summary>
	public IReadOnlyList<string> Lines => _lines;

	/// <summary>The literal terminator for <see cref="LineEnding"/>.</summary>
	public string NewLine => LineEnding switch
	{
		LineEndingStyle.Lf => "\n",
		LineEndingStyle.Cr => "\r",
		_ => "\r\n"
	};

	/// <summary>The position one past the last character of the document.</summary>
	public TextPosition EndPosition => new( _lines.Count - 1, _lines[^1].Length );

	/// <summary>The whole document as one string, joined with <see cref="NewLine"/>.</summary>
	public string Text
	{
		get => string.Join( NewLine, _lines );
		set => Replace( new TextRange( TextPosition.Zero, EndPosition ), value ?? string.Empty );
	}

	/// <summary>One line, without its terminator. Out-of-range indices return an empty string.</summary>
	public string GetLine( int index )
	{
		if ( index < 0 || index >= _lines.Count )
			return string.Empty;

		return _lines[index];
	}

	/// <summary>Length of one line in characters.</summary>
	public int GetLineLength( int index )
	{
		if ( index < 0 || index >= _lines.Count )
			return 0;

		return _lines[index].Length;
	}

	/// <summary>Whether a line has been edited since the last save. Drives the gutter's change bar.</summary>
	public bool IsLineModified( int index )
	{
		if ( index < 0 || index >= _lineDirty.Count )
			return false;

		return _lineDirty[index];
	}

	/// <summary>Clamps a position into the document.</summary>
	public TextPosition Clamp( TextPosition position )
	{
		var line = Math.Clamp( position.Line, 0, _lines.Count - 1 );
		var column = Math.Clamp( position.Column, 0, _lines[line].Length );
		return new TextPosition( line, column );
	}

	/// <summary>Clamps both ends of a range into the document.</summary>
	public TextRange Clamp( TextRange range ) => new( Clamp( range.Start ), Clamp( range.End ) );

	/// <summary>The text covered by a range, joined with <see cref="NewLine"/>.</summary>
	public string GetText( TextRange range )
	{
		var normalized = Clamp( range.Normalized );
		var start = normalized.Start;
		var end = normalized.End;

		if ( start == end )
			return string.Empty;

		if ( start.Line == end.Line )
			return _lines[start.Line].Substring( start.Column, end.Column - start.Column );

		var builder = new StringBuilder();
		builder.Append( _lines[start.Line], start.Column, _lines[start.Line].Length - start.Column );

		for ( var line = start.Line + 1; line < end.Line; line++ )
		{
			builder.Append( NewLine );
			builder.Append( _lines[line] );
		}

		builder.Append( NewLine );
		builder.Append( _lines[end.Line], 0, end.Column );
		return builder.ToString();
	}

	/// <summary>Whole-line text including nothing after the last line's content.</summary>
	public string GetLines( int firstLine, int lastLine )
	{
		firstLine = Math.Clamp( firstLine, 0, _lines.Count - 1 );
		lastLine = Math.Clamp( lastLine, firstLine, _lines.Count - 1 );

		return GetText( new TextRange(
			new TextPosition( firstLine, 0 ),
			new TextPosition( lastLine, _lines[lastLine].Length ) ) );
	}

	/// <summary>
	/// The one mutation primitive. Replaces <paramref name="range"/> with <paramref name="text"/> and
	/// returns the change. A no-op replacement still returns a change whose ranges are equal, but does
	/// not bump the version or raise <see cref="Changed"/>.
	/// </summary>
	public TextChange Replace( TextRange range, string text )
	{
		text ??= string.Empty;

		var normalized = Clamp( range.Normalized );
		var start = normalized.Start;
		var end = normalized.End;

		var removed = GetText( normalized );

		if ( removed.Length == 0 && text.Length == 0 )
			return new TextChange( new TextRange( start, end ), string.Empty, string.Empty, start, Version );

		if ( string.Equals( removed, text, StringComparison.Ordinal ) )
			return new TextChange( new TextRange( start, end ), removed, text, end, Version );

		var inserted = SplitLines( text );

		var prefix = _lines[start.Line].Substring( 0, start.Column );
		var suffix = _lines[end.Line].Substring( end.Column );

		TextPosition insertedEnd;

		if ( inserted.Count == 1 )
		{
			var single = prefix + inserted[0] + suffix;
			RemoveLineRange( start.Line, end.Line );
			_lines.Insert( start.Line, single );
			_lineDirty.Insert( start.Line, true );
			insertedEnd = new TextPosition( start.Line, prefix.Length + inserted[0].Length );
		}
		else
		{
			RemoveLineRange( start.Line, end.Line );

			for ( var i = 0; i < inserted.Count; i++ )
			{
				var value = inserted[i];

				if ( i == 0 )
					value = prefix + value;

				if ( i == inserted.Count - 1 )
					value += suffix;

				_lines.Insert( start.Line + i, value );
				_lineDirty.Insert( start.Line + i, true );
			}

			insertedEnd = new TextPosition( start.Line + inserted.Count - 1, inserted[^1].Length );
		}

		Version++;

		var change = new TextChange( new TextRange( start, end ), removed, text, insertedEnd, Version );
		Changed?.Invoke( this, change );
		return change;
	}

	/// <summary>Convenience: inserts text at a position.</summary>
	public TextChange Insert( TextPosition position, string text ) => Replace( TextRange.At( position ), text );

	/// <summary>Convenience: deletes a range.</summary>
	public TextChange Delete( TextRange range ) => Replace( range, string.Empty );

	/// <summary>Converts a position to a character offset from the start of the document.</summary>
	public int ToOffset( TextPosition position )
	{
		var clamped = Clamp( position );
		var newLineLength = NewLine.Length;
		var offset = 0;

		for ( var i = 0; i < clamped.Line; i++ )
			offset += _lines[i].Length + newLineLength;

		return offset + clamped.Column;
	}

	/// <summary>Converts a character offset to a position.</summary>
	public TextPosition FromOffset( int offset )
	{
		if ( offset <= 0 )
			return TextPosition.Zero;

		var newLineLength = NewLine.Length;
		var remaining = offset;

		for ( var i = 0; i < _lines.Count; i++ )
		{
			var span = _lines[i].Length + newLineLength;

			if ( remaining < span || i == _lines.Count - 1 )
				return new TextPosition( i, Math.Clamp( remaining, 0, _lines[i].Length ) );

			remaining -= span;
		}

		return EndPosition;
	}

	/// <summary>Marks the buffer clean and clears every per-line change bar.</summary>
	public void MarkSaved()
	{
		SavedVersion = Version;

		for ( var i = 0; i < _lineDirty.Count; i++ )
			_lineDirty[i] = false;
	}

	/// <summary>
	/// Reads a file from disk into this document, preserving its byte-order mark, encoding and line
	/// endings. Never throws: on failure the document is left untouched and <see cref="LoadError"/> is set.
	/// </summary>
	public bool Load( string path )
	{
		LoadError = null;

		if ( string.IsNullOrWhiteSpace( path ) )
		{
			LoadError = "No path given.";
			return false;
		}

		try
		{
			var bytes = File.ReadAllBytes( path );
			var encoding = DetectEncoding( bytes, out var bom, out var preamble );
			var text = encoding.GetString( bytes, preamble, bytes.Length - preamble );

			Encoding = encoding;
			HasByteOrderMark = bom;
			LineEnding = DetectLineEnding( text );
			FilePath = path;

			SetLinesFrom( text );
			Version++;
			SavedVersion = Version;

			Changed?.Invoke( this, new TextChange( TextRange.Empty, string.Empty, text, EndPosition, Version ) );
			return true;
		}
		catch ( Exception e )
		{
			LoadError = e.Message;
			PrismLog.Warn( $"Prism: could not read '{path}': {e.Message}" );
			return false;
		}
	}

	/// <summary>Creates a document from a file. Returns a document even on failure — check <see cref="LoadError"/>.</summary>
	public static TextDocument FromFile( string path )
	{
		var document = new TextDocument();
		document.Load( path );
		return document;
	}

	/// <summary>
	/// Writes the document back to disk with its original encoding, byte-order mark and line endings.
	/// Never throws: on failure <see cref="SaveError"/> is set and false is returned.
	/// </summary>
	public bool Save( string path = null )
	{
		SaveError = null;

		var target = string.IsNullOrWhiteSpace( path ) ? FilePath : path;

		if ( string.IsNullOrWhiteSpace( target ) )
		{
			SaveError = "No path given.";
			return false;
		}

		try
		{
			var directory = Path.GetDirectoryName( target );

			if ( !string.IsNullOrEmpty( directory ) && !Directory.Exists( directory ) )
				Directory.CreateDirectory( directory );

			var encoding = Encoding ?? new UTF8Encoding( false );
			var body = encoding.GetBytes( Text );

			if ( HasByteOrderMark )
			{
				var preamble = encoding.GetPreamble();

				if ( preamble.Length > 0 )
				{
					var combined = new byte[preamble.Length + body.Length];
					Buffer.BlockCopy( preamble, 0, combined, 0, preamble.Length );
					Buffer.BlockCopy( body, 0, combined, preamble.Length, body.Length );
					body = combined;
				}
			}

			File.WriteAllBytes( target, body );

			FilePath = target;
			MarkSaved();
			return true;
		}
		catch ( Exception e )
		{
			SaveError = e.Message;
			PrismLog.Warn( $"Prism: could not write '{target}': {e.Message}" );
			return false;
		}
	}

	/// <summary>Detects the dominant line ending in a block of text. Empty text defaults to CRLF.</summary>
	public static LineEndingStyle DetectLineEnding( string text )
	{
		if ( string.IsNullOrEmpty( text ) )
			return LineEndingStyle.CrLf;

		var crlf = 0;
		var lf = 0;
		var cr = 0;

		for ( var i = 0; i < text.Length; i++ )
		{
			var c = text[i];

			if ( c == '\r' )
			{
				if ( i + 1 < text.Length && text[i + 1] == '\n' )
				{
					crlf++;
					i++;
				}
				else
				{
					cr++;
				}
			}
			else if ( c == '\n' )
			{
				lf++;
			}
		}

		if ( crlf == 0 && lf == 0 && cr == 0 )
			return LineEndingStyle.CrLf;

		if ( crlf >= lf && crlf >= cr )
			return LineEndingStyle.CrLf;

		return lf >= cr ? LineEndingStyle.Lf : LineEndingStyle.Cr;
	}

	/// <summary>Splits text on any line ending. Always returns at least one element.</summary>
	public static List<string> SplitLines( string text )
	{
		var result = new List<string>();

		if ( string.IsNullOrEmpty( text ) )
		{
			result.Add( string.Empty );
			return result;
		}

		var start = 0;

		for ( var i = 0; i < text.Length; i++ )
		{
			var c = text[i];

			if ( c == '\r' )
			{
				result.Add( text.Substring( start, i - start ) );

				if ( i + 1 < text.Length && text[i + 1] == '\n' )
					i++;

				start = i + 1;
			}
			else if ( c == '\n' )
			{
				result.Add( text.Substring( start, i - start ) );
				start = i + 1;
			}
		}

		result.Add( text.Substring( start ) );
		return result;
	}

	void SetLinesFrom( string text )
	{
		_lines.Clear();
		_lineDirty.Clear();
		_lines.AddRange( SplitLines( text ) );

		for ( var i = 0; i < _lines.Count; i++ )
			_lineDirty.Add( false );
	}

	void RemoveLineRange( int first, int last )
	{
		var count = last - first + 1;
		_lines.RemoveRange( first, count );
		_lineDirty.RemoveRange( first, count );
	}

	static Encoding DetectEncoding( byte[] bytes, out bool bom, out int preambleLength )
	{
		bom = false;
		preambleLength = 0;

		if ( bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF )
		{
			bom = true;
			preambleLength = 3;
			return new UTF8Encoding( true );
		}

		if ( bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE )
		{
			bom = true;
			preambleLength = 2;
			return new UnicodeEncoding( false, true );
		}

		if ( bytes.Length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF )
		{
			bom = true;
			preambleLength = 2;
			return new UnicodeEncoding( true, true );
		}

		return new UTF8Encoding( false );
	}

	/// <summary>Diagnostic rendering.</summary>
	public override string ToString() => $"{Title} ({_lines.Count} lines, v{Version}{(IsModified ? "*" : "")})";
}