From f58603ea2ca7ba7baf3c2ce7365521c662206692 Mon Sep 17 00:00:00 2001 From: "Brian M. Carr" Date: Thu, 8 Oct 2026 11:49:59 -0500 Subject: [PATCH 1/2] Keep Markdown intact when cleaning plain // comments Edge detection treated any leading non-alphanumeric run as frame chrome, so ordinary // comments lost a leading code span backtick or reference-link bracket, shared list markers were stripped from every line, and lines with no alphanumerics (code fences, table rules) were blanked. Comments with no border lines and no column-zero fence are now treated as plain: only their common leading whitespace is removed and every line in the content range is kept. Framed, Holub, and JavaDoc comments keep the existing behavior. Co-Authored-By: Claude Opus 5.5 --- .../protoc/util/comment/CommentParser.kt | 55 ++++++++ src/test/kotlin/CommentTests.kt | 130 ++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/src/main/kotlin/com/engine/protoc/util/comment/CommentParser.kt b/src/main/kotlin/com/engine/protoc/util/comment/CommentParser.kt index 465b34e..afd0ee3 100644 --- a/src/main/kotlin/com/engine/protoc/util/comment/CommentParser.kt +++ b/src/main/kotlin/com/engine/protoc/util/comment/CommentParser.kt @@ -69,6 +69,10 @@ public abstract class CommentParser { // A single content line can't reveal a non-whitespace frame edge — frames need multiple lines // to corroborate. Trailing punctuation like "]." or "." on one line is content, not a frame. private val rightSingleLineEdgePattern = Regex(""".*?(\s+)$""") + + // A top or bottom border line of a framed comment, e.g. "*****", "====\", or "//////". + // Backticks and tildes are deliberately excluded so a closing Markdown code fence is content. + private val frameBorderPattern = Regex("""[*=/\\|#+_-]+""") public fun of(rawComment: String): ParseContext = ParseContext(rawComment) } @@ -95,6 +99,35 @@ public abstract class CommentParser { * This may be an empty range if no content exists for the content. */ public val contentLineIndices: IntRange by lazy { + if (isPlain) { + val first = rawCommentLines.indexOfFirst { it.isNotBlank() } + if (first < 0) IntRange.EMPTY else first..rawCommentLines.indexOfLast { it.isNotBlank() } + } else { + framedContentLineIndices + } + } + + /** + * True when the comment has a top or bottom border line made only of frame characters, as in a + * block comment frame or a JavaDoc opening `*`. + */ + public val hasFrameChrome: Boolean by lazy { + val nonBlank = rawCommentLines.filter { it.isNotBlank() } + nonBlank.size > 1 && (frameBorderPattern.matches(nonBlank.first().trim()) || frameBorderPattern.matches(nonBlank.last().trim())) + } + + /** + * True when the comment has no frame chrome and no column-zero fence (such as the Holub `*` edge), + * which is the case for ordinary `//` comments. Only common leading whitespace is removed from a + * plain comment, and lines without alphanumeric characters (code fences, table rules, list + * markers) are kept as content, so Markdown in the comment survives cleaning. + */ + private val isPlain: Boolean by lazy { + !hasFrameChrome && + (framedContentLines.size < 2 || framedCommonEdges.first.firstOrNull()?.isWhitespace() != false) + } + + private val framedContentLineIndices: IntRange by lazy { var firstContentLineIndex: Int? = null var lastContentLineIndex: Int? = null rawCommentLines.indices.forEach { i -> @@ -116,6 +149,10 @@ public abstract class CommentParser { rawCommentLines.slice(contentLineIndices) } + private val framedContentLines: List by lazy { + rawCommentLines.slice(framedContentLineIndices) + } + /** * The sanitized region of lines that include comment content. * May include internal blank lines. @@ -129,6 +166,20 @@ public abstract class CommentParser { * This is useful for identification of fences, padding, and other comment chrome. */ public val rawCommonEdges: Pair by lazy { + if (isPlain) { + val leftEdge = rawContentLines + .filter { it.isNotBlank() } + .map { line -> line.takeWhile { it.isWhitespace() } } + .reduceOrNull { acc, it -> acc.commonPrefixWith(it) } + ?: "" + leftEdge to "" + } else { + framedCommonEdges + } + } + + private val framedCommonEdges: Pair by lazy { + val rawContentLines = framedContentLines when (rawContentLines.size) { // it's empty 0 -> "" to "" @@ -236,6 +287,10 @@ public abstract class CommentParser { */ public fun cleanedLine(index: Int): String { return cleanedLineCache.computeIfAbsent(index) { i -> + if (isPlain) { + return@computeIfAbsent rawCommentLines[i].removePrefix(rawCommonEdges.first).trimEnd() + } + /* * special casing for first line and fencing messing up left edge detection */ diff --git a/src/test/kotlin/CommentTests.kt b/src/test/kotlin/CommentTests.kt index 6bea162..54d2c81 100644 --- a/src/test/kotlin/CommentTests.kt +++ b/src/test/kotlin/CommentTests.kt @@ -281,6 +281,136 @@ class CommentTests : """.trimMargin(), expectedCleanedResult = "The greeting will be randomly selected [MissingAnchor].", ), + TestCase( + description = "single-line comment starting with a code span", + source = """ + |// `entity_type` was unspecified. + | + """.trimMargin(), + protoc = """ + | `entity_type` was unspecified. + | + """.trimMargin(), + expectedCleanedResult = "`entity_type` was unspecified.", + ), + TestCase( + description = "single-line comment starting with a reference link", + source = """ + |// [Widget] is returned on success. + | + """.trimMargin(), + protoc = """ + | [Widget] is returned on success. + | + """.trimMargin(), + expectedCleanedResult = "[Widget] is returned on success.", + ), + TestCase( + description = "multiple single-line comments that all start with a reference link", + source = """ + |// [Widget] for widgets. + |// [Gadget] for gadgets. + | + """.trimMargin(), + protoc = """ + | [Widget] for widgets. + | [Gadget] for gadgets. + | + """.trimMargin(), + expectedCleanedResult = """ + |[Widget] for widgets. + |[Gadget] for gadgets. + """.trimMargin(), + ), + TestCase( + description = "multiple single-line comments forming a bulleted list", + source = """ + |// - first + |// - second + |// - third + | + """.trimMargin(), + protoc = """ + | - first + | - second + | - third + | + """.trimMargin(), + expectedCleanedResult = """ + |- first + |- second + |- third + """.trimMargin(), + ), + TestCase( + description = "single-line comments with a paragraph break and indented list", + source = """ + |// Returns one of: + |// + |// * `A` when + |// continued + |// * `B` + | + """.trimMargin(), + protoc = """ + | Returns one of: + | + | * `A` when + | continued + | * `B` + | + """.trimMargin(), + expectedCleanedResult = """ + |Returns one of: + | + |* `A` when + | continued + |* `B` + """.trimMargin(), + ), + TestCase( + description = "single-line comments containing a fenced code block", + source = """ + |// Example: + |// ```json + |// {"a": 1} + |// ``` + | + """.trimMargin(), + protoc = """ + | Example: + | ```json + | {"a": 1} + | ``` + | + """.trimMargin(), + expectedCleanedResult = """ + |Example: + |```json + |{"a": 1} + |``` + """.trimMargin(), + ), + TestCase( + description = "single-line comments containing a table", + source = """ + |// | Code | Meaning | + |// |------|---------| + |// | 1 | One | + | + """.trimMargin(), + protoc = """ + | | Code | Meaning | + | |------|---------| + | | 1 | One | + | + """.trimMargin(), + expectedCleanedResult = """ + || Code | Meaning | + ||------|---------| + || 1 | One | + """.trimMargin(), + ), ) context("Comment parsing tests") { From 2423f5d928338d12bb033abb1cbcf6f4efb537ce Mon Sep 17 00:00:00 2001 From: "Brian M. Carr" Date: Thu, 8 Oct 2026 11:55:48 -0500 Subject: [PATCH 2/2] Add protoc-utils-markdown for shared doc-comment link handling Reference-link resolution, failure reporting, and Markdown-to-plain-text conversion were implemented separately in protoc-gen-markdown and protoc-gen-openapi. This module extracts them so both plugins share one implementation: - ReferenceIndex indexes every proto element under every name a comment can use and resolves labels with protoc-gen-markdown's scope rules, mapping elements to a plugin-supplied target type. - ReferenceLinkProcessor is a commonmark LinkProcessor with NONE / WARN / FAIL_ON_INVALID modes, referenceLink URL overrides, and collected failures with a CodeGeneratorResponse-ready summary. - MarkdownText renders documents back to CommonMark and flattens them to plain text or a first sentence. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 +- README.md | 3 + gradle/libs.versions.toml | 4 + gradle/license/allowed-licenses.json | 4 +- markdown/README.md | 37 +++ markdown/build.gradle.kts | 54 +++ .../protoc/util/markdown/MarkdownText.kt | 92 ++++++ .../protoc/util/markdown/ReferenceElement.kt | 50 +++ .../protoc/util/markdown/ReferenceIndex.kt | 308 ++++++++++++++++++ .../util/markdown/ReferenceLinkFailure.kt | 55 ++++ .../protoc/util/markdown/ReferenceLinkMode.kt | 18 + .../util/markdown/ReferenceLinkProcessor.kt | 186 +++++++++++ .../src/test/kotlin/ReferenceLinkTests.kt | 263 +++++++++++++++ 13 files changed, 1076 insertions(+), 3 deletions(-) create mode 100644 markdown/README.md create mode 100644 markdown/build.gradle.kts create mode 100644 markdown/src/main/kotlin/com/engine/protoc/util/markdown/MarkdownText.kt create mode 100644 markdown/src/main/kotlin/com/engine/protoc/util/markdown/ReferenceElement.kt create mode 100644 markdown/src/main/kotlin/com/engine/protoc/util/markdown/ReferenceIndex.kt create mode 100644 markdown/src/main/kotlin/com/engine/protoc/util/markdown/ReferenceLinkFailure.kt create mode 100644 markdown/src/main/kotlin/com/engine/protoc/util/markdown/ReferenceLinkMode.kt create mode 100644 markdown/src/main/kotlin/com/engine/protoc/util/markdown/ReferenceLinkProcessor.kt create mode 100644 markdown/src/test/kotlin/ReferenceLinkTests.kt diff --git a/CLAUDE.md b/CLAUDE.md index 3a602fa..575bb56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Build / test commands -- `./gradlew build` — compiles, runs ktlint, and runs the test suite for both modules. +- `./gradlew build` — compiles, runs ktlint, and runs the test suite for every module. - `./gradlew test` — runs tests only. The root `:test` task transitively triggers the recorder's GraalVM `nativeCompile` and a `publishToMavenLocal` (see *Test fixture wiring*), so a clean checkout's first run takes several minutes for the native image build. - `./gradlew test --tests "ServiceAndMethodWrapperTests"` — single test class. (`-i` for info logging.) - `./gradlew ktlintCheck` / `./gradlew ktlintFormat` — lint / autoformat. ktlint excludes everything under `build/` (workaround for the plugin not honoring generated-source exclusions). **Always run `ktlintFormat` after editing Kotlin source** so the next CI run isn't blocked on a trivial formatting failure. @@ -17,10 +17,11 @@ The `pr.yaml` workflow just calls `build.yaml` (which runs `./gradlew build` aft ## Module layout -The repo is a multi-project Gradle build with two modules: +The repo is a multi-project Gradle build with three modules: - **Root project `:protoc-utils`** (`src/main/kotlin/com/engine/protoc/util/…`) — the published Kotlin library. Pure JVM, no native code. - **Subproject `:protoc-utils-recorder`** (`recorder/`) — a tiny GraalVM-native protoc plugin (single `Main.kt`) that copies stdin to a file named `code-generator-request.binpb`. It is shipped as multi-platform native binaries to Maven Central, **not** as a JVM jar. +- **Subproject `:protoc-utils-markdown`** (`markdown/`) — a JVM library, depending on the root library and commonmark-java, that resolves proto reference links in doc comments and converts comment Markdown to plain text. Kept separate so the core library stays free of the commonmark dependency. `settings.gradle.kts` walks the tree and includes every directory containing a `build.gradle.kts` (skipping `build`, `buildSrc`, `tmp`, `scratch`, dotfile dirs, and dirs containing `.gradle_ignore`). Subproject names are derived as `:-`, so the directory `recorder/` becomes `:protoc-utils-recorder`. To add a subproject, just create a directory with a `build.gradle.kts` — no manual `include(…)` needed. diff --git a/README.md b/README.md index 5504c35..d624b09 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ dependencies { The companion `protoc-utils-recorder` plugin is published as multi-platform native binaries under the same group; see [`recorder/README.md`](recorder/README.md) for how to consume those. +The companion `protoc-utils-markdown` library resolves proto reference links (`[Widget]`, `[Service.Rpc]`) in doc comments and converts comment Markdown to plain text; see [`markdown/README.md`](markdown/README.md). + --- ## Why this exists @@ -79,6 +81,7 @@ val comment: String? = nameElement?.location?.leadingComments?.cleaned | `cleaned` | The comment text with fencing characters (`//`, `/*`, `*`, etc.) stripped | The `cleaned` form is what you almost always want when generating documentation or OpenAPI descriptions. +For ordinary `//` comments only the common leading whitespace is removed, so Markdown in the comment (a leading code span or reference link, list markers, code fences, tables) is preserved. ### Extension options diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7fe799d..41bbcb5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,8 @@ kotest = "6.1.4" kotlin = "2.4.20" protobuf = "4.34.1" +commonmark = "0.28.0" +slf4j = "2.0.17" # plugin versions google-osdetector-plugin = "1.7.3" @@ -21,6 +23,8 @@ cyclonedx = { id = "org.cyclonedx.bom", version.ref = "cyclonedx" } license-report = { id = "com.github.jk1.dependency-license-report", version.ref = "jk1-dependency-license-report" } [libraries] +commonmark = { group = "org.commonmark", name = "commonmark", version.ref = "commonmark" } +slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } protobuf-java = { group = "com.google.protobuf", name = "protobuf-java", version.ref = "protobuf" } # test dependencies diff --git a/gradle/license/allowed-licenses.json b/gradle/license/allowed-licenses.json index dcde8a7..9ca893c 100644 --- a/gradle/license/allowed-licenses.json +++ b/gradle/license/allowed-licenses.json @@ -2,6 +2,8 @@ "allowedLicenses": [ { "moduleLicense": "Apache License, Version 2.0" }, { "moduleLicense": "MIT License" }, - { "moduleLicense": "The 3-Clause BSD License" } + { "moduleLicense": "The 2-Clause BSD License" }, + { "moduleLicense": "The 3-Clause BSD License" }, + { "moduleName": "org.slf4j:slf4j-api" } ] } diff --git a/markdown/README.md b/markdown/README.md new file mode 100644 index 0000000..d5152fe --- /dev/null +++ b/markdown/README.md @@ -0,0 +1,37 @@ +# protoc-utils-markdown + +CommonMark helpers shared by Engine's protoc plugins for handling proto doc comments. + +```kotlin +implementation("com.engine:protoc-utils-markdown:VERSION") +``` + +## Reference links + +Doc comments refer to other proto elements with CommonMark reference-link syntax: `[Widget]`, `[acme.v1.Widget]`, `[Widget.name]`, `[Status.STATUS_ACTIVE]`, `[WidgetService.GetWidget]`, or the full form `[display text][Widget]`. + +- `ReferenceIndex` indexes every message, field, enum, enum value, service, and RPC in a set of files. + A plugin-supplied function maps each `ReferenceElement` to a target `T` (an href, a rendering decision, …); returning `null` leaves the element unindexed. + Bare names resolve against the members of the comment's own descriptor before global short names, so a comment on `message Widget` can write `[name]`. +- `ReferenceLinkProcessor` is a commonmark-java `LinkProcessor` that rewrites resolved references as links (`ReferenceRendering.Link`) or code spans (`ReferenceRendering.Code`). + Inline links, images, code spans, and escaped brackets are left alone. + Labels in the `overrides` map (parsed from repeated `referenceLink=