Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions dev-docs/ref-guide/antora.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,38 @@ Instead, update `antora.template.yaml`, and `gradlew buildLocalSite` will build

The only reason you will likely need to change the `antora.template.yml` is if you are introducing new variables for dependency versions.

== Auto-generated CLI Reference Pages

The `bin/solr` command-line tool documentation is partially auto-generated from the picocli `@Command`, `@Option`, and `@Parameters` annotations in the Java source.

=== How it works

For each command registered in `org.apache.solr.cli.SolrCLI` that uses picocli annotations, a dedicated AsciiDoc page is generated by `picocli.codegen.docgen.manpage.ManPageGenerator` and committed to:

----
solr/solr-ref-guide/modules/deployment-guide/pages/cli/
----

The generated pages are post-processed to be Antora-compatible (correct page title, `:page-toclevels:` attribute, ASF license header, DO NOT EDIT notice).
The navigation section in `deployment-nav.adoc` between the `// CLI-DOCS-START` and `// CLI-DOCS-END` markers is also updated automatically.

The main landing page `solr-control-script-reference.adoc` provides an overview of the available tools with links to each man-page.

=== Gradle targets

`./gradlew :solr:solr-ref-guide:generateCliDocs`::
Regenerates all pages under `pages/cli/` from the current picocli annotations and updates the nav.
Run this after modifying any `@Command`, `@Option`, or `@Parameters` annotation in `solr/core/src/java/org/apache/solr/cli/`.

`./gradlew :solr:solr-ref-guide:checkCliDocsUpToDate`::
Verifies that the committed pages in `pages/cli/` are in sync with the current annotations.
This target is wired into `gradlew check`, so CI will catch annotation changes that were not followed by a `generateCliDocs` run.

=== Adding docs for a new tool

The new command will automatically appear in the generated pages and the navigation when running `gradlew generateCliDocs`.
You will need to tie it in to the `solr-control-script-reference.adoc` yourself.

== Building the HTML Site

A Gradle target `gradlew buildLocalSite` will build the full HTML site (found in `solr/solr-ref-guide/build/site`).
Expand Down
20 changes: 20 additions & 0 deletions solr/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -210,3 +210,23 @@ dependencies {
testImplementation libs.opentelemetry.sdk.testing
testImplementation libs.dropwizard.metrics.core
}

// Expose a configuration for CLI doc generation in the solr-ref-guide project.
// This bundles the full runtime classpath plus picocli-codegen (ManPageGenerator)
// and the compiled CLI classes so that generateCliDocs can invoke ManPageGenerator.
configurations {
cliDocsRuntime {
canBeResolved = true
canBeConsumed = true
// Inherit all runtime dependencies (implementation + runtimeOnly transitively)
extendsFrom configurations.runtimeClasspath
}
}

dependencies {
// picocli-codegen provides ManPageGenerator (not in runtime, only needed for doc gen)
cliDocsRuntime libs.picocli.codegen
// Include the compiled CLI classes themselves
cliDocsRuntime files(sourceSets.main.output.classesDirs)
cliDocsRuntime files(sourceSets.main.output.resourcesDir)
}
225 changes: 225 additions & 0 deletions solr/solr-ref-guide/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,16 @@ configurations {
officialPlaybook
localPlaybook
localJavadocs
cliClasspath {
canBeResolved = true
canBeConsumed = false
}
}

dependencies {
localJavadocs project(path: ":solr:documentation", configuration: 'javadocs')
localJavadocs project(path: ":solr:documentation", configuration: 'site')
cliClasspath project(path: ':solr:core', configuration: 'cliDocsRuntime')
}

ext {
Expand Down Expand Up @@ -540,6 +545,226 @@ task buildOfficialSite(type: NpxTask) {
}
}

/*
CLI Documentation Generation from Picocli Annotations
Generates per-command AsciiDoc reference pages for the Antora ref guide.
Usage:
./gradlew :solr:solr-ref-guide:generateCliDocs -- regenerate pages/cli/
./gradlew :solr:solr-ref-guide:checkCliDocsUpToDate -- verify in-sync (also run by check)
*/

def ASF_LICENSE_HEADER = """\
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
"""

def DO_NOT_EDIT_NOTICE = """\
// DO NOT EDIT -- this page is auto-generated from picocli annotations.
// To update: modify the @Command/@Option annotations in the Java source, then run:
// ./gradlew :solr:solr-ref-guide:generateCliDocs
"""

// Convert a generated filename stem (e.g. "solr-zk-ls") to an Antora page title
// e.g. "solr-zk-ls" -> "bin/solr zk ls"
String cliFileNameToTitle(String baseName) {
def parts = baseName.split('-')
return "bin/solr " + parts.drop(1).join(' ')
}

