Posts

Plugins de Swift Package Manager: automatiza tareas y extiende el proceso de compilación

Arturo Rivas Arias

Swift Package Manager no sirve únicamente para distribuir librerías y resolver dependencias. También permite ampliar el flujo de trabajo mediante plugins escritos en Swift capaces de ejecutar herramientas, validar un proyecto, generar código o incorporar pasos adicionales al proceso de compilación.

La principal ventaja de este sistema es que la automatización pasa a formar parte del propio paquete. En lugar de depender de un script guardado en una carpeta, de una configuración manual de Xcode o de una herramienta instalada en el sistema operativo, el paquete puede declarar qué necesita, cuándo debe ejecutarse y qué permisos requiere.

Un plugin se define como un target especial dentro de Package.swift y utiliza la API del módulo PackagePlugin. Su código se ejecuta en un proceso separado de Swift Package Manager y no se enlaza con la aplicación ni aumenta el tamaño de su binario. Es una herramienta de desarrollo, no una dependencia disponible durante la ejecución del producto.

Los dos tipos de plugins

Swift Package Manager ofrece dos tipos de extensión principales: los plugins de comando y los plugins de herramienta de compilación. Aunque ambos se escriben en Swift, resuelven problemas diferentes y se ejecutan en momentos distintos.

Plugins de comando

Un plugin de comando representa una acción que el desarrollador ejecuta de forma explícita. Puede utilizarse para comprobar convenciones del proyecto como reglas de estilo, actualizar archivos, calcular métricas, generar documentación o aplicar un formateador.

Su ejecución no está asociada al grafo de compilación. Desde el terminal de comando se invoca mediante swift package, seguido del nombre declarado por el plugin:

swift package audit-resources

También es posible consultar los comandos disponibles para el paquete:

swift package plugin --list

El siguiente fichero declara un plugin que revisa los nombres de los recursos del proyecto:

// swift-tools-version: 6.0

import PackageDescription

let package = Package(
    name: "ResourceAuditor",
    products: [
        .plugin(
            name: "ResourceAuditPlugin",
            targets: ["ResourceAuditPlugin"]
        )
    ],
    targets: [
        .plugin(
            name: "ResourceAuditPlugin",
            capability: .command(
                intent: .custom(
                    verb: "audit-resources",
                    description: "Comprueba los nombres de los recursos"
                )
            )
        )
    ]
)

El código del plugin se coloca normalmente en Plugins/ResourceAuditPlugin/Plugin.swift y debe adoptar el protocolo CommandPlugin:

import Foundation
import PackagePlugin

@main
struct ResourceAuditPlugin: CommandPlugin {
    func performCommand(
        context: PluginContext,
        arguments: [String]
    ) async throws {
        let allowedExtensions = ["png", "jpg", "json"]
        let packageURL = context.package.directoryURL

        let enumerator = FileManager.default.enumerator(
            at: packageURL,
            includingPropertiesForKeys: nil
        )

        var invalidFiles: [URL] = []

        while let fileURL = enumerator?.nextObject() as? URL {
            guard allowedExtensions.contains(fileURL.pathExtension) else {
                continue
            }

            let filename = fileURL.deletingPathExtension().lastPathComponent

            if filename.contains(" ") || filename.first?.isUppercase == true {
                invalidFiles.append(fileURL)
            }
        }

        if invalidFiles.isEmpty {
            Diagnostics.remark("Todos los recursos respetan la convención")
        } else {
            for fileURL in invalidFiles {
                Diagnostics.error(
                    "Nombre de recurso no válido: \(fileURL.lastPathComponent)"
                )
            }
        }
    }
}

PluginContext proporciona una representación controlada del paquete, sus productos y sus targets. Por su parte, Diagnostics permite comunicar observaciones, advertencias y errores de una forma que Swift Package Manager y Xcode pueden presentárselo correctamente al desarrollador.

Plugins de herramienta de compilación

Un plugin de compilación se ejecuta como parte del proceso de construcción de un target. Su objetivo habitual es generar código fuente o recursos, transformar archivos de entrada o ejecutar validaciones que deben cumplirse antes de producir el binario final.

A diferencia de un plugin de comando, este plugin no suele realizar directamente el trabajo pesado. En su lugar, describe uno o varios comandos que el sistema de compilación ejecutará posteriormente.

