¡Hola de nuevo! 🙌 Esperamos que estés emocionado por continuar con el desarrollo de nuestro analizador de argumentos de línea de comandos, zig-argueando. En esta segunda parte, nos enfocaremos en crear la estructura básica de nuestra biblioteca. Empecemos.

Diseñando la estructura de datos de los argumentos 📝

Necesitamos una estructura de datos que represente un argumento de línea de comandos y vamos a dedicarle un tiempo sustancial. Comprendo que puedes estar pensando, “¿Por qué debería pasar tanto tiempo pensando en estructuras de datos cuando hay tanto código que escribir?” Créeme, he estado allí. Pero a través de los años he aprendido que las estructuras de datos son fundamentales por varias razones:

  1. Eficiencia: Las estructuras de datos correctas pueden hacer una gran diferencia en la eficiencia de tu aplicación. A veces, la diferencia entre un algoritmo O(n^2) y uno O(n log n) es simplemente la elección de la estructura de datos adecuada. Una decisión temprana y consciente puede evitar muchos problemas de rendimiento en el futuro.

  2. Organización: El diseño de las estructuras de datos afecta a la organización de tu código. Una buena estructura de datos puede facilitar la lectura y la comprensión del código, haciendo que sea más fácil de mantener y extender.

  3. Abstracción: Las estructuras de datos nos permiten abstraer detalles complicados y centrarnos en problemas de alto nivel. Por ejemplo, no necesitamos saber exactamente cómo funciona una tabla hash para beneficiarnos de su capacidad para buscar elementos en tiempo constante.

  4. Modelado de datos: Las estructuras de datos son una forma de modelar la realidad en nuestros programas. Un buen modelo de datos puede facilitar la resolución de problemas y hacer que nuestro código sea más flexible y adaptable a los cambios.

Por eso es importante dedicarle tiempo desde el principio. No es solo por el hecho de “hacerlo bien” desde el comienzo. Es también por las consecuencias a largo plazo que pueden surgir si no se toma en cuenta. Las estructuras de datos mal elegidas pueden resultar en código lento, difícil de entender y de mantener, e incluso incorrecto.

Es cierto que el desarrollo de software, después de todo, es un proceso iterativo. Puedes comenzar con una estructura de datos inicial simple que cumpla con los requisitos básicos de tu aplicación. A medida que tu aplicación crece y evoluciona, podrías encontrarte en la necesidad de revisar y ajustar estas estructuras para acomodar nuevas características o mejorar el rendimiento.

Sin embargo, quiero añadir una nota de precaución aquí: este camino puede ser realmente arduo. Cambiar la estructura de datos fundamental de un programa en funcionamiento puede ser un proceso complicado y propenso a errores. Podrías tener que reescribir grandes partes de tu código y hacer un extenso trabajo de pruebas para asegurarte de que todo sigue funcionando como se espera.

No quiero desalentarte de hacer iteraciones sobre tus estructuras de datos, pero sí es importante tener en cuenta que un poco de tiempo invertido en la planificación inicial puede ahorrar mucho tiempo y dolores de cabeza más adelante. Es un equilibrio delicado, ¿verdad? Pero esa es parte de la belleza y el desafío de nuestra profesión.

En Zig, utilizaremos struct para definir nuestros modelos de datos. En nuestro modelo, vamos a representar parámetros que pueden ser de tipo option o positional. Los argumentos de tipo option pueden tener un nombre corto y otro largo.

Modelo de datos Param

⚠️ ¡Atención, programador! Este post utiliza la versión 0.11.0-dev.3971 de Zig

const Param = struct {
    kind: Kind,
    tag: usize = 0,
    help: []const u8 = "",
    check: ?*const Checks.Fn = null,
};

const Kind = union(KindTag) {
    option: Option,
    positional: Positional,
};

const Option = struct {
    format: Format = .flag,
    short: []const u8 = "",
    long: []const u8 = "",
};

pub const Positional = struct {
    format: Format,
};

Ambos tipos de parámetros definen un formato format que puede ser flag, single o multi representado parámetros tipo flag, que sólo pueden aparecer una vez o múltiples veces respectivamente. El formato, si no es flag, especifica un parser para convertir el argumento en un tipo de datos concreto. Además los parámetros pueden establecer una función personalizada de validación y definir el texto que aparecerá en la ayuda.

Modelo de datos Format

const Format = union(FormatTag) {
    flag: void,
    single: Single,
    multi: Multi,
};

pub const Single = struct {
    parser: []const u8 = "STR",
    default: ?[]const u8 = null,
};

pub const Multi = struct {
    parser: []const u8 = "STR",
    defaults: ?[]const []const u8 = null,
    min: usize = 1,
    max: usize = 1,
};

Un parser permite convertir y validar un argumento en texto a un tipo concreto en Zig. Para ello definimos dos punteros funciones, uno para realizar el parsing en sí y el otro para validar el valor obtenido. Además podremos definir el texto que aparecerá en la ayuda.

Modelo de datos Parser

Todos los elementos anteriores forman parte de la estructura principal CommandLineParser que incluye la lista de parsers y paramétros, así como los texto de ayuda que incluyen cabecera, pie y descripción; y por supuesto las opciones de procesado.

Modelo de datos CommandLineParser

Implementando la función de análisis de argumentos 💡

Con la estructura básica en su lugar, es hora de implementar la función del CommandLineParser que analizará los argumentos. Esta función tomará la lista de argumentos proporcionada por el usuario (normalmente a través de std.process.args()) y llenará la estructura de datos Args.

