diff --git a/PLUGIN-SPEC-METADATA.md b/PLUGIN-SPEC-METADATA.md new file mode 100644 index 0000000..39d5c85 --- /dev/null +++ b/PLUGIN-SPEC-METADATA.md @@ -0,0 +1,356 @@ +# Config Metadata Generation + +## Overview + +The `nextflow-plugin-gradle` plugin provides automatic generation of JSON metadata from configuration classes annotated with `@Description`, `@ConfigOption`, and `@ScopeName` annotations. This metadata enables IDE autocomplete, validation, and documentation tooling for Nextflow plugin configuration options. + +## How It Works + +The `ConfigMetadataTask` uses runtime reflection to scan compiled Groovy/Java classes that implement the `ConfigScope` interface and extract annotation metadata. The task: + +1. Loads compiled classes from the plugin's classpath +2. Scans specified packages for classes implementing `ConfigScope` +3. Extracts `@ScopeName`, `@Description`, and `@ConfigOption` annotations +4. Generates a schema-compliant JSON file at `build/generated/resources/META-INF/plugin.json` +5. Includes the JSON file in the plugin JAR automatically + +### Why Not Annotation Processors? + +This implementation uses a Gradle task with runtime reflection instead of Java annotation processors because: + +- **Groovy Compatibility**: Annotation processors don't reliably work with Groovy's joint compilation +- **Runtime Annotations**: Existing annotations use `@Retention(RUNTIME)`, which aren't visible to annotation processors +- **Simplicity**: Gradle task approach is easier to debug and maintain +- **No Breaking Changes**: Works with existing annotation definitions + +## Usage + +### Basic Setup + +In your plugin's `build.gradle`: + +```groovy +plugins { + id 'io.nextflow.nextflow-plugin' version '1.0.0-beta.11' +} + +nextflowPlugin { + // ... other plugin configuration ... + + // Enable config metadata generation + configPackages = ['your.plugin.config.package'] +} +``` + +### Configuration Options + +#### `configPackages` (optional) + +List of package names to scan for configuration classes. + +```groovy +nextflowPlugin { + // Scan specific packages + configPackages = ['nextflow.cloud.aws.config', 'nextflow.cloud.aws.batch'] + + // Or scan all packages (leave empty or omit) + configPackages = [] +} +``` + +**Default behavior:** If `configPackages` is empty or not specified, all compiled classes will be scanned. + +## Annotations + +### Required Annotations + +Your configuration classes must use these annotations: + +#### `@ConfigScope` Interface + +Marker interface that identifies a class as a configuration scope: + +```groovy +import nextflow.config.schema.ConfigScope + +class AwsConfig implements ConfigScope { + // ... +} +``` + +#### `@ScopeName` (optional) + +Defines the configuration scope name: + +```groovy +import nextflow.config.schema.ScopeName + +@ScopeName("aws") +class AwsConfig implements ConfigScope { + // ... +} +``` + +#### `@Description` (optional) + +Documents configuration scopes and options: + +```groovy +import nextflow.script.dsl.Description + +@Description(""" + The `aws` scope controls interactions with AWS. +""") +class AwsConfig implements ConfigScope { + + @ConfigOption + @Description(""" + AWS region (e.g. `us-east-1`). + """) + String region +} +``` + +#### `@ConfigOption` (required for fields) + +Marks a field as a configuration option: + +```groovy +import nextflow.config.schema.ConfigOption + +@ConfigOption +String region + +@ConfigOption +Integer maxConnections +``` + +## Output Format + +The generated `META-INF/plugin.json` conforms to the [Nextflow plugin schema v1](https://raw.githubusercontent.com/nextflow-io/schemas/main/plugin/v1/schema.json). + +### Example Output + +```json +{ + "definitions": [ + { + "type": "ConfigScope", + "spec": { + "name": "aws", + "description": "The `aws` scope controls interactions with AWS.", + "children": [ + { + "type": "ConfigOption", + "spec": { + "name": "region", + "type": "String", + "description": "AWS region (e.g. `us-east-1`)." + } + }, + { + "type": "ConfigOption", + "spec": { + "name": "maxConnections", + "type": "Integer", + "description": "Maximum number of connections." + } + } + ] + } + } + ] +} +``` + +### Schema Structure + +- **`definitions`**: Array of configuration scopes +- **`type`**: Always `"ConfigScope"` for scope definitions +- **`spec.name`**: Scope name from `@ScopeName` (optional) +- **`spec.description`**: Scope description from `@Description` (optional) +- **`spec.children`**: Array of configuration options + - **`type`**: Always `"ConfigOption"` for option definitions + - **`spec.name`**: Field name + - **`spec.type`**: Java type name (e.g., `String`, `Integer`, `Boolean`) + - **`spec.description`**: Option description from `@Description` (optional) + +## Build Integration + +### Task Dependencies + +The `configMetadata` task: +- Depends on `compileGroovy` (runs after compilation) +- Is a dependency of `jar` (runs before JAR packaging) +- Outputs to `build/generated/resources/META-INF/plugin.json` + +### Generated Resources + +The generated JSON file is automatically included in the plugin JAR: + +``` +plugin.jar +└── META-INF/ + └── plugin.json +``` + +### Manual Task Execution + +To generate metadata without building the entire plugin: + +```bash +./gradlew configMetadata +``` + +To force regeneration: + +```bash +./gradlew configMetadata --rerun-tasks +``` + +## Complete Example + +### Configuration Class + +```groovy +package nextflow.cloud.aws.config + +import nextflow.config.schema.ConfigOption +import nextflow.config.schema.ConfigScope +import nextflow.config.schema.ScopeName +import nextflow.script.dsl.Description + +@ScopeName("aws") +@Description(""" + The `aws` scope controls interactions with AWS, including AWS Batch and S3. +""") +class AwsConfig implements ConfigScope { + + @ConfigOption + @Description(""" + AWS region (e.g. `us-east-1`). + """) + String region + + @ConfigOption + @Description(""" + AWS account access key. + """) + String accessKey + + @ConfigOption + @Description(""" + AWS account secret key. + """) + String secretKey +} +``` + +### Plugin Build Configuration + +```groovy +plugins { + id 'io.nextflow.nextflow-plugin' version '1.0.0-beta.11' +} + +nextflowPlugin { + nextflowVersion = '25.08.0-edge' + provider = 'Your Name' + description = 'AWS cloud integration plugin' + className = 'nextflow.cloud.aws.AwsPlugin' + + // Enable config metadata generation + configPackages = ['nextflow.cloud.aws.config'] +} +``` + +### Build and Verify + +```bash +# Build the plugin +./gradlew build + +# Verify generated metadata +cat build/generated/resources/META-INF/plugin.json + +# Verify it's included in JAR +jar tf build/libs/your-plugin.jar | grep plugin.json +``` + +## Troubleshooting + +### No Metadata Generated + +**Problem:** Task runs but generates empty `definitions` array. + +**Solutions:** +1. Ensure `configPackages` includes the correct package names +2. Verify config classes implement `ConfigScope` interface +3. Check that classes are compiled (run `compileGroovy` first) +4. Enable debug logging: `./gradlew configMetadata --debug` + +### Classes Not Found + +**Problem:** `ClassNotFoundException` or `NoClassDefFoundError` in logs. + +**Solutions:** +1. Ensure all dependencies are available at compile time +2. Check that annotation classes are in `compileOnly` dependencies +3. Verify classpath includes both `classesDirs` and `compileClasspath` + +### Groovy Method Dispatch Errors + +**Problem:** `Could not find method` errors during task execution. + +**Cause:** Groovy's dynamic method dispatch doesn't work across classloaders. + +**Solution:** The task already handles this by inlining all method calls and using `invokeMethod()` for annotation value access. If you see this error, report it as a bug. + +### Annotations Not Visible + +**Problem:** `@Description` or `@ConfigOption` values are null or missing. + +**Solutions:** +1. Verify annotations have `@Retention(RetentionPolicy.RUNTIME)` +2. Check annotation imports are correct +3. Ensure annotations are on the correct elements (TYPE, FIELD) + +## Advanced Usage + +### Custom Output Location + +The output file location is configurable via task properties: + +```groovy +tasks.named('configMetadata') { + outputFile = layout.buildDirectory.file('custom/path/metadata.json') +} +``` + +### Additional Processing + +To post-process the generated JSON: + +```groovy +tasks.register('validateMetadata') { + dependsOn configMetadata + + doLast { + def json = file('build/generated/resources/META-INF/plugin.json') + // Validate against schema, generate TypeScript definitions, etc. + } +} +``` + +## Related Documentation + +- [Nextflow Plugin Schema v1](https://raw.githubusercontent.com/nextflow-io/schemas/main/plugin/v1/schema.json) +- [Nextflow Plugin Development Guide](https://www.nextflow.io/docs/latest/plugins.html) +- [nf-amazon Example](https://github.com/nextflow-io/nextflow/tree/master/plugins/nf-amazon) - Reference implementation + +## Version History + +- **1.0.0-beta.11** (Oct 2025) + - Initial release of config metadata generation + - Schema-compliant JSON output + - Support for @Description, @ConfigOption, @ScopeName annotations diff --git a/changelog.txt b/changelog.txt index d3ceeed..0008357 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,10 @@ +1.0.0-beta.11 - 9 Oct 2025 +- Add ConfigMetadataTask to generate schema-compliant JSON metadata from @Description annotations +- Add configPackages configuration option to NextflowPluginConfig +- Automatically generate and include configuration metadata in plugin JARs as META-INF/plugin.json +- Support for @Description, @ConfigOption, and @ScopeName annotations +- Generated JSON conforms to Nextflow plugin schema v1 + 1.0.0-beta.10 - 8 Oct 2025 - Add CLAUDE.md documentation for AI-assisted development - Update RegistryClient to use JSON POST for createRelease endpoint diff --git a/src/main/groovy/io/nextflow/gradle/ConfigMetadataTask.groovy b/src/main/groovy/io/nextflow/gradle/ConfigMetadataTask.groovy new file mode 100644 index 0000000..7bcbff9 --- /dev/null +++ b/src/main/groovy/io/nextflow/gradle/ConfigMetadataTask.groovy @@ -0,0 +1,255 @@ +package io.nextflow.gradle + +import groovy.json.JsonOutput +import org.gradle.api.DefaultTask +import org.gradle.api.file.FileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction + +import java.io.Closeable + +/** + * Gradle task to generate JSON metadata from {@code @Description}, {@code @ConfigOption}, + * and {@code @ScopeName} annotations on configuration classes. + * + *