import PackageDescription

let package = Package(
    name: "ConfigurationKit",
    targets: [
        .executableTarget(
            name: "ConfigGenerator"
        ),
        .plugin(
            name: "ConfigGenerationPlugin",
            capability: .buildTool(),
            dependencies: ["ConfigGenerator"]
        ),
        .target(
            name: "ConfigurationKit",
            plugins: [
                .plugin(name: "ConfigGenerationPlugin")
            ]
        )
    ]
)

En este ejemplo, ConfigGenerator es un ejecutable convencional que realiza la transformación, mientras que ConfigGenerationPlugin se ocupa de integrarlo en la compilación.

import Foundation
import PackagePlugin

@main
struct ConfigGenerationPlugin: BuildToolPlugin {
    func createBuildCommands(
        context: PluginContext,
        target: Target
    ) async throws -> [Command] {
        guard let sourceTarget = target.sourceModule else {
            return []
        }

        let inputURL = sourceTarget.directoryURL
            .appending(path: "app-config.json")

        guard FileManager.default.fileExists(atPath: inputURL.path) else {
            return []
        }

        let tool = try context.tool(named: "ConfigGenerator")
        let outputURL = context.pluginWorkDirectoryURL
            .appending(path: "GeneratedAppConfig.swift")

        return [
            .buildCommand(
                displayName: "Generando configuración de la aplicación",
                executable: tool.url,
                arguments: [
                    inputURL.path,
                    outputURL.path
                ],
                inputFiles: [inputURL],
                outputFiles: [outputURL]
            )
        ]
    }
}

Al declarar las entradas y salidas, el plugin pasa a formar parte del grafo de dependencias. El sistema de compilación puede comprobar las fechas y el contenido de los archivos para evitar ejecuciones innecesarias. Si app-config.json no ha cambiado y el archivo generado sigue disponible, la herramienta no necesita volver a ejecutarse.

buildCommand frente a prebuildCommand

Los plugins de compilación pueden crear dos clases de comandos. Elegir correctamente entre ellas tiene un impacto directo en el rendimiento de las compilaciones incrementales.

buildCommand es la opción recomendada cuando se conocen de antemano todos los archivos de entrada y salida. Esa información permite al sistema ejecutar la herramienta únicamente cuando una entrada cambia o falta una salida.

prebuildCommand, en cambio, se utiliza cuando el número o el nombre de los archivos generados no puede conocerse hasta que la herramienta analiza su contenido. Estos comandos se ejecutan antes de cada compilación y deben implementar su propio sistema de caché cuando el trabajo sea costoso.

.prebuildCommand(
    displayName: "Actualizando modelos generados",
    executable: tool.url,
    arguments: [schemaDirectory.path, outputDirectory.path],
    outputFilesDirectory: outputDirectory
)

Utilizar un prebuildCommand por comodidad puede penalizar considerablemente los tiempos de compilación. Siempre que las salidas sean predecibles, resulta preferible declarar un buildCommand con entradas y salidas concretas.

El aislamiento de los plugins

Los plugins se ejecutan dentro de un entorno aislado en las plataformas compatibles. De forma predeterminada no pueden acceder libremente a la red ni escribir en cualquier ubicación del sistema de archivos. Todos disponen de un directorio temporal de trabajo, pero cualquier permiso adicional debe declararse y ser aprobado.

Esta restricción es especialmente importante porque un plugin es código ejecutable procedente de una dependencia. Al añadir un paquete no solo estamos incorporando su código fuente: también podemos estar permitiendo que una herramienta se ejecute en nuestra máquina o en el servidor de integración continua.

Un plugin de comando que necesite modificar el paquete debe solicitar el permiso correspondiente en el manifiesto:

.plugin(
    name: "ManifestUpdaterPlugin",
    capability: .command(
        intent: .custom(
            verb: "update-manifest",
            description: "Actualiza el manifiesto de contenidos"
        ),
        permissions: [
            .writeToPackageDirectory(
                reason: "Necesita actualizar el archivo ContentManifest.json"
            )
        ]
    )
)

Los plugins de herramienta de compilación no pueden modificar directamente el código fuente del paquete. Sus resultados deben escribirse en el directorio de trabajo proporcionado por el contexto, desde donde Swift Package Manager los incorpora al proceso de compilación.