pub fn parseArgs(comptime self: CommandLineParser, allocator: std.mem.Allocator) Args(self) {
    var it = try std.process.ArgIterator.initWithAllocator(allocator);
    defer it.deinit();
    return self.parse(&it, allocator);
}

La función parseArgs recibe una lista de argumentos y procesa cada uno de ellos en orden. Si encuentra un argumento que no reconoce, o si se proporciona un valor para un argumento que no lo requiere, registra un problema. Si todo va bien, devuelve la estructura de datos Args llena con la información recopilada.

La estructura Args, que recibe el resultado del análisis de la línea de comandos, contiene:

  • exe, que tiene el nombre del ejecutable
  • args, que contiene una estructura con los nombres de las opciones y otro campo especial positionals con los argumentos posicionales
  • problems, es una lista de problemas encontrados durante el análisis de los argumentos

Modelo de datos Args

En este punto hay que hacer notar que todas las estructuras CommandLineParser, Args se han crean en tiempo de compilación. ¡Fantástico!

Usando las estructuras 🛠

Zig-argueando propociona funciones con valores por defecto para inicializar las estructuras, de esta forma obtenemos muchas ventajas:

  1. Podemos mantener una interface más estable y simple al usuario.
  2. Podemos modificar estructuras internas sin que nuestros usuarios tengan que cambiar el código
  3. Es más sencillo inicializar las estructuras porque tienen valores predefinidos “válidos”
  4. Las estructuras no admiten campos que no sean relevantes para el objeto que se define

Por ejemplo, para definir un parámetro que es un flag se proporciona la función flag, que admite una estructura que tiene preestablecidos sus campos:

pub const DefaultFlag = struct {
    short: []const u8 = "",
    long: []const u8 = "",
    help: []const u8 = "",
};

pub fn flag(comptime opts: DefaultFlag) Param {
    return Param{
        .kind = .{ .option = .{
            .short = opts.short,
            .long = opts.long,
            .format = .flag,
        } },
        .help = opts.help,
    };
}

Es muy fácil ahora definir un parámetro tipo flag que tenga como nombre largo --verbose y corto -v, con su correspondiente texto de ayuda:

 flag(.{ .long = "verbose", .short = "v", .help = "Enable verbose output." }),

Así proporcionamos funciones para definir parámetros flag, flagHelp, option, multiOption, singlePositional y multiPositional, y quién sabe si alguno más en el futuro.

Esto permite definir los analizadores de una manera muy intuitiva a nuestro usuarios. En muchas ocasiones, la simplicidad para los usuarios es nuestra complejidad:


    const clp = comptime Argueando.CommandLineParser.init(.{
        .header=
        \\    \                                                |        
        \\   _ \     __|  _` |  |   |   _ \   _` |  __ \    _` |   _ \  
        \\  ___ \   |    (   |  |   |   __/  (   |  |   |  (   |  (   | 
        \\_/    _\ _|   \__, | \__,_| \___| \__,_| _|  _| \__,_| \___/  
        \\              |___/  
        ,.params = &[_]Argueando.Param{
            flagHelp(.{ .long = "help", .short = "h", .help = "Shows this help." }),
            flag(.{ .long = "version", .help = "Output version information and exit." }),
            flag(.{ .long = "verbose", .short = "v", .help = "Enable verbose output." }),
            option(.{ .long = "port", .short = "p", .parser = "TCP_PORT", .default = "1234", .help = "Listening Port." }),
            option(.{ .long = "host", .short = "H", .parser = "TCP_HOST", .default = "localhost", .help = "Host name" }),
            singlePositional(.{ .parser = "DIR", .default = ".", .check = &Check.Dir(.{ .mode = .read_only }).f }),
        }, //
        .desc = "This command starts an HTTP Server and serves static content from directory DIR.", //
        .footer = "More info: <https://d4c7.github.io/zig-zagueando/>.",
    });

    var s = clp.parseArgs(allocator);
    defer s.deinit();

    if (s.helpRequested()) {
        try s.printHelp(std.io.getStdErr().writer());
        return;
    }

    if (s.hasProblems()) {
        try s.printProblems(std.io.getStdErr().writer(), .AllProblems);
        return;
    }
$ ./sample-argueando --help
    \                                                |        
   _ \     __|  _` |  |   |   _ \   _` |  __ \    _` |   _ \  
  ___ \   |    (   |  |   |   __/  (   |  |   |  (   |  (   | 
_/    _\ _|   \__, | \__,_| \___| \__,_| _|  _| \__,_| \___/  
              |___/  
Usage: sample-argueando [(-h|--help)]
    [--version] [(-v|--verbose)] [(-p|--port)=TCP_PORT] [(-H|--host)=TCP_HOST]
    [DIR]

This command starts an HTTP Server and serves static content from directory DIR.

  -h, --help             Shows this help.
      --version          Output version information and exit.
  -v, --verbose          Enable verbose output.
  -p, --port=TCP_PORT    Listening Port.
                         Default value: 1234
  -H, --host=TCP_HOST    Host name
                         Default value: localhost

  TCP_PORT    TCP port value between 0 and 65535. Use port 0 to dynamically assign a port
              Can use base prefix (0x,0o,0b). 
  TCP_HOST    TCP host name or IP. 
  DIR         Directory 

More info: <https://d4c7.github.io/zig-zagueando/>.

Conclusiones de la Segunda Parte ✨

Hemos definido los modelos de datos de nuestra biblioteca zig-argueando. Puedes ver código en desarrollo de la librería en GitHub, que iremos mejorando, analizando y explicando poco a poco. ¡Hasta la próxima! 👋