// Return the short nav label for a command file (last segment of dash-separated name)
// e.g. "solr-zk-ls" -> "ls"
String cliFileNameToNavLabel(String baseName) {
return baseName.split('-').last()
}

// Post-process a raw ManPageGenerator AsciiDoc file for Antora compatibility.
// The title lives inside the man-section-header block, so we replace the whole
// header block with an Antora-compatible page title + attributes.
String postProcessCliManPage(File rawFile, String asfHeader, String doNotEditNotice) {
def content = rawFile.text
def baseName = rawFile.name.replace('.adoc', '')
def newTitle = cliFileNameToTitle(baseName)

// Replace the entire man-section-header block (which contains :doctype:manpage, the page title
// "= solr-start(1)", etc.) with our Antora-compatible title and page attributes.
content = content.replaceAll(
/(?s)\/\/ tag::picocli-generated-man-section-header\[\].*?\/\/ end::picocli-generated-man-section-header\[\]\n\n?/,
"= ${newTitle}\n:page-toclevels: 2\n\n")

// Fix xref paths: ManPageGenerator generates "xref:bin/solr-*.adoc" relative to the bin/ subdir,
// but in Antora xrefs are resolved from the module's pages/ root, so pages/cli/ files need "cli/" prefix.
content = content.replace('xref:bin/', 'xref:cli/')

// ManPageGenerator renders the command's qualified name as "bin/solr-start" (parent-dash-child).
// Replace occurrences of that with the correct spaced form "bin/solr start".
def picocliQualifiedName = 'bin/solr-' + baseName.substring('solr-'.length())
content = content.replace(picocliQualifiedName, newTitle)

// Wrap the synopsis paragraph in a literal block to preserve line breaks and indentation.
// ManPageGenerator generates a plain paragraph with picocli AsciiDoc marks (*bold*, _italic_)
// which Asciidoctor collapses to a single line. A `....` literal block preserves whitespace
// but does not render inline formatting, so we strip the marks for a clean monospace output.
content = content.replaceAll(
/(?s)(== Synopsis\n\n)(.*?)(\n\n\/\/ end::picocli-generated-man-section-synopsis\[\])/
) { List<String> m ->
def synopsis = m[2]
.replaceAll(/\*([^*]+)\*/, '$1') // *bold* → plain
.replaceAll(/_([^_]+)_/, '$1') // _italic_ → plain
"${m[1]}....\n${synopsis}\n....${m[3]}"
}

// Strip the outer full-manpage tag wrappers
content = content.replace("// tag::picocli-generated-full-manpage[]\n", '')
content = content.replace("// end::picocli-generated-full-manpage[]\n", '')

return asfHeader + doNotEditNotice + "\n" + content.stripTrailing() + "\n"
}

// Build the CLI sub-page nav entries from the generated .adoc files in destDir.
// Returns a list of nav lines like "** xref:cli/solr-start.adoc[start]"
List<String> buildCliNavEntries(File cliDir) {
// Sort so that parent commands always precede their children.
// The trick: append '\u0000' (NUL, ASCII 0) to the stem before sorting.
// NUL < '-' (ASCII 45), so "solr-zk\0" < "solr-zk-cp\0", placing the parent first.
def files = ((cliDir.listFiles() ?: []) as List<File>)
.findAll { it.name.endsWith('.adoc') }
.sort { (it.name.replace('.adoc', '') + '\u0000') }
return files.collect { file ->
def baseName = file.name.replace('.adoc', '')
def parts = baseName.split('-')
// depth: "solr-start" has 2 parts → "**", "solr-zk-ls" has 3 parts → "***"
def stars = '*' * parts.length
def label = cliFileNameToNavLabel(baseName)
"${stars} xref:cli/${file.name}[${label}]"
}
}

// Run ManPageGenerator and return the set of generated files (excluding the top-level solr.adoc).
// Because SolrCLI is annotated @Command(name="bin/solr"), ManPageGenerator writes all files into
// a "bin/" subdirectory of outputDir (e.g. outputDir/bin/solr-start.adoc).
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix this - use name="solr" and remove this hack

List<File> runManPageGenerator(File outputDir) {
// Pre-create the "bin/" subdir so ManPageGenerator can write into it
def binDir = new File(outputDir, "bin")
binDir.mkdirs()

javaexec {
classpath = configurations.cliClasspath
mainClass = 'picocli.codegen.docgen.manpage.ManPageGenerator'
args = ['--outdir', outputDir.absolutePath, 'org.apache.solr.cli.SolrCLI']
}

// All generated files land in <outputDir>/bin/; exclude solr.adoc (the top-level parent page)
return (binDir.listFiles() as List<File>)
.findAll { f -> f.name.endsWith('.adoc') && f.name != 'solr.adoc' }
.sort { it.name }
}

