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.
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&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 ? "*" : "")})";
}