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.