task generateCliDocs {
group = 'generation'
description = 'Regenerate CLI reference pages in pages/cli/ from picocli annotations.'

dependsOn ':solr:core:classes'

def rawOutputDir = file("${buildDir}/generated-cli-docs-raw")
def destDir = file("modules/deployment-guide/pages/cli")
def navFile = file("modules/deployment-guide/deployment-nav.adoc")

inputs.files(configurations.cliClasspath)
outputs.dir(destDir)

doLast {
rawOutputDir.mkdirs()
destDir.mkdirs()

def rawFiles = runManPageGenerator(rawOutputDir)

rawFiles.each { rawFile ->
def processed = postProcessCliManPage(rawFile, ASF_LICENSE_HEADER, DO_NOT_EDIT_NOTICE)
new File(destDir, rawFile.name).text = processed
}

// Update the CLI nav section in deployment-nav.adoc between marker comments
def navContent = navFile.text
if (navContent.contains('// CLI-DOCS-START')) {
def navEntries = buildCliNavEntries(destDir).join('\n')
def replacement = "// CLI-DOCS-START\n* xref:solr-control-script-reference.adoc[]\n${navEntries}\n// CLI-DOCS-END"
navContent = navContent.replaceAll(/(?s)\/\/ CLI-DOCS-START.*?\/\/ CLI-DOCS-END/, replacement)
navFile.text = navContent
} else {
logger.warn("deployment-nav.adoc does not contain // CLI-DOCS-START marker; nav not updated.")
}

logger.lifecycle("Generated ${rawFiles.size()} CLI doc pages in ${destDir}")
}
}

task checkCliDocsUpToDate {
group = 'verification'
description = 'Verify that pages/cli/ CLI docs are in sync with picocli annotations.'

dependsOn ':solr:core:classes'

def rawOutputDir = file("${buildDir}/generated-cli-docs-check")
def committedDir = file("modules/deployment-guide/pages/cli")

inputs.files(configurations.cliClasspath)
inputs.dir(committedDir)

doLast {
rawOutputDir.mkdirs()

def rawFiles = runManPageGenerator(rawOutputDir)
def issues = []

rawFiles.each { rawFile ->
def processed = postProcessCliManPage(rawFile, ASF_LICENSE_HEADER, DO_NOT_EDIT_NOTICE)
def committed = new File(committedDir, rawFile.name)
if (!committed.exists()) {
issues << "MISSING committed file (new command): ${rawFile.name}"
} else {
def processedNorm = processed.replace('\r\n', '\n')
def committedNorm = committed.text.replace('\r\n', '\n')
if (processedNorm != committedNorm) {
issues << "OUT OF DATE: ${rawFile.name}"
}
}
}

// Check for committed pages that are no longer generated (deleted commands)
if (committedDir.exists()) {
def generatedNames = rawFiles.collect { it.name }.toSet()
((committedDir.listFiles() ?: []) as List<File>).findAll { it.name.endsWith('.adoc') }.each { committed ->
if (!generatedNames.contains(committed.name)) {
issues << "STALE (command removed): ${committed.name}"
}
}
}

if (!issues.isEmpty()) {
throw new GradleException(
"CLI docs are out of date. Run './gradlew :solr:solr-ref-guide:generateCliDocs' to regenerate.\n" +
"Issues:\n" + issues.collect { " - ${it}" }.join('\n'))
}
logger.lifecycle("CLI docs are up to date.")
}
}

check.dependsOn checkCliDocsUpToDate

/*
Compiling, Testing and Validation for the java examples in the Solr Ref Guide
*/
Expand Down
15 changes: 15 additions & 0 deletions solr/solr-ref-guide/modules/deployment-guide/deployment-nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,22 @@

.Deployment Guide

// CLI-DOCS-START
* xref:solr-control-script-reference.adoc[]
** xref:cli/solr-start.adoc[start]
** xref:cli/solr-status.adoc[status]
** xref:cli/solr-stop.adoc[stop]
** xref:cli/solr-version.adoc[version]
** xref:cli/solr-zk.adoc[zk]
*** xref:cli/solr-zk-cp.adoc[cp]
*** xref:cli/solr-zk-downconfig.adoc[downconfig]
*** xref:cli/solr-zk-ls.adoc[ls]
*** xref:cli/solr-zk-mkroot.adoc[mkroot]
*** xref:cli/solr-zk-mv.adoc[mv]
*** xref:cli/solr-zk-rm.adoc[rm]
*** xref:cli/solr-zk-upconfig.adoc[upconfig]
*** xref:cli/solr-zk-updateacls.adoc[updateacls]
// CLI-DOCS-END

* Installation & Deployment
** xref:system-requirements.adoc[]
Expand Down
Loading