Editor/HotCodeEditor/CodeView/TextSearch.cs
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;

public readonly record struct SearchMatch( TextPos Start, TextPos End, Match RegexMatch );

public record struct SearchOptions( string Query, bool MatchCase, bool WholeWord, bool UseRegex );

/// <summary>
/// Finding text in a <see cref="TextBuffer"/>. Plain searches go through Regex too (escaped)
/// so every option behaves the same way.
/// </summary>
public static class TextSearch
{
	public const int MaxMatches = 10000;

	/// <summary>
	/// All matches in order. Returns an empty list and sets <paramref name="error"/> for a bad regex.
	/// </summary>
	public static List<SearchMatch> FindAll( TextBuffer buffer, SearchOptions options, out string error )
	{
		error = null;
		var results = new List<SearchMatch>();
		if ( string.IsNullOrEmpty( options.Query ) ) return results;

		var regex = Build( options, out error );
		if ( regex is null ) return results;

		var text = string.Join( "\n", buffer.Lines );
		var lineStarts = new int[buffer.LineCount];
		for ( int i = 1; i < buffer.LineCount; i++ )
			lineStarts[i] = lineStarts[i - 1] + buffer.Lines[i - 1].Length + 1;

		try
		{
			for ( var m = regex.Match( text ); m.Success && results.Count < MaxMatches; m = m.NextMatch() )
			{
				// Zero-length matches (like "^") can't be selected or replaced sensibly
				if ( m.Length == 0 ) continue;
				results.Add( new SearchMatch( ToPos( lineStarts, m.Index ), ToPos( lineStarts, m.Index + m.Length ), m ) );
			}
		}
		catch ( RegexMatchTimeoutException )
		{
			error = "Search took too long";
		}

		return results;
	}

	/// <summary>
	/// What a match gets replaced with. With regex on, $1 / ${name} refer to groups.
	/// </summary>
	public static string ReplacementFor( SearchMatch match, string replacement, SearchOptions options )
	{
		replacement ??= "";
		return options.UseRegex ? match.RegexMatch.Result( replacement ) : replacement;
	}

	private static Regex Build( SearchOptions options, out string error )
	{
		error = null;

		var pattern = options.UseRegex ? options.Query : Regex.Escape( options.Query );

		// Like \b, but also works when the query starts or ends with a symbol
		if ( options.WholeWord ) pattern = $@"(?<![\w]){pattern}(?![\w])";

		var flags = RegexOptions.Multiline | RegexOptions.CultureInvariant;
		if ( !options.MatchCase ) flags |= RegexOptions.IgnoreCase;

		try
		{
			return new Regex( pattern, flags, TimeSpan.FromMilliseconds( 250 ) );
		}
		catch ( ArgumentException e )
		{
			error = e.Message;
			return null;
		}
	}

	private static TextPos ToPos( int[] lineStarts, int offset )
	{
		var line = Array.BinarySearch( lineStarts, offset );
		if ( line < 0 ) line = ~line - 1;
		return new TextPos( line, offset - lineStarts[line] );
	}
}