Cómo crear tu primer Swift Package Plugin
Libranner Santos
20 abril, 20237min de lectura
Los plugins son scripts de Swift que pueden realizar acciones en un paquete Swift o en un proyecto de Xcode.
Estos pueden ser muy útiles cuando queremos compartir herramientas con nuestro equipo, para mejorar el proceso de desarrollo.
En esta oportunidad vamos a ver paso a paso como crear un plugin siguiendo este requerimiento de ejemplo:
El plugin debe tomar los archivos de localizaciones (xxx.strings) de cada paquete y crear en dentro de la carpeta Excludes una versión del archivo en un formato JSON como este:
{"<Nombre del paquete>" : {"key-1": "value1","key-2": "value2",...}}
¡Manos a la obra!
Conociendo el proyecto de inicio
Primero descarga el proyecto de inicio que se encuentra en este enlace: Paquete Inicial.
Veamos que contiene el proyecto:
- El proyecto contiene dos sub-paquetes:
MyPackage
ySecondPackage
. - Ambos tienen varios directorios, pero para nuestro ejemplo nos interesan los siguientes:
- Excludes donde alojaremos nuestro JSON a generar.
- Resources este directorio aloja los recursos como imágenes y localizaciones.
- Los demás directorios son solo para ilustrar un poco, no son relevantes para el ejemplo.
Ahora que tenemos el proyecto inicial, creemos el plugin.
Creando un command plugin
Hay dos clases de plugin, Command (comando) y Build Tool (herramienta compilación). En este caso, crearemos uno de tipo comando. Esto nos permitirá ejecutar nuestro plugin usando el menú de contexto de Xcode, haciendo click derecho sobre la carpeta del paquete.
Creando la estructura del plugin
-
Lo primero que tenemos que hacer es crear una carpeta para alojar el plugin, por convención debemos usar el nombre Plugins para la misma. Esta carpeta debe crearse al mismo nivel que la carpeta Sources que se encuentran en el paquete.
-
Dentro de la carpeta Plugins creamos la carpeta para el plugin que estaremos trabajando, el nombre será LocalizationsGenerator. Esta carpeta contendrá el código de nuestro plugin.
-
Dentro de la carpeta LocalizationsGenerator creamos el archivo Plugin.swift.
Con esos primeros pasos hemos creado todos los archivos y directorios que necesitaremos, es importante que conservemos la jerarquía indicada, porque si no tendremos problemas en los siguientes pasos.
Modificando el Manifiesto
Ahora procedemos a modificar nuestro manifiesto. Abrimos el archivo Package.swift, colocamos una coma luego de la declaración del target
, SecondPackage, y escribimos:
.plugin(name: "LocalizationsGenerator",capability: .command(intent: .custom(verb: "gen-locales",description: "Genera archivos de localizaciones en formato json"),permissions: [.writeToPackageDirectory(reason: "Necesito permiso para crear los archivos.")]))
Con esto le indicamos al manifiesto que el proyecto contiene un target de tipo plugin. Para esto indicamos el nombre y en la propiedad capability
establecemos que es de tipo command
.
Command acepta dos parámetros intent
donde indicamos verb
, que es la cadena de caracteres con la cual podremos ejecutar este plugin desde la terminal, en este caso, gen-locales
, así mismo pasamos la descripción. Por último, indicamos los permisos que necesitaremos en el parámetro permissions
, en nuestro caso, necesitaremos permisos de escritura para poder crear los archivos JSON.
Escribiendo el código de nuestro plugin
Ya tenemos todo el proyecto configurado y ahora necesitamos escribir el código que ejecutará nuestro plugin.
Abrimos Plugin.swift y escribimos el siguiente código:
import Foundationimport PackagePlugin@mainstruct LocalizationsGenerator: CommandPlugin {func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws {}}
Creamos un struct
llamado LocalizationsGenerator
precedido por la notación @main
. Esto quiere decir que este código será lo primero a ejecutarse.
Dentro de LocalizationsGenerator
conformamos a CommandPlugin
, el cual solo tiene un requisito performCommand(context:arguments:)
, lo que pongamos dentro de este método será la lógica de nuestro plugin.
Copia el siguiente código dentro de performCommand(context:arguments:)
:
for target in context.package.targets {guard let target = target as? SourceModuleTarget, target.kind != .test else {continue}for file in target.sourceFiles(withSuffix: ".strings") {guardlet content = try? String(contentsOfFile: file.path.string)else {continue}let json = encode(from: [target.moduleName : convertToDictionary(content)])try? save(content: json, for: target.moduleName)}}
En este código estamos iterando sobre todos los targets del proyecto. Luego nos aseguramos de que sean de tipo SourceModuleTarget
y que no sean tests. Si estas dos condiciones son correctas, procedemos a leer cada uno de los archivos que tengan la extensión .strings.
Cuando ya tenemos el contenido del archivo, procedemos a llamar el método encode()
el cual convierte un diccionario en JSON.
Por último, llamamos el método save(content:for:)
, el cual creará el archivo Localizations.json dentro de la carpeta Excludes.
Copiamos el siguiente código dentro de LocalizationsGenerator
:
func convertToDictionary(_ content: String) -> [String: String] {var result = [String: String]()guard let regex = try? NSRegularExpression(pattern: "\"(.*?)\"\\s*=\\s*\"(.*?)\";", options: []) else {return result}regex.enumerateMatches(in: content, options: [], range: NSRange(content.startIndex..., in: content)) { match, _, _ inguard let match = match else { return }guardlet keyRange = Range(match.range(at: 1), in: content),let valueRange = Range(match.range(at: 2), in: content) else {return}result[String(content[keyRange])] = String(content[valueRange])}return result}func save(content: String, for packageName: String) throws {try content.write(toFile: "./Sources/\(packageName)/Excludes/Localizations.json", atomically: true, encoding: .utf8)}func encode(from dictionary: [String: [String: String]]) -> String {guard let jsonData = try? JSONSerialization.data(withJSONObject: dictionary, options: .prettyPrinted) else {return ""}guard let jsonString = String(data: jsonData, encoding: .utf8) else {return ""}return jsonString}
Para evitar extender el artículo no explicaremos estos bloques de código, pero son necesarios para convertir el contenido de los archivos .strings
a JSON y guardarlo en la carpeta Excludes.
Usando nuestro plugin
Compila el paquete, luego haz click derecho sobre grupo que contiene el paquete. En la parte inferior podrás dar click sobre el comando que hemos creado LocalizationsGenerator:
Algunas veces no funciona a la primera y necesitamos cerrar Xcode y abrirlo nuevamente.
Cuando seleccionemos el comando aparecerá el siguiente modal:
Aquí podemos elegir sobre cuales targets queremos ejecutar nuestro plugin. Por ahora solo presionamos Run, para ejecutarlo en todos.
Luego, aparecerá el siguiente mensaje para confirmar que otorgamos al plugin los permisos de escritura.
Aquí podemos ver el mensaje que indicamos cuando declaramos el plugin en el manifiesto, "Necesito permiso para crear los archivos.".
Comprobando los resultados
Nuestra tarea consistía en generar archivos json con los datos de los archivos de localización. Si verificamos las carpetas Excludes en cada paquete, veremos que sean generado satisfactoriamente:
Ejecutando el plugin desde la terminal
Recordemos que cuando declaramos el plugin indicamos un parámetro llamado verb
, al cual le asignamos el valor gen-locales
. Esto nos permite ejecutar el plugin desde la terminal.
En la terminal nos movemos hasta el directorio donde está nuestro paquete, y ejecutamos el siguiente comando:
swift package gen-localesPlugin ‘LocalizationsGenerator’ wants permission to write to the package directory.Stated reason: “Necesito permiso para crear los archivos.”.Allow this plugin to write to the package directory? (yes/no) yes
En la terminal también debemos autorizar el permiso de escritura. Una alternativa para no tener que responder yes en la terminal. Es usar el siguiente comando:
swift package --allow-writing-to-package-directory gen-locales
!Haz creado tu primer plugin, felicidades! Si quieres verificar contra el proyecto final, puedes descargarlo usando este enlace: Paquete Final.