Skip to content

Build a Recursive Find in Files Function

In the previous post, we built a utility to find text inside files within a single folder. Today we’re upgrading that utility to support recursive searching with depth control. We’ll also refactor the code to keep it maintainable as it gets more complex.

The Goal

We want to:

  1. Search through subfolders automatically.
  2. Add a maxDepth parameter so we don’t accidentally crawl the entire hard drive.
  3. Keep the simple version working using method overloading.

Step 1: Move File Reading to its own Method

The original FindInFiles function did everything: validated the folder, looped through children, read file content, and matched text. Adding recursion to that would make the code look ugly, so let’s refactor our code.

We’ll start by moving the file-reading logic into its own private method: SearchFile. This keeps the “search” logic separate from the “looping” logic.

Private Sub SearchFile(file As FolderItem, searchTerm As String, hits() As SearchHit)
  Try
    Var input As TextInputStream = TextInputStream.Open(file)
    input.Encoding = Encodings.UTF8
    Var lineNumber As Integer = 0
    While Not input.EndOfFile
      Var line As String = input.ReadLine
      lineNumber = lineNumber + 1
      If line.Contains(searchTerm, ComparisonOptions.CaseInsensitive) Then
        hits.Add(New SearchHit(file.NativePath, lineNumber, line))
      End If
    Wend
    input.Close
  Catch error As IOException
    // Skip unreadable files
  End Try
End Sub

Step 2: Recursive Folder Traversal

Now we create SearchFolder. This method loops through a folder. If it finds a file, it calls SearchFile. If it finds a folder, it calls itself.

To keep this safe, we Use maxDepth:

  • maxDepth = 0: Search only this folder.
  • maxDepth > 0: Search this folder and N levels of subfolders.
  • maxDepth = -1: Search everything (unlimited).
Private Sub SearchFolder(folder As FolderItem, searchTerm As String, hits() As SearchHit, maxDepth As Integer)
   Try
    For Each item As FolderItem In folder.Children
      If item Is Nil Or Not item.Exists Or Not item.IsReadable Then Continue
      If item.IsFolder Then
        If maxDepth <> 0 Then
          Var nextDepth As Integer = If(maxDepth > 0, maxDepth - 1, maxDepth)
          SearchFolder(item, searchTerm, hits, nextDepth)
        End If
      Else
        SearchFile(item, searchTerm, hits)
      End If
    Next
  Catch error As IOException
    // Skip inaccessible folders
  End Try
End Sub

The logic in nextDepth lets us count down if there’s a limit, or stay at -1 if there isn’t.

Step 3: Support Both Versions with Method Overloading

Overloading is a feature that lets you have multiple methods with the same name, as long as they have different parameters. In our case, it allows us to provide a simple version for one folder, and a recursive version with a depth limit. Xojo will automatically pick the right one based on the arguments you pass. You can read more about it in the Xojo Documentation.

The default version (depth 0):

Public Function FindInFiles(targetFolder As FolderItem, searchTerm As String) As SearchHit()
  Return FindInFiles(targetFolder, searchTerm, 0)
End Function

The recursive version:

Public Function FindInFiles(targetFolder As FolderItem, searchTerm As String, maxDepth As Integer) As SearchHit()
  Var hits() As SearchHit
  If targetFolder Is Nil Or Not targetFolder.Exists Or Not targetFolder.IsFolder Or searchTerm.IsEmpty Then
    Return hits
  End If
  Try
    SearchFolder(targetFolder, searchTerm, hits, maxDepth)
  Catch error As IOException
    // Skip inaccessible folders
  End Try
  Return hits
End Function

By separating the validation, traversal, and processing, the code is much easier to modify. If you want to add Regex support, you only change SearchFile. If you want to filter by file extension, you only change SearchFolder.

Summary

Now, you can search a single folder just like before, or crawl a directory tree with one extra parameter:

// Search up to 3 levels deep
Var results() As SearchUtils.SearchHit = SearchUtils.FindInFiles(myFolder, "TODO", 3)

The Complete Recursive Code

The SearchUtils Module

Module SearchUtils

  Class SearchHit
    Public Property FilePath As String
    Public Property LineNumber As Integer
    Public Property LineText As String

    Public Sub Constructor(filePath As String, lineNumber As Integer, lineText As String)
      Self.FilePath = filePath
      Self.LineNumber = lineNumber
      Self.LineText = lineText
    End Sub
  End Class

  Public Function FindInFiles(targetFolder As FolderItem, searchTerm As String) As SearchHit()
    // Simple usage: depth = 0
    Return FindInFiles(targetFolder, searchTerm, 0)
  End Function

  Public Function FindInFiles(targetFolder As FolderItem, searchTerm As String, maxDepth As Integer) As SearchHit()
    // Recursive usage: maxDepth ( -1 is unlimited )

    Var hits() As SearchHit

    If targetFolder Is Nil Or Not targetFolder.Exists Or Not targetFolder.IsFolder Or searchTerm.IsEmpty Then
      Return hits
    End If

    Try
      SearchFolder(targetFolder, searchTerm, hits, maxDepth)
    Catch error As IOException
      // Skip inaccessible folders
    End Try

    Return hits
  End Function

  Private Sub SearchFolder(folder As FolderItem, searchTerm As String, hits() As SearchHit, maxDepth As Integer)
    Try
      For Each item As FolderItem In folder.Children
        If item Is Nil Or Not item.Exists Or Not item.IsReadable Then Continue

        If item.IsFolder Then
          If maxDepth <> 0 Then
            Var nextDepth As Integer = If(maxDepth > 0, maxDepth - 1, maxDepth)
            SearchFolder(item, searchTerm, hits, nextDepth)
          End If
        Else
          SearchFile(item, searchTerm, hits)
        End If
      Next
    Catch error As IOException
      // Skip inaccessible folders
    End Try
  End Sub

  Private Sub SearchFile(file As FolderItem, searchTerm As String, hits() As SearchHit)
    Try
      Var input As TextInputStream = TextInputStream.Open(file)
      input.Encoding = Encodings.UTF8
      Var lineNumber As Integer = 0

      While Not input.EndOfFile
        Var line As String = input.ReadLine
        lineNumber = lineNumber + 1

        If line.Contains(searchTerm, ComparisonOptions.CaseInsensitive) Then
          hits.Add(New SearchHit(file.NativePath, lineNumber, line))
        End If
      Wend

      input.Close
    Catch error As IOException
      // Skip unreadable files
    End Try
  End Sub
End Module

Happy coding!

Gabriel is a digital marketing enthusiast who loves coding with Xojo to create cool software tools for any platform. He is always eager to learn and share new ideas!