Click here to Skip to main content
15,901,426 members
Articles / Programming Languages / C#

Fast Search and Replace in Large Number of Files: A Practical Guide

Rate me:
Please Sign up or sign in to vote.
3.40/5 (3 votes)
8 May 2024GPL337 min read 3.4K   39   3   5
This is a guide, written at an intermediate level, to performing high-speed search and replace operations across thousands of files in C# using advanced techniques such as memory-mapped files, asynchronous processing, and user-friendly interfaces with modal progress dialogs.
A practical guide to performing rapid search and replace operations across many files using C# is provided. Efficient file processing techniques, including memory-mapped files for high-speed I/O and asynchronous programming for parallelism, are covered. Implementing a user-friendly interface with a modal progress dialog that displays real-time progress and file paths while preventing interaction with the main application is also presented. This is ideal for developers looking to automate and accelerate large-scale text replacement tasks.

Contents

Image 1

Figure 1. Screenshot of the main window of the sample application distributed with this article.

Image 2

Figure 2. Screenshot the progress dialog box that the application displays when the Do It! button is clicked, and the Starting Folder, Find What, and Replace With fields have all been provided with valid inputs.  (Excuse the poor resolution.)

Image 3

Figure 3. Screenshot of the Text Replacement Console App, also implemented in this article.

This is the first article I've posted in quite a long time.  I am one of the original 36 users of The Code Project.  It's good to be back. 

Introduction

In addition to being a developer, I'm a user of the popular editor Notepad++.  (BTW, I am just mentioning this particular tool; this article is NOT, in any way, to be construed as a Third-Party Tool article.) I do not use it for coding, but I use it to work with large text files. Its Find in Files and Replace in Files tools are also very helpful for searching and replacing text in a large number (and we're talking in the tens of thousands) of files in really large directory trees.  I'm working on a Visual Studio Solution that contains almost 1,000 projects.