This task scans compiled classes for configuration metadata annotations and generates + * a single {@code plugin.json} file in {@code META-INF/} that conforms to the Nextflow + * plugin schema.

+ * + *

Generated JSON structure:

+ *
+ * {
+ *   "definitions": [
+ *     {
+ *       "type": "ConfigScope",
+ *       "spec": {
+ *         "name": "aws",
+ *         "description": "The `aws` scope controls interactions with AWS...",
+ *         "children": [
+ *           {
+ *             "type": "ConfigOption",
+ *             "spec": {
+ *               "name": "region",
+ *               "type": "String",
+ *               "description": "AWS region (e.g. `us-east-1`)."
+ *             }
+ *           }
+ *         ]
+ *       }
+ *     }
+ *   ]
+ * }
+ * 
+ * + * @author Paolo Di Tommaso + */ +class ConfigMetadataTask extends DefaultTask { + + /** + * List of package names to scan for configuration classes. + * If not specified, the task will attempt to scan all packages. + */ + @Input + @Optional + final ListProperty configPackages + + /** + * The compiled classes directories to scan for configuration classes. + */ + @InputFiles + FileCollection classesDirs + + /** + * The classpath needed to load the classes (includes dependencies). + */ + @InputFiles + FileCollection classpath + + /** + * Output file where the plugin.json metadata will be generated. + * Defaults to {@code build/generated/resources/META-INF/plugin.json}. + */ + @OutputFile + final RegularFileProperty outputFile + + ConfigMetadataTask() { + group = 'nextflow plugin' + description = 'Generate JSON metadata from @Description annotations on config classes' + + configPackages = project.objects.listProperty(String) + configPackages.convention([]) + + outputFile = project.objects.fileProperty() + outputFile.convention(project.provider { + project.layout.buildDirectory.file("generated/resources/META-INF/plugin.json").get() + }) + + // Default to main source set + // Use compileClasspath instead of runtimeClasspath because plugins typically + // use compileOnly for nextflow dependencies + classesDirs = project.sourceSets.main.output.classesDirs + classpath = project.sourceSets.main.compileClasspath + } + + @TaskAction + void generateMetadata() { + def outputFileObj = outputFile.get().asFile + outputFileObj.parentFile.mkdirs() + + // Create class loader with compiled classes and dependencies + def urls = (classesDirs.files + classpath.files).collect { it.toURI().toURL() } as URL[] + def classLoader = new URLClassLoader(urls, (ClassLoader) null) + + try { + // Load annotation classes dynamically to avoid compile-time dependencies + def ConfigScope = loadClassSafely(classLoader, 'nextflow.config.schema.ConfigScope') + def ConfigOption = loadClassSafely(classLoader, 'nextflow.config.schema.ConfigOption') + def ScopeName = loadClassSafely(classLoader, 'nextflow.config.schema.ScopeName') + def Description = loadClassSafely(classLoader, 'nextflow.script.dsl.Description') + + if (!ConfigScope || !ConfigOption || !ScopeName || !Description) { + project.logger.info("ConfigMetadataTask: Required annotation classes not found, skipping metadata generation") + return + } + + // Collect all config scope definitions + def definitions = [] + int processedCount = 0 + + // Determine packages to scan + def packages = configPackages.get() + if (packages.isEmpty()) { + // If no packages specified, scan all classes + project.logger.debug("ConfigMetadataTask: No packages specified, scanning all compiled classes") + packages = findAllPackages(classesDirs) + } + + // Process each package + packages.each { pkg -> + def pkgPath = pkg.replace('.', '/') + classesDirs.each { classesDir -> + def pkgDir = new File(classesDir, pkgPath) + if (pkgDir.exists() && pkgDir.isDirectory()) { + pkgDir.listFiles()?.each { file -> + if (file.name.endsWith('.class') && !file.name.contains('$')) { + def className = "${pkg}.${file.name[0..-7]}" + try { + def clazz = classLoader.loadClass(className) + if (ConfigScope.isAssignableFrom(clazz)) { + // Build schema-compliant definition + def scopeSpec = [:] + + // Extract @ScopeName annotation + def scopeNameAnnot = clazz.getAnnotation(ScopeName) + if (scopeNameAnnot) { + scopeSpec.name = scopeNameAnnot.invokeMethod('value', null) + } + + // Extract class-level @Description annotation + def descAnnot = clazz.getAnnotation(Description) + if (descAnnot) { + def descValue = descAnnot.invokeMethod('value', null) + scopeSpec.description = descValue?.stripIndent()?.trim() ?: "" + } + + // Extract field metadata as children + def children = [] + clazz.getDeclaredFields().each { field -> + def configOption = field.getAnnotation(ConfigOption) + if (configOption) { + def optionSpec = [ + name: field.name, + type: field.type.simpleName + ] + + // Extract field-level @Description + def fieldDesc = field.getAnnotation(Description) + if (fieldDesc) { + def fieldDescValue = fieldDesc.invokeMethod('value', null) + optionSpec.description = fieldDescValue?.stripIndent()?.trim() ?: "" + } + + children.add([ + type: "ConfigOption", + spec: optionSpec + ]) + } + } + + if (children) { + scopeSpec.children = children + } + + if (scopeSpec) { + definitions.add([ + type: "ConfigScope", + spec: scopeSpec + ]) + project.logger.lifecycle("ConfigMetadataTask: Processed ${className}") + processedCount++ + } + } + } catch (ClassNotFoundException e) { + project.logger.debug("ConfigMetadataTask: Could not load class ${className}: ${e.message}") + } catch (NoClassDefFoundError e) { + project.logger.debug("ConfigMetadataTask: Missing dependency for ${className}: ${e.message}") + } catch (Exception e) { + project.logger.warn("ConfigMetadataTask: Error processing ${className}: ${e.message}") + } + } + } + } + } + } + + // Write single plugin.json file with all definitions + def pluginMetadata = [definitions: definitions] + outputFileObj.text = JsonOutput.prettyPrint(JsonOutput.toJson(pluginMetadata)) + project.logger.lifecycle("ConfigMetadataTask: Generated metadata for ${processedCount} configuration class(es) in ${outputFileObj.name}") + + } finally { + // Clean up class loader resources + if (classLoader instanceof Closeable) { + classLoader.close() + } + } + } + + /** + * Find all packages in the compiled classes directories. + */ + private List findAllPackages(FileCollection classesDirs) { + def packages = [] as Set + classesDirs.each { classesDir -> + if (classesDir.exists()) { + classesDir.eachFileRecurse { file -> + if (file.isFile() && file.name.endsWith('.class')) { + def relativePath = classesDir.toPath().relativize(file.toPath()).toString() + def pkg = relativePath.replace(File.separator, '.').replaceAll(/\.[^.]+\.class$/, '') + if (pkg.contains('.')) { + packages.add(pkg.substring(0, pkg.lastIndexOf('.'))) + } + } + } + } + } + return packages.toList() + } + + /** + * Safely load a class, returning null if not found. + */ + private Class loadClassSafely(ClassLoader loader, String className) { + try { + return loader.loadClass(className) + } catch (ClassNotFoundException | NoClassDefFoundError e) { + project.logger.debug("ConfigMetadataTask: Class not available: ${className}") + return null + } + } +} diff --git a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy index 8d6ea9b..b67537b 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPlugin.groovy @@ -88,6 +88,18 @@ class NextflowPlugin implements Plugin { project.tasks.jar.from(project.layout.buildDirectory.dir('resources/main')) project.tasks.compileTestGroovy.dependsOn << extensionPointsTask + // configMetadata - generates JSON metadata from @Description annotations + def configMetadataTask = project.tasks.register('configMetadata', ConfigMetadataTask) { task -> + task.dependsOn(project.tasks.compileGroovy) + // Wire the configPackages from the plugin configuration + task.configPackages.convention(project.provider { + project.extensions.getByType(NextflowPluginConfig).configPackages + }) + } + project.tasks.jar.dependsOn << configMetadataTask + // ensure the generated metadata files are included in the JAR + project.tasks.jar.from(project.layout.buildDirectory.dir('generated/resources')) + // packagePlugin - builds the zip file project.tasks.register('packagePlugin', PluginPackageTask) project.tasks.packagePlugin.dependsOn << [ diff --git a/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy b/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy index ab9bc2d..15df306 100644 --- a/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy +++ b/src/main/groovy/io/nextflow/gradle/NextflowPluginConfig.groovy @@ -67,6 +67,12 @@ class NextflowPluginConfig { */ boolean useDefaultDependencies = true + /** + * List of package names to scan for configuration classes with @Description annotations. + * If empty, all packages will be scanned (optional). + */ + List configPackages = [] + /** * Configure registry publishing settings (optional) */