wandres.dev
CONSTRUCTOR · Build con Meson

Anatomía de un meson.build

El lenguaje de Meson por dentro: la llamada project y lo que fija para todo el árbol, la familia completa de targets, las opciones propias en meson.options y el modelo de dos fases que separa configurar de compilar.

⏱ 17 min

Un meson.build no es un script ni un archivo de configuración: es un programa escrito en un lenguaje deliberadamente mutilado. No admite funciones definidas por el usuario, no tiene recursión, no puede recorrer el sistema de archivos a su antojo ni consultar el entorno cuando le apetece. Esa amputación es la funcionalidad entera. Lo que el lenguaje te impide decir es, casi punto por punto, aquello que volvería irreproducible tu compilación; y lo que sí te deja decir se traduce a un grafo de Ninja que ya no depende de nadie.

🎯 Al terminar esta lección sabrás
  • Leer la firma completa de project() y saber qué fija para todo el árbol de directorios.
  • Distinguir la familia de targets y elegir con criterio entre executable, library y static_library.
  • Declarar opciones propias en meson.options y consumirlas con get_option().
  • Separar la fase de configuración de la de compilación y saber qué se congela en cada una.

La llamada project y lo que fija

project() debe ser la primera llamada del meson.build raíz: nada puede precederla, ni siquiera una asignación. Meson lo exige porque hasta que no sabe el nombre, los lenguajes y las opciones por defecto no puede inicializar los compiladores, y todo lo demás depende de ellos.

project('mates', 'c',
    version: '1.4.0',
    license: 'MIT',
    meson_version: '>=1.3.0',
    default_options: [
        'c_std=c23',
        'warning_level=3',
        'werror=true',
        'default_library=static',
    ])

El primer argumento posicional es el nombre del proyecto, y no es decorativo: identifica al proyecto cuando alguien lo consume como subproyecto y da nombre al tarball que produce meson dist. Los siguientes posicionales son los lenguajes; declarar c es lo que hace que Meson busque y valide un compilador de C antes de seguir.

version queda disponible en todo el árbol mediante meson.project_version() y viaja después al archivo de pkg-config generado, de modo que la versión se escribe una sola vez. meson_version fija un suelo mínimo de la herramienta y hace que la configuración falle con un mensaje claro en lugar de reventar más tarde, en una línea cualquiera, con un error incomprensible sobre una función que en esa versión aún no existía.

El matiz que más gente pasa por alto está en default_options: son valores por defecto, no imposiciones. Cualquier -D que el usuario pase en meson setup gana, y lo mismo ocurre con las opciones que el empaquetador de una distribución fije por política. Si de verdad necesitas que un target concreto se compile con un estándar o un nivel de avisos determinado pase lo que pase, eso no se declara aquí sino target a target con override_options.

Targets: la familia y su semántica

Un target es un nodo del grafo con reglas para reconstruirlo. Meson ofrece una familia acotada y cada miembro codifica una decisión distinta sobre quién elige el resultado.

🏗️

executable

Produce un binario ejecutable. Acepta las mismas claves que las bibliotecas más las de enlazado final.

📚

library

Delega en la opción default_library: quien compila decide si sale estática, dinámica o ambas.

🔒

static_library y shared_library

Fuerzan la forma. Úsalas solo cuando el formato sea un requisito técnico, no una preferencia.

⚙️

custom_target

La vía de escape: una orden arbitraria con entradas y salidas declaradas. Poderosa y opaca.

La distinción entre library() y sus dos primas rígidas es la más interesante. Al usar library() cedes la decisión a quien construye el proyecto, que es exactamente quien tiene la información para tomarla: un empaquetador de distribución querrá bibliotecas dinámicas con SONAME, alguien que fabrique un binario autocontenido querrá estáticas. Escribir static_library() porque a ti te resulta cómodo es robarle esa decisión sin motivo.

inc = include_directories('include')

fuentes = files(
    'src/vector.c',
    'src/matriz.c',
)

mates = library('mates', fuentes,
    include_directories: inc,
    c_args: ['-DMATES_INTERNO=1'],
    gnu_symbol_visibility: 'hidden',
    version: meson.project_version(),
    soversion: '1',
    install: true)

mates_dep = declare_dependency(
    link_with: mates,
    include_directories: inc)

executable('demo', 'demo/main.c', dependencies: mates_dep)

Dos detalles cargan casi todo el peso de este fragmento. El primero es files(): convierte cadenas en objetos de archivo resueltos respecto al meson.build que los menciona, de forma que esa lista puede viajar a un target declarado en otro directorio sin romperse. Una cadena suelta no sobrevive a ese viaje, y ese es el origen de la mitad de los builds rotos al reorganizar carpetas con subdir().

El segundo es declare_dependency(). Devuelve un objeto del mismo tipo que devuelve dependency() para una biblioteca externa, y esa uniformidad es la idea central del diseño: un consumidor no distingue si lo que recibe viene de tu árbol, del sistema o de un subproyecto descargado. Sin ella, cada usuario de tu biblioteca tendría que repetir a mano las rutas de include y los flags, y bastaría con que uno se olvidara para tener un binario incoherente.

Opciones: las del sistema y las tuyas

Meson trae un catálogo de opciones integradas que no tienes que declarar: buildtype, optimization, debug, warning_level, werror, c_std, default_library, prefix, b_sanitize, b_lto, b_ndebug, b_coverage. Se consultan y se cambian sin reconfigurar de cero.

meson configure build                      # lista todas con su valor actual
meson configure build -Dwerror=false       # cambia una en caliente
meson setup build --wipe                   # reconfigura desde cero conservando los -D

