Creando una función sencilla para Buscar en Archivo

A continuación encontrarás traducido al castellano el artículo escrito por Gabriel Ludosanu y publicado originalmente en el Blog oficial de Xojo.

Tarde o temprano la mayoría de las aplicaciones requieren de la capacidad de encontrar texto entre el contenido de los archivos, ya se trate de archivos de registro, datos exportados o bien código fuente.

En esta entrada crearemos con Xojo una utilidad sencilla para Buscar en Archivos, y que permita:

  • Buscar en los archivos de una carpeta.
  • Leer cada archivo línea a línea.
  • Encontrar coincidencias de texto plano sin considerar mayúsculas/minúsculas.
  • Devolver la ruta al archivo, el número de línea y la línea coincidente.

El objetivo es simple: crear algo útil al tiempo que dejamos la puerta abierta para futuras extensiones.

¿Qué realizará esta función Buscar en Archivos?

Definamos su funcionalidad:

  • Buscar una carpeta.
  • Buscar sólo texto plano.
  • Saltar sub-carpetas, por ahora.
  • Devolver la ruta del archivo coincidente, el número de línea y contenido de dicha línea.

Paso 1: Clase en un módulo… y algunas propiedades

Antes de afrontar el sistema de archivos, definamos lo que entendemos como línea coincidente.

Podríamos devolver cadenas en bruto, pero eso resultaría lioso realmente rápido. Una pequeña clase mantiene el código legible y nos permite poder ampliarla en el futuro. En esta versión prefiero mantener dicha clase en el mismo módulo que la función de búsqueda. Así mantiene toda la utilidad auto-contenida en vez de tener un proyecto con múltiples tipos.

Comencemos por tanto añadiendo un módulo SearchUtils y, dentro de dicho módulo, añadiremos la clase SearchHit.

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
 
End Module

Cada resultado almacena tres cosas:

  • La ruta del archivo.
  • El número de línea.
  • La línea coincidente.

Paso 2: Revisar la carpeta

FolderItem.Children nos permite recorrer los contenidos de una carpeta utilizando un bucle For Each; manteniendo así un código más claro y conciso.

De igual modo también podemos rechazar algunas entradas no deseadas con mayor rapidez:

  • La carpeta es Nil.
  • La carpeta no existe.
  • El ítem recibido no es realmente una carpeta.
  • El término de búsqueda está vacío.
  • Este proceso es simple, rápido y efectivo.

Paso 3: Leer archivos línea a línea

En el caso de los archivos de texto, TextInputStream.Open es la herramienta correcta. A partir de aquí, podemos llamar a ReadLine hasta que EndOfFile reciba el valor True.

Prefiero hacerlo así en vez de emplear ReadAll para una utilidad como esta. ReadAll está bien cuando el archivo es pequeño, pero la lectura línea a línea resulta mejor y de mayor utilidad en este caso. Mantiene el uso de la memoria contenido, y también nos permite registrar el número de línea con mayor facilidad.

En el caso de este código de ejemplo, utilizaré de forma explícita UTF-8:

input.Encoding = Encodings.UTF8

Esto permite que el ejemplo resulte predecible. No significa que este código pueda decodificar de forma correcta cualquier archivo de texto que le arrojes. Los archivos sin una codificación conocida o bien con contenido binario son un problema aparte; y requieren su propia solución.

Paso 4: Encontrar el texto

En el caso de una búsqueda sobre texto plano, String.Contains hace exactamente lo que necesitamos:

line.Contains(searchTerm, ComparisonOptions.CaseInsensitive)

Maneja las correspondencias de texto, sin importar el uso de mayúsculas/minúsculas, sin precisar de código adicional o los dolores de cabeza asociados al uso de RegEx.

Paso 5: El módulo, clase y función completos

Module SearchUtils
 
  Public 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()
    Var hits() As SearchHit
 
    If targetFolder Is Nil Then
      Return hits
    End If
 
    If Not targetFolder.Exists Or Not targetFolder.IsFolder Then
      Return hits
    End If
 
    If searchTerm.IsEmpty Then
      Return hits
    End If
 
    For Each item As FolderItem In targetFolder.Children
      If item Is Nil Then Continue
      If item.IsFolder Then Continue
      If Not item.Exists Then Continue
      If Not item.IsReadable Then Continue
 
      Try
        Var input As TextInputStream = TextInputStream.Open(item)
        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(item.NativePath, lineNumber, line))
          End If
        Wend
 
        input.Close
 
      Catch error As IOException
        ' Skip files that cannot be read as text.
        Continue
      End Try
    Next
 
    Return hits
  End Function
 