Plugins y herramientas ejecutables

La separación entre el plugin y la herramienta que realiza el trabajo es una de las decisiones más importantes de esta arquitectura. El plugin utiliza PackagePlugin para inspeccionar el paquete y construir comandos, mientras que un target ejecutable independiente puede utilizar Foundation, Swift Argument Parser u otras librerías necesarias para procesar los datos.

El código de un plugin tiene dependencias muy limitadas. Puede importar módulos estándar como Foundation, pero no enlazar directamente cualquier librería declarada por el paquete. Cuando la lógica crece, conviene moverla a un ejecutable y hacer que el plugin lo localice mediante context.tool(named:).

Este diseño tiene varias ventajas:

  • La herramienta puede probarse de forma independiente.
  • El plugin conserva un tamaño contenido y centrado en la integración.
  • El ejecutable puede reutilizarse desde el terminal o desde otros sistemas.
  • Swift Package Manager compila la herramienta para la plataforma anfitriona, no para la plataforma de destino de la aplicación.

Uso desde Xcode

Xcode puede descubrir y ejecutar plugins declarados por un paquetes. Los plugins de comando aparecen entre las acciones disponibles del paquete, mientras que los plugins de compilación se ejecutan automáticamente cuando están asociados a un target.

También existe el módulo XcodeProjectPlugin, que permite adaptar un plugin para trabajar con targets pertenecientes a un proyecto de Xcode y no solamente con targets definidos en Package.swift. La implementación puede añadirse de forma condicional para conservar la compatibilidad con Swift Package Manager desde el terminal:

#if canImport(XcodeProjectPlugin)
import XcodeProjectPlugin

extension ResourceAuditPlugin: XcodeCommandPlugin {
    func performCommand(
        context: XcodePluginContext,
        arguments: [String]
    ) async throws {
        for target in context.xcodeProject.targets {
            Diagnostics.remark("Revisando el target \(target.displayName)")
        }
    }
}
#endif

Esto permite distribuir una misma automatización para equipos que trabajan con paquetes independientes y para aplicaciones organizadas mediante proyectos o espacios de trabajo de Xcode.

Ejecución en integración continua

Los plugins de comando también pueden formar parte de flujos de integración continua. Cuando necesitan permisos, la ejecución interactiva no resulta adecuada porque el servidor no puede responder a una solicitud de confirmación.

Swift Package Manager permite conceder explícitamente determinados permisos desde la línea de comandos:

swift package \
    --allow-writing-to-package-directory \
    update-manifest

Para conexiones de red puede utilizarse la opción correspondiente, aunque conviene limitarla al mínimo y revisar cuidadosamente el código de cualquier plugin de terceros antes de autorizarla.

Un plugin de validación puede ejecutarse antes de los tests y devolver errores mediante Diagnostics.error. De esta forma, una convención incumplida detiene la integración con el mismo mecanismo que un error de compilación, sin mantener scripts diferentes para cada entorno.

Cuándo merece la pena crear un plugin

Un plugin resulta especialmente útil cuando una tarea debe estar versionada junto al paquete, ser reproducible para todo el equipo, y cualquier integrador si se trata de una librería, y funcionar de la misma forma desde Xcode, el terminal y el servidor de integración continua.

Los plugins de comando encajan bien con tareas manuales como auditorías, generación de informes, actualización de archivos o mantenimiento del repositorio. Los plugins de compilación son más adecuados cuando el resultado forma parte del producto construido y debe regenerarse automáticamente al cambiar sus entradas.

No todas las automatizaciones necesitan convertirse en un plugin. Un script sencillo puede seguir siendo suficiente para una operación interna y ocasional. El plugin empieza a aportar valor cuando la tarea debe distribuirse, descubrirse y ejecutarse de forma coherente sin exigir una instalación global o instrucciones adicionales.

Los plugins convierten Swift Package Manager en algo más que un gestor de dependencias. Permiten empaquetar no solo el código que utiliza una aplicación, sino también las herramientas y reglas necesarias para desarrollarlo. Bien diseñados, eliminan pasos manuales, reducen diferencias entre entornos y hacen que el proceso de compilación sea más reproducible y fácil de mantener.