Las tuyas van en meson.options, en la raíz del proyecto. El archivo se llamaba meson_options.txt hasta la versión 1.1 y ese nombre sigue funcionando, pero el nuevo es el que verás en proyectos recientes.

option('telemetria', type: 'boolean', value: false,
       description: 'Compila el modulo de telemetria')

option('backend', type: 'combo', choices: ['epoll', 'kqueue', 'poll'],
       value: 'poll', description: 'Multiplexor de eventos')

option('zlib', type: 'feature', value: 'auto',
       description: 'Compresion con zlib')

option('max_conexiones', type: 'integer', min: 1, max: 65535, value: 1024)

El tipo feature merece una lectura atenta porque no es un booleano con otro nombre: tiene tres estados —habilitado, deshabilitado y automático— y existe precisamente porque un booleano no sabe expresar no lo sé todavía. Con auto, la funcionalidad se activa si la dependencia aparece y se desactiva en silencio si no; con enabled, su ausencia detiene la configuración con un error explícito; con disabled, Meson ni siquiera busca. Esa tercera posición es la diferencia entre un paquete que se compila sin cifrado porque nadie se dio cuenta y uno que se niega a compilarse mintiendo sobre lo que hace.

Las opciones se leen con get_option() y suelen desembocar en un header generado, que es la forma limpia de que el preprocesador vea lo que decidió el build:

conf = configuration_data()
conf.set('MATES_VERSION', meson.project_version())
conf.set10('CON_TELEMETRIA', get_option('telemetria'))
conf.set('MAX_CONEXIONES', get_option('max_conexiones'))
configure_file(output: 'config.h', configuration: conf)

set10 existe para el idioma habitual de C: escribe el valor como 1 o 0 para que el header sirva con #if, no solo con #ifdef. Es un detalle minúsculo que evita la clase de error donde una macro definida a cero se comporta como verdadera.

El modelo de dos fases

Todo lo anterior se evalúa una sola vez, durante meson setup. El intérprete recorre los meson.build, construye el grafo en memoria y lo escribe como build.ninja. A partir de ahí, meson compile no vuelve a interpretar nada: solo lanza Ninja sobre un archivo ya generado.

flowchart TB
A[meson.build y meson.options] --> B[meson setup]
B --> C[Interprete evalua el lenguaje una vez]
C --> D[Grafo de targets en memoria]
D --> E[build.ninja escrito en disco]
E --> F[meson compile lanza ninja]
F --> G[Objetos y binarios reconstruidos solo si cambian]
C -.regla de regeneracion.-> E

De ahí se derivan dos consecuencias prácticas. La primera es que todo lo que calcules en el lenguaje queda congelado en la configuración: si tu meson.build consulta el reloj o lista un directorio con run_command(), ese resultado se fija y no se actualizará aunque el mundo cambie. Por eso run_command() en tiempo de configuración es casi siempre un olor a diseño, y las tareas que deben rehacerse cuando cambian sus entradas van en custom_target(), que sí vive dentro del grafo.

La segunda es que Meson inserta una regla de regeneración en el propio build.ninja: si tocas un meson.build, la siguiente compilación reejecuta la configuración sola, antes de compilar nada. No existe el paso manual de volver a lanzar el configure que arrastran otros sistemas.

Lo que un lenguaje de build no te deja decir vale más que lo que sí

La historia de los sistemas de construcción es la historia de un error repetido: dar al autor del build un lenguaje de propósito general. Autotools se apoya en m4 y shell, los Makefiles ejecutan shell arbitrario, CMake acabó fabricando un lenguaje propio que creció hasta volverse programable. En los tres casos el resultado es el mismo: cualquier cosa se puede expresar y, por tanto, ninguna herramienta puede analizar el build sin ejecutarlo. Meson toma el camino contrario y lo hace a conciencia: sin funciones de usuario, sin recursión, con variables que son valores y no referencias, con bucles solo sobre colecciones ya conocidas. Renunciar a esa expresividad es lo que compra todo lo demás. Es lo que permite que meson introspect conteste qué targets existen sin compilar nada, que compile_commands.json salga gratis y tu editor entienda tu proyecto, que la compilación cruzada funcione porque nada en el lenguaje ejecuta binarios de la máquina destino, que una interfaz gráfica pueda listar y modificar las opciones sin interpretar código ajeno. La lección trasciende a Meson y reaparece en cada capa de la industria: los formatos declarativos ganan sobre los imperativos no porque sean más elegantes, sino porque un artefacto que solo se puede ejecutar es un artefacto que solo se puede ejecutar, mientras que uno que se puede leer se puede además comprobar, transformar, cachear y explicar. Cuando diseñes cualquier configuración, la pregunta importante no es qué necesito poder expresar, sino qué me conviene no poder expresar nunca.

⚔️ Disecciona tu propio build
  1. Escribe un meson.build con project() completo: versión, licencia, suelo de meson_version y default_options con c_std=c23, warning_level=3 y werror=true.
  2. Divide el proyecto en include/ y src/, usa files() y include_directories(), y expón la biblioteca con declare_dependency(). Comprueba que el ejecutable no repite ni una ruta de include.
  3. Declara una opción feature en meson.options y observa la diferencia entre configurarla como auto, enabled y disabled cuando la dependencia no está instalada.
  4. Genera un config.h con configure_file() y set10, e inspecciónalo dentro del directorio de build.
  5. Lanza meson introspect --targets build y meson configure build, y explica por qué ninguno de los dos necesita compilar nada para responder.