End Module

Así funciona

Revisemos las partes importantes.

Validación de la entrada

Este bloque evita hacer trabajo innecesario y evita problemas en tiempo de ejecución:

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

La regla de oro: Descarta entradas no soportadas lo antes posible.

Iteración por la carpeta

Este es el núcleo del bucle:

For Each item As FolderItem In targetFolder.Children

Exploramos solamente los contenidos inmediatos de la carpeta. Si un elemento es otra carpeta… la saltamos.

If item.IsFolder Then Continue

Lectura segura del texto

Cada archivo se abre con TextInputStream.Open dentro un bloque Try…Catch:

Try
  Var input As TextInputStream = TextInputStream.Open(item)
  input.Encoding = Encodings.UTF8
  ...
Catch error As IOException
  Continue
End Try

De este modo un archivo que no se pueda leer no dará al traste con la búsqueda. Se saltará y aun se podrá procesar el resto de la carpeta.

Correspondencia línea a línea

Aquí es donde tiene lugar la búsqueda real:

While Not input.EndOfFile
  Var line As String = input.ReadLine
  lineNumber = lineNumber + 1
 
  If line.Contains(searchTerm, ComparisonOptions.CaseInsensitive) Then
    hits.Add(New SearchHit(item.NativePath, lineNumber, line))
  End If
Wend

Dado que estamos leyendo el archivo línea a línea, realizar el conteo de líneas es trivial.

¿Por qué mantenemos SearchHit en el módulo?

Porque pertenece a esta utilidad. La función de búsqueda y el tipo de su resultado son parte de la misma pequeña unidad o comportamiento, de modo que mantenerlas juntas hace que el proyecto sea más sencillo de explorar y de transformar en una biblioteca.

También significa que el código que usemos fuera del módulo utilice un tipo dentro de un espacio propio, evitando así posibles colisiones:

Var results() As SearchUtils.SearchHit = SearchUtils.FindInFiles(folder, "xojo")

Usando la función

Este es un ejemplo simple que permite al usuario seleccionar una carpeta, escribiendo los resultados en el registro de depuración:

Var folder As FolderItem = FolderItem.ShowSelectFolderDialog
If folder Is Nil Then Return
 
Var results() As SearchUtils.SearchHit = SearchUtils.FindInFiles(folder, "error")
 
For Each hit As SearchUtils.SearchHit In results
  System.DebugLog(hit.FilePath + " | line " + hit.LineNumber.ToString + " | " + hit.LineText)
Next

Si quisieras mostrar los resultados en un DesktopListBox, también podría hacerse de forma sencilla:

Var folder As FolderItem = FolderItem.ShowSelectFolderDialog
If folder Is Nil Then Return
 
Var results() As SearchUtils.SearchHit = FindInFiles(folder, "error")
 
ListBox1.RemoveAllRows
ListBox1.ColumnCount = 3
 
For Each hit As SearchUtils.SearchHit In results
  ListBox1.AddRow(hit.FilePath)
  ListBox1.CellTextAt(ListBox1.LastAddedRowIndex, 1) = hit.LineNumber.ToString
  ListBox1.CellTextAt(ListBox1.LastAddedRowIndex, 2) = hit.LineText
Next

Algunos resultados de ejemplo podrían tener la siguiente forma:

C:\Logs\app.log | line 18 | Error connecting to database
C:\Logs\app.log | line 42 | Error writing audit record
C:\Configs\service.txt | line 7 | Last error message: timeout

Limitaciones prácticas de esta versión

Esta utilidad tiene algunas limitaciones.

Asume UTF-8

Resulta adecuado para muchos de los archivos de texto actuales, pero no para todos. Si tratas con codificaciones mixtas entonces tendrás que tomar un enfoque más inteligente que permita detectar la codificación del archivo.

No explora las subcarpetas

Es algo deliberado. La búsqueda recursiva es útil pero añade otra capa de comportamiento y haría que el código mostrado en este ejemplo fuese más complejo de leer/seguir.

Conclusiones

Una función para buscar en los contenidos de los archivos es una de esas utilidades que parecen pequeñas pero resulta realmente útil. La mostrada en este artículo es básica. Una vez que la tengas funcionando, podrás añadir recursividad o bien búsquedas basadas en RegEx para potenciarla, entre otras capacidades.

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *