Cobertura de código para GoAWK
Por Volodymyr Gubarkov · CTO y cofundador
- Publicado el
- Actualizado el
- 15 min de lectura

Mi investigación reveló que ninguna implementación de AWK contaba con una función de cobertura de código. La más adecuada para incorporarla resultó ser GoAWK.
Resumen (TL;DR)
Contribuí al proyecto GoAWK implementando la funcionalidad de cobertura de código.
Estoy agradecido a Ben Hoyt (el creador de GoAWK) por su gran paciencia y su ayuda durante todo mi proceso de contribución, con revisiones de código minuciosas y perspicaces.
¿Qué es la cobertura de código?
En términos sencillos, la cobertura de código es una medida que indica qué parte de su código fuente se ejecuta con las pruebas.
Pero ¿por qué querríamos tenerla para AWK?
Cómo surgió la idea / motivaciones
Makesure es un ejecutor de tareas y comandos que estoy desarrollando. Se parece un poco a la conocida herramienta make, pero sin la mayoría de sus peculiaridades (¡y con un par de funciones únicas!).
Puede sorprender, pero lo escribí en AWK. No voy a mentir: escribirlo en AWK empezó más por curiosidad que por una necesidad real. Para mí, los límites fomentan la creatividad. Hoy en día pocas personas saben que AWK es un lenguaje de programación completo, aunque muy minimalista.
Makesure nació más bien como un experimento para ver hasta dónde se puede llegar escribiendo una herramienta en AWK. Resulta que bastante lejos. No creo que esto sea una excepción, sino un patrón constante, debido al verdadero genio de los autores de AWK.
Por poner un ejemplo de otro proyecto escrito en AWK:
¡Escribí un compilador en awk!
A bytecode; quería usar el compilador basado en awk como etapa inicial de arranque (bootstrap) de un compilador autoalojado (self-hosted). Por inquietante que parezca, funcionó bien. Para mi decepción, resultó ser más rápido que la versión autoalojada. Pero ni de lejos es el lenguaje adecuado para escribir compiladores. No tener estructuras de datos de verdad era un problema. Pero quedó en un código sorprendentemente limpio de unas 1500 líneas. awk sigue siendo el lenguaje al que recurro para tareas pequeñas y puntuales de programación y de procesamiento de texto.
La otra idea que hay detrás de elegir AWK para Makesure es la facilidad del análisis sintáctico. Por ejemplo, al analizar esta sintaxis de Makesure:
@goal built
@depends_on tested
gcc code.cSi conoce un poco de AWK, entenderá que esta sintaxis es bastante fácil de analizar con él. AWK ya divide el texto en palabras por usted, así que lo único que necesita es esto:
if ($1 == "@goal") handleGoal($2)
else if ($1 == "@depends_on") handleDependency($2)
else handleCodeLine($0)En esencia, usar una herramienta limitada para el trabajo (AWK) le anima a respetar el principio Worse is better, del que soy un firme defensor. No se inventa una sintaxis sofisticada, sino que se recurre a una sintaxis que se pueda analizar de forma directa.
Pasó el tiempo y el código fuente de Makesure creció hasta convertirse en un archivo awk bastante grande. La herramienta tenía una suite de pruebas extensa, pero no estaba seguro de lo buena que era la cobertura ni de si se probaban todos los escenarios críticos.
Así, la necesidad de comprobar la cobertura se hizo evidente. Mi investigación reveló que ninguna implementación de AWK disponía de una función de cobertura de código. Así que decidí añadir esa función a una implementación de AWK. La más adecuada para esa incorporación resultó ser GoAWK.
Por supuesto, otras de mis motivaciones eran practicar mis habilidades de programación en Go y AWK.
Cómo funciona la cobertura, en términos sencillos
En esencia, para recopilar estadísticas de cobertura de código usamos la instrumentación del código fuente. Es decir, cada línea de código se anota con un fragmento de código de seguimiento (más detalles abajo). Así, cuando se ejecuta el programa instrumentado, el código de seguimiento se activa cada vez que se alcanza una línea de código. Al finalizar el programa, todos los datos recopilados del perfil de cobertura se escriben en un archivo. Después, este archivo se utiliza para generar un informe de cobertura legible para las personas, como el siguiente:

En qué se diferencia la cobertura de código en AWK de la de los lenguajes más extendidos
Para probar makesure utilizo la herramienta tush. Ofrece una forma muy agradable y sencilla de probar una herramienta de línea de comandos (CLI) como una caja negra.
Las pruebas en tush tienen este aspecto:
$ command --that --should --execute correctly
| expected stdout output
$ command --that --will --cause error
@ expected stderr output
? expected-exit-code(en mi caso, command es makesure)
Al ejecutar estas pruebas, tush ejecuta todas las líneas que empiezan por $ y compara sin más la salida real con la salida esperada mediante un diff normal. Si hay alguna diferencia, la prueba falla y se muestra el diff al usuario.
En esencia, estas pruebas son pruebas end-to-end, situadas en la cima de la conocida pirámide de pruebas. Esto es muy bueno, ya que este estilo de pruebas equivale a la forma en que un usuario real utiliza la herramienta, lo que da muchas más probabilidades de detectar errores reales.
Con este enfoque creé una suite de pruebas completa para Makesure, con un archivo de pruebas típico como este.
El enfoque habitual para probar (como en Go, Python o Java) consiste en crear un conjunto de pruebas (normalmente en varios archivos fuente) y luego usar un “ejecutor de pruebas” (test runner) para ejecutarlas todas a la vez. Y ahí radica una diferencia sutil pero importante respecto al enfoque descrito arriba.
Cuando se usa un ejecutor de pruebas, este se ejecuta una sola vez para todas las pruebas, de modo que puede formar el perfil de cobertura global cuando todas ellas han terminado.
En el caso de tush, el programa bajo prueba (makesure, y por tanto el awk que hay debajo) se invoca muchas veces con distintos argumentos. Esto significa que necesitamos una forma de ensamblar el perfil de cobertura final de manera incremental.
Esto plantea retos de implementación adicionales. Necesitamos que ejecuciones separadas de un programa AWK puedan añadir datos a un único perfil de cobertura. Esto implica que el formato interno de este archivo debe ser “anexable”, pero también exige que la herramienta de informes que consume el archivo comprenda esta “anexabilidad”.
Cómo pensé en abordar el problema
Hace un par de años me topé con el artículo Code Coverage for Solidity. El artículo me resultó muy ilustrativo, ya que daba suficiente explicación técnica y describía los retos típicos de implementar la cobertura de código para un lenguaje de programación concreto.
El autor basó su implementación en la herramienta de cobertura Istanbul, pensada inicialmente para JavaScript. Pero consiguió generar perfiles de cobertura para otro lenguaje (Solidity) en formato Istanbul, lo que le permitió reutilizar gratis la herramienta de informes de Istanbul.
Mi plan era usar un enfoque similar. Pero mi investigación mostró que el formato de perfil de cobertura de Istanbul no resultaba adecuado para añadir datos al final.
La otra línea de mi razonamiento estuvo marcada por el clásico libro The AWK Programming Language de Alfred V. Aho, Brian W. Kernighan y Peter J. Weinberger (la A, la W y la K de AWK). Dicho sea de paso, este libro es un auténtico placer de leer y sigue siendo asombrosamente actual, a pesar de haberse publicado en 1988.
En las páginas 167-169 de este libro figura la sección “7.2 Profiling”. Describe un enfoque muy sencillo para implementar el perfilado de un programa con solo dos scripts de AWK: makeprof y printprof (¡son solo unas pocas líneas de código cada uno!).makeprof realiza una transformación (ciertamente ingenua) del código de entrada insertando una instrucción de la forma _LBcnt[i]++; después de cada llave de apertura {. Luego añade una cláusula END que vuelca en un archivo los recuentos de instrucciones recopilados en _LBcnt.printprof asocia los recuentos de instrucciones de este archivo al programa original y muestra los recuentos junto con el código fuente original.
¡Me fascinó por completo la sencillez y la claridad de este enfoque!
A pesar de que este enfoque dista mucho de ser perfecto y práctico, llegué a plantearme seriamente usar una versión mejorada del mismo. Sin embargo, estaba claro que, para hacerlo más preciso y robusto, tendría que transformar el AST (árbol de sintaxis abstracta) del programa AWK y no limitarme a una modificación ingenua del código fuente.
Enfoque de implementación elegido
Mientras trabajaba a fondo con AWK en mi proyecto, un día me topé con GoAWK. Por curiosidad, probé a ejecutar Makesure con él. Esto reveló algunos errores en GoAWK, que notifiqué y que Ben Hoyt, su creador, corrigió con prontitud. Así que, finalmente, GoAWK superó la suite de pruebas de Makesure.
Por aquel entonces empecé a interesarme por aprender el lenguaje de programación Go. Por eso me alegró contribuir y corregir algunos errores menores que encontré. En general, me gustó mucho el proyecto GoAWK y, sobre todo, la persona que hay detrás. No puedo evitar recomendar el blog técnico de Ben, muy bien escrito e interesante.
Así que pensé que, fuera cual fuese el enfoque de implementación que eligiera, quería usar GoAWK como base. Como GoAWK estaba escrito en Go, decidí echar un vistazo al soporte de cobertura del propio Go. Fue entonces cuando me topé con la “cover story” de Go. ¡Me convenció de inmediato!
La funcionalidad no era tan avanzada como la de Istanbul, pero seguía siendo suficiente. La implementación parecía relativamente sencilla. Pero, sobre todo, ¡su formato de perfil de cobertura parece estar diseñado pensando en poder añadir datos al final! (Aunque, que yo sepa, las propias herramientas de Go no utilizan directamente esta característica).
Así que decidí que debíamos añadir la funcionalidad de cobertura de código a GoAWK, pero usando el formato de perfil de cobertura de Go, de modo que pudiéramos reutilizar la maquinaria de Go (go tool cover) para generar el informe final.
Intento fallido (chapucero)
Para entender mejor lo que sigue, puede resultar útil que el lector lea primero los propios textos de Ben sobre GoAWK.
GoAWK utiliza patrones bastante estándar en la implementación de lenguajes de programación y en la estrategia de ejecución:
1. En primer lugar, el código fuente AWK de entrada (como cadena de texto) lo procesa el analizador léxico (lexer), que genera una lista de tokens.
2. A continuación, la lista de tokens sirve de entrada para el analizador sintáctico (parser), cuya salida es un AST (árbol de sintaxis abstracta).
- GoAWK también tiene la noción de un resolvedor (resolver), que inicialmente formaba parte del parser. Analiza el AST y lo anota con algunos datos adicionales, como información sobre los tipos de las variables.
3. Después, el AST pasa por el compilador de bytecode, que produce una lista de opcodes y sus argumentos: el bytecode de GoAWK.
4. Por último, el bytecode sirve de entrada al intérprete, que lleva dentro una máquina virtual. Ejecuta los opcodes instrucción a instrucción para ejecutar el programa.
Ahora necesitaba entender cómo encajar la funcionalidad de cobertura de código en este esquema.
Como expliqué antes, necesitamos instrumentar el código fuente para el seguimiento de la cobertura. Es mucho más fácil explicarlo con un ejemplo:
Dado el siguiente código fuente AWK:
BEGIN {
print "will always run"
if ((1 + 1) == 2) {
print "should run"
} else {
print "won't run"
}
}el código instrumentado tendrá este aspecto:
BEGIN {
__COVER["3"] = 1 # track coverage
print "will always run"
if ((1 + 1) == 2) {
__COVER["1"] = 1 # track coverage
print "should run"
} else {
__COVER["2"] = 1 # track coverage
print "won't run"
}
}Era obvio que esta inserción de código de seguimiento debía hacerse a nivel del AST. Así que imaginé que tendríamos que añadir un paso adicional entre el 2 y el 3: tomaría el AST como entrada, lo pasaría por un “anotador de cobertura” (aún por añadir) y produciría el AST transformado.
¡Pero en la práctica no fue tan sencillo! El problema principal era el estrecho acoplamiento entre el parser y el resolver. De hecho, ambos ocurrían en la misma pasada, así que era imposible ejecutarlos por separado.
Permítame explicarle por qué esto es importante. Si me limitaba a modificar el AST añadiendo los nodos necesarios para las instrucciones __COVER["N"] = 1, esos nuevos nodos carecerían de algunos datos diminutos (pero importantes) que rellena el resolver. Así que, de algún modo, necesitaba pasar el AST actualizado por el paso de resolución una vez más. Pero eso era técnicamente imposible, porque el resolver (al ser parte del parser) solo podía consumir lo que consume el parser, es decir, la lista de tokens.
Así que pensé: ¿por qué no volver a generar el código fuente AWK a partir del AST modificado y empezar todo el proceso desde el paso 1? Por suerte, GoAWK ya tenía una implementación de String() para todos los nodos del AST, así que podía volver a generar código fuente AWK a partir del AST. Por desgracia, este código fuente AWK no tenía garantizada su corrección en todos los casos, porque su finalidad era mostrar el AST analizado para depuración (la opción -d).
Fue entonces cuando añadí el primer parche sucio: parcheé el AST instrumentado (más exactamente, las piezas insertadas) como si lo hubiera hecho el propio resolver.
El segundo parche que utilicé fue todavía más desagradable. Cuando insertaba la instrumentación de cobertura así:
__COVER["2"] = 1También almaceno internamente la información que la describe, como el nombre del archivo y las líneas de código que se cubren. Esta información se guarda en trackedBlock, una estructura.
Así que internamente tenemos lo siguiente (pseudocódigo de Go):
trackedBlocks[2] = trackedBlock{
path: "/path/to/script.awk",
start: "2:3",
end: "35:13",
numStmts: 34,
}Guardar estos datos es necesario para construir el perfil de cobertura resultante (en el formato que usa go cover), que tiene este aspecto:
...
/path/to/script.awk:2.3,35.13 34 1
/path/to/script.awk:41.39,41.51 1 0
/path/to/script.awk:40.5,41.39 2 0
/path/to/script.awk:42.27,42.42 1 0
...Cada línea anterior describe la cobertura recopilada para algún bloque de código y tiene el siguiente formato:
<source_file_path>:<column_from>.<line_from>,<column_to>.<line_to> <num_stmts> <exec_count>Ahora, recuerde que se puede ejecutar AWK con varios archivos fuente, así:
awk -f file1.awk -f file2.awk -f file3.awkPero GoAWK, en el paso 1 anterior, simplemente unía todos los archivos fuente en una única cadena y la usaba como entrada del lexer.
Esto significa que, cuando obtenemos el AST tras el paso 2, no hay forma de saber de qué archivo de entrada procede cada nodo del AST. Por tanto, no podemos rellenar el campo path de nuestro trackedBlock durante la instrumentación.
Así que, para pasar al AST la información de path necesaria, había que hacerla pasar tanto por el lexer como por el parser, pensé. Para ello introduje un token “falso” que insertaba al principio de cada archivo. De este modo, cuando se unían los archivos fuente, este token representaba los límites entre archivos, lo que significaba que, en el momento del análisis, podía seguir la pista de los nombres de archivo y de las posiciones locales dentro de cada uno.
Refactorizaciones
Aunque el enfoque funcionaba bien en la práctica, estaba claro que era feo y que no tenía ninguna posibilidad de fusionarse tal cual. Ben presentó una lista de observaciones de la revisión de código sobre cuestiones estructurales que quería ver resueltas antes de fusionar esto.
No había más remedio que acometer un par de refactorizaciones importantes antes de intentar fusionar mi funcionalidad de cobertura de código.
Sinceramente, no estaba nada molesto, ¡sino más bien entusiasmado! En primer lugar, tuve la oportunidad de hacer un trabajo aún más útil para un proyecto que me gustaba. Y me alegraba seguir afilando mi kung-fu en Go.
Lo primero y más importante era desacoplar los pasos de análisis y de resolución. Daba un poco de miedo tocarlo. Incluso Ben describió esta parte del código así:
De hecho, el resolver fue una de las piezas de código más difíciles que he escrito en mucho tiempo. Es la única parte del código fuente de GoAWK con la que no estoy especialmente contento. Funciona, pero está hecho un lío, y sigo sin estar seguro de haber cubierto todos los casos límite.
Pero creo que entendí cómo funcionaba en general y cómo podía hacer la refactorización. Leer los textos técnicos de Ben fue de gran ayuda.
Necesitaba una forma de recorrer (y actualizar) el AST para el paso de resolución posterior al análisis.
Para ello utilicé el patrón visitor, muy similar al que se usa en el propio Go. Una vez implementada la funcionalidad del visitor, el recorrido del AST necesario para la resolución resultó sencillo.
Mi otra refactorización tenía como finalidad convertir las posiciones globales del código fuente unido de nuevo en posiciones locales de los archivos fuente de entrada. Esto era necesario para librarme del parche n.º 2 mencionado antes.
Cuando ambas refactorizaciones fueron revisadas y fusionadas en master, pude enviar, sobre esa base, mi implementación limpia y definitiva de la cobertura de código.
En general, recomiendo encarecidamente este enfoque para las contribuciones grandes. No debería intentar meter todos sus cambios en un único pull request. Cada PR debe ser muy cohesivo, es decir, resolver un único problema. Así es mucho más fácil para el responsable del proyecto revisar y aceptar esos PR. Además, esos PR tendrán valor por sí mismos, aunque no todos lleguen a la rama master. Como dice John Ousterhout, la descomposición de problemas es la idea más importante de toda la informática.
Resultados
Al ejecutar la cobertura de GoAWK sobre Makesure se descubrió código sin cubrir. Esto ayudó a encontrar un error (que ya se ha corregido).
Lo que falta
Como he mencionado, el enfoque de Go para la cobertura de código no es muy avanzado. Implementa la cobertura de instrucciones en lugar de la cobertura de ramas (lo mismo se aplica a la cobertura de GoAWK). Por simplicidad, no cubre algunos casos límite. Otras herramientas de cobertura, como Istanbul, pueden ofrecer una cobertura más detallada, como la de ambas cláusulas de un operador ternario o la de la rama else invisible.
Para ilustrar este último punto, imaginemos este código:
if (condition) {
print "body"
}
print "after"Puede tenerlo todo en verde y aun así haber algo sin cubrir: la rama implícita “else” no está cubierta. En otras palabras, queremos asegurarnos de que se cubren tanto la variante verdadera como la falsa de condition.
Estaría muy bien mejorar esto para la cobertura de GoAWK y quizá incluso para el propio Go, ¡pero lo dejaré para un trabajo futuro!
Enlaces
Artículo de Ben Hoyt sobre la cobertura de código en GoAWK
Documentación de la cobertura de GoAWK
GoAWK, un intérprete de AWK escrito en Go
El blog de Go: “The cover story”, de Rob Pike
The AWK Programming Language – el libro de los creadores de AWK, ¡de lectura obligada!
Sobre el autor

CTO y cofundador
Centrado principalmente en el desarrollo backend y la configuración de infraestructuras. Tiene una sólida experiencia en el diseño e implementación de sistemas orientados a objetos y en el desarrollo de arquitecturas de sistemas.
Más de Volodymyr GubarkovCategoría:Desarrollo de software
Volver al blog