Sitting at my desk one day, I noticed just how fast Notepad++ performs the Find in Files and Replace in Files operation(s).  I will not try to quantify it here; I am speaking more intuitively.  At the time, I was writing a tool to do automated searches and replacements in files within large Visual Studio Solution(s), such as the production one I am working on. I found that the tool, as written, was taking much longer to do similar operation(s).  So, I wanted to sit down and figure out how to reproduce the performance of Notepad++ in Windows Forms without necessarily going into the Notepad++ source code (Notepad++, as many may be aware, is an open-source tool.  However, it's written in C++, not C#.)

Whether you're refactoring code, updating file paths, or modifying configuration settings, the need to perform search and replace operations across many files is a common challenge in software development. Manual editing is tedious and error-prone, while traditional search utilities may lack the efficiency and flexibility required for complex tasks. Fortunately, with the power of C# and modern programming techniques, we can automate this process and achieve impressive speed, accuracy, and usability results.

Problem to be solved

Performing search and replace operations across many files can be daunting, especially when dealing with hundreds or thousands of files.

Manual editing is time-consuming and error-prone, while basic search utilities may lack the efficiency and flexibility required for complex tasks. This guide will explore a practical approach to performing lightning-fast search and replace operations using C#.

We'll leverage advanced techniques such as memory-mapped files and asynchronous processing to achieve blazing-fast results.

Additionally, we'll discuss how to create a user-friendly interface with a modal progress dialog that displays progress and file paths while preventing interaction with the main application.

Requirements

Before we delve into the implementation, let's outline the requirements for our solution:

  1. Efficiency: The solution must be capable of handling a large number of files efficiently without significant performance degradation.
  2. Accuracy: Search and replace operations should be accurate and reliable, ensuring changes are applied correctly across all files.
  3. Flexibility: The solution should be flexible enough to handle various file types and formats, including text files, source code files, and configuration files.
  4. User-friendliness: While automation is key, the solution should also provide a user-friendly interface for specifying search and replace criteria and monitoring progress.

Possible Approaches

File Enumeration

The first step is to enumerate all the files within the target directory and its subdirectories. We can achieve this using the Directory.EnumerateFiles method in C# allows us to search through directories recursively.  See Listing 1 for an example of calling this method.

NOTE: For handling such tasks as using Directory.EnumerateFilesUsing the AlphaFS NuGet package is desirable instead of using the same functionality from the System.IO namespace.   AlphaFS has a higher performance and can handle pathnames up to 32,767 characters long, whereas the System.IO version has a more limited performance.

C#
string[] files = Directory.EnumerateFiles(directoryPath, "*", SearchOption.AllDirectories).ToArray();

Listing 1. Calling the Directory.EnumerateFiles method.

NOTE: An alternative approach is, instead of calling .ToArray() on the result of Directory.EnumerateFiles(), instead, to place the call to Directory.EnumerateFiles() call into a foreach loop.  Such considerations are not totally beyond the scope of the article, but let us proceed.  You can do tests of each approach depending on your tastes/performance requirements.  I recommend using an x64 build configuration, though, if you know that your array will contain a potentially massive number of files.

Search and Replace

Traditional Approach: Basic, Sequential Find and Replace

Once we have a list of files, we can iterate through each one and perform the search-and-replace operation. We'll read the contents of each file, apply the replacement, and then write the modified content back to the file.

An example of doing this is shown in Listing 2.

C#
foreach (string file in files)
{
    string content = File.ReadAllText(file);
    content = content.Replace(searchText, replaceText);
    File.WriteAllText(file, content);
}

Listing 2. An intuitive way of conducting a search-and-replace-text-in-files operation. files is the files variable from Listing 1.

Advantages and drawbacks

This method has its advantages and its drawbacks.  It's useful to note that the code in Listing 2 doesn't match the performance of Notepad++. 

Advantages

Here are some of the advantages of using the code snippet provided in Listing 2 for performing search-and-replace operations across multiple files:

1. Simplicity

  • Advantage: The code snippet is straightforward to understand, making it accessible to developers at various skill levels. This simplicity allows for quicker implementation without a steep learning curve.
  • Benefit: Developers can quickly write and test this code, useful for smaller-scale projects or simple automation tasks.

2. Synchronous Execution

  • Advantage: Because the code operates synchronously, it maintains a predictable flow. This can be advantageous for simpler applications or scripts where complex threading or asynchronous operations aren't needed.
  • Benefit: It provides a linear execution path, allowing developers to follow the logic and easily debug when necessary. It's especially useful when operating in environments where asynchronous programming might introduce unnecessary complexity.

3. Low setup overhead

  • Advantage: The code snippet requires no additional setup or complex configurations. It uses standard C# libraries, making integrating into existing projects easy without additional dependencies.
  • Benefit: Developers can quickly adapt and integrate this code into a project without additional tools or extensive setup processes.

4. Compatibility

  • Advantage: The approach relies on standard file I/O operations, universally supported across C# applications. This makes it compatible with various platforms and environments.
  • Benefit: Compatibility ensures the code works on different systems without needing specialized libraries or environment-specific adjustments.

5. Immediate feedback

  • Advantage: Since the code operates synchronously and reads/writes files straightforwardly, it provides immediate feedback on the operation's success or failure.
  • Benefit: This characteristic is useful for smaller scripts or batch operations where you need to know immediately if the task was successful or if an error occurred.

6. Low resource complexity

  • Advantage: Because the code snippet does not use advanced techniques like parallel processing, asynchronous operations, or memory-mapped files, it has lower resource complexity.
  • Benefit: This makes the code suitable for environments with limited resources or systems where complex resource management isn't needed. It's useful when simplicity and low overhead are priorities.

While the code snippet in Listing 2 might not be optimal for high-performance or large-scale operations, its simplicity, low setup overhead, and compatibility make it a good choice for smaller tasks or simpler automation scripts. It provides a straightforward way to perform search-and-replace operations without additional complexity, making it ideal for quick prototyping, small projects, or simpler batch-processing tasks.

Drawbacks

Performing search-and-replace operations on many files using the simple approach in the provided code snippet has several drawbacks. Here are some of the key disadvantages, along with their potential performance impacts:

1. Memory Consumption

  • Drawback: Reading the entire content of a file into memory  File.ReadAllText can lead to high memory usage, especially with large files. This approach is particularly risky when processing large files concurrently, as it can exhaust system memory.
  • Performance Impact: High memory consumption can result in pressure, leading to slowdowns due to increased garbage collection, memory swapping, or even out-of-memory exceptions. This impact can be especially severe in resource-limited environments.

2. Lack of Parallelism

  • Drawback: The code snippet processes files sequentially. This approach doesn't take advantage of multi-core processors and can result in longer execution times.
  • Performance Impact: Without parallelism, the time to process all files grows linearly with the number of files. This can lead to significant delays when processing large batches of files.

3. Redundant Disk I/O

  • Drawback: The code snippet reads the entire content of each file into memory, performs the replacement, and then writes the entire content back to disk, regardless of whether any changes occurred. This leads to unnecessary disk I/O operations.
  • Performance Impact: Excessive disk I/O can slow the process, especially if disk operations are slow or the system runs on a traditional hard drive instead of an SSD. The redundancy can also increase wear on storage devices.

4. No Incremental Processing

  • Drawback: The approach reads and writes the entire file, even for minor text changes. This lack of incremental processing can be inefficient, especially for large files where only a small portion requires modification.
  • Performance Impact: Reading and writing large files in their entirety can result in high resource consumption and slower processing times. Incremental processing, which modifies only specific parts of a file, could reduce this overhead.

5. No Error Handling or Rollback

  • Drawback: The code snippet does not incorporate error handling or rollback mechanisms. If an error occurs during processing, there's no way to revert to the original state, which can lead to data loss or corruption.
  • Performance Impact: Lack of error handling can lead to unreliable results and may require re-processing, adding additional time and effort to fix the problem.

6. Single-threaded Execution

  • Drawback: Processing each file on the same thread without asynchronous or multi-threaded execution can cause the application to become unresponsive and limit performance.
  • Performance Impact: Single-threaded execution can cause UI freezes and delays, especially when performing operations on many files. Without parallelism or asynchronous processing, the process may take significantly longer.

To address these drawbacks, we will implement optimized solutions that leverage memory-mapped files to reduce memory consumption, parallel processing to improve performance, and asynchronous programming to maintain UI responsiveness. Additionally, you can implement error handling and rollback mechanisms to ensure robustness and reliability during search-and-replace operations.

Using Asynchronous Techniques

While the above approach can work, say, on a small number of small files, it may not be optimal for large files or a significant number of files. To improve performance, we can leverage parallel processing and asynchronous programming techniques.

Here's Listing 2 again, although, this time, we're using Task and async/await :

C#
await Task.WhenAll(files.Select(async file =>
{
    string content = await File.ReadAllTextAsync(file);
    content = content.Replace(searchText, replaceText);
    await File.WriteAllTextAsync(file, content);
}));

Listing 3. Enhancing our code using Task and async/await.

Advantages and drawbacks

Advantages

Here are the advantages of the code snippet provided in Listing 3:

1. Asynchronous Execution

  • Advantage: The use of asynchronous operations allows for non-blocking execution. Each file operation is awaited, meaning the processing can continue without freezing the main application thread.
  • Benefit: Asynchronous execution improves responsiveness, especially in a UI context. It avoids application freezes and provides a smoother user experience.

2. Parallel Processing

  • Advantage: Using Task.WhenAll with Select allows for parallel processing of multiple files. The await keyword ensures that all tasks are complete before proceeding, enabling concurrent execution.
  • Benefit: Parallel processing can significantly reduce the time required to process many files. It takes advantage of multi-core processors, improving overall performance.

3. Resource Efficiency

  • Advantage: Asynchronous processing is more resource-efficient, as it doesn't require creating dedicated threads for each operation. It uses fewer system resources compared to traditional multi-threading.
  • Benefit: This efficiency can lead to lower memory consumption and reduced CPU usage, resulting in better performance and scalability.

4. Scalability

  • Advantage: This approach scales well with many files, allowing concurrent execution without overburdening the system.
  • Benefit: Scalability is critical when dealing with thousands of files. Asynchronous execution allows the system to handle more operations simultaneously, reducing processing time.

5. Improved Performance

  • Advantage: Parallel processing and asynchronous execution improve performance, as multiple file operations can occur simultaneously.
  • Benefit: Improved performance leads to faster completion times, allowing you to process large file sets more quickly.

6. Reduced UI Blocking

  • Advantage: By awaiting asynchronous tasks, the UI thread remains responsive. This approach is particularly beneficial in GUI applications where user interaction should not be blocked.
  • Benefit: Keeping the UI responsive improves the user experience, reduces frustration, and allows for smooth operation.

The code snippet provided in Listing 3 has several advantages, including asynchronous execution, parallel processing, resource efficiency, scalability, improved performance, and reduced UI blocking. By leveraging asynchronous programming and Task.WhenAll, you can efficiently perform search and replace operations on a large number of files without compromising responsiveness or scalability. This approach is ideal for large-scale text replacement tasks where performance and efficiency are key.

Drawbacks

The code snippet provided in Listing 3 has several advantages due to its asynchronous nature, but it also has some potential disadvantages and drawbacks. Here are some of the key drawbacks to consider:

1. Complexity of Asynchronous Code

  • Drawback: Asynchronous code can be more complex to write, understand, and debug than synchronous code. Developers must be careful with exception handling, task cancellation, and other nuances of asynchronous programming.
  • Potential Impact: The increased complexity may lead to bugs, race conditions, or unintended behavior if handled improperly. Developers unfamiliar with asynchronous programming may face a steeper learning curve.

2. Resource Management

  • Drawback: Although asynchronous code is generally more resource-efficient, it can lead to high resource utilization if not managed carefully. Multiple files' simultaneous reading and writing can strain system resources, especially on lower-end hardware.
  • Potential Impact: High resource utilization could lead to performance degradation, increased memory consumption, or system slowdowns. It's essential to monitor resource usage when running asynchronous tasks in parallel.

3. Potential Overhead

  • Drawback: Asynchronous operations introduce some overhead, as the context needs to be switched when tasks are awaited. This overhead may be noticeable when performing many small operations concurrently.
  • Potential Impact: For smaller files or smaller sets of operations, the overhead of asynchronous execution could offset the performance gains, leading to longer processing times.

4. Error Handling Challenges

  • Drawback: Handling errors in asynchronous code can be challenging. Since tasks are executed concurrently, exceptions might not be handled immediately, leading to potential data loss or corruption.
  • Potential Impact: If errors aren't handled correctly, it could result in incomplete operations, lost data, or other issues. Developers must ensure robust error handling to manage exceptions effectively.

5. Limited Debugging Support

  • Drawback: Debugging asynchronous code can be more complex due to task concurrency. Tracing the flow of execution and identifying issues can be challenging.
  • Potential Impact: Limited debugging support could slow the development process and make identifying and fixing bugs harder. Tools and techniques for debugging asynchronous code are essential to mitigate this drawback.

6. Thread Pool Limitations

  • Drawback: Asynchronous operations use thread pools to manage tasks. If too many tasks run concurrently, the thread pool could be exhausted, leading to delays or bottlenecks.
  • Potential Impact: Exhausting the thread pool could cause delays in task execution or prevent other tasks from running promptly. This impact could be significant when processing a large number of files concurrently.

While the asynchronous code snippet has advantages regarding parallelism and resource efficiency, it also has potential drawbacks related to complexity, resource management, error handling, debugging, and thread pool limitations. Developers should carefully weigh these drawbacks against the benefits to determine if asynchronous processing is the right approach for their use case. If asynchronous complexity becomes a concern, a simpler synchronous approach might be more suitable for smaller tasks or projects where responsiveness is less critical.

Sample: Using Memory-Mapped Files to Mimic Notepad++

To demonstrate an optimal way of doing Find in Files and Replace in Files on a massive number of files in a speedy manner, we're going to use MemoryMappedFile and its friends.

To mimic Notepad++'s approach for fast text replacement in C#, you can consider a different strategy involving memory-mapped files and views. Memory-mapped files allow you to map a file or a part of a file directly into memory, which can significantly speed up read and write operations, especially for large files.

Here's a basic outline of how you could implement this approach:

  1. Use memory-mapped files to map the input files directly into memory.
  2. Use memory-mapped views to search and replace text within the mapped memory efficiently.
  3. Write the modified content directly back to the memory-mapped file.
  4. Repeat this process for each input file.

Here's a simplified example of how you could implement this in C#:

C#
// Program.cs
using System;
using System.IO;
using System.IO.MemoryMappedFiles;
using System.Linq;
using System.Text;
using System.Threading;
using File = Alphaleonis.Win32.Filesystem.File;
using Directory = Alphaleonis.Win32.Filesystem.Directory;
using Path = Alphaleonis.Win32.Filesystem.Path;
 
namespace TextReplacementConsoleApp
{
    public static class ListExtensions
    {
        /// <summary>
        /// Compares the <paramref name="value" /> object with the
        /// <paramref name="testObjects" /> provided, to see if any of the
        /// <paramref name="testObjects" /> is a match.
        /// </summary>
        /// <typeparam name="T"> Type of the object to be tested. </typeparam>
        /// <param name="value"> Source object to check. </param>
        /// <param name="testObjects">
        /// Object or objects that should be compared to value
        /// with the <see cref="M:System.Object.Equals" /> method.
        /// </param>
        /// <returns>
        /// True if any of the <paramref name="testObjects" /> equals the value;
        /// false otherwise.
        /// </returns>
        public static bool IsAnyOf<T>(this T valueparams T[] testObjects)
            => testObjects.Contains(value);
    }
 
    /// <summary>
    /// Defines the behaviors of the application.
    /// </summary>
    public static class Program
    {
        /// <summary>
        /// The entry point of the application.
        /// </summary>
        [STAThread]
        public static void Main()
        {
            Console.Title = "Text Replacement Console App";
 
            const string directoryPath = @"C:\Users\Brian Hart\source\repos\astrohart\NuGetPackageAutoUpdater";
            const string searchText = "Foo";
            const string replaceText = "Bar";
 
            Console.WriteLine($"Searching all code in '{directoryPath}'...");
            Console.WriteLine($"Replacing '{searchText}' with '{replaceText}'...");
 
            Console.WriteLine($"Start Time: {DateTime.Now:O}");
 
            ReplaceTextInFiles(directoryPath, searchText, replaceText);
 
            Console.WriteLine($"End Time: {DateTime.Now:O}");
            Console.WriteLine("Text replacement completed.");
 
            Console.ReadKey();
        }
 
        /// <summary>
        /// Reads text from a memory-mapped file accessor.
        /// </summary>
        /// <param name="accessor">The memory-mapped file accessor.</param>
        /// <param name="length">The length of the text to read.</param>
        /// <returns>The text read from memory.</returns>
        /// <remarks>
        /// This method reads text from a memory-mapped file accessor and returns it as a
        /// string.
        /// It reads the specified length of bytes from the accessor and decodes them using
        /// UTF-8 encoding.
        /// </remarks>
        private static string ReadTextFromMemory(
            UnmanagedMemoryAccessor accessor,
            long length
        )
        {
            // text contents of the file.
            var result = string.Empty;
 
            try
            {
                // check for conditions that would prohibit our success
                if (accessor == nullreturn result;
                if (!accessor.CanRead) return result;
                if (length <= 0Lreturn result;
 
                var bytes = new byte[length];
                accessor.ReadArray(0, bytes, 0, (int)length);
                result = Encoding.UTF8.GetString(bytes);
            }
            catch (Exception ex)
            {
                // write the exception information to the console
                Console.WriteLine($"ERROR: {ex.Message}");

                // If an exception was caught for any reason, then return the empty string
                result = string.Empty;
            }
 
            return result;
        }
 
        /// <summary>
        /// Replaces text in the specified file.
        /// </summary>
        /// <param name="filePath">The path of the file to perform text replacement on.</param>
        /// <param name="searchText">The text to search for in the file.</param>
        /// <param name="replaceText">The text to replace the search text with.</param>
        /// <remarks>
        /// This method performs text replacement in the specified file. It reads
        /// the content of the file, replaces occurrences of the search text with the
        /// replace text, and writes the modified content back to the file. If the file
        /// path contains specific directories (such as <c>.git</c><c>.vs</c>, etc.), it
        /// skips the replacement.
        /// </remarks>
        private static void ReplaceTextInFile(
            string filePath,
            string searchText,
            string replaceText
        )
        {
            /*
             * Account for this algorithm being run on a
             * Visual Studio solution consisting only of
             * C# projects, and in a local Git repo.
             */
 
            if (filePath.Contains(@"\.git\")) return;
            if (filePath.Contains(@"\.vs\")) return;
            if (filePath.Contains(@"\packages\")) return;
            if (filePath.Contains(@"\bin\")) return;
            if (filePath.Contains(@"\obj\")) return;
            if (!Path.GetExtension(filePath)
                     .IsAnyOf(
                         ".txt"".cs"".resx"".config"".json"".csproj",
                         ".settings"".md"
                     ))
                return;
 
            using (var fileStream = File.Open(
                       filePath, FileMode.Open, FileAccess.ReadWrite,
                       FileShare.None
                   ))
            {
                var originalLength = fileStream.Length;
 
                // If the original file length is zero, return early
                if (originalLength == 0return;
 
                using (var mmf = MemoryMappedFile.CreateFromFile(
                           fileStream, null, originalLength,
                           MemoryMappedFileAccess.ReadWrite,
                           HandleInheritability.None, false
                       ))
                {
                    using (var accessor = mmf.CreateViewAccessor(
                               0, originalLength,
                               MemoryMappedFileAccess.ReadWrite
                           ))
                    {
                        // Read the content from memory.  If no text was obtained, stop.
                        var text = ReadTextFromMemory(accessor, originalLength);
                        if (string.IsNullOrWhiteSpace(text))
                            return;
 
                        // Perform text replacement
                        text = text.Replace(searchText, replaceText);
 
                        // Calculate the length of the modified text
                        long modifiedLength = Encoding.UTF8.GetByteCount(text);
 
                        // If the modified text is larger, extend the file size
                        if (modifiedLength > originalLength)
                        {
                            fileStream.SetLength(modifiedLength);
 
                            // Re-open the file stream after extending the size
                            fileStream.Seek(0, SeekOrigin.Begin);
                            using (var newMmf = MemoryMappedFile.CreateFromFile(
                                       fileStream, null, modifiedLength,
                                       MemoryMappedFileAccess.ReadWrite,
                                       HandleInheritability.None, false
                                   ))
                            {
                                using (var newAccessor =
                                       newMmf.CreateViewAccessor(
                                           0, modifiedLength,
                                           MemoryMappedFileAccess.ReadWrite
                                       ))
 
                                    // Write the modified content back to memory
                                    WriteTextToMemory(newAccessor, text);
                            }
                        }
                        else
                        {
                            // Write the modified content back to memory
                            WriteTextToMemory(accessor, text);
                        }
                    }
                }
            }
        }
 
        /// <summary>
        /// Replaces text in all files within the specified directory and its
        /// subdirectories.
        /// </summary>
        /// <param name="directoryPath">The path of the directory to search for files.</param>
        /// <param name="searchText">The text to search for in each file.</param>
        /// <param name="replaceText">The text to replace the search text with.</param>
        /// <remarks>
        /// This method recursively searches for files within the specified
        /// directory and its subdirectories. For each file found, it calls
        /// <see cref="ReplaceTextInFile" /> to perform text replacement. Certain
        /// directories (e.g., <c>.git</c><c>.vs</c>, etc.) are excluded from text
        /// replacement.  Modify that part of the code to suit your taste.
        /// </remarks>
        private static void ReplaceTextInFiles(
            string directoryPath,
            string searchText,
            string replaceText
        )
        {
            try
            {
                if (!Directory.Exists(directoryPath))
                {
                    Console.WriteLine(
                        $"ERROR: The folder '{directoryPath}' was not found on the file system."
                    );
                    return;
                }
 
                var files = Directory.EnumerateFiles(
                                         directoryPath, "*",
                                         SearchOption.AllDirectories
                                     )
                                     .Where(
                                         file => !file.Contains(@"\.git\") &&
                                                 !file.Contains(@"\.vs\") &&
                                                 !file.Contains(
                                                     @"\packages\"
                                                 ) &&
                                                 !file.Contains(@"\bin\") &&
                                                 !file.Contains(@"\obj\")
                                     )
                                     .ToList();
 
                var completedFiles = 0;
 
                foreach (var file in files)
                {
                    if (!File.Exists(file)) continue;
                    ReplaceTextInFile(file, searchText, replaceText);
                    Interlocked.Increment(ref completedFiles);
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"ERROR: {ex.Message}");
            }
        }
 
        /// <summary>
        /// Writes the specified text to a memory-mapped view accessor.
        /// </summary>
        /// <param name="accessor">The memory-mapped view accessor to write to.</param>
        /// <param name="text">The text to write.</param>
        /// <remarks>
        /// This method writes the UTF-8 encoded bytes of the
        /// <paramref name="text" /> to the memory-mapped view accessor starting at the
        /// beginning (offset zero). It is the responsibility of the caller to ensure that
        /// the
        /// length of the text matches the length of the memory-mapped view accessor.
        /// </remarks>
        private static void WriteTextToMemory(
            UnmanagedMemoryAccessor accessor,
            string text
        )
        {
            try
            {
                // check for conditions that would prohibit our success
                if (accessor == nullreturn;
                if (!accessor.CanWrite) return;
                if (string.IsNullOrWhiteSpace(text)) return;
 
                var bytes = Encoding.UTF8.GetBytes(text);
                accessor.WriteArray(0, bytes, 0, bytes.Length);
            }
            catch (Exception ex)
            {
                // write the exception information to the console
                Console.WriteLine($"ERROR: {ex.Message}");
            }
        }
    }
}

Listing 4. Code to implement reading, replacing, and then writing text with MemoryMappedFile and its friends.

NOTE: The code above assumes that you have the AlphaFS NuGet package installed and that we're working with a C# Console Application project template using the .NET Framework 4.8.

The approach shown in Listing 4 should perform significantly better than, e.g., Listing 2, as it directly manipulates the file content in memory without repeatedly reading and writing to the disk. However, depending on your specific requirements and constraints, this code may need further optimization and error handling.

(The code provided in Listing 4 is the entire source code of the Text Replacement Console App sample application provided with this article, by the way.)

Let's examine the code's organization and operation. I'll walk you through Listing 4 line by line.

Explanation of the Sample Code

This code is a C# console application that performs text search and replace operations on files within a specified directory, utilizing memory-mapped files for efficient file operations. The code includes various methods and features designed to ensure reliable and fast text replacement. Let's break down its key components and explain each section.

Namespaces

The code imports several namespaces, including System, System.IO, System.IO.MemoryMappedFiles, System.Linq, System.Text, and System.Threading. These namespaces provide access to the core functionalities used in the code, such as file operations, memory-mapped files, text encoding, and threading.

ListExtensions Class

The ListExtensions class defines an extension method IsAnyOf<T>. This method checks if a given object (referred to by the value parameter) matches any object in an array (the testObjects). It uses the Contains method to determine if value is in the testObjects array. Note the use of the this keyword for the value parameter and the application of the params keyword on the testObjects parameter.  This gives the IsAnyOf<T> method powerful syntactic sugar, so you can say, for example, if (!filename.IsAnyOf("foo", "bar", "baz") etc. The method that calls it filters which files should be processed based on their extension.

Program Class

The Program class contains the following key sections:

Main Method

This is the entry point of the console application. It sets the console title, defines some constants for the directory path, search text, and replacement text, and then calls the `ReplaceTextInFiles` method to perform the text replacement. It also outputs the start and end times to the console to track the operation's duration.

When updating this code, you may wish to put different hard-coded values in for the constants or modify the code to prompt the user for the values. These enhancements, and others, are left as an exercise for the reader.

ReadTextFromMemory Method

This method reads text from a MemoryMappedFileAccessor passed to it. It checks various conditions to ensure safe reading, such as whether the accessor is null, whether it is open for reading and whether the specified length is greater than zero. It uses UTF-8 encoding to convert the bytes to a string and handles exceptions by displaying an error message on the console and then returning an empty string if something goes wrong.  If everything succeeds, then the string produced by the operation(s) performed by the method is returned as the result.

ReplaceTextInFile Method

This method performs the search and replace operation on a specific file. It opens the file, memory-maps it, and reads the content. If the content is valid, it replaces the text and calculates the new length. If the new content length exceeds the original, it extends the file size. Otherwise, it writes the modified content back to the memory-mapped file. The method skips certain directories (like `.git`, `.vs`, etc.) and checks if the file has specific extensions before processing.

ReplaceTextInFiles Method

This method replaces text across all files within the specified directory and subdirectories. It enumerates the files, filters out those in certain directories, skips those not located on the file system, and iterates over the list to call ReplaceTextInFile for each file. The method handles exceptions and reports errors to the console.  The method also keeps track of the number of completed files in a completedFiles variable.  The System.Threading.Interlocked.Threading.Interlocked.Increment method is used to increment the value of the completedFiles variable after each loop for thread safety, which is an atomic operation unlike using the ++ (increment) operator.  This is not necessary in a single-threaded app, such as this one, but is a best practice in a multithreaded app (as opposed to using the ++ operator).  I include it here for completeness' sake.

WriteTextToMemory Method

This method writes text to a memory-mapped view accessor. It checks for conditions that prohibit successful writing, such as a null accessor or inability to write, and uses UTF-8 encoding to convert the text to bytes. It also includes error handling to catch exceptions and output error messages to the console. This operation eventually alters the contents of the file on the disk.

Summary

This code represents a straightforward implementation of a console application to perform text search and replace operations on many files. The application can achieve high-speed read/write operations by leveraging memory-mapped files without loading entire files into memory. The code also uses error handling to ensure robustness and reports errors to the console if something goes wrong.

Additionally, the code contains various optimizations, such as checking file extensions before processing, skipping certain directories, and adjusting file lengths after modification. Overall, this code provides a solid foundation for a text replacement tool that can efficiently process many files with reliability and speed.

Sample: Windows Forms Tool for Using Memory-Mapped Files to Mimic Notepad++

To provide a user-friendly interface, we'll create a Windows Forms application that allows users to specify the target directory, search text, and replacement text. We'll also display a modal progress dialog that shows progress and file paths while preventing user interaction with the main application during the operation(s).

The application demonstrates the same technique as the console application. Still, this time, the ProgressReporter class and a progress dialog box are also used to show the user the operation's progress and to help the application remain responsive to user input.  The progress dialog is also modal; the application remains responsive, yet the user cannot click anywhere else until the operation is complete.

For ultra-fast file I/O operations, we can utilize memory-mapped files. By mapping a file directly into memory, we can perform read and write operations with minimal overhead, resulting in significant performance gains.

A screenshot of the main window of the application is shown in Figure 1.  The progress dialog is shown in Figure 2.  Rather than bore you to death with a tutorial on creating a Windows Forms application, I will show you a listing of the code instead and then explain each listing.

The Program.cs file of the sample application is boring.  It is as you would expect for any WinForm app.  Therefore, we will not dive into its functionality.

ProgressReport Class

One of the new ingredients (different from the console app described above) is the ProgressReport class.  The code below defines a C# class named ProgressReport within the TextReplacementApp namespace:

C#
namespace TextReplacementApp
{
    /// <summary>
    /// Represents a progress report containing information about the current file
    /// being processed
    /// and the progress percentage.
    /// </summary>
    public class ProgressReport
    {
        /// <summary>
        /// Initializes a new instance of the
        /// <see cref="T:TextReplacementApp.ProgressReport" /> class with the specified
        /// current file and progress percentage.
        /// </summary>
        /// <param name="currentFile">The path of the current file being processed.</param>
        /// <param name="progressPercentage">The progress percentage of the operation.</param>
        public ProgressReport(string currentFile, int progressPercentage)
        {
            CurrentFile = currentFile;
            ProgressPercentage = progressPercentage;
        }
 
        /// <summary>
        /// Gets the path of the current file being processed.
        /// </summary>
        public string CurrentFile { get; }
 
        /// <summary>
        /// Gets the progress percentage of the operation.
        /// </summary>
        public int ProgressPercentage { get; }
    }
}

Listing 5. The ProgressReport class.

This class is designed to represent a progress report for a text replacement operation, providing information about the current file being processed and the progress percentage of the overall operation. Here's a breakdown of the key components and an explanation of each:

Namespace

  • TextReplacementApp: This is the namespace that encapsulates the ProgressReport class. It indicates that this class is part of a broader application related to text replacement.

ProgressReport Class

The ProgressReport class has the following key components:

Class Description

  • The XML documentation comment (/// <summary>) describes the purpose of the class, indicating that it represents a progress report with information about the current file being processed and the progress percentage.

Constructor

  • The constructor (ProgressReport) initializes a new instance of the class with the specified currentFile (the path of the current file being processed) and progressPercentage (the percentage of progress completed in the text replacement operation).
    • It accepts two parameters:
      • currentFile: A string representing the file path of the current file being processed.
      • progressPercentage: An integer indicating the progress percentage of the operation.
  • The constructor assigns the provided values to the respective properties, allowing the creation of a ProgressReport object with specific information.

Properties

The ProgressReport class contains two read-only properties:

  1. CurrentFile:

    • Represents the path of the current file being processed.
    • The get accessor returns the value provided when the class instance was created.
  2. ProgressPercentage:

    • Represents the progress percentage of the text replacement operation.
    • The get accessor returns the progress percentage value provided during initialization.

Purpose of the Class

The ProgressReport class is intended to be used in scenarios where you must report the progress of a text replacement operation or similar process. It provides key information about the current file being processed and the overall progress percentage. This class can be used to update user interfaces, display progress in a console application, or trigger other events based on the current progress.

Usage Example

A typical use case for ProgressReport might involve a progress bar in a Windows Forms application or a similar component in a console application. As the text replacement operation progresses through different files, an instance  ProgressReport could be created to represent the current status and report it to the user interface or other listeners.

Overall, the ProgressReport class is a simple, encapsulated way to represent progress information for text replacement operations, allowing applications to report meaningful data to users or other components.

We'll see it used later when we review the code of the ProgressDialog and MainWindow classes.

ProgressDialog Class

We will now look at the source code of the ProgressDialog class.  This dialog is shown in Figure 2; therefore, I will not bore you with a listing of the ProgressDialog.Designer.cs file that implements its look and feel.  It's just a static text label and a ProgressBar component.  However, the real magic is in the ProgressDialog.cs class, which illustrates how to have a modal progress dialog using the System.Progress<T> class:

C#
using System;
using System.Windows.Forms;
 
namespace TextReplacementApp
{
    /// <summary>
    /// Represents a dialog used to display the progress of an operation.
    /// </summary>
    /// <remarks>
    /// The <see cref="T:TextReplacementApp.ProgressDialog" /> class provides
    /// a simple dialog for displaying progress information during long-running
    /// operations. It contains a label to show the current file being processed and a
    /// progress bar to indicate the overall progress of the operation.
    /// </remarks>
    public partial class ProgressDialog : Form
    {
        /// <summary>
        /// Constructs a new instance of <see cref="T:TextReplacementApp.ProgressDialog" />
        /// and returns a reference to it.
        /// </summary>
        public ProgressDialog()
            => InitializeComponent();
 
        /// <summary>
        /// Updates the progress of the operation being displayed in the progress dialog.
        /// </summary>
        /// <param name="filePath">The path of the current file being processed.</param>
        /// <param name="progressPercentage">The percentage of completion of the operation.</param>
        /// <remarks>
        /// This method updates the text displayed for the current file being processed
        /// and adjusts the progress bar to reflect the progress percentage.
        /// If invoked from a different thread than the one that created the control,
        /// it will use <see cref="M:System.Windows.Forms.Control.Invoke" /> to marshal the call to the proper
        /// thread.
        /// </remarks>
        public void UpdateProgress(string filePath, int progressPercentage)
        {
            if (InvokeRequired)
            {
                Invoke(
                    new Action(
                        () => UpdateProgress(filePath, progressPercentage)
                    )
                );
                return;
            }
 
            lblFilePath.Text = filePath;
            progressBar.Value = progressPercentage;
        }
 
        /// <summary>Raises the <see cref="E:System.Windows.Forms.Form.Load" /> event.</summary>
        /// <param name="e">
        /// An <see cref="T:System.EventArgs" /> that contains the event
        /// data.
        /// </param>
        protected override void OnLoad(EventArgs e)
        {
            base.OnLoad(e);
 
            Text = Application.ProductName;
        }
    }
}

Listing 6. Code of the ProgressDialog class.

The class defines a dialog to display a long-running operation's progress, such as our file-processing task. It includes a Label to show the fully-qualified pathname of the current file being processed and a ProgressBar to indicate the operation's progress percentage.

Let's break down each section of the code:

Namespaces

The code imports the following namespaces:

  • System: Provides fundamental system-level types and methods.
  • System.Windows.Forms: Contains classes for creating Windows-based applications, including forms, controls, and events.

ProgressDialog Class

The ProgressDialog class is derived from Form, making it a Windows Forms dialog box. It is designed to display progress information, with a Label for the current file and a progress bar for progress indication.

Class Description

  • The XML documentation comment (/// <summary>) describes the class as a dialog used to display the progress of an operation. The remarks section provides additional details about the components of the dialog: a label to show the current file being processed and a progress bar to indicate the operation's progress.

Constructor

  • The constructor (ProgressDialog) initializes a new instance of the class. It calls InitializeComponent, typically generated by the Windows Forms Designer and is responsible for setting up the form's controls and layout.

UpdateProgress Method

  • This method is used to update the progress information displayed in the dialog.
  • It takes two parameters:
    • filePath: The path of the current file being processed.
    • progressPercentage: The percentage of completion of the operation.
  • The method checks if the current thread is different from the one that created the control (InvokeRequired). If so, it uses Invoke to ensure the update occurs on the correct thread, preventing cross-thread exceptions.
  • If the current thread is correct, it updates the label (lblFilePath) with the file path and sets the progress bar's value (progressBar.Value) to the progress percentage.

OnLoad Method

  • This overridden method is called when the dialog is loaded. It raises the Form.Load event, allowing additional initialization. In this code, it sets the dialog's title (Text) to the application product name (Application.ProductName).

Usage and Purpose

The ProgressDialog class is intended to be used as a modal dialog that displays progress information during a long-running operation. It's useful for providing visual feedback to users, allowing them to see which file is being processed and how much progress has been made. The UpdateProgress method is called to update the dialog with the current file and progress percentage. The use of Invoke ensures thread safety when updating the UI from a different thread.

Overall, this code provides a simple implementation of a progress dialog, useful in scenarios where long-running operations require user feedback. It can be used in a variety of Windows Forms applications to improve user experience by keeping users informed about the progress of their tasks.

An exercise for the reader is to implement a Cancel button and set the ControlBox property of the form to true so that an 'X' button becomes visible on the dialog's title bar, providing an alternative means of canceling it. Notice that the reader will then need to respond to a click of either the 'X' button or the Cancel button by closing the dialog and terminating the file-processing operation.  As it stands now, the user interface of this tool does not support canceling the file-processing operation.

Notice how slim this class is.  The MainWindow calls this class and manages the use of System.Progress<T> to provide updates to the ProgressDialog by calling its UpdateProgress method.  We'll look at MainWindow.cs in a little while; but, before we do, let's first come up with a JSON-formatted configuration file to save the values entered by the user in the user interface.

Application Configuration

Since the sample Windows Forms application demonstrated by this article is meant to be user-friendly, let's save the values that the user types in to the Starting FolderFind What, and Replace With text boxes between successive invocations of the application in a JSON-formatted configuration file.

Listing 7 shows the configuration file, config.json, that I've placed under the %LOCALAPPDATA% folder on the user's computer:

JavaScript
{
  "directory_path""C:\\Users\\Brian Hart\\source\\repos\\astrohart\\MyAwesomeApp",
  "replace_with""Bar",
  "find_what""Foo"
}

Listing 7. JSON-formatted configuration file.

As can be seen above, the configuration file is not very sophisticated. It is mainly used to persist the settings that the user enters into the GUI controls on the main form between application launches. In practice, it's a good idea to persist these values if this becomes a frequently used tool.

Once I developed the JSON file, I then used quicktype.io to turn it into C# (no affiliation or endorsement of that side is expressed or implied by its mention here).

The result was the AppConfig class, which is used to model the configuration settings in a type-safe manner:

C#
using Newtonsoft.Json;
 
namespace TextReplacementApp
{
    /// <summary>
    /// Represents the configuration settings of the application.
    /// </summary>
    /// <remarks>
    /// This class encapsulates the configuration settings of the application,
    /// including the directory path, search text, and replace text.
    /// </remarks>
    public class AppConfig
    {
        /// <summary>
        /// Gets or sets the directory path stored in the configuration.
        /// </summary>
        /// <value>
        /// A <see cref="T:System.String" /> representing the directory path.
        /// </value>
        [JsonProperty("directory_path")]
        public string DirectoryPath { getset; }
 
        /// <summary>
        /// Gets or sets the replace text stored in the configuration.
        /// </summary>
        /// <value>
        /// A <see cref="T:System.String" /> representing the replace text.
        /// </value>
        [JsonProperty("replace_with")]
        public string ReplaceWith { getset; }
 
        /// <summary>
        /// Gets or sets the search text stored in the configuration.
        /// </summary>
        /// <value>
        /// A <see cref="T:System.String" /> representing the search text.
        /// </value>
        [JsonProperty("find_what")]
        public string FindWhat { getset; }
    }
}

Listing 8. The AppConfig class.

This C# code snippet defines the AppConfig class, which models the data stored in a configuration file for our Windows Forms-based text-replacement tool. It uses the Newtonsoft.Json library to facilitate JSON serialization and deserialization, enabling the application to persist user settings between launches.

Let's break down and explain each section of the code:

Namespaces

  • Newtonsoft.Json: This namespace is part of the Newtonsoft.Json library, a popular JSON serialization/deserialization framework for .NET. It allows easy conversion between C# objects and JSON format.  It is available once the Newtonsoft.Json NuGet package has been installed in the project.
  • TextReplacementApp: The namespace encapsulates the AppConfig class, indicating that it belongs to the text replacement tool.  This is the root namespace of our example project.

AppConfig Class

The AppConfig class encapsulates the configuration settings for the application. It represents the data that will be stored in a JSON file to persist user settings between launches of the Windows Forms tool.

Class Description

  • The XML documentation comment (/// <summary>) describes the purpose of the class: to represent the application's configuration settings. The remarks section elaborates on the encapsulated settings, including directory path, search text, and replace text.

Properties

The AppConfig class contains three public properties representing different configuration settings:

  1. DirectoryPath

    • Represents the directory path stored in the configuration, where the Find in Files or Replace In Files operation is to start.
    • Annotated with [JsonProperty("directory_path")], which specifies the JSON property name used during serialization and deserialization. This attribute ensures the property is correctly mapped to the corresponding JSON field.
    • Provides a getter and setter, allowing external code to access and modify the directory path.
  2. ReplaceWith

    • Represents the replacement text stored in the configuration.
    • Annotated with [JsonProperty("replace_with")], mapping the property to the corresponding JSON field during serialization/deserialization.
    • Includes getter and setter methods for external access and modification.
  3. FindWhat

    • Represents the search text stored in the configuration.
    • Annotated with [JsonProperty("find_what")], mapping the property to the corresponding JSON field.
    • Includes getter and setter methods.

Usage and Purpose

The AppConfig class is used to persist configuration settings in a JSON file, allowing a Windows Forms-based text-replacement tool to retain user-entered settings between launches. When the application starts, the configuration is loaded from the JSON file, and the user-entered settings (such as directory path, search text, and replace text) are restored. When the application exits or the settings change, the configuration is saved to the JSON file, ensuring the user's preferences are retained for when the user next launches the tool.

Benefits of Using JSON

Using JSON to serialize and deserialize configuration settings has several benefits:

  • Human-Readable: JSON format is human-readable, making it easy to inspect and manually edit the configuration file.
  • Language-Agnostic: JSON is widely used across different programming languages, allowing for interoperability.
  • Flexibility: JSON serialization allows for flexible structure and easy addition or removal of configuration properties without complex changes.

Summary

The AppConfig class is an effective way to model and persist configuration settings in a Windows Forms-based text-replacement tool. By leveraging the Newtonsoft.Json library, the class can serialize and deserialize settings to and from a JSON file, enabling the application to retain user preferences between launches. This approach is useful for creating a persistent user experience, allowing users to continue from where they left off the next time they launch the tool.

Main Window Implementation

Let's look, again, at the look and feel of the main window:

Image 4

There are three TextBoxes and two Buttons on the main window.  The text boxes are Starting FolderFind What, and Replace With, respectively.  I also added the Browse and Do It! buttons.  The Browse button helps the user select a file on their computer (using a FolderBrowserDialog), and the Do It! button (having its Name property set to btnDoIt, naturally), which is made the AcceptButton of the form so that the user can activate it by either clicking it or pressing the ENTER key on the keyboard.

When using the Designer, I left the default form as generated by Visual Studio, except:

  • I set its Size property to 662 x 219;

  • I set its AcceptButton property to btnDoIt (the Name of the Do It! button);
  • I set its FormBorderStyle property to FixedSingle in order to prevent resizing the form;
  • I set its MaximizeBox property to false to prevent maximizing the form;
  • I set an Icon; and
  • I set the StartPosition property to be CenterScreen.

Main Window Source Code

I know the reader, at this point, must be eagerly hoping to see the source code of the main window.  Before I show this code, let me just mention that I also brought over the ListExtensions class, shown above in Listing 4, from the console app, developed earlier in this article, to give me the IsAnyOf<T> method from the console app.

Here's (at long last) the code of the main application window (except the part of it that is in the MainWindow.Designer.cs file, which I'll let the reader review on their own):

C#
using Newtonsoft.Json;
using System;
using System.IO;
using System.IO.MemoryMappedFiles;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using Directory = Alphaleonis.Win32.Filesystem.Directory
using File = Alphaleonis.Win32.Filesystem.File;
using Path = Alphaleonis.Win32.Filesystem.Path
 
namespace TextReplacementApp
{
    /// <summary>
    /// Represents the main form of the TextReplacementApp application.
    /// </summary>
    /// <remarks>
    /// The <see cref="T:TextReplacementApp.MainWindow" /> class provides the
    /// user interface for the application, allowing users to specify the directory
    /// where text replacement should occur and the text to search for and replace. It
    /// also initiates the text replacement process and displays progress using a
    /// progress dialog.
    /// </remarks>
    public partial class MainWindow : Form
    {
        /// <summary>
        /// The application configuration settings.
        /// </summary>
        /// <remarks>
        /// This field stores the application configuration settings,
        /// including the directory path, search text, and replace text.
        /// </remarks>
        private readonly AppConfig appConfig;
 
        /// <summary>
        /// The fully-qualified pathname to the application configuration file.
        /// </summary>
        /// <remarks>
        /// This field stores the location of the application configuration file
        /// on the file system. The file is named <c>config.json</c> and is stored in the
        /// <c>%LOCALAPPDATA%\xyLOGIX\File Text Replacer Tool</c> directory.
        /// </remarks>
        private readonly string configFilePath = Path.Combine(
            Environment.GetFolderPath(
                Environment.SpecialFolder.LocalApplicationData
            ), "xyLOGIX, LLC""File Text Replacer Tool""config.json"
        );
 
        /// <summary>
        /// Constructs a new instance of <see cref="T:TextReplacementApp.MainWindow" /> and
        /// returns a reference to it.
        /// </summary>
        public MainWindow()
        {
            InitializeComponent();
 
            // Initialize AppConfig and load settings
            appConfig = LoadConfig();
            UpdateTextBoxesFromConfig();
        }
 
        /// <summary>
        /// Raises the <see cref="E:System.Windows.Forms.Form.FormClosing" />
        /// event.
        /// </summary>
        /// <param name="e">
        /// A <see cref="T:System.Windows.Forms.FormClosingEventArgs" />
        /// that contains the event data.
        /// </param>
        protected override void OnFormClosing(FormClosingEventArgs e)
        {
            base.OnFormClosing(e);
 
            // Save config when the form is closing
            SaveConfig();
        }
 
        /// <summary>
        /// Loads the configuration settings from the specified JSON file.
        /// </summary>
        /// <returns>
        /// An instance of <see cref="T:TextReplacementApp.AppConfig" />
        /// containing the loaded configuration settings. If the file does not exist, a new
        /// instance of <see cref="T:TextReplacementApp.AppConfig" /> is returned.
        /// </returns>
        /// <remarks>
        /// This method reads the configuration settings from the specified JSON
        /// file and deserializes them into an
        /// <see cref="T:TextReplacementApp.AppConfig" />
        /// instance. If the file does not exist, a new instance of
        /// <see cref="T:TextReplacementApp.AppConfig" /> is returned with all properties
        /// set to empty strings.
        /// </remarks>
        private AppConfig LoadConfig()
        {
            AppConfig result;
 
            try
            {
                if (!File.Exists(configFilePath))
 
                    // If config file doesn't exist, return a new instance
                    return new AppConfig();
 
                // Load config from JSON file
                var json = File.ReadAllText(configFilePath);
                result = JsonConvert.DeserializeObject<AppConfig>(json);
            }
            catch (Exception ex)
            {
                // display an alert with the exception text
                MessageBox.Show(
                    this, ex.Message, Application.ProductName,
                    MessageBoxButtons.OK, MessageBoxIcon.Stop
                );
 
                result = default;
            }
 
            return result;
        }
 
        /// <summary>
        /// Event handler for the <see cref="E:System.Windows.Forms.Control.Click" /> event
        /// event of the <b>Browse</b> button.
        /// </summary>
        /// <param name="sender">The object that triggered the event.</param>
        /// <param name="e">The event arguments.</param>
        /// <remarks>
        /// This method is called when the user clicks the <b>Browse</b> button to
        /// select a directory using the folder browser dialog. It updates the text of the
        /// directory path textbox with the selected directory path.
        /// </remarks>
        private void OnClickBrowseButton(object sender, EventArgs e)
        {
            var result = folderBrowserDialog1.ShowDialog(this);
            if (result == DialogResult.OK &&
                !string.IsNullOrWhiteSpace(folderBrowserDialog1.SelectedPath))
                txtDirectoryPath.Text = folderBrowserDialog1.SelectedPath;
        }
 
        /// <summary>
        /// Event handler for the <see cref="E:System.Windows.Forms.Control.Click" /> event
        /// of the <b>Do It</b> button.
        /// </summary>
        /// <param name="sender">The object that triggered the event.</param>
        /// <param name="e">The event arguments.</param>
        /// <remarks>
        /// This method is called when the user clicks the <b>Do It</b> button
        /// to initiate the text replacement process. It performs validation,
        /// creates a progress dialog, starts a background task for text replacement,
        /// and displays the progress dialog modally.
        /// </remarks>
        private void OnClickDoItButton(object sender, EventArgs e)
        {
            var directoryPath = txtDirectoryPath.Text.Trim();
            var searchText = txtSearchText.Text.Trim();
            var replaceText = txtReplaceText.Text.Trim();
 
            // validation
            if (string.IsNullOrEmpty(directoryPath) ||
                !Directory.Exists(directoryPath))
            {
                MessageBox.Show(
                    "Please select a valid directory.", Application.ProductName,
                    MessageBoxButtons.OK, MessageBoxIcon.Error
                );
                return;
            }
 
            if (string.IsNullOrEmpty(searchText))
            {
                MessageBox.Show(
                    "Please type in some text to find.",
                    Application.ProductName, MessageBoxButtons.OK,
                    MessageBoxIcon.Error
                );
                return;
            }
 
            if (string.IsNullOrEmpty(replaceText))
            {
                MessageBox.Show(
                    "Please type in some text to replace the found text with.",
                    Application.ProductName, MessageBoxButtons.OK,
                    MessageBoxIcon.Error
                );
                return;
            }
 
            using (var progressDialog = new ProgressDialog())
            {
                // Use Progress<T> to report progress
                var progressReporter = new Progress<ProgressReport>(
                    report =>
                    {
                        progressDialog.UpdateProgress(
                            report.CurrentFile, report.ProgressPercentage
                        );
                    }
                );
 
                // Start a new task for text replacement
                Task.Run(
                    () =>
                    {
                        ReplaceTextInFiles(
                            directoryPath, searchText, replaceText,
                            progressReporter
                        );
 
                        // close the progress dialog
                        if (InvokeRequired)
                            progressDialog.BeginInvoke(
                                new MethodInvoker(progressDialog.Close)
                            );
                        else
                            progressDialog.Close();
 
                        // Show completion message
                        MessageBox.Show(
                            "Text replacement completed.""Success",
                            MessageBoxButtons.OK, MessageBoxIcon.Information
                        );
                    }
                );
 
                progressDialog.ShowDialog(this);
            }
        }
 
        /// <summary>
        /// Event handler for the
        /// <see cref="E:System.Windows.Forms.Control.TextChanged" /> event of the
        /// <b>Starting Folder</b> text box. Updates the
        /// <see cref="P:TextReplacementApp.AppConfig.DirectoryPath" /> property with the
        /// trimmed text from the directory path text box.
        /// </summary>
        /// <param name="sender">The object that raised the event.</param>
        /// <param name="e">The event data.</param>
        private void OnTextChangedDirectoryPath(object sender, EventArgs e)
            => appConfig.DirectoryPath = txtDirectoryPath.Text.Trim();
 
        /// <summary>
        /// Event handler for the
        /// <see cref="E:System.Windows.Forms.Control.TextChanged" /> event of the
        /// <b>Replace With</b> text box. Updates the
        /// <see cref="P:TextReplacementApp.AppConfig.ReplaceWith" /> property with the
        /// trimmed text from the directory path text box.
        /// </summary>
        /// <param name="sender">The object that raised the event.</param>
        /// <param name="e">The event data.</param>
        private void OnTextChangedReplaceText(object sender, EventArgs e)
            => appConfig.ReplaceWith = txtReplaceText.Text.Trim();
 
        /// <summary>
        /// Event handler for the
        /// <see cref="E:System.Windows.Forms.Control.TextChanged" /> event of the
        /// <b>Find What</b> text box. Updates the
        /// <see cref="P:TextReplacementApp.AppConfig.FindWhat" /> property with the
        /// trimmed text from the directory path text box.
        /// </summary>
        /// <param name="sender">The object that raised the event.</param>
        /// <param name="e">The event data.</param>
        private void OnTextChangedSearchText(object sender, EventArgs e)
            => appConfig.FindWhat = txtSearchText.Text.Trim();
 
        /// <summary>
        /// Reads text from a memory-mapped file accessor.
        /// </summary>
        /// <param name="accessor">The memory-mapped file accessor.</param>
        /// <param name="length">The length of the text to read.</param>
        /// <returns>The text read from memory.</returns>
        /// <remarks>
        /// This method reads text from a memory-mapped file accessor and returns it as a
        /// string.
        /// It reads the specified length of bytes from the accessor and decodes them using
        /// UTF-8 encoding.
        /// </remarks>
        private string ReadTextFromMemory(
            UnmanagedMemoryAccessor accessor,
            long length
        )
        {
            // text contents of the file.
            var result = string.Empty;
 
            try
            {
                // check for conditions that would prohibit our success
                if (accessor == nullreturn result;
                if (!accessor.CanRead) return result;
                if (length <= 0Lreturn result;
 
                var bytes = new byte[length];
                accessor.ReadArray(0, bytes, 0, (int)length);
                result = Encoding.UTF8.GetString(bytes);
            }
            catch (Exception ex)
            {
                // display an alert with the exception text
                MessageBox.Show(
                    this, ex.Message, Application.ProductName,
                    MessageBoxButtons.OK, MessageBoxIcon.Stop
                );
 
                result = string.Empty;
            }
 
            return result;
        }
 
        /// <summary>
        /// Replaces text in the specified file.
        /// </summary>
        /// <param name="filePath">The path of the file to perform text replacement on.</param>
        /// <param name="searchText">The text to search for in the file.</param>
        /// <param name="replaceText">The text to replace the search text with.</param>
        /// <remarks>
        /// This method performs text replacement in the specified file. It reads
        /// the content of the file, replaces occurrences of the search text with the
        /// replace text, and writes the modified content back to the file. If the file
        /// path contains specific directories (such as <c>.git</c><c>.vs</c>, etc.), it
        /// skips the replacement.
        /// </remarks>
        private void ReplaceTextInFile(
            string filePath,
            string searchText,
            string replaceText
        )
        {
            /*
             * Account for this algorithm being run on a
             * Visual Studio solution consisting only of
             * C# projects, and in a local Git repo.
             */
 
            if (filePath.Contains(@"\.git\")) return;
            if (filePath.Contains(@"\.vs\")) return;
            if (filePath.Contains(@"\packages\")) return;
            if (filePath.Contains(@"\bin\")) return;
            if (filePath.Contains(@"\obj\")) return;
            if (!Path.GetExtension(filePath)
                     .IsAnyOf(
                         ".txt"".cs"".resx"".config"".json"".csproj",
                         ".settings"".md"
                     ))
                return;
 
            using (var fileStream = File.Open(
                       filePath, FileMode.Open, FileAccess.ReadWrite,
                       FileShare.None
                   ))
            {
                var originalLength = fileStream.Length;
 
                // If the original file length is zero, return early
                if (originalLength == 0return;
 
                using (var mmf = MemoryMappedFile.CreateFromFile(
                           fileStream, null, originalLength,
                           MemoryMappedFileAccess.ReadWrite,
                           HandleInheritability.None, false
                       ))
                {
                    using (var accessor = mmf.CreateViewAccessor(
                               0, originalLength,
                               MemoryMappedFileAccess.ReadWrite
                           ))
                    {
                        // Read the content from memory
                        var text = ReadTextFromMemory(accessor, originalLength);
                        if (string.IsNullOrWhiteSpace(text))
                            return;
 
                        // Perform text replacement
                        text = text.Replace(searchText, replaceText);
 
                        // Calculate the length of the modified text
                        long modifiedLength = Encoding.UTF8.GetByteCount(text);
 
                        // If the modified text is larger, extend the file size
                        if (modifiedLength > originalLength)
                        {
                            fileStream.SetLength(modifiedLength);
 
                            // Re-open the file stream after extending the size
                            fileStream.Seek(0, SeekOrigin.Begin);
                            using (var newMmf = MemoryMappedFile.CreateFromFile(
                                       fileStream, null, modifiedLength,
                                       MemoryMappedFileAccess.ReadWrite,
                                       HandleInheritability.None, false
                                   ))
                            {
                                using (var newAccessor =
                                       newMmf.CreateViewAccessor(
                                           0, modifiedLength,
                                           MemoryMappedFileAccess.ReadWrite
                                       ))
 
                                    // Write the modified content back to memory
                                    WriteTextToMemory(newAccessor, text);
                            }
                        }
                        else
                        {
                            // Write the modified content back to memory
                            WriteTextToMemory(accessor, text);
                        }
                    }
                }
            }
        }
 
        /// <summary>
        /// Replaces text in all files within the specified directory and its
        /// subdirectories.
        /// </summary>
        /// <param name="directoryPath">The path of the directory to search for files.</param>
        /// <param name="searchText">The text to search for in each file.</param>
        /// <param name="replaceText">The text to replace the search text with.</param>
        /// <param name="progressReporter">
        /// An object for reporting progress during text
        /// replacement.
        /// </param>
        /// <remarks>
        /// This method recursively searches for files within the specified
        /// directory and its subdirectories. For each file found, it calls
        /// <see cref="ReplaceTextInFile" /> to perform text replacement. Progress is
        /// reported using the specified <paramref name="progressReporter" />. Certain
        /// directories (e.g., <c>.git</c><c>.vs</c>, etc.) are excluded from text
        /// replacement.
        /// </remarks>
        private void ReplaceTextInFiles(
            string directoryPath,
            string searchText,
            string replaceText,
            IProgress<ProgressReport> progressReporter
        )
        {
            var files = Directory.EnumerateFiles(
                                     directoryPath, "*",
                                     SearchOption.AllDirectories
                                 )
                                 .Where(
                                     file => !file.Contains(@"\.git\") &&
                                             !file.Contains(@"\.vs\") &&
                                             !file.Contains(@"\packages\") &&
                                             !file.Contains(@"\bin\") &&
                                             !file.Contains(@"\obj\")
                                 )
                                 .ToList();
 
            var totalFiles = files.Count;
            var completedFiles = 0;
 
            foreach (var file in files)
            {
                ReplaceTextInFile(file, searchText, replaceText);
                Interlocked.Increment(ref completedFiles);
 
                // Report progress
                var progressPercentage =
                    (int)((double)completedFiles / totalFiles * 100);
                var progressReport = new ProgressReport(
                    file, progressPercentage
                );
                progressReporter.Report(progressReport);
            }
        }
 
        /// <summary>
        /// Saves the current application configuration to a JSON file.
        /// </summary>
        /// <remarks>
        /// The method first checks if the directory containing the configuration
        /// file exists, and creates it if it does not. Then, it serializes the
        /// <see cref="T:TextReplacementApp.AppConfig" /> object to JSON format using
        /// <c>Newtonsoft.Json</c>, and writes the JSON string to the configuration file.
        /// </remarks>
        private void SaveConfig()
        {
            try
            {
                // check for any conditions that might prevent us from succeeding.
                if (string.IsNullOrWhiteSpace(configFilePath)) return;
 
                var directory = Path.GetDirectoryName(configFilePath);
                if (!Directory.Exists(directory))
                    Directory.CreateDirectory(directory);
 
                var json = JsonConvert.SerializeObject(
                    appConfig, Formatting.Indented
                );
                if (string.IsNullOrWhiteSpace(json)) return;
 
                File.WriteAllText(configFilePath, json);
            }
            catch (Exception ex)
            {
                // display an alert with the exception text
                MessageBox.Show(
                    this, ex.Message, Application.ProductName,
                    MessageBoxButtons.OK, MessageBoxIcon.Stop
                );
            }
        }
 
        /// <summary>
        /// Updates the text boxes on the main form with the values stored in the
        /// application configuration.
        /// </summary>
        /// <remarks>
        /// This method retrieves the directory path, search text, and replace
        /// text from the <see cref="T:TextReplacementApp.AppConfig" /> object and sets the
        /// corresponding text properties of the text boxes on the main form to these
        /// values.
        /// </remarks>
        private void UpdateTextBoxesFromConfig()
        {
            txtDirectoryPath.Text = appConfig.DirectoryPath;
            txtSearchText.Text = appConfig.FindWhat;
            txtReplaceText.Text = appConfig.ReplaceWith;
        }
 
        /// <summary>
        /// Writes the specified text to a memory-mapped view accessor.
        /// </summary>
        /// <param name="accessor">The memory-mapped view accessor to write to.</param>
        /// <param name="text">The text to write.</param>
        /// <remarks>
        /// This method writes the UTF-8 encoded bytes of the
        /// <paramref name="text" /> to the memory-mapped view accessor starting at the
        /// beginning (offset zero). It is the responsibility of the caller to ensure that
        /// the
        /// length of the text matches the length of the memory-mapped view accessor.
        /// </remarks>
        private void WriteTextToMemory(
            UnmanagedMemoryAccessor accessor,
            string text
        )
        {
            try
            {
                // check for conditions that would prohibit our success
                if (accessor == nullreturn;
                if (!accessor.CanWrite) return;
                if (string.IsNullOrWhiteSpace(text)) return;
 
                var bytes = Encoding.UTF8.GetBytes(text);
                accessor.WriteArray(0, bytes, 0, bytes.Length);
            }
            catch (Exception ex)
            {
                // display an alert with the exception text
                MessageBox.Show(
                    this, ex.Message, Application.ProductName,
                    MessageBoxButtons.OK, MessageBoxIcon.Stop
                );
            }
        }
    }
}

Listing 9. The source code of the main application window.

The code displayed in Listing 9 defines the main form for our Windows Forms-based text replacement tool, providing the application's user interface (UI).  It encompasses various functionalities, including managing application configurations, handling text replacement operations, displaying a progress dialog, and performing validation. Here's a detailed breakdown of each section of the code:

Namespaces

The code imports several key namespaces:

  • Newtonsoft.Json: Used for JSON serialization and deserialization.
  • System, System.IO, System.Text, System.Threading, System.Threading.Tasks: Fundamental system operations and utilities.
  • System.Windows.Forms: Provides classes for Windows Forms applications, including form management and UI controls.
  • Directory = Alphaleonis.Win32.Filesystem.Directory: Due to the usage of the AlphaFS NuGet package, this alias helps to resolve namespace conflicts with System.IO.Directory.
  • File = Alphaleonis.Win32.Filesystem.File: Due to the usage of the AlphaFS NuGet package, this alias helps to resolve namespace conflicts with System.IO.File.
  • Path = Alphaleonis.Win32.Filesystem.Path: Due to the usage of the AlphaFS NuGet package, this alias helps to resolve namespace conflicts with System.IO.Path.

TextReplacementApp Namespace

This namespace encapsulates the MainWindow class, representing the primary form of the text replacement tool.

MainWindow Class

The MainWindow class extends Form, representing the primary UI for the application. This class provides various functionalities to manage the configuration, trigger the text replacement process, and update progress.

Class Description

The XML documentation comments describe the class's role in providing a user interface for text replacement. The remarks section outlines the form's features, including specifying the directory for text replacement and triggering the process.

Fields

The MainWindow class has several fields that store configuration information and the path to the configuration file:

  • appConfig: This field stores an instance of AppConfig, which represents the application's configuration settings (directory path, search text, replace text).
  • configFilePath: This field holds the fully-qualified pathname to the configuration file. It combines %LOCALAPPDATA% with other path segments to create the file path.

Constructor

The constructor initializes a new instance of MainWindow. It calls InitializeComponent() to set up the form's controls and layout, initializes appConfig, and loads the configuration settings with LoadConfig(). It then updates the text boxes on the form with UpdateTextBoxesFromConfig().

OnFormClosing method override

This overridden method is called when the form is closing. It ensures that the configuration is saved before the form is closed by calling SaveConfig(). This helps persist the user's settings between application launches.

LoadConfig Method

This method loads the configuration settings from a JSON file. If the configuration file doesn't exist, it returns a new instance of AppConfig with empty values. If the file exists, it reads the content and deserializes it into an instance of AppConfig. The method includes error handling to display an error message if something goes wrong.

Event Handlers

The code includes various event handlers for UI interactions:

  • OnClickBrowseButton: This event handler is triggered when the user clicks the Browse button to select a directory. It updates the text box with the selected directory path.
  • OnClickDoItButton: This handler is triggered when the user clicks the Do It! button to start the text replacement process. It validates input, creates a progress dialog, starts a background task for text replacement, and displays the progress dialog modally.
  • OnTextChangedDirectoryPath, OnTextChangedReplaceText, OnTextChangedSearchText: These handlers update the appConfig properties when the corresponding text boxes change.

UpdateTextBoxesFromConfig method

This method updates the text boxes on the form with the values stored in the AppConfig object. It helps synchronize the form's UI with the application configuration.

Text-Replacement Methods

The code includes methods that are very similar to those used in our console app, but with slight modifications, to perform text replacement operations:

  • ReadTextFromMemory: Reads text from a memory-mapped file accessor. It checks conditions for successful reading and returns the text as a UTF-8-encoded string.
  • ReplaceTextInFile: Replaces text in a specified file. It reads the content, replaces occurrences of search text with replace text, and writes the modified content back to memory. The method skips certain directories and uses memory-mapped files to improve efficiency.
  • ReplaceTextInFiles: Replaces text in all files within a specified directory and its subdirectories. It iterates over all files and calls ReplaceTextInFile to perform the replacement. It also reports progress to the progress dialog using a provided IProgress<ProgressReport> object from the System namespace and the ProgressDialog.UpdateProgress method discussed previously.
  • WriteTextToMemory: Writes specified text to a memory-mapped view accessor. It ensures safe writing by checking necessary conditions, then writes the UTF-8-encoded bytes to memory.

Summary

Overall, this code defines the core functionality for a Windows Forms-based text replacement tool. It provides user interface elements for specifying the directory and text replacement options, manages application configuration, and performs text replacement operations while updating a progress dialog to keep users informed. Using memory-mapped files and error handling, the code ensures efficiency and robustness in handling large-scale text replacement tasks.

Conclusion

Performing search and replace operations across many files doesn't have to be a daunting task. With the right tools and techniques, we can automate the process and achieve impressive results in terms of speed, accuracy, and usability. By leveraging the power of C# and modern programming practices (not to mention that RAM is a faster storage medium, generally, than file I/O --- though that claim is more dubious with SSD-based file systems), we can streamline our workflow and focus on more important tasks. Remember to keep your code clean, modular, well-documented, and happy coding!

Additional Notes

In our implementation, we utilized memory-mapped files for ultra-fast file I/O operations, asynchronous programming for improved performance, and a modal progress dialog for a user-friendly experience. The modal progress dialog was achieved by launching the search and replace operation in a separate task and displaying it modally. This allows users to monitor progress and file paths while preventing interaction with the main application until the operation is complete.

For more advanced scenarios, consider implementing additional features such as error handling, cancellation support, and advanced search options. Additionally, you can explore other techniques, such as regular expressions, for more complex search and replace patterns.

Remember to test your solution thoroughly and consider edge cases such as file permissions, file encoding, and handling large files. With careful planning and attention to detail, you can build a robust and efficient search-and-replace tool that meets the needs of your specific use case.

This article was originally posted at https://github.com/astrohart/TextReplacementApp

License

This article, along with any associated source code and files, is licensed under The GNU General Public License (GPLv3)


Written By
Architect
United States United States
Dr. Brian Hart obtained his Ph.D. in Astrophysics from the University of California, Irvine, in 2008. Under Professor David Buote, Dr. Hart researched the structure and evolution of the universe. Dr. Hart is an Astrodynamicist / Space Data Scientist with Point Solutions Group in Colorado Springs, CO, supporting Space Operations Command, United States Space Force. Dr. Hart is a Veteran of the U.S. Army and the U.S. Navy, having most recently served at Fort George G. Meade, MD, as a Naval Officer with a Cyber Warfare Engineer designator. Dr. Hart has previously held positions at Jacobs Engineering supporting Cheyenne Mountain/Space Force supporting tests, with USSPACECOM/J58 supporting operators using predictive AI/ML with Rhombus Power, and with SAIC supporting the Horizon 2 program at STARCOM. Dr. Hart is well known to the community for his over 150 technical publications and public speaking events. Originally from Minneapolis/Saint Paul, Minnesota, Dr. Hart lives in Colorado Springs with his Black Lab, Bruce, and likes bowling, winter sports, exploring, and swimming. Dr. Hart has a new movie coming out soon, a documentary called "Galaxy Clusters: Giants of the Universe," about his outer space research. The movie showcases the Chandra X-ray Observatory, one of NASA’s four great observatories and the world’s most powerful telescopes for detecting X-rays. The movie has been accepted for screening at the U.S. Air Force Academy ("USAFA" for short) Planetarium and will highlight how scientists use clusters of galaxies, the largest bound objects in the Universe, to learn more about the formation and evolution of the cosmos --- as well as the space telescopes used for this purpose, and the stories of the astronauts who launched them and the scientists who went before Dr. Hart in learning more about the nature of the Universe.

Comments and Discussions

 
QuestionTextReplacementService class Pin
Roberto Dalmonte10-May-24 6:18
Roberto Dalmonte10-May-24 6:18 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA8-May-24 21:27
professionalȘtefan-Mihai MOGA8-May-24 21:27 
GeneralRe: My vote of 5 Pin
Brian C Hart9-May-24 5:38
professionalBrian C Hart9-May-24 5:38 
GeneralMy vote of 5 Pin
LightTempler8-May-24 18:52
LightTempler8-May-24 18:52 
GeneralRe: My vote of 5 Pin
Brian C Hart9-May-24 5:38
professionalBrian C Hart9-May-24 5:38 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.