diff --git a/Dokka-plugin-kdoc2json/README.md b/Dokka-plugin-kdoc2json/README.md index 2915145e..086eed20 100644 --- a/Dokka-plugin-kdoc2json/README.md +++ b/Dokka-plugin-kdoc2json/README.md @@ -1 +1,209 @@ -Alex to fill in this file with the right details +# JSON Dokka Plugin + +A custom Dokka plugin that replaces Dokka's default HTML renderer to output a raw, structured JSON representation of your Kotlin documentation. + +This plugin is designed for "headless" documentation pipelines where you want Dokka to handle the complex parsing, AST resolution, and multi-platform expect/actual merging, but you want to render the final visual output using a custom Static Site Generator (SSG) or templating engine (such as Pebble, Jinja, or React). + +--- + +## 1. Introduction + +This plugin intercepts base Dokka's pipeline just before rendering to output JSON data in place of HTML files. It maps Dokka's internal Documentable Abstract Syntax Tree (AST) into clean, serializable Data Transfer Objects (DTOs) and writes them to disk as `.json` files. It preserves package hierarchies, generic bounds, platform source sets, and documentation tags while allowing frontend developers total freedom over the final HTML/CSS. + +## 2. Getting Started + +### Building the Plugin + +Clone this repository and publish the plugin to your local Maven repository: + +```bash +cd kdoc-to-json +./gradlew publishToMavenLocal +``` + +### Applying the Plugin + +In the target project where you want to generate documentation, add the plugin to your Dokka dependencies block: + +```kotlin +dependencies { + dokkaPlugin("org.appdevforall.dokka:kdoc-to-json:1.0.0-SNAPSHOT") +} +``` + +> **Dokka version compatibility:** `kdoc-to-json/build.gradle.kts` compiles against `dokka-core`/`dokka-base` version `2.2.0-Beta` by default (chosen for compatibility with the [kotlin-stdlib-docs build tool](https://github.com/JetBrains/kotlin/tree/master/libraries/tools/kotlin-stdlib-docs)). Your consuming project's `org.jetbrains.dokka` Gradle plugin version should match (or be binary-compatible with) that version — see `examples/example-data-processor/build.gradle.kts` for a working setup. If you need a different Dokka version, update the `compileOnly` versions in `kdoc-to-json/build.gradle.kts` and republish before applying the plugin to a project on that version. + +### Trying It Out with the Example Library + +`examples/example-data-processor` is a small sample Kotlin library already wired up to use this plugin (see its `build.gradle.kts`). To build the plugin and generate its JSON documentation in one step, run: + +```bash +./scripts/build-example.sh +``` + +This publishes `kdoc-to-json` to your local Maven repository and then runs Dokka against the example library. Output is written to `examples/example-data-processor/build/dokka/html/`. + +### Sanity-Checking Rendered Output + +`scripts/sanity_check.py` helps validate documentation rendered downstream from this plugin's JSON (e.g. by a templating engine like Pebble or Jinja that turns the JSON into HTML pages): + +```bash +# Check that every file in a list (e.g. Dokka's package-list) exists in the rendered output +python3 scripts/sanity_check.py check-list files.txt path/to/rendered-html + +# Compare a standard Dokka HTML build against a JSON-derived HTML build, page for page +python3 scripts/sanity_check.py compare-base path/to/dokka-html path/to/rendered-html + +# Scan a directory of rendered HTML files for broken internal links +python3 scripts/sanity_check.py check-links path/to/rendered-html +``` + +Each subcommand accepts `--output-file/-o` to write a full results log to disk. + +`scripts/verify_sourceset_whitelist.py` recursively scans a rendered JSON output directory and confirms every file's top-level `sourceSets` field intersects a given whitelist — useful for confirming the plugin's `sourceSetWhitelist` config option (or a downstream filter) actually excluded everything it should have: + +```bash +python3 scripts/verify_sourceset_whitelist.py path/to/rendered-json jvm js +``` + +Pass one or more source set names (space- or comma-separated) as the whitelist. Files without a top-level `sourceSets` field (e.g. `all-types.json`, the multimodule root `index.json`) are synthetic/aggregate outputs and are skipped rather than flagged. Accepts `--output-file/-o` to log violations and `--verbose/-v` to print every file checked. + +## 3. Configuration Options + +You can configure the JSON plugin by extending `DokkaPluginParametersBaseSpec` and registering it in your `dokka` configuration block. This utilizes the modern Dokka V2 plugin API. + +```kotlin +import org.jetbrains.dokka.gradle.engine.plugins.DokkaPluginParametersBaseSpec +import org.jetbrains.dokka.InternalDokkaApi +import javax.inject.Inject + +@OptIn(InternalDokkaApi::class) +abstract class JsonOutputPluginParameters @Inject constructor( + name: String +) : DokkaPluginParametersBaseSpec(name, "org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { + + // Define the plugin's behavior via a JSON string + override fun jsonEncode(): String = """{ + "logLevel": "debug", + "logFile": "build/dokka_json.log", + "omitFields": ["sources"], + "replaceHtmlExtension": false, + "omitNulls": true + }""" +} + +dokka { + pluginsConfiguration { + registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) + register("org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { } + } +} +``` + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `logLevel` | String | `"debug"` | Controls the verbosity of the plugin's internal logger (`"info"`, `"debug"`, `"warn"`, `"error"`). | +| `logFile` | String | *(Optional)* | Absolute or relative path to output the plugin's debug logs. Highly recommended as Dokka often swallows standard output. | +| `replaceHtmlExtension` | Boolean | `false` | If `true`, the plugin will rewrite all internal relative URLs to end in `.json` instead of `.html`. | +| `omitFields` | List | `[]` | A list of JSON keys to completely strip from the final output (e.g., `["breadcrumbs", "sources"]`). Useful for reducing disk footprint. | +| `omitNulls` | Boolean | `false` | If `true`, deeply filters the AST payload to remove any keys where the value is null, an empty string, an empty array, or an empty object. | +| `classDiscriminator` | String | `"kind"` | The JSON key used to discriminate between polymorphic `Documentable` types (e.g., `"kind": "class"`). Must not collide with an existing DTO field name (e.g. `"type"` or `"name"`), or serialization will fail. | +| `prettyPrint` | Boolean | `false` | If `true`, formats the written JSON files with indentation for human readability instead of compact single-line output. | +| `sourceSetWhitelist` | List | `[]` | A list of source set names (matching the values that appear in the output `sourceSets` field, e.g. `["jvm"]`). If non-empty, any Documentable that isn't present in at least one whitelisted source set has its output file omitted, and a message is logged with the symbol's name and its `sourceSets`. Leave empty to disable filtering (default: all source sets included). | + +> **`omitNulls` also strips *empty* values, not just `null`.** Despite the name, `omitNulls: true` removes a key whenever its value is `null`, `""`, `[]`, or `{}` (see the filter in `JsonRenderer.filterJson`) — so with it enabled, `"functions": []` doesn't appear at all rather than appearing as an empty array. Consumers must treat a **missing** key as equivalent to its empty value (e.g. `functions is defined and functions is not empty`, as in the Pebble example in §8), not assume every key is always present. + +## 4. Understanding Dokka Terminology + +To successfully consume the JSON output, it helps to understand a few core Dokka concepts that dictate the structure of the data: + +* **Documentable**: A node in Dokka's AST. Classes, functions, properties, packages, and modules are all `Documentable` objects. +* **DRI (Dokka Resource Identifier)**: A unique string identifier for every symbol in your codebase. (e.g., `kotlin.collections/List/size/#/PointingToDeclaration/`). DRIs are what Dokka uses to link disparate parts of the codebase together. +* **SourceSet**: Represents a target platform or compilation unit (e.g., `jvm`, `js`, `common`, `native`). Dokka merges declarations across SourceSets, which is why properties like `visibility` or `type` are mapped by SourceSet in the JSON. +* **PageNode**: Dokka's representation of a literal page that will be written to disk. The JSON plugin maps a `PageNode` back to its underlying `Documentable` to generate the JSON payload. + +## 5. How It Works: Architecture & Lifecycle + +The plugin operates in two distinct phases: + +### Phase 1: The `JsonRenderer` (Synchronous AST Traversal) + +The plugin implements the Dokka `Renderer` interface, completely overriding the default HTML generation. It walks the `RootPageNode` tree synchronously. For every page, it extracts the underlying `Documentable`, passes it to the `ModelMapper`, and translates the complex Dokka AST into clean Kotlin DTOs. These DTOs are serialized using `kotlinx.serialization` and written to disk. + +### Phase 2: The `LinkPostProcessor` (Cross-Module Resolution) + +Because Dokka resolves links across different modules *during* the HTML rendering phase, our JSON plugin must do the same. When the `JsonRenderer` encounters a DRI that belongs to an external module, it writes `"url": "unresolved:"`. +Once all JSON files are written, the `LinkPostProcessor` spins up. It reads all JSON files on disk, builds a master index of every DRI, and performs a regex replacement to patch all `unresolved:` links into valid relative file paths. + +## 6. The JSON Output Format + +### Directory Structure + +The plugin mirrors Dokka's standard hierarchical folder structure. However, instead of `index.html` files, you will find `.json` files. + +Special aggregated files include: + +* `index.json`: The index.json files serve as the primary entry points for modules, packages, and classes, containing the structural metadata and immediate member declarations specific to each hierarchical level. +* `all-types.json`: Created at the root of a module. Contains a flat, searchable array of every class, interface, object, and type alias in that module. +* `package-list`: A standard Dokka package list. + +### The Semantic Model (Polymorphism) + +The JSON payloads are strictly typed. Every top-level object and nested member contains a `"kind"` discriminator (e.g., `"kind": "class"`, `"kind": "function"`, `"kind": "TypeAliased"`). This makes it incredibly easy to parse the JSON back into typed objects in your frontend layer. + +*(To minimize disk footprint, you can leverage the `omitFields` and `omitNulls` configuration options. The plugin uses a custom recursive JSON filter to strip out empty lists, objects, and null values before writing to disk).* + +## 7. Resolving Cross-Module Links + +`LinkPostProcessor` rewrites every `unresolved:` marker in a single pass, so the final on-disk JSON never contains an `unresolved:` string to grep for — resolved DRIs become a relative path, and DRIs it couldn't find become a bare `"#"`. To find which links failed to resolve, check the Dokka build log rather than the output JSON: the post-processor logs a `Failed to resolve N DRIs (patched to "#")` warning listing every DRI it couldn't place. + +A link typically fails to resolve when: + +1. The target module was not included in the Dokka multi-module task. +2. The target dependency is an external library, and external documentation links were not properly configured in the `build.gradle.kts` file. + +If the DRI *is* present in the current build, the `LinkPostProcessor` will automatically patch it to a relative path like `../../kotlin-stdlib/kotlin.collections/-list/index.json`. + +## 8. Consuming the JSON (Example: Pebble) + +Because the JSON maintains Dokka's strict hierarchy, templating engines like Pebble or Jinja can iterate over it natively. + +For example, to render a table of functions for a class: + +```pebble +{% if functions is defined and functions is not empty %} +

Functions

+ + {% for member in functions %} + + + + + {% endfor %} +
{{ member.name }} + {# Render the parameters #} + fun {{ member.name }}( + {% for param in member.parameters %} + {{ param.name }}: {{ param.type.name }} + {% endfor %} + ) +
+{% endif %} +``` + +## 9. Building the Kotlin Standard Library Docs (`scripts/kotlin`) + +`scripts/kotlin/` contains everything needed to generate both the default HTML docs and the kdoc-to-json JSON docs for the Kotlin standard library (`kotlin-stdlib`, `kotlin-test`, `kotlin-reflect`), side by side, for comparison purposes. + +* **`scripts/kotlin/build.gradle.kts`** — a drop-in replacement for the `build.gradle.kts` in a `kotlin-stdlib-docs` checkout (the Dokka doc-build module inside JetBrains' [kotlin](https://github.com/JetBrains/kotlin) repo, at `libraries/tools/kotlin-stdlib-docs`). It adds a `dokkaGenerateModuleJson` task and gates the `kdoc-to-json` plugin (classpath + `pluginsConfiguration`) behind `gradle.startParameter.taskNames` so the plugin is only active when `dokkaGenerateModuleJson` is explicitly requested — every other Dokka task (`dokkaGenerateHtml`, `dokkaGeneratePublicationHtml`, etc.) is unaffected and still produces normal HTML. +* **`scripts/kotlin/build-kotlin-stdlib.sh`** — installs that `build.gradle.kts` into a `kotlin-stdlib-docs` checkout (backing up the original as `build.gradle.kts.orig` on first run) and then runs both `dokkaGenerateHtml` and `dokkaGenerateModuleJson`, each with its own `-PdocsBuildDir`, so the two outputs land in separate directories instead of overwriting each other. + +**Usage:** + +```bash +./scripts/kotlin/build-kotlin-stdlib.sh /path/to/kotlin/libraries/tools/kotlin-stdlib-docs [output-dir] +``` + +This writes HTML output to `/html/latest/all-libs` and JSON output to `/json/latest/all-libs` (`output-dir` defaults to `scripts/kotlin/build-output`). + +> **Provenance / staleness warning:** `scripts/kotlin/build.gradle.kts` was derived from the `kotlin-stdlib-docs/build.gradle.kts` in JetBrains' `kotlin` repo as of commit [`cfcb49fd0113`](https://github.com/JetBrains/kotlin/commit/cfcb49fd0113d2300a2b677c4fc2e16dddff7df5) ("[stdlib] Update Dokka to 2.2.0-Beta and migrate to DGPv2"). That upstream file is not under our control and can change — new source sets, Dokka API changes, or a different doc-build structure could all require re-diffing our modifications against a newer upstream version. If `build-kotlin-stdlib.sh` starts failing against a newer `kotlin` checkout, compare `scripts/kotlin/build.gradle.kts` against the current upstream `kotlin-stdlib-docs/build.gradle.kts` and re-apply the `useJsonPlugin`/`dokkaGenerateModuleJson` additions by hand. diff --git a/Dokka-plugin-kdoc2json/TEST_PLAN.md b/Dokka-plugin-kdoc2json/TEST_PLAN.md new file mode 100644 index 00000000..43dcbb4f --- /dev/null +++ b/Dokka-plugin-kdoc2json/TEST_PLAN.md @@ -0,0 +1,112 @@ +# Test Plan: kdoc-to-json Core Functionality (PR #18) + +## Scope + +`tests/test_*.sh` currently covers every `JsonPluginConfig` option (`omitFields`, `omitNulls`, `logLevel`, `logFile`, `classDiscriminator`, `prettyPrint`, `replaceHtmlExtension`, `sourceSetWhitelist`) against the existing `examples/example-data-processor` fixture. None of that exercises whether the plugin actually maps Dokka's model correctly — that's everything in `ModelMapper.kt`, the traversal/index-generation logic in `JsonRenderer.kt`, and the cross-module link resolution in `LinkPostProcessor.kt`. This plan covers that remaining surface. + +It follows the existing harness pattern (`tests/lib.sh`, one `test_*.sh` per concern, run via `run_all.sh`), reusing `publish_plugin`/`run_dokka`/`assert_*` where possible. New Kotlin fixtures will need to be added to `examples/example-data-processor` since the current one (`DataProcessor`, `ConnectionManager`, `Level1/Level2/Level3`, `Provider`, `Meta`, `DataMap`) doesn't exercise most of the type-system and doc-tag branches below. + +## 1. Fixture gaps to fill first + +The example library needs new declarations before several tests below are possible. Recommend adding a `com.example.testlib.Advanced` (or similar) file covering: + +| Construct | Why it's missing today | Exercises | +| --- | --- | --- | +| Generic class/function with bounded type param (`>`) | `Provider` has an unbounded param | `TypeParameterDto.bounds`, `mapProjection` variance branches | +| Function with `in`/`out` variance use-site (`Consumer`) | nothing uses declaration- or use-site variance | `CovarianceDto`/`ContravarianceDto`/`InvarianceDto` | +| Nullable and platform types (`String?`, a Java-interop call like `java.util.Date`) | everything today is non-null Kotlin | `NullableDto`, `PrimitiveJavaTypeDto`/`JavaObjectDto` | +| Extension function/property, suspend function, infix/operator function, function with default parameter value | none exist | `FunctionalTypeConstructorDto.isExtensionFunction`, `additionalModifiers`, `ExtrasDto.defaultValues` | +| Annotation with multiple parameters, applied more than once, and an annotation used on a parameter | `@Meta` has one param, used once, only on classes | `AnnotationWrapperDto.params`, `ExtrasDto.annotations` per-target | +| Data class, sealed class hierarchy, companion object with real members | `companion` is always null today | `companion` field, `supertypes` with a sealed base | +| A class that shadows/overrides `equals`/`toString`, and a class implementing `Throwable` | nothing trips `ObviousMember`/`ExceptionInSupertypes` | `ExtrasDto.isObviousMember`, `isException` | +| KDoc with `@see`, `@throws`, a fenced code block, a `@sample` tag pointing at a runnable function, nested ``/``/list markup, and a raw `<`/`>`/`&` in prose | current KDoc is plain prose + `@param`/`@return`/`@author` | `extractText` HTML-tag branches and escaping, `Sample` fallback-to-page-content logic in `mapDocNodes` | +| `expect`/`actual` declarations across two source sets (or reuse a second Gradle source set) | example lib is JVM-only, single source set | `expectPresentInSet`, per-source-set maps (`visibility`, `sources`, `modifier`, `underlyingType`) resolving differently per set | + +## 2. DTO mapping correctness (`ModelMapper.mapToDto`) + +For each Documentable kind, assert the emitted JSON has the right `kind` discriminator and the fields that only apply to that kind are populated correctly (not just non-null, but matching the actual source construct). + +| Test | Assertion | +| --- | --- | +| Module → Package → Class → Function nesting | Root `index.json` deserializes to a `ModuleDto` containing the expected package names; a package `index.json` lists its classlikes/functions/properties/typeAliases with correct names and counts | +| Class vs. Interface vs. Enum vs. Object vs. Annotation | Each emits its own `kind` value and only its own DTO's fields (e.g. `EnumDto.entries` is non-empty and `ClassDto` has no `entries` key) | +| `EnumEntryDto` | Each `Level3` entry (`ACTIVE`, `INACTIVE`) appears with its own KDoc text intact | +| Constructor mapping | `DFunction.isConstructor` round-trips as `true` for constructors and `false` for regular functions | +| Property with getter/setter | `PropertyDto.getter`/`setter` populated for a `var`, `setter` null for a `val` | +| `TypeAliasDto` | `DataMap`'s `underlyingType` resolves to a `GenericTypeConstructorDto` for `Map`, not a raw string | +| Nested classlikes at every depth | `Level1 → Level2 → Level3` each produce the correct `breadcrumbs` list (name + url pairs) in root-to-leaf order | +| Shallow vs. deep recursion | A package's `index.json` lists its classes with `shallow=true` (empty `functions`/`properties`/`classlikes` on the nested entries), while that class's own `index.json` has those same members fully populated | + +## 3. Type/Bound/Projection mapping + +| Test | Assertion | +| --- | --- | +| Nullable wrapping | `String?` maps to `NullableDto(inner=...)`, not a flag on the inner bound | +| Generic type with projections | `List` (or the new bounded-generic fixture) maps to `GenericTypeConstructorDto` with one projection, correct `presentableName` | +| Variance | `in T` / `out T` map to `ContravarianceDto`/`CovarianceDto`; invariant default maps to `InvarianceDto` | +| Functional types | A lambda parameter type maps to `FunctionalTypeConstructorDto` with `isExtensionFunction`/`isSuspendable` set correctly for a plain lambda, an extension lambda, and a suspend lambda | +| Java interop | A call into a Java stdlib type maps to `PrimitiveJavaTypeDto` (for primitives) or `JavaObjectDto`/`GenericTypeConstructorDto` as appropriate, with a resolvable `url` when Dokka has external doc links configured, or `unresolved:` otherwise (see §5) | +| Type parameter bounds | A bounded generic (`>`) emits `TypeParameterDto.bounds` with the bound's own DRI/url, not just the parameter name | + +## 4. Documentation tag & text extraction (`mapDocNodes`, `extractText`) + +| Test | Assertion | +| --- | --- | +| HTML escaping | KDoc prose containing literal `<`, `>`, `&` is escaped in the output text, not passed through raw (would otherwise corrupt a downstream HTML renderer) | +| Nested inline markup | `**bold** with *italic* and \`code\`` produces correctly nested ``/``/`` tags, not flattened or mis-ordered | +| Block-level tags | A fenced code block, a blockquote, and a bulleted list in KDoc all round-trip to their respective `
`/`
`/`
  • ` wrappers | +| `@see`/`@throws`/custom named tags | Emit `TagWrapperDto.name` correctly and resolve any `[Foo]`-style links inside them to a real `url` via `resolveUrl` | +| `@sample` | When the KDoc `@sample` tag has no inline body, the plugin falls back to pulling the runnable sample's source text from the page's `ContentCodeBlock`; verify multiple `@sample` tags on one doc pull the *correct* sample each (via `sampleIndex`), not just the first one repeated | +| Per-source-set documentation | On a construct present in two source sets with different KDoc (or an expect/actual pair), `documentation` is keyed by source set and each entry only contains that set's content | + +## 5. `resolveUrl` / link resolution edge cases + +| Test | Assertion | +| --- | --- | +| In-module link | A `[Provider]`-style KDoc link or a supertype reference resolves to a real relative path pointing at the target's own JSON file | +| Genuinely external/unconfigured link | Resolves to `"unresolved:"` in the pre-postprocess output, confirming the fallback marker still fires (needed for §7 below to have something to resolve) | +| `replaceHtmlExtension` interaction | Confirmed already covered by `test_replace_html_extension.sh` at the config layer — no new test needed here, just noting the overlap | + +## 6. `JsonRenderer` traversal & index generation + +| Test | Assertion | +| --- | --- | +| `package-list` | Contains the `$dokka.format:json-v1$` / `$dokka.linkExtension:json$` header lines and every real package name, sorted, with whitelisted-out packages excluded | +| `all-types.json` | Contains one entry per class/interface/enum/object/annotation/typeAlias across the whole module, each with the correct `kind` string, sorted by name | +| Multimodule `index.json` | When `context.configuration.modules` is non-empty, a root `index.json` is written with a `ModuleReferenceDto` per module and correct relative URLs (with `.json`/`.html` extension per `replaceHtmlExtension`) | +| File path parity with Dokka's own layout | For a representative set of pages, the `.json` file's path (via `locationProvider.resolve(..., skipExtension = true)`) matches the `.html` path Dokka's default renderer would produce, extension aside — this is what `scripts/kotlin/test_kotlin_stdlib.sh` already checks at scale; worth a smaller, fast version of that check in the local test suite against `example-data-processor` so it doesn't require a full stdlib build to catch a regression | +| Breadcrumbs at the root and at max depth | Root module page has an empty (or single-entry) breadcrumb list; `Level1.Level2.Level3` has a 4-or-5-entry list in the right order | + +## 7. `LinkPostProcessor` cross-module resolution + +| Test | Assertion | +| --- | --- | +| Two-pass index + replace | After running the full plugin, no `.json` file on disk contains the literal string `unresolved:` (matches the guarantee in the README) | +| Relative path depth | A DRI referenced from a deeply nested page (e.g. `Level1/Level2/Level3`) gets the correct number of `../` segments back to the file that DRI resolves to, and a root-level file gets `./` | +| Genuinely unresolvable DRI | Patched to the literal string `"#"`, and the build log contains a `Failed to resolve N DRIs` warning naming it (this is currently only documented, not tested) | +| "Last-writer-wins" for expect/actual | When a DRI legitimately resolves in two files (expect + actual declarations), the postprocessor doesn't crash or produce inconsistent output — pick one deterministically and assert it's a valid link, not asserting a specific winner | + +## 8. Structural parity vs. baseline HTML build + +The repo already has the tooling for this — it just needs to run as part of the test suite rather than manually: + +- `scripts/verify_package_index.py`: run against `example-data-processor`'s JSON output and assert exit code 0 (every object in a package's `index.json` has a page at the URL it points to). +- `scripts/kotlin/test_kotlin_stdlib.sh`: the real stress test — confirms the JSON build and the default HTML build produce a 1:1 set of pages for a large, real-world, multiplatform codebase (kotlin-stdlib). This should be run at least once before merging, even if it's too slow for the fast local suite, since it's the only test that exercises multiplatform expect/actual and large-scale multimodule behavior end to end. +- `scripts/sanity_check.py compare-base`: page-for-page diff between a stock Dokka HTML build and one derived from this plugin's JSON, to catch any page silently dropped or renamed. + +## 9. Robustness / edge cases + +| Test | Assertion | +| --- | --- | +| Malformed plugin config | **Verified only partially true** (see `tests/test_robustness.sh`): valid JSON with an unrelated unknown key is tolerated (Dokka's own decoder and our manual `ignoreUnknownKeys` fallback both handle it). But genuinely invalid JSON syntax, and valid JSON with a wrong-typed known field, both crash the *entire Gradle build* — Dokka's own Gradle plugin deserializes the raw `jsonEncode()` string against `JsonPluginConfig` client-side (via Jackson) before our worker/renderer code ever runs, so the `try`/`catch` in `JsonRenderer.render` can never be reached for those two cases. Nothing in this plugin's own code can fix that; it's an upstream Dokka Gradle Plugin behavior. | +| No config at all | Build succeeds using all-default `JsonPluginConfig` | +| Empty/near-empty module | A module with a package but no public declarations doesn't throw; produces a valid (if mostly empty) `package-list` and module `index.json` | +| `classDiscriminator` collision | Setting it to an existing field name (e.g. `"name"`) — README calls this out as a footgun; confirm it fails with a clear serialization error rather than silently corrupting output | + +## Suggested execution order + +1. Add the fixture gaps in §1 to `example-data-processor` (this unblocks nearly everything else and is the highest-leverage single change). +2. §2–§4 (DTO/type/doc-tag mapping) as new `tests/test_*.sh` scripts against the expanded fixture — fast, deterministic, run on every commit. +3. §6–§7 (renderer/link-postprocessor behavior) — same harness, still fast. +4. §9 (robustness) — cheap to add alongside the above. +5. §8 (kotlin-stdlib parity) — run manually or in a separate slower CI job before merging; not suitable for the fast local suite given the build time. diff --git a/Dokka-plugin-kdoc2json/TODO.md b/Dokka-plugin-kdoc2json/TODO.md new file mode 100644 index 00000000..1f3edac1 --- /dev/null +++ b/Dokka-plugin-kdoc2json/TODO.md @@ -0,0 +1,10 @@ +TODO @Alex + +All items below are done as of 2026-07-02 — see README.md for details. + +- [x] Move hardcoded options in JsonRenderer (Documentable type discriminator string, pretty printing, etc.) to be config options +- [x] Remove all references to "alex" (the default log file points to my home directory on my own machine) +- [x] Update plugin name (should not be provided under package my.dokka.plugin, "json-output-plugin" should be changed to kdoc-to-json or something) +- [x] Provide script for building the example library and example usage +- [x] Move output comparison/link validity check scripts into this repository +- [x] Update usage to indicate that a user needs to set a matching Dokka version in *json-output-plugin/build.gradle.kts* (default is version 2.2.0-Beta because that's compatible with the current [kotlin-stdlib-docs build tool](https://github.com/JetBrains/kotlin/tree/master/libraries/tools/kotlin-stdlib-docs) diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts b/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts new file mode 100644 index 00000000..7c2e2c5b --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/build.gradle.kts @@ -0,0 +1,61 @@ +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.gradle.engine.plugins.DokkaPluginParametersBaseSpec +import javax.inject.Inject + +plugins { + kotlin("jvm") version "1.9.23" + // This must match the dokka-core/dokka-base version that kdoc-to-json/build.gradle.kts + // was compiled against, or the plugin may fail to load or behave unexpectedly. + id("org.jetbrains.dokka") version "2.2.0-Beta" +} + +repositories { + // CRITICAL: This allows Gradle to find your locally published kdoc-to-json plugin + mavenLocal() + mavenCentral() +} + +dependencies { + // Inject your custom Dokka plugin into the documentation pipeline + dokkaPlugin("org.appdevforall.dokka:kdoc-to-json:1.0.0-SNAPSHOT") +} + +@OptIn(InternalDokkaApi::class) +abstract class JsonOutputPluginParameters @Inject constructor( + name: String +) : DokkaPluginParametersBaseSpec(name, "org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { + // The tests/ directory drives this example project through many different plugin configs + // without hand-editing this build script for each run: point KDOC2JSON_TEST_CONFIG at a + // JSON file and its contents are used verbatim as the plugin config for this build. + override fun jsonEncode(): String { + val overridePath = System.getenv("KDOC2JSON_TEST_CONFIG") + if (overridePath != null) { + return File(overridePath).readText() + } + return """{ + "logLevel": "debug", + "omitFields": [], + "replaceHtmlExtension": true, + "omitNulls": true, + "prettyPrint": true + }""" + } +} + +dokka { + // Without this, Dokka can't resolve @sample tags at all: the referenced function's + // source is never located, so the Sample doc tag comes through with empty text + // regardless of the plugin's own sampleIndex fallback logic. + dokkaSourceSets.configureEach { + samples.from("src/main/kotlin") + } + + pluginsConfiguration { + // Lets tests/test_robustness.sh exercise the "no config registered at all" path + // (distinct from an empty/malformed config, which still registers *something*). + if (System.getenv("KDOC2JSON_NO_PLUGIN_CONFIG") != "1") { + registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) + register("org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { } + } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.jar b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.properties b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df6a6ad7 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew new file mode 100755 index 00000000..b9bb139f --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew.bat b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew.bat new file mode 100644 index 00000000..aa5f10b0 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts b/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts new file mode 100644 index 00000000..1afa5665 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "testlib" diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/emptypkg/InternalOnly.kt b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/emptypkg/InternalOnly.kt new file mode 100644 index 00000000..0d303f07 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/emptypkg/InternalOnly.kt @@ -0,0 +1,10 @@ +package com.example.emptypkg + +/** + * Not public, so Dokka's default `documentedVisibilities = [PUBLIC]` excludes it. + * Exists purely so this package has a source file but zero documented declarations, + * exercising the plugin's handling of an empty/near-empty module (TEST_PLAN.md §9). + */ +internal class InternalHelper { + fun doNothing() {} +} diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Advanced.kt b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Advanced.kt new file mode 100644 index 00000000..bb92af2c --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Advanced.kt @@ -0,0 +1,240 @@ +package com.example.testlib + +import java.util.Date + +/** + * A container restricted to naturally-ordered values. + * + * Exercises a bounded generic type parameter (`T : Comparable`). + */ +class BoundedContainer>(val value: T) { + fun isGreaterThan(other: T): Boolean = value > other +} + +/** + * A running count that can be incremented. + * + * Exercises a mutable (`var`) property, which has both a getter and a setter. + */ +class Counter { + var count: Int = 0 +} + +/** + * Invokes each of [plain], [extension], and [suspending] with placeholder values. + * + * Exercises the three functional-type shapes: a plain lambda, an extension + * lambda, and a suspend lambda. + */ +suspend fun applyCallbacks( + plain: (Int) -> String, + extension: String.() -> Int, + suspending: suspend () -> Unit +) { + plain(0) + "".extension() + suspending() +} + +/** + * Copies every element of [source] into [destination]. + * + * Exercises use-site variance: `out Number` on the read-only source and + * `in Number` on the mutable destination. + */ +fun copyItems(source: List, destination: MutableList) { + destination.addAll(source) +} + +/** + * Returns [value], or `"N/A"` if it is `null`. + * + * Exercises a nullable Kotlin type (`String?`). + */ +fun formatOrDefault(value: String?): String = value ?: "N/A" + +/** + * Returns the current time as a Java platform type. + * + * Exercises Java interop (`java.util.Date`). + */ +fun currentJavaDate(): Date = Date() + +/** + * The number of whitespace-separated words in this string. + * + * Exercises an extension property. + */ +val String.wordCount: Int + get() = trim().split("\\s+".toRegex()).filter { it.isNotEmpty() }.size + +/** + * Returns this string, uppercased and with an exclamation mark appended. + * + * Exercises an extension function. + */ +fun String.shout(): String = uppercase() + "!" + +/** + * Doubles [value] after a (simulated) suspension point. + * + * Exercises a suspend function. + */ +suspend fun computeAfterPause(value: Int): Int { + return value * 2 +} + +/** + * Adds [other] to this integer. + * + * Exercises an infix function. + */ +infix fun Int.addTo(other: Int): Int = this + other + +/** + * A simple 2D integer vector. + * + * Exercises an operator function (`plus`). + */ +data class Vector2(val x: Int, val y: Int) { + operator fun plus(other: Vector2): Vector2 = Vector2(x + other.x, y + other.y) +} + +/** + * Greets [name]. + * + * Exercises a function with a default parameter value. + */ +fun greet(name: String, greeting: String = "Hello"): String = "$greeting, $name!" + +/** + * Attaches a name and priority to a declaration. Can be applied more than once + * to the same declaration. + * + * Exercises a multi-parameter, repeatable annotation. + */ +@Repeatable +annotation class Tag(val name: String, val priority: Int = 0) + +/** + * A declaration tagged more than once. + * + * Exercises an annotation applied multiple times to the same target. + */ +@Tag("first", priority = 1) +@Tag("second", priority = 2) +class TaggedThing + +/** + * Echoes [value] back. + * + * Exercises an annotation applied to a function parameter. + */ +fun annotatedParam(@Tag("param-tag") value: Int): Int = value + +/** + * An immutable 2D point. + * + * Exercises a data class. + */ +data class Point(val x: Int, val y: Int) + +/** + * A shape with a computable area. + * + * Exercises a sealed class hierarchy. + */ +sealed class Shape { + abstract fun area(): Double + + /** + * A circular [Shape]. + */ + data class Circle(val radius: Double) : Shape() { + override fun area(): Double = Math.PI * radius * radius + } + + /** + * A rectangular [Shape]. + */ + data class Rectangle(val width: Double, val height: Double) : Shape() { + override fun area(): Double = width * height + } +} + +/** + * A named configuration, only buildable through its companion. + * + * Exercises a companion object with real members. + */ +class Configuration private constructor(val name: String) { + companion object { + const val DEFAULT_NAME = "default" + + fun createDefault(): Configuration = Configuration(DEFAULT_NAME) + } +} + +/** + * A value class identified purely by [id]. + * + * Exercises overriding `equals`, `hashCode`, and `toString`. + */ +class CustomEquals(val id: Int) { + override fun equals(other: Any?): Boolean = other is CustomEquals && other.id == id + override fun hashCode(): Int = id + override fun toString(): String = "CustomEquals(id=$id)" +} + +/** + * A custom checked-style exception. + * + * Exercises a class implementing `Throwable`. + */ +class CustomException(message: String) : Throwable(message) + +/** + * Divides [numerator] by [denominator]. + * + * Supports raw markup in prose, e.g. `1 < 2` and `A & B`, to verify HTML escaping. + * + * Formatting check: **bold**, *italic*, and a list: + * - first item + * - second item + * + * ``` + * val result = safeDivide(10, 2) + * ``` + * + * > Division by zero throws [ArithmeticException] rather than returning `Infinity`. + * + * @param numerator the value to divide + * @param denominator the value to divide by, must not be zero + * @return the quotient as a [Double] + * @throws ArithmeticException if [denominator] is zero + * @see [CustomException] + * @sample com.example.testlib.safeDivideSample + * @sample com.example.testlib.safeDivideSampleAlternate + */ +fun safeDivide(numerator: Int, denominator: Int): Double { + if (denominator == 0) throw ArithmeticException("Division by zero") + return numerator.toDouble() / denominator +} + +/** + * Runnable sample referenced by the first `@sample` tag on [safeDivide]. + */ +fun safeDivideSample() { + val result = safeDivide(10, 2) + println(result) +} + +/** + * Runnable sample referenced by the second `@sample` tag on [safeDivide], used to + * verify multiple `@sample` tags each pull their own source rather than repeating + * the first one. + */ +fun safeDivideSampleAlternate() { + val result = safeDivide(7, 3) + println(result) +} diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Core.kt b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Core.kt new file mode 100644 index 00000000..7987b029 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Core.kt @@ -0,0 +1,44 @@ +package com.example.utils + +/** + * Handles the parsing and transformation of raw input strings into structured formats. + * It is highly recommended to run these operations on a background thread to prevent UI blocking. + * + * @author Alex + */ +class DataProcessor { + + /** + * Sanitizes and normalizes a single data record. Strips trailing whitespace + * and enforces standard UTF-8 encoding. + * + * @param payload The raw string payload received from the network. + * @param maxRetries The number of parsing attempts before throwing a timeout exception. + * @return A clean, formatted string ready for database insertion. + */ + fun processRecord(payload: String, maxRetries: Int): String { + return "Processed Payload" + } + + /** + * Clears the internal processing cache to free up memory footprint. + * + * @param force If true, interrupts active parsing jobs before clearing the cache. + */ + fun flushCache(force: Boolean) { + // Cache cleared + } +} + +/** + * A basic manager for handling network connection lifecycles and timeouts. + */ +class ConnectionManager { + + /** + * Establishes a secure TLS connection to the primary database node. + */ + fun connect() { + // Connection established + } +} diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Nesting.kt b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Nesting.kt new file mode 100644 index 00000000..4989f95e --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Nesting.kt @@ -0,0 +1,40 @@ +package com.example.testlib + +/** + * A singleton object to fulfill the `DObject` requirement. + * This acts as the root of our deep hierarchy. + */ +object Level1 { + + /** + * A nested class to fulfill the `DClass` requirement. + * * **Hierarchy Depth 1:** The breadcrumbs for this class will be `Overview / com.example.testlib / Level1 / Level2`. + * **Internal Link 3:** Implements the [Provider] interface. + */ + @Meta("Deeply nested class") + class Level2 : Provider { + + override val data: DataMap = emptyMap() + + override fun process(input: DataMap): DataMap { + return input + } + + /** + * An enum class to fulfill the `DEnum` requirement. + * * **Hierarchy Depth 2:** The breadcrumbs for this enum will be `Overview / com.example.testlib / Level1 / Level2 / Level3`. + * This successfully proves deep hierarchy rendering. + */ + enum class Level3 { + /** + * Enum entry to fulfill the `DEnumEntry` requirement. + */ + ACTIVE, + + /** + * Enum entry to fulfill the `DEnumEntry` requirement. + */ + INACTIVE + } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Types.kt b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Types.kt new file mode 100644 index 00000000..d437e27e --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/example-data-processor/src/main/kotlin/com/example/testlib/Types.kt @@ -0,0 +1,22 @@ +package com.example.testlib + +/** + * A generic map of string keys to arbitrary values, used as the payload type for [Provider]. + * Exists to exercise `DTypeAlias` rendering in the JSON output. + */ +typealias DataMap = Map + +/** + * A generic contract for types that expose and transform a data payload. + * Exists to exercise `DInterface` rendering in the JSON output. + */ +interface Provider { + val data: T + fun process(input: T): T +} + +/** + * Attaches an arbitrary human-readable description to a declaration. + * Exists to exercise `DAnnotation` rendering in the JSON output. + */ +annotation class Meta(val description: String) diff --git a/Dokka-plugin-kdoc2json/examples/html-baseline/build.gradle.kts b/Dokka-plugin-kdoc2json/examples/html-baseline/build.gradle.kts new file mode 100644 index 00000000..74ed76af --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/html-baseline/build.gradle.kts @@ -0,0 +1,19 @@ +// A stock-Dokka-HTML sibling of examples/example-data-processor, reusing the exact +// same sources but WITHOUT the kdoc-to-json plugin. Exists purely so +// tests/test_renderer.sh can confirm our JSON renderer writes each page at the same +// path (extension aside) that Dokka's own default HTML renderer would -- a silent +// path mismatch here would mean some page went missing or was misplaced. +plugins { + kotlin("jvm") version "1.9.23" + id("org.jetbrains.dokka") version "2.2.0-Beta" +} + +repositories { + mavenCentral() +} + +kotlin { + sourceSets.main { + kotlin.srcDir("../example-data-processor/src/main/kotlin") + } +} diff --git a/Dokka-plugin-kdoc2json/examples/html-baseline/gradle/wrapper/gradle-wrapper.jar b/Dokka-plugin-kdoc2json/examples/html-baseline/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/Dokka-plugin-kdoc2json/examples/html-baseline/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Dokka-plugin-kdoc2json/examples/html-baseline/gradle/wrapper/gradle-wrapper.properties b/Dokka-plugin-kdoc2json/examples/html-baseline/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df6a6ad7 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/html-baseline/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Dokka-plugin-kdoc2json/examples/html-baseline/gradlew b/Dokka-plugin-kdoc2json/examples/html-baseline/gradlew new file mode 100755 index 00000000..b9bb139f --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/html-baseline/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Dokka-plugin-kdoc2json/examples/html-baseline/gradlew.bat b/Dokka-plugin-kdoc2json/examples/html-baseline/gradlew.bat new file mode 100644 index 00000000..aa5f10b0 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/html-baseline/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/Dokka-plugin-kdoc2json/examples/html-baseline/settings.gradle.kts b/Dokka-plugin-kdoc2json/examples/html-baseline/settings.gradle.kts new file mode 100644 index 00000000..28d1dee3 --- /dev/null +++ b/Dokka-plugin-kdoc2json/examples/html-baseline/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "html-baseline" diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/build.gradle.kts b/Dokka-plugin-kdoc2json/kdoc-to-json/build.gradle.kts new file mode 100644 index 00000000..3343a62e --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + kotlin("jvm") version "1.9.24" + kotlin("plugin.serialization") version "1.9.24" + `maven-publish` +} + +group = "org.appdevforall.dokka" +version = "1.0.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + compileOnly("org.jetbrains.dokka:dokka-core:2.2.0-Beta") + compileOnly("org.jetbrains.dokka:dokka-base:2.2.0-Beta") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") +} + +publishing { + publications { + create("maven") { + from(components["java"]) + } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.jar b/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..1b33c55b Binary files /dev/null and b/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.jar differ diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.properties b/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..df6a6ad7 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew b/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew new file mode 100755 index 00000000..b9bb139f --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew.bat b/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew.bat new file mode 100644 index 00000000..aa5f10b0 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/settings.gradle.kts b/Dokka-plugin-kdoc2json/kdoc-to-json/settings.gradle.kts new file mode 100644 index 00000000..937bcc6f --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "kdoc-to-json" \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonOutputPlugin.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonOutputPlugin.kt new file mode 100644 index 00000000..6209fb10 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonOutputPlugin.kt @@ -0,0 +1,21 @@ +package org.appdevforall.dokka.kdoc2json + +import org.jetbrains.dokka.CoreExtensions +import org.jetbrains.dokka.base.DokkaBase +import org.jetbrains.dokka.plugability.DokkaPlugin +import org.jetbrains.dokka.plugability.DokkaPluginApiPreview +import org.jetbrains.dokka.plugability.PluginApiPreviewAcknowledgement + +class JsonOutputPlugin : DokkaPlugin() { + + // FIX: plugin() is a standard function in Dokka 1.9+, not a property delegate. + // We use a getter so it is evaluated lazily when the context is available. + private val dokkaBase get() = plugin() + + val jsonRenderer by extending { + CoreExtensions.renderer providing ::JsonRenderer override dokkaBase.htmlRenderer + } + + @OptIn(DokkaPluginApiPreview::class) + override fun pluginApiPreviewAcknowledgement() = PluginApiPreviewAcknowledgement +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt new file mode 100644 index 00000000..61e3273a --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonPluginConfig.kt @@ -0,0 +1,16 @@ +package org.appdevforall.dokka.kdoc2json + +import kotlinx.serialization.Serializable +import org.jetbrains.dokka.plugability.ConfigurableBlock + +@Serializable +data class JsonPluginConfig( + val logLevel: String = "debug", + val omitFields: List = emptyList(), + val logFile: String? = null, + val replaceHtmlExtension: Boolean = false, + val omitNulls: Boolean = false, + val classDiscriminator: String = "kind", + val prettyPrint: Boolean = false, + val sourceSetWhitelist: List = emptyList() +) : ConfigurableBlock \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt new file mode 100644 index 00000000..67e2b63d --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/JsonRenderer.kt @@ -0,0 +1,268 @@ +package org.appdevforall.dokka.kdoc2json + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.* +import org.appdevforall.dokka.kdoc2json.dtos.* +import org.jetbrains.dokka.base.DokkaBase +import org.jetbrains.dokka.model.* +import org.jetbrains.dokka.pages.PageNode +import org.jetbrains.dokka.pages.RootPageNode +import org.jetbrains.dokka.pages.WithDocumentables +import org.jetbrains.dokka.plugability.DokkaContext +import org.jetbrains.dokka.plugability.configuration +import org.jetbrains.dokka.plugability.plugin +import org.jetbrains.dokka.plugability.querySingle +import org.jetbrains.dokka.renderers.Renderer +import java.io.File +import java.util.concurrent.ConcurrentLinkedQueue + +class JsonRenderer(private val context: DokkaContext) : Renderer { + + override fun render(root: RootPageNode) { + val fqcn = "org.appdevforall.dokka.kdoc2json.JsonOutputPlugin" + var config = configuration(context) + + if (config == null) { + val rawConfig = context.configuration.pluginsConfiguration.find { it.fqPluginName == fqcn }?.values + if (rawConfig != null) { + context.logger.info("Dokka native config parsing failed. Manually parsing: $rawConfig") + try { + // A dedicated instance because finalConfig (and the "real" json below) isn't known yet; + // ignoreUnknownKeys tolerates extra keys in this user-authored config rather than + // failing to parse it at all. + config = Json { ignoreUnknownKeys = true }.decodeFromString(rawConfig) + } catch (e: Exception) { + context.logger.error("Manual config parsing failed: ${e.message}") + } + } else { + context.logger.warn("No JSON config found in pluginsConfiguration for $fqcn! Falling back to defaults.") + } + } + + val finalConfig = config ?: JsonPluginConfig() + val logger = PluginLogger(context.logger, finalConfig.logLevel, finalConfig.logFile) + + val json = Json { + prettyPrint = finalConfig.prettyPrint + classDiscriminator = finalConfig.classDiscriminator + } + + logger.info("Initializing JSON Renderer with config: $finalConfig") + + val locationProvider = context.plugin() + .querySingle { locationProviderFactory } + .getLocationProvider(root) + + val outputDir = context.configuration.outputDir + logger.debug("Output directory set to: ${outputDir.absolutePath}") + + // --- Collect and write actual packages to package-list --- + val packages = mutableSetOf() + fun collectPackages(node: PageNode) { + if (node is WithDocumentables) { + node.documentables.forEach { doc -> + if (doc is org.jetbrains.dokka.model.DPackage) { + if (!passesSourceSetWhitelist(doc.sourceSets, finalConfig.sourceSetWhitelist)) { + val docSourceSets = doc.sourceSets.map { it.sourceSetID.toString().substringAfterLast("/") } + logger.info("Omitting '${doc.name}' from package-list: sourceSets=$docSourceSets not in whitelist ${finalConfig.sourceSetWhitelist}") + return@forEach + } + doc.dri.packageName?.takeIf { it.isNotBlank() }?.let { packages.add(it) } + } + } + } + node.children.forEach { collectPackages(it) } + } + collectPackages(root) + + val packageListFile = File(outputDir, "package-list") + packageListFile.parentFile.mkdirs() + + val packageListContent = buildString { + appendLine("\$dokka.format:json-v1\$") + appendLine("\$dokka.linkExtension:json\$") + packages.sorted().forEach { + appendLine(it) + } + } + + packageListFile.writeText(packageListContent) + logger.debug("Generated package-list with ${packages.size} packages.") + + if (context.configuration.modules.isNotEmpty()) { + logger.info("Multimodule project detected. Generating root index.json...") + + val ext = if (finalConfig.replaceHtmlExtension) "json" else "html" + val rootDto = MultimoduleRootDto( + name = root.name, + modules = context.configuration.modules.map { module -> + ModuleReferenceDto( + name = module.name, + url = "${module.relativePathToOutputDirectory.invariantSeparatorsPath}/index.$ext" + ) + } + ) + + val outputFile = File(outputDir, "index.json") + outputFile.parentFile.mkdirs() + + val rawJsonElement = json.encodeToJsonElement(DocumentableDto.serializer(), rootDto) + val filteredJsonElement = filterJson(rawJsonElement, finalConfig.omitFields, finalConfig.omitNulls) + outputFile.writeText(json.encodeToString(JsonElement.serializer(), filteredJsonElement)) + + logger.debug("Wrote Multimodule Root JSON to: ${outputFile.name}") + } + + // Thread-safe list to aggregate all type documentables during traversal + val allTypesList = ConcurrentLinkedQueue() + + fun traverse(node: PageNode, ancestors: List) { + val currentPath = ancestors + node + + if (node is WithDocumentables && node.documentables.isNotEmpty()) { + val documentable = node.documentables.first() + logger.debug("Processing documentable: ${documentable.name} (${documentable.dri})") + + if (!passesSourceSetWhitelist(documentable.sourceSets, finalConfig.sourceSetWhitelist)) { + val docSourceSets = documentable.sourceSets.map { it.sourceSetID.toString().substringAfterLast("/") } + logger.info("Omitting '${documentable.name}': sourceSets=$docSourceSets not in whitelist ${finalConfig.sourceSetWhitelist}") + node.children.forEach { traverse(it, currentPath) } + return + } + + if (documentable is DClasslike || documentable is DTypeAlias) { + var typeUrl = locationProvider.resolve(node, context = null, skipExtension = false) + if (typeUrl != null && finalConfig.replaceHtmlExtension && !typeUrl.startsWith("http")) { + typeUrl = typeUrl.replace(".html", ".json") + } + + val kindStr = when (documentable) { + is DClass -> "class" + is DInterface -> "interface" + is DEnum -> "enum" + is DObject -> "object" + is DAnnotation -> "annotation" + is DTypeAlias -> "typeAlias" + else -> "type" + } + + allTypesList.add( + TypeIndexEntryDto( + name = documentable.name ?: "Unknown", + kind = kindStr, + dri = documentable.dri.toString(), + url = typeUrl, + sourceSets = documentable.sourceSets.map { it.sourceSetID.toString().substringAfterLast("/") } + ) + ) + } + + val breadcrumbs = currentPath.map { ancestor -> + var url = locationProvider.resolve(ancestor, context = node, skipExtension = false) + if (url != null && finalConfig.replaceHtmlExtension && !url.startsWith("http")) { + url = url.replace(".html", ".json") + } + BreadcrumbNode(name = ancestor.name, url = url) + } + + val mapper = ModelMapper( + locationProvider = locationProvider, + contextNode = node, + logger = logger, + replaceHtmlExtension = finalConfig.replaceHtmlExtension, + sourceSetWhitelist = finalConfig.sourceSetWhitelist + ) + + val dto = mapper.mapToDto(documentable, breadcrumbs) + + if (dto != null) { + try { + val pagePath = locationProvider.resolve(node, context = null, skipExtension = true) + val outputFile = File(outputDir, "$pagePath.json") + outputFile.parentFile.mkdirs() + + val rawJsonElement = json.encodeToJsonElement(DocumentableDto.serializer(), dto) + // Deeply filters out the nulls and empties before writing + val filteredJsonElement = filterJson(rawJsonElement, finalConfig.omitFields, finalConfig.omitNulls) + outputFile.writeText(json.encodeToString(JsonElement.serializer(), filteredJsonElement)) + + logger.debug("Wrote JSON to: ${outputFile.name}") + } catch (e: Exception) { + // One unwritable/unserializable page (permissions, disk full, a + // serialization edge case) shouldn't abort every other page's output -- + // matches the resilience LinkPostProcessor's own per-file passes already have. + logger.warn("Failed to write JSON for '${documentable.name}' (${documentable.dri}): ${e.message}") + } + } + } + + node.children.forEach { traverse(it, currentPath) } + } + + traverse(root, emptyList()) + + if (allTypesList.isNotEmpty()) { + logger.info("Generating all-types.json index...") + val allTypesDto = AllTypesDto( + types = allTypesList.sortedBy { it.name } + ) + val allTypesFile = File(outputDir, "all-types.json") + allTypesFile.parentFile.mkdirs() + + val rawJsonElement = json.encodeToJsonElement(DocumentableDto.serializer(), allTypesDto) + val filteredJsonElement = filterJson(rawJsonElement, finalConfig.omitFields, finalConfig.omitNulls) + allTypesFile.writeText(json.encodeToString(JsonElement.serializer(), filteredJsonElement)) + + logger.debug("Wrote All-Types JSON to: ${allTypesFile.name}") + } + + LinkPostProcessor.postProcess(context) + logger.info("JSON rendering completed.") + } + + // --- RECURSIVE JSON AST FILTER --- + private fun filterJson(element: JsonElement, omitFields: List, omitNulls: Boolean): JsonElement { + if (omitFields.isEmpty() && !omitNulls) return element + + return when (element) { + is JsonObject -> { + val filteredMap = element.entries + .filterNot { omitFields.contains(it.key) } + .mapNotNull { (key, value) -> + val filteredValue = filterJson(value, omitFields, omitNulls) + if (omitNulls && isNullOrEmpty(filteredValue)) { + null + } else { + key to filteredValue + } + } + .toMap() + JsonObject(filteredMap) + } + is JsonArray -> { + val mapped = element.map { filterJson(it, omitFields, omitNulls) } + if (omitNulls) { + JsonArray(mapped.filterNot { isNullOrEmpty(it) }) + } else { + JsonArray(mapped) + } + } + else -> element + } + } + + private fun isNullOrEmpty(element: JsonElement): Boolean { + return element is JsonNull || + (element is JsonPrimitive && element.isString && element.content.isEmpty()) || + (element is JsonArray && element.isEmpty()) || + (element is JsonObject && element.isEmpty()) + } + + private fun passesSourceSetWhitelist( + sourceSets: Set, + whitelist: List + ): Boolean { + if (whitelist.isEmpty()) return true + return sourceSets.any { it.sourceSetID.toString().substringAfterLast("/") in whitelist } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt new file mode 100644 index 00000000..0393a81c --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/LinkPostProcessor.kt @@ -0,0 +1,83 @@ +package org.appdevforall.dokka.kdoc2json + +import kotlinx.serialization.json.* +import org.jetbrains.dokka.plugability.DokkaContext + +object LinkPostProcessor { + fun postProcess(context: DokkaContext) { + val dir = context.configuration.outputDir + context.logger.info("Running Universal Cross-Module Link Resolution in ${dir.absolutePath}...") + + val jsonFiles = dir.walkTopDown().filter { it.extension == "json" }.toList() + val driIndex = mutableMapOf() + + // --- PASS 1: Build Global Index --- + for (file in jsonFiles) { + try { + val text = file.readText() + val rootElement = Json.parseToJsonElement(text) + val relativePath = file.parentFile.toRelativeString(dir).replace("\\", "/") + extractDris(rootElement, relativePath, driIndex) + } catch (e: Exception) { + context.logger.warn("Failed to index ${file.name}: ${e.message}") + } + } + + context.logger.info("Indexed ${driIndex.size} DRIs across ${jsonFiles.size} JSON files.") + + // --- PASS 2: Resolve Links --- + val unresolvedRegex = """unresolved:([^\s")\\]+)""".toRegex() + var replacedCount = 0 + val unresolvedDris = mutableSetOf() + + for (file in jsonFiles) { + try { + val text = file.readText() + if (text.contains("unresolved:")) { + val relativeParent = file.parentFile.toRelativeString(dir).replace("\\", "/") + val depth = if (relativeParent.isEmpty()) 0 else relativeParent.split("/").filter { it.isNotEmpty() }.size + val rootPrefix = if (depth == 0) "./" else "../".repeat(depth) + + val replaced = unresolvedRegex.replace(text) { matchResult -> + val dri = matchResult.groupValues[1] + val resolved = driIndex[dri] + + if (resolved != null) { + replacedCount++ + "$rootPrefix$resolved" + } else { + unresolvedDris.add(dri) + "#" + } + } + file.writeText(replaced) + } + } catch (e: Exception) { + context.logger.warn("Failed to resolve links in ${file.name}: ${e.message}") + } + } + context.logger.info("Successfully resolved $replacedCount cross-module links!") + if (unresolvedDris.isNotEmpty()) { + context.logger.warn("Failed to resolve ${unresolvedDris.size} DRIs (patched to \"#\"): ${unresolvedDris.joinToString(", ")}") + } + } + + private fun extractDris(element: JsonElement, relativePath: String, index: MutableMap) { + if (element is JsonObject) { + val dri = (element["dri"] as? JsonPrimitive)?.contentOrNull + val url = (element["url"] as? JsonPrimitive)?.contentOrNull + + if (dri != null && url != null && !url.startsWith("unresolved:") && !url.startsWith("http") && !url.startsWith("#")) { + val cleanParent = relativePath.trim('/') + val fullUrl = if (cleanParent.isEmpty()) url else "$cleanParent/$url" + // Last-writer-wins: a DRI can legitimately resolve to multiple files (e.g. expect/actual + // declarations across source sets), and any of those locations is a valid link target. + index[dri] = fullUrl + } + + element.values.forEach { extractDris(it, relativePath, index) } + } else if (element is JsonArray) { + element.forEach { extractDris(it, relativePath, index) } + } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt new file mode 100644 index 00000000..8095161c --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/ModelMapper.kt @@ -0,0 +1,524 @@ +package org.appdevforall.dokka.kdoc2json + +import org.appdevforall.dokka.kdoc2json.dtos.* +import org.jetbrains.dokka.base.resolvers.local.LocationProvider +import org.jetbrains.dokka.links.DRI +import org.jetbrains.dokka.links.PointingToDeclaration +import org.jetbrains.dokka.model.* +import org.jetbrains.dokka.model.doc.* +import org.jetbrains.dokka.model.properties.PropertyContainer +import org.jetbrains.dokka.pages.PageNode +import org.jetbrains.dokka.pages.ContentPage +import org.jetbrains.dokka.pages.ContentNode +import org.jetbrains.dokka.pages.ContentCodeBlock +import org.jetbrains.dokka.pages.ContentText +import org.jetbrains.dokka.pages.ContentBreakLine +import org.jetbrains.dokka.pages.ContentKind + +class ModelMapper( + private val locationProvider: LocationProvider, + private val contextNode: PageNode, + private val logger: PluginLogger, + private val replaceHtmlExtension: Boolean, + private val sourceSetWhitelist: List = emptyList() +) { + private fun resolveUrl(dri: DRI?, sourceSets: Set): String? { + if (dri == null) return null + + var url = locationProvider.resolve(dri, sourceSets, contextNode) + if (url == null) { + url = locationProvider.resolve(dri, emptySet(), contextNode) + } + + if (url == null && dri.callable != null) { + // Synthetic accessors (getX/setX), constructors, and callable/type parameters never + // get their own PageNode -- Dokka renders them inline on their enclosing + // declaration's page. Falling back to that declaration's own DRI (same + // package/class, no callable, pointing at the declaration itself) turns what would + // otherwise be a permanently dead "#" link (see LinkPostProcessor) into a real link + // to the page that actually documents this member. + val parentDri = dri.copy(callable = null, target = PointingToDeclaration) + url = locationProvider.resolve(parentDri, sourceSets, contextNode) + ?: locationProvider.resolve(parentDri, emptySet(), contextNode) + } + + if (url == null) { + url = "unresolved:${dri}" + } + + if (replaceHtmlExtension && !url.startsWith("http") && !url.startsWith("unresolved:")) { + url = url.replace(".html", ".json") + } + return url + } + + // --- ADDED shallow PARAMETER HERE --- + fun mapToDto(doc: Documentable, breadcrumbs: List = emptyList(), shallow: Boolean = false): DocumentableDto? { + logger.debug("Mapping documentable ${doc.name} of type ${doc::class.java.simpleName} (shallow=$shallow)") + + val displaySourceSets = doc.sourceSets.map { it.toDisplaySourceSet() }.toSet() + val url = resolveUrl(doc.dri, displaySourceSets) + + return when (doc) { + is DModule -> ModuleDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), + sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + // Pass shallow = true to children so the tree stops recursing! + packages = if (shallow) emptyList() else filterWhitelisted(doc.packages).mapNotNull { mapToDto(it, emptyList(), true) } + ) + is DPackage -> PackageDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, + typeAliases = if (shallow) emptyList() else filterWhitelisted(doc.typealiases).mapNotNull { mapToDto(it, emptyList(), true) } + ) + is DClass -> ClassDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + constructors = if (shallow) emptyList() else filterWhitelisted(doc.constructors).mapNotNull { mapToDto(it, emptyList(), true) }, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + companion = if (shallow) null else doc.companion?.takeIfWhitelisted()?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> + list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } + }, + modifier = mapSourceSetDependent(doc.modifier) { _, it -> it.name }, + isExpectActual = doc.isExpectActual, + typealiases = emptyList() + ) + is DEnum -> EnumDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + entries = if (shallow) emptyList() else filterWhitelisted(doc.entries).mapNotNull { mapToDto(it, emptyList(), true) }, + constructors = if (shallow) emptyList() else filterWhitelisted(doc.constructors).mapNotNull { mapToDto(it, emptyList(), true) }, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + companion = if (shallow) null else doc.companion?.takeIfWhitelisted()?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> + list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } + }, + isExpectActual = doc.isExpectActual, + typealiases = emptyList() + ) + is DEnumEntry -> EnumEntryDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) } + ) + is DFunction -> FunctionDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + isConstructor = doc.isConstructor, + parameters = doc.parameters.mapNotNull { mapToDto(it) as? ParameterDto }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + type = mapBound(doc.type, displaySourceSets), + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + receiver = doc.receiver?.let { mapToDto(it) as? ParameterDto }, + modifier = mapSourceSetDependent(doc.modifier) { _, it -> it.name }, + isExpectActual = doc.isExpectActual, + contextParameters = emptyList() + ) + is DInterface -> InterfaceDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + companion = if (shallow) null else doc.companion?.takeIfWhitelisted()?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> + list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } + }, + modifier = mapSourceSetDependent(doc.modifier) { _, it -> it.name }, + isExpectActual = doc.isExpectActual, + typealiases = emptyList() + ) + is DObject -> ObjectDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + supertypes = mapSourceSetDependent(doc.supertypes) { ss, list -> + list.map { TypeConstructorWithKindDto(mapBound(it.typeConstructor, setOf(ss.toDisplaySourceSet())), it.kind.toString()) } + }, + isExpectActual = doc.isExpectActual, + typealiases = emptyList() + ) + is DAnnotation -> AnnotationDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + functions = if (shallow) emptyList() else filterWhitelisted(doc.functions).mapNotNull { mapToDto(it, emptyList(), true) }, + properties = if (shallow) emptyList() else filterWhitelisted(doc.properties).mapNotNull { mapToDto(it, emptyList(), true) }, + classlikes = if (shallow) emptyList() else filterWhitelisted(doc.classlikes).mapNotNull { mapToDto(it, emptyList(), true) }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + companion = if (shallow) null else doc.companion?.takeIfWhitelisted()?.let { mapToDto(it, emptyList(), true) as? ObjectDto }, + constructors = if (shallow) emptyList() else filterWhitelisted(doc.constructors).mapNotNull { mapToDto(it, emptyList(), true) }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + isExpectActual = doc.isExpectActual + ) + is DProperty -> PropertyDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + type = mapBound(doc.type, displaySourceSets), + receiver = doc.receiver?.let { mapToDto(it) as? ParameterDto }, + setter = doc.setter?.let { mapToDto(it) as? FunctionDto }, + getter = doc.getter?.let { mapToDto(it) as? FunctionDto }, + modifier = mapSourceSetDependent(doc.modifier) { _, it -> it.name }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + isExpectActual = doc.isExpectActual, + contextParameters = emptyList() + ) + is DParameter -> ParameterDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + type = mapBound(doc.type, displaySourceSets) + ) + is DTypeParameter -> TypeParameterDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + bounds = doc.bounds.map { mapBound(it, displaySourceSets) }, + variantTypeParameter = mapVariance(doc.variantTypeParameter, displaySourceSets) + ) + is DTypeAlias -> TypeAliasDto( + dri = doc.dri.toString(), name = doc.name, url = url, + documentation = mapDocNodes(doc.documentation), sourceSets = mapSourceSets(doc.sourceSets), + expectPresentInSet = doc.expectPresentInSet?.sourceSetID?.toString(), + extras = mapExtras(doc.extra), + breadcrumbs = breadcrumbs, + type = mapBound(doc.type, displaySourceSets), + underlyingType = mapSourceSetDependent(doc.underlyingType) { ss, it -> mapBound(it, setOf(ss.toDisplaySourceSet())) }, + visibility = mapSourceSetDependent(doc.visibility) { _, it -> it.name }, + generics = doc.generics.mapNotNull { mapToDto(it) as? TypeParameterDto }, + sources = mapSourceSetDependent(doc.sources) { _, it -> it.path } + ) + else -> null + } + } + + // VERSION-COUPLED: isObviousMember/isException below detect Dokka-internal extras by + // comparing ::class.java.simpleName against string literals, because ObviousMember and + // ExceptionInSupertypes aren't public API to check against directly. Bumping the Dokka + // version pinned in kdoc-to-json/build.gradle.kts (2.2.0-Beta) could rename, move, or remove + // either class with no compile error -- the simpleName check would just always return false. + // DokkaVersionCheck.runOnce below verifies the exact FQCNs this file depends on are still + // loadable, so that kind of break is a loud warning instead of a silent one. + private fun mapExtras(extra: PropertyContainer<*>): ExtrasDto { + DokkaVersionCheck.runOnce(logger) + val isObviousMember = extra.allOfType().any { it::class.java.simpleName == "ObviousMember" } + val isException = extra.allOfType().any { it::class.java.simpleName == "ExceptionInSupertypes" } + + val annotationsMap = extra.allOfType().firstOrNull()?.directAnnotations?.entries?.associate { (ss, list) -> + ss.sourceSetID.toString() to list.map { anno -> + AnnotationWrapperDto( + dri = anno.dri.toString(), + params = anno.params.entries.associate { (k, v) -> k to v.toString() }, + url = resolveUrl(anno.dri, setOf(ss.toDisplaySourceSet())) + ) + } + } ?: emptyMap() + + val defaultValuesMap = mutableMapOf() + extra.allOfType().firstOrNull { it::class.java.simpleName == "DefaultValue" }?.let { defValue -> + try { + val valueMethod = defValue::class.java.methods.firstOrNull { it.name == "getValue" || it.name == "getExpression" } + val valueObj = valueMethod?.invoke(defValue) + if (valueObj is Map<*, *>) { + valueObj.forEach { (ss, expr) -> + val ssName = ss?.let { it::class.java.getMethod("getSourceSetID").invoke(it).toString() } ?: "unknown" + defaultValuesMap[ssName] = expr.toString() + } + } else if (valueObj != null) { + defaultValuesMap["unknown"] = valueObj.toString() + } + } catch (e: Exception) { + logger.debug("Failed to safely extract DefaultValue: ${e.message}") + } + } + + val additionalModifiersMap = extra.allOfType().firstOrNull()?.content?.entries?.associate { (ss, set) -> + ss.sourceSetID.toString() to set.map { modifier -> + try { + modifier::class.java.getMethod("getName").invoke(modifier).toString().lowercase() + } catch (e: Exception) { + modifier.toString().substringAfterLast("$").substringBefore("@").lowercase() + } + } + } ?: emptyMap() + + return ExtrasDto( + annotations = annotationsMap, + defaultValues = defaultValuesMap, + additionalModifiers = additionalModifiersMap, + isObviousMember = isObviousMember, + isException = isException + ) + } + + private fun mapProjection(proj: Projection, sourceSets: Set): ProjectionDto { + return when (proj) { + is Star -> StarDto + is Variance<*> -> when (proj) { + is Covariance<*> -> CovarianceDto(mapBound(proj.inner, sourceSets)) + is Contravariance<*> -> ContravarianceDto(mapBound(proj.inner, sourceSets)) + is Invariance<*> -> InvarianceDto(mapBound(proj.inner, sourceSets)) + } + is Bound -> mapBound(proj, sourceSets) + } + } + + // A DTypeParameter's own variantTypeParameter is a Projection (Star | Variance | Bound) even + // though in practice Dokka only ever hands us a Variance here -- Star/bare-Bound would only + // show up for a use-site projection, not a type parameter's own declared variance. Rather + // than a hard `as VarianceDto` cast that throws ClassCastException if that assumption is ever + // wrong, degrade to Invariance around whatever bound (if any) we can salvage. + private fun mapVariance(proj: Projection, sourceSets: Set): VarianceDto { + val mapped = mapProjection(proj, sourceSets) + return mapped as? VarianceDto ?: InvarianceDto(mapped as? BoundDto ?: UnresolvedBoundDto("*")) + } + + private fun mapBound(bound: Bound, sourceSets: Set): BoundDto { + return when (bound) { + is TypeParameter -> TypeParameterBoundDto(bound.dri.toString(), bound.name, bound.presentableName, resolveUrl(bound.dri, sourceSets)) + is Nullable -> NullableDto(mapBound(bound.inner, sourceSets), resolveUrl(null, sourceSets)) + is DefinitelyNonNullable -> DefinitelyNonNullableDto(mapBound(bound.inner, sourceSets)) + is TypeAliased -> TypeAliasedDto(mapBound(bound.typeAlias, sourceSets), mapBound(bound.inner, sourceSets), resolveUrl(null, sourceSets)) + is PrimitiveJavaType -> PrimitiveJavaTypeDto(bound.name) + is JavaObject -> JavaObjectDto() + is Void -> VoidDto() + is Dynamic -> DynamicDto() + is UnresolvedBound -> UnresolvedBoundDto(bound.name) + is GenericTypeConstructor -> GenericTypeConstructorDto( + dri = bound.dri.toString(), + projections = bound.projections.map { mapProjection(it, sourceSets) }, + presentableName = bound.presentableName, + url = resolveUrl(bound.dri, sourceSets) + ) + is FunctionalTypeConstructor -> FunctionalTypeConstructorDto( + dri = bound.dri.toString(), + projections = bound.projections.map { mapProjection(it, sourceSets) }, + isExtensionFunction = bound.isExtensionFunction, + isSuspendable = bound.isSuspendable, + presentableName = bound.presentableName, + url = resolveUrl(bound.dri, sourceSets) + ) + } + } + + private fun mapDocNodes(docs: SourceSetDependent): Map> { + return docs.entries.associate { (sourceSet, node) -> + val displaySourceSet = sourceSet.toDisplaySourceSet() + val displaySourceSets = setOf(displaySourceSet) + + val pageSamples = mutableListOf() + if (contextNode is ContentPage) { + fun walk(n: ContentNode) { + if (n is ContentCodeBlock && + n.dci.kind == ContentKind.Sample && + n.sourceSets.contains(displaySourceSet) + ) { + fun extractContentText(cn: ContentNode): String { + if (cn is ContentText) return cn.text + if (cn is ContentBreakLine) return "\n" + return cn.children.joinToString("") { extractContentText(it) } + } + pageSamples.add(extractContentText(n)) + } + n.children.forEach { walk(it) } + } + walk(contextNode.content) + } + + var sampleIndex = 0 + val tags = node.children.map { tagWrapper -> + val type = tagWrapper::class.java.simpleName + var text = extractText(tagWrapper.root, displaySourceSets).trim() + + if (type == "Sample" && text.isEmpty()) { + if (sampleIndex < pageSamples.size) { + text = pageSamples[sampleIndex] + sampleIndex++ + } + } + + TagWrapperDto( + type = type, + text = text, + name = if (tagWrapper is NamedTagWrapper) tagWrapper.name else null + ) + } + sourceSet.sourceSetID.toString() to tags + } + } + + // Escapes an HTML attribute value (e.g. the href of an tag built from KDoc content). + // "&" must be escaped first, or escaping "\"" afterwards would double-escape any """ + // that was already literally present in the source text. + private fun escapeHtmlAttribute(value: String): String { + return value.replace("&", "&").replace("\"", """) + } + + private fun extractText(tag: DocTag, sourceSets: Set): String { + return when (tag) { + is Text -> tag.body.replace("&", "&").replace("<", "<").replace(">", ">") + + is P -> "

    ${tag.children.joinToString("") { extractText(it, sourceSets) }}

    " + is Br -> "
    " + is BlockQuote -> "
    ${tag.children.joinToString("") { extractText(it, sourceSets) }}
    " + + is B -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + is Strong -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + is I -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + is Em -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + + is CodeInline -> "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + is CodeBlock -> "
    ${tag.children.joinToString("") { extractText(it, sourceSets) }}
    " + + is Ul -> "
      ${tag.children.joinToString("") { extractText(it, sourceSets) }}
    " + is Ol -> "
      ${tag.children.joinToString("") { extractText(it, sourceSets) }}
    " + is Li -> "
  • ${tag.children.joinToString("") { extractText(it, sourceSets) }}
  • " + + is H1 -> "

    ${tag.children.joinToString("") { extractText(it, sourceSets) }}

    " + is H2 -> "

    ${tag.children.joinToString("") { extractText(it, sourceSets) }}

    " + is H3 -> "

    ${tag.children.joinToString("") { extractText(it, sourceSets) }}

    " + is H4 -> "

    ${tag.children.joinToString("") { extractText(it, sourceSets) }}

    " + is H5 -> "
    ${tag.children.joinToString("") { extractText(it, sourceSets) }}
    " + is H6 -> "
    ${tag.children.joinToString("") { extractText(it, sourceSets) }}
    " + + is A -> { + val href = escapeHtmlAttribute(tag.params["href"] ?: "") + "
    ${tag.children.joinToString("") { extractText(it, sourceSets) }}" + } + is DocumentationLink -> { + val href = escapeHtmlAttribute(resolveUrl(tag.dri, sourceSets) ?: "") + "${tag.children.joinToString("") { extractText(it, sourceSets) }}" + } + + is CustomDocTag -> tag.children.joinToString("") { extractText(it, sourceSets) } + else -> tag.children.joinToString("") { extractText(it, sourceSets) } + } + } + + private fun abbreviateSourceSet(id: String): String { + return id.substringAfterLast("/") + } + + private fun mapSourceSetDependent( + dependent: SourceSetDependent, + mapper: (org.jetbrains.dokka.DokkaConfiguration.DokkaSourceSet, T) -> R + ): Map { + return dependent.entries.associate { + abbreviateSourceSet(it.key.sourceSetID.toString()) to mapper(it.key, it.value) + } + } + + private fun mapSourceSets(sets: Set): List { + return sets.map { abbreviateSourceSet(it.sourceSetID.toString()) } + } + + private fun passesWhitelist(doc: Documentable): Boolean { + if (sourceSetWhitelist.isEmpty()) return true + return doc.sourceSets.any { abbreviateSourceSet(it.sourceSetID.toString()) in sourceSetWhitelist } + } + + // Drops a shallow child reference (e.g. a package/classlike/function listed in a parent's + // own index) whose source sets don't overlap the whitelist, so index pages don't dangle + // links to files that JsonRenderer omits entirely. + private fun T.takeIfWhitelisted(): T? { + if (passesWhitelist(this)) return this + logger.info("Omitting reference to '${name}' from index: sourceSets=${mapSourceSets(sourceSets)} not in whitelist $sourceSetWhitelist") + return null + } + + private fun filterWhitelisted(docs: List): List = docs.mapNotNull { it.takeIfWhitelisted() } +} + +// One-time-per-process sanity check for mapExtras' reflective, simpleName-based detection of +// Dokka-internal extras (see the VERSION-COUPLED comment on mapExtras). Verifies the exact FQCNs +// that detection depends on are still loadable against whatever Dokka version is actually on the +// classpath, so a future Dokka bump that renames/moves/removes one of these fails loudly instead +// of silently degrading to "never detected." +private object DokkaVersionCheck { + @Volatile + private var checked = false + + private val REFLECTED_DOKKA_CLASSES = listOf( + "org.jetbrains.dokka.model.ObviousMember", + "org.jetbrains.dokka.model.ExceptionInSupertypes", + "org.jetbrains.dokka.model.DefaultValue", + "org.jetbrains.dokka.model.AdditionalModifiers" + ) + + @Synchronized + fun runOnce(logger: PluginLogger) { + if (checked) return + checked = true + REFLECTED_DOKKA_CLASSES.forEach { fqcn -> + try { + Class.forName(fqcn) + } catch (e: ClassNotFoundException) { + logger.warn( + "Dokka-internal class '$fqcn' not found on the classpath. " + + "mapExtras' reflective detection of ObviousMember/ExceptionInSupertypes/" + + "DefaultValue/AdditionalModifiers may be silently broken for this Dokka version." + ) + } + } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt new file mode 100644 index 00000000..fa00999d --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/PluginLogger.kt @@ -0,0 +1,65 @@ +package org.appdevforall.dokka.kdoc2json + +import org.jetbrains.dokka.utilities.DokkaLogger +import java.io.File + +class PluginLogger( + private val dokkaLogger: DokkaLogger, + levelStr: String, + logFilePath: String? = null // <--- ADD THIS +) { + private val level = when (levelStr.lowercase()) { + "debug" -> 0 + "info" -> 1 + "warn" -> 2 + "error" -> 3 + else -> 1 + } + + private val logFile: File? = logFilePath?.let { path -> + val file = File(path) + file.parentFile?.mkdirs() + file + } + + // Synchronize to prevent mangled logs if Dokka processes multiple modules in parallel + private fun writeToFile(msg: String) { + if (logFile != null) { + synchronized(this) { + logFile.appendText("$msg\n") + } + } + } + + fun debug(msg: String) { + if (level <= 0) { + val formatted = "[JSON Plugin DEBUG] $msg" + dokkaLogger.info(formatted) + writeToFile(formatted) + } + } + + fun info(msg: String) { + if (level <= 1) { + val formatted = "[JSON Plugin INFO] $msg" + dokkaLogger.info(formatted) + writeToFile(formatted) + } + } + + fun warn(msg: String) { + if (level <= 2) { + val formatted = "[JSON Plugin WARN] $msg" + dokkaLogger.warn(formatted) + writeToFile(formatted) + } + } + + fun error(msg: String) { + if (level <= 3) { + val formatted = "[JSON Plugin ERROR] $msg" + dokkaLogger.error(formatted) + writeToFile(formatted) + } + } +} \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt new file mode 100644 index 00000000..e77e4d79 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/kotlin/dtos/SemanticModelDtos.kt @@ -0,0 +1,422 @@ +package org.appdevforall.dokka.kdoc2json.dtos + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// --- Documentation Tags --- + +@Serializable +data class TagWrapperDto( + val type: String, + val text: String, + val name: String? = null +) + +// --- Extras & Properties --- + +@Serializable +data class AnnotationWrapperDto( + val dri: String, + val params: Map, + val url: String? = null +) + +@Serializable +data class ExtrasDto( + val annotations: Map> = emptyMap(), + val defaultValues: Map = emptyMap(), + val additionalModifiers: Map> = emptyMap(), + val isObviousMember: Boolean = false, + val isException: Boolean = false +) + +// --- Bounds & Projections (Types) --- + +@Serializable +sealed class ProjectionDto + +@Serializable +@SerialName("Star") +object StarDto : ProjectionDto() + +@Serializable +sealed class VarianceDto : ProjectionDto() { + abstract val inner: BoundDto +} +@Serializable @SerialName("Covariance") data class CovarianceDto(override val inner: BoundDto) : VarianceDto() +@Serializable @SerialName("Contravariance") data class ContravarianceDto(override val inner: BoundDto) : VarianceDto() +@Serializable @SerialName("Invariance") data class InvarianceDto(override val inner: BoundDto) : VarianceDto() + +@Serializable +sealed class BoundDto : ProjectionDto() { + abstract val url: String? +} + +@Serializable @SerialName("TypeParameter") +data class TypeParameterBoundDto(val dri: String, val name: String, val presentableName: String?, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("Nullable") +data class NullableDto(val inner: BoundDto, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("DefinitelyNonNullable") +data class DefinitelyNonNullableDto(val inner: BoundDto, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("TypeAliased") +data class TypeAliasedDto(val typeAlias: BoundDto, val inner: BoundDto, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("PrimitiveJavaType") +data class PrimitiveJavaTypeDto(val name: String, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("JavaObject") data class JavaObjectDto(override val url: String? = null) : BoundDto() +@Serializable @SerialName("Void") data class VoidDto(override val url: String? = null) : BoundDto() +@Serializable @SerialName("Dynamic") data class DynamicDto(override val url: String? = null) : BoundDto() + +@Serializable @SerialName("UnresolvedBound") +data class UnresolvedBoundDto(val name: String, override val url: String? = null) : BoundDto() + +@Serializable @SerialName("GenericTypeConstructor") +data class GenericTypeConstructorDto( + val dri: String, + val projections: List, + val presentableName: String?, + override val url: String? = null +) : BoundDto() + +@Serializable @SerialName("FunctionalTypeConstructor") +data class FunctionalTypeConstructorDto( + val dri: String, + val projections: List, + val isExtensionFunction: Boolean, + val isSuspendable: Boolean, + val presentableName: String?, + override val url: String? = null +) : BoundDto() + +@Serializable +data class TypeConstructorWithKindDto( + val typeConstructor: BoundDto, + val kind: String +) + +// --- Primary Documentables --- + +@Serializable +sealed class DocumentableDto { + abstract val dri: String + abstract val name: String? + abstract val url: String? + abstract val documentation: Map> + abstract val sourceSets: List + abstract val expectPresentInSet: String? + abstract val extras: ExtrasDto + abstract val breadcrumbs: List +} + +@Serializable +@SerialName("module") +data class ModuleDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val packages: List +) : DocumentableDto() + +@Serializable +@SerialName("package") +data class PackageDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List, + val typeAliases: List +) : DocumentableDto() + +@Serializable +@SerialName("class") +data class ClassDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val constructors: List, + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val companion: ObjectDto?, + val generics: List, + val supertypes: Map>, + val modifier: Map, + val isExpectActual: Boolean, + val typealiases: List +) : DocumentableDto() + +@Serializable +@SerialName("enum") +data class EnumDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val entries: List, + val constructors: List, + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val companion: ObjectDto?, + val supertypes: Map>, + val isExpectActual: Boolean, + val typealiases: List +) : DocumentableDto() + +@Serializable +@SerialName("enumEntry") +data class EnumEntryDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List +) : DocumentableDto() + +@Serializable +@SerialName("function") +data class FunctionDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val isConstructor: Boolean, + val parameters: List, + val sources: Map, + val visibility: Map, + val type: BoundDto, + val generics: List, + val receiver: ParameterDto?, + val modifier: Map, + val isExpectActual: Boolean, + val contextParameters: List +) : DocumentableDto() + +@Serializable +@SerialName("interface") +data class InterfaceDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val companion: ObjectDto?, + val generics: List, + val supertypes: Map>, + val modifier: Map, + val isExpectActual: Boolean, + val typealiases: List +) : DocumentableDto() + +@Serializable +@SerialName("object") +data class ObjectDto( + override val dri: String, + override val name: String?, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val supertypes: Map>, + val isExpectActual: Boolean, + val typealiases: List +) : DocumentableDto() + +@Serializable +@SerialName("annotation") +data class AnnotationDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val functions: List, + val properties: List, + val classlikes: List, + val sources: Map, + val visibility: Map, + val companion: ObjectDto?, + val constructors: List, + val generics: List, + val isExpectActual: Boolean +) : DocumentableDto() + +@Serializable +@SerialName("property") +data class PropertyDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val sources: Map, + val visibility: Map, + val type: BoundDto, + val receiver: ParameterDto?, + val setter: FunctionDto?, + val getter: FunctionDto?, + val modifier: Map, + val generics: List, + val isExpectActual: Boolean, + val contextParameters: List +) : DocumentableDto() + +@Serializable +@SerialName("parameter") +data class ParameterDto( + override val dri: String, + override val name: String?, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val type: BoundDto +) : DocumentableDto() + +@Serializable +@SerialName("typeParameter") +data class TypeParameterDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val bounds: List, + val variantTypeParameter: VarianceDto +) : DocumentableDto() + +@Serializable +@SerialName("typeAlias") +data class TypeAliasDto( + override val dri: String, + override val name: String, + override val url: String?, + override val documentation: Map>, + override val sourceSets: List, + override val expectPresentInSet: String?, + override val extras: ExtrasDto, + override val breadcrumbs: List = emptyList(), + val type: BoundDto, + val underlyingType: Map, + val visibility: Map, + val generics: List, + val sources: Map +) : DocumentableDto() + +// --- Multimodule References --- + +@Serializable +data class ModuleReferenceDto( + val name: String, + val url: String +) + +@Serializable +@SerialName("multimoduleRoot") +data class MultimoduleRootDto( + override val dri: String = "multimodule-root", + override val name: String?, + override val url: String? = null, + override val documentation: Map> = emptyMap(), + override val sourceSets: List = emptyList(), + override val expectPresentInSet: String? = null, + override val extras: ExtrasDto = ExtrasDto(), + override val breadcrumbs: List = emptyList(), + val modules: List +) : DocumentableDto() + +@Serializable +data class BreadcrumbNode( + val name: String, + val url: String? +) + +// --- NEW: All Types Index --- + +@Serializable +data class TypeIndexEntryDto( + val name: String, + val kind: String, + val dri: String, + val url: String?, + val sourceSets: List +) + +@Serializable +@SerialName("allTypes") +data class AllTypesDto( + override val dri: String = "all-types", + override val name: String? = "All Types", + override val url: String? = "all-types.json", + override val documentation: Map> = emptyMap(), + override val sourceSets: List = emptyList(), + override val expectPresentInSet: String? = null, + override val extras: ExtrasDto = ExtrasDto(), + override val breadcrumbs: List = emptyList(), + val types: List +) : DocumentableDto() \ No newline at end of file diff --git a/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin new file mode 100644 index 00000000..1fe64b64 --- /dev/null +++ b/Dokka-plugin-kdoc2json/kdoc-to-json/src/main/resources/META-INF/services/org.jetbrains.dokka.plugability.DokkaPlugin @@ -0,0 +1 @@ +org.appdevforall.dokka.kdoc2json.JsonOutputPlugin diff --git a/Dokka-plugin-kdoc2json/scripts/build-example.sh b/Dokka-plugin-kdoc2json/scripts/build-example.sh new file mode 100755 index 00000000..0a439abb --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/build-example.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Builds the kdoc-to-json plugin, publishes it to the local Maven repository, +# and runs it against the example library to produce sample JSON output. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +echo "==> [1/2] Publishing kdoc-to-json to the local Maven repository..." +(cd "$ROOT_DIR/kdoc-to-json" && ./gradlew publishToMavenLocal) + +echo "==> [2/2] Generating JSON documentation for examples/example-data-processor..." +(cd "$ROOT_DIR/examples/example-data-processor" && ./gradlew dokkaGenerate) + +OUTPUT_DIR="$ROOT_DIR/examples/example-data-processor/build/dokka/html" +echo +echo "Done. JSON output written to: $OUTPUT_DIR" diff --git a/Dokka-plugin-kdoc2json/scripts/kotlin/build-kotlin-stdlib.sh b/Dokka-plugin-kdoc2json/scripts/kotlin/build-kotlin-stdlib.sh new file mode 100755 index 00000000..9d3296dc --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/kotlin/build-kotlin-stdlib.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Builds the kotlin-stdlib/kotlin-test/kotlin-reflect API docs twice against a +# kotlin-stdlib-docs checkout: once with Dokka's default HTML renderer, and once with the +# kdoc-to-json plugin's JSON renderer (via the dokkaGenerateModuleJson task). Each build writes +# to its own output directory so the two can be compared directly. +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "Usage: $0 [output-dir]" >&2 + exit 1 +fi + +STDLIB_DOCS_DIR="$(cd "$1" && pwd)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_ROOT="$(mkdir -p "${2:-$SCRIPT_DIR/build-output}" && cd "${2:-$SCRIPT_DIR/build-output}" && pwd)" +HTML_OUTPUT_DIR="$OUTPUT_ROOT/html" +JSON_OUTPUT_DIR="$OUTPUT_ROOT/json" + +if [ ! -f "$STDLIB_DOCS_DIR/settings.gradle.kts" ] || [ ! -x "$STDLIB_DOCS_DIR/gradlew" ]; then + echo "Error: '$STDLIB_DOCS_DIR' doesn't look like a kotlin-stdlib-docs checkout (missing settings.gradle.kts or gradlew)." >&2 + exit 1 +fi + +echo "==> Installing kdoc-to-json-enabled build.gradle.kts into $STDLIB_DOCS_DIR" +if [ -f "$STDLIB_DOCS_DIR/build.gradle.kts" ] && [ ! -f "$STDLIB_DOCS_DIR/build.gradle.kts.orig" ]; then + cp "$STDLIB_DOCS_DIR/build.gradle.kts" "$STDLIB_DOCS_DIR/build.gradle.kts.orig" + echo " (original build.gradle.kts backed up to build.gradle.kts.orig)" +fi +cp "$SCRIPT_DIR/build.gradle.kts" "$STDLIB_DOCS_DIR/build.gradle.kts" + +echo "==> [1/2] Generating default HTML documentation..." +(cd "$STDLIB_DOCS_DIR" && ./gradlew dokkaGenerateHtml "-PdocsBuildDir=$HTML_OUTPUT_DIR") + +echo "==> [2/2] Generating JSON documentation via kdoc-to-json..." +(cd "$STDLIB_DOCS_DIR" && ./gradlew dokkaGenerateModuleJson "-PdocsBuildDir=$JSON_OUTPUT_DIR") + +echo +echo "Done." +echo " HTML docs: $HTML_OUTPUT_DIR/latest/all-libs" +echo " JSON docs: $JSON_OUTPUT_DIR/latest/all-libs" diff --git a/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts b/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts new file mode 100644 index 00000000..db4c7298 --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/kotlin/build.gradle.kts @@ -0,0 +1,214 @@ +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +import org.jetbrains.dokka.gradle.engine.plugins.DokkaPluginParametersBaseSpec +import org.jetbrains.dokka.InternalDokkaApi +import javax.inject.Inject + +plugins { + base + `dokka-convention` +} + +@OptIn(InternalDokkaApi::class) +abstract class JsonOutputPluginParameters @Inject constructor( + name: String +) : DokkaPluginParametersBaseSpec(name, "org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { + + // Define the plugin's behavior via a JSON string + override fun jsonEncode(): String = """{ + "logLevel": "debug", + "logFile": "build/dokka_json.log", + "omitFields": ["sources"], + "replaceHtmlExtension": false, + "omitNulls": true, + "sourceSetWhitelist": ["common", "jvm"] + }""" +} + +// kdoc-to-json overrides Dokka's HTML renderer wholesale, so it must only be added to the +// dokkaPlugin classpath (and registered in the dokka{} config) when dokkaGenerateModuleJson +// is actually among the requested tasks -- otherwise every other Dokka task +// (dokkaGenerateHtml, dokkaGeneratePublicationHtml, etc.) would silently emit JSON instead +// of HTML. Note this matches against exactly-typed task names; abbreviated/camelCase task +// matching on the CLI won't be detected here. +val useJsonPlugin = gradle.startParameter.taskNames.any { it.substringAfterLast(":") == "dokkaGenerateModuleJson" } + +allprojects { + repositories { + // 1. Your Custom Local Repo (Where the bash script just downloaded the plugins) + maven(url = rootProject.projectDir.parentFile.parentFile.parentFile.resolve("build/repo")) + + // 2. Standard Local Maven Cache (~/.m2/repository) + mavenLocal() + + // 3. Maven Central (Keep this for standard standard stable libraries like Gson/Coroutines) + mavenCentral() + + // ALL REMOTE JETBRAINS SNAPSHOT SERVERS HAVE BEEN REMOVED! + } + + // --- ADDED THIS EXCLUSION BLOCK --- + // Globally ignore the remote playground plugin since it is trapped on the dead 503 server. + configurations.all { + exclude(group = "org.jetbrains.dokka", module = "kotlin-playground-samples-plugin") + } +} + +val isTeamcityBuild = project.hasProperty("teamcity.version") || +System.getenv("TEAMCITY_VERSION") != null + +// kotlin/libraries/tools/kotlin-stdlib-docs -> kotlin +val kotlin_root = rootProject.file("../../../").absoluteFile.invariantSeparatorsPath +val kotlin_libs by extra(layout.buildDirectory.dir("libs").get().asFile.path) + +val rootProperties = java.util.Properties().apply { + file(kotlin_root).resolve("gradle.properties").inputStream().use { stream -> load(stream) } +} +val defaultSnapshotVersion: String by rootProperties +val kotlinLanguageVersion: String by rootProperties + +val githubRevision = if (isTeamcityBuild) project.property("githubRevision") else "master" +val artifactsVersion by extra(if (isTeamcityBuild) project.property("deployVersion") as String else defaultSnapshotVersion) +val artifactsRepo by extra(if (isTeamcityBuild) project.property("kotlinLibsRepo") as String else "$kotlin_root/build/repo") +val dokka_version: String by project + +println("# Parameters summary:") +println(" isTeamcityBuild: $isTeamcityBuild") +println(" dokka version: $dokka_version") +println(" githubRevision: $githubRevision") +println(" language version: $kotlinLanguageVersion") +println(" artifacts version: $artifactsVersion") +println(" artifacts repo: $artifactsRepo") + + +val outputDir = (findProperty("docsBuildDir") as String?)?.let{ file(it) } ?: layout.buildDirectory.dir("doc").get().asFile +val inputDirPrevious = file(findProperty("docsPreviousVersionsDir") as String? ?: "$outputDir/previous") +val outputDirPartial = outputDir.resolve("partial") +val kotlin_native_root = file("$kotlin_root/kotlin-native").absolutePath +val templatesDir = file(findProperty("templatesDir") as String? ?: "$projectDir/templates").invariantSeparatorsPath + +val cleanDocs by tasks.registering(Delete::class) { + delete(outputDir) +} + +tasks.clean { + dependsOn(cleanDocs) +} + +val prepare by tasks.registering { + dependsOn(":kotlin_big:extractLibs") +} + +version = (findProperty("version") as String?).takeIf { it != "unspecified"} ?: kotlinLanguageVersion +val isLatest = (findProperty("isLatest") as String?)?.toBoolean() ?: true + + +(getTasksByName("dokkaGenerateHtml", true) + getTasksByName("dokkaGenerate", true) + getTasksByName( + "dokkaGenerateModuleHtml", true +) + getTasksByName("dokkaGeneratePublicationHtml", true)).forEach { + it.dependsOn(prepare) +} + +dependencies { + dokka(project(":kotlin-stdlib")) + dokka(project(":kotlin-test")) + dokka(project(":kotlin-reflect")) + if (useJsonPlugin) { + dokkaPlugin("org.appdevforall.dokka:kdoc-to-json:1.0.0-SNAPSHOT") + } +} + +subprojects { + pluginManager.withPlugin("org.jetbrains.dokka") { + if (useJsonPlugin) { + dependencies { + "dokkaPlugin"("org.appdevforall.dokka:kdoc-to-json:1.0.0-SNAPSHOT") + } + + // Apply the same configuration so the subprojects don't fall back to defaults + dokka { + pluginsConfiguration { + registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) + register("org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { } + } + } + } + } +} + +buildscript { + dependencies { + classpath("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + } +} + +dokka { + val moduleDirName = "all-libs" + + pluginsConfiguration { + versioning { + version.set(kotlinLanguageVersion) + if (isLatest) { + olderVersionsDir.set(inputDirPrevious.resolve(moduleDirName)) + } + } + + if (isLatest) { + register("VersionFilterPlugin") { + targetVersion = kotlinLanguageVersion + } + } + + if (useJsonPlugin) { + // Custom plugin registration + registerBinding(JsonOutputPluginParameters::class, JsonOutputPluginParameters::class) + register("org.appdevforall.dokka.kdoc2json.JsonOutputPlugin") { } + } + } + + moduleName.set("Kotlin libraries") + + dokkaPublications.html { + if (isLatest) { + outputDirectory.set(outputDir.resolve("latest").resolve(moduleDirName)) + } else { + outputDirectory.set( + outputDir.resolve("previous").resolve(moduleDirName).resolve(kotlinLanguageVersion) + ) + } + } +} + +/// Capture all project state at configuration time to stay configuration-cache compatible +val dokkaOutputDirectory = dokka.dokkaPublications.html.get().outputDirectory.get().asFile +val moduleArtifacts = configurations["dokka"].allDependencies.withType(ProjectDependency::class.java) + .map { dependency -> + val dependencyProject = project(dependency.path) + dependencyProject.layout.buildDirectory.file("dokka-module/html/module-descriptor.json").get().asFile to + dependencyProject.layout.buildDirectory.file("dokka-module/html/module/package-list").get().asFile + } + +getTasksByName("dokkaGeneratePublicationHtml", false).forEach { task -> + // Copy into locals so the doLast closure captures these values, not the enclosing + // build-script object (which the configuration cache cannot serialize). + val outputDirectory = dokkaOutputDirectory + val artifacts = moduleArtifacts + task.doLast { + artifacts.forEach { (jsonFile, packageList) -> + val fileAsJsonObject = Json.decodeFromString(jsonFile.readText()) + val modulePath = (fileAsJsonObject.get("modulePath") as JsonPrimitive).content + val targetDir = outputDirectory.resolve(modulePath) + targetDir.mkdirs() + packageList.copyTo(targetDir.resolve(packageList.name), overwrite = true) + } + } +} + +val dokkaGenerateModuleJson by tasks.registering { + group = "documentation" + description = "Builds the kotlin-stdlib/kotlin-test/kotlin-reflect API docs as JSON via the kdoc-to-json Dokka plugin." + dependsOn(prepare) + dependsOn(tasks.named("dokkaGeneratePublicationHtml")) +} diff --git a/Dokka-plugin-kdoc2json/scripts/kotlin/test_kotlin_stdlib.sh b/Dokka-plugin-kdoc2json/scripts/kotlin/test_kotlin_stdlib.sh new file mode 100755 index 00000000..1a28fab7 --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/kotlin/test_kotlin_stdlib.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Verifies that the .html pages from a default Dokka run and the .json pages from the +# kdoc-to-json plugin are a one-to-one match: every .html file has a same-named .json file +# at the same place in the hierarchy, and every .json file has a same-named .html file. +# Non-page assets in the HTML tree (css/js/images/etc.) are ignored -- only .html and .json +# files are compared, with extensions swapped accordingly. +set -euo pipefail + +if [ $# -lt 2 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +HTML_DIR="$(cd "$1" && pwd)" +JSON_DIR="$(cd "$2" && pwd)" + +missing_json=0 +missing_html=0 + +echo "==> Checking every .html file in $HTML_DIR has a matching .json file..." +while IFS= read -r -d '' f; do + rel="${f#"$HTML_DIR"/}" + if [ ! -f "$JSON_DIR/${rel%.html}.json" ]; then + echo " MISSING .json for: $rel" + missing_json=$((missing_json + 1)) + fi +done < <(find "$HTML_DIR" -type f -name '*.html' -print0) + +echo "==> Checking every .json file in $JSON_DIR has a matching .html file..." +while IFS= read -r -d '' f; do + rel="${f#"$JSON_DIR"/}" + if [ ! -f "$HTML_DIR/${rel%.json}.html" ]; then + echo " MISSING .html for: $rel" + missing_html=$((missing_html + 1)) + fi +done < <(find "$JSON_DIR" -type f -name '*.json' -print0) + +echo +echo "Summary: $missing_json .html file(s) with no matching .json, $missing_html .json file(s) with no matching .html." + +if [ "$missing_json" -eq 0 ] && [ "$missing_html" -eq 0 ]; then + echo "SUCCESS: one-to-one match between HTML and JSON output." + exit 0 +else + echo "FAILED: structure mismatch between HTML and JSON output." + exit 1 +fi diff --git a/Dokka-plugin-kdoc2json/scripts/sanity_check.py b/Dokka-plugin-kdoc2json/scripts/sanity_check.py new file mode 100755 index 00000000..97b6605e --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/sanity_check.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +import os +import re +import argparse +from urllib.parse import urlparse, unquote + +def write_log(output_file, lines): + """Helper function to write results to a specified output file.""" + if output_file: + try: + with open(output_file, 'w', encoding='utf-8') as f: + for line in lines: + f.write(line + '\n') + print(f"📄 Results successfully logged to: {output_file}") + except Exception as e: + print(f"⚠️ Failed to write to output file {output_file}: {e}") + +def check_list(file_list, pebble_dir, output_file): + """ + Verifies that every file in a given list exists in the rendered JSON+Pebble docs. + """ + print(f"--- Running List Check ---") + print(f"File list: {file_list}") + print(f"Pebble directory: {pebble_dir}") + + missing_files = [] + try: + with open(file_list, 'r', encoding='utf-8') as f: + for line in f: + path = line.strip() + if not path: + continue + + # Strip leading './' if present + if path.startswith('./'): + path = path[2:] + + # If the list contains .json files, expect .html in the rendered output + if path.endswith('.json'): + path = path[:-5] + '.html' + + full_path = os.path.join(pebble_dir, path) + + if not os.path.exists(full_path): + missing_files.append(path) + + except FileNotFoundError: + print(f"Error: The file list '{file_list}' was not found.") + return + + if missing_files: + print(f"❌ FAILED: {len(missing_files)} files from the list are missing in the rendered output.") + for missing in missing_files[:15]: + print(f" - {missing}") + if len(missing_files) > 15: + print(f" ... and {len(missing_files) - 15} more.") + + # Write missing files to output file if requested + write_log(output_file, missing_files) + else: + print("✅ SUCCESS: All files in the list exist in the rendered documentation.") + print() + +def compare_base(base_dir, pebble_dir, output_file): + """ + Verifies that every generated HTML file in the base plugin's documentation + has a corresponding HTML page produced by JSON+Pebble. + """ + print(f"--- Running Base Comparison Check ---") + print(f"Base docs directory: {base_dir}") + print(f"Pebble directory: {pebble_dir}") + + if not os.path.isdir(base_dir): + print(f"Error: Base directory '{base_dir}' not found.") + return + + missing_files = [] + total_files = 0 + + for root, _, files in os.walk(base_dir): + for file in files: + if file.endswith('.html'): + total_files += 1 + base_path = os.path.join(root, file) + rel_path = os.path.relpath(base_path, base_dir) + pebble_path = os.path.join(pebble_dir, rel_path) + + if not os.path.exists(pebble_path): + missing_files.append(rel_path) + + if missing_files: + print(f"❌ FAILED: {len(missing_files)} out of {total_files} HTML files from the base docs are missing in the Pebble docs.") + for missing in missing_files[:15]: + print(f" - {missing}") + if len(missing_files) > 15: + print(f" ... and {len(missing_files) - 15} more.") + + # Write missing files to output file if requested + write_log(output_file, missing_files) + else: + print(f"✅ SUCCESS: All {total_files} HTML files from the base documentation exist in the Pebble documentation.") + print() + +def check_links(docs_dir, output_file): + """ + Verifies all internal links to other pages, tracking status for every link. + """ + print(f"--- Running Link Status Check ---") + print(f"Documentation directory: {docs_dir}") + + if not os.path.isdir(docs_dir): + print(f"Error: Documentation directory '{docs_dir}' not found.") + return + + link_log_lines = [] + broken_count = 0 + total_links_checked = 0 + + # Regex to find href attributes + href_regex = re.compile(r'href\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE) + + for root, _, files in os.walk(docs_dir): + for file in files: + if file.endswith('.html'): + file_path = os.path.join(root, file) + relative_src_file = os.path.relpath(file_path, docs_dir) + + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + + links = href_regex.findall(content) + + for link in links: + parsed = urlparse(link) + + # Ignore external links, mailto, and javascript protocols + if parsed.scheme in ('http', 'https', 'mailto', 'javascript', 'data'): + continue + + # Ignore empty paths (e.g., href="#anchor" on the same page) + if not parsed.path: + continue + + total_links_checked += 1 + + # Resolve target path relative to the directory of the file containing the link + target_rel_path = unquote(parsed.path) + target_abs_path = os.path.normpath(os.path.join(root, target_rel_path)) + + # If pointing to a directory, assume it's looking for index.html + if os.path.isdir(target_abs_path): + target_abs_path = os.path.join(target_abs_path, 'index.html') + + if not os.path.exists(target_abs_path): + broken_count += 1 + link_log_lines.append(f"[BROKEN] File: {relative_src_file} -> Link: {link}") + else: + link_log_lines.append(f"[VALID] File: {relative_src_file} -> Link: {link}") + + # Output a concise summary to console + if broken_count > 0: + print(f"❌ FAILED: Found {broken_count} broken internal links out of {total_links_checked} total links checked.") + else: + print(f"✅ SUCCESS: All {total_links_checked} internal links checked are completely valid!") + + # Write the complete mapping status (valid + broken) to output file if requested + if output_file: + write_log(output_file, link_log_lines) + print() + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Sanity check tools for JSON+Pebble Dokka output.") + subparsers = parser.add_subparsers(dest="command", required=True, help="Sub-commands") + + # Create a parent parser for shared arguments across commands + parent_parser = argparse.ArgumentParser(add_help=False) + parent_parser.add_argument("--output-file", "-o", help="Path to a file where results/errors will be logged.", default=None) + + # Command 1: check-list + parser_list = subparsers.add_parser("check-list", parents=[parent_parser], help="Check if files in a text list exist in the rendered docs.") + parser_list.add_argument("file_list", help="Path to the text file containing the list of files (e.g. files_json.txt)") + parser_list.add_argument("pebble_dir", help="Path to the generated JSON+Pebble documentation directory") + + # Command 2: compare-base + parser_compare = subparsers.add_parser("compare-base", parents=[parent_parser], help="Compare standard HTML output with JSON+Pebble output.") + parser_compare.add_argument("base_dir", help="Path to the standard Dokka HTML documentation directory") + parser_compare.add_argument("pebble_dir", help="Path to the generated JSON+Pebble documentation directory") + + # Command 3: check-links + parser_links = subparsers.add_parser("check-links", parents=[parent_parser], help="Find and map status of internal links in a documentation directory.") + parser_links.add_argument("docs_dir", help="Path to the documentation directory to scan") + + args = parser.parse_args() + + if args.command == "check-list": + check_list(args.file_list, args.pebble_dir, args.output_file) + elif args.command == "compare-base": + compare_base(args.base_dir, args.pebble_dir, args.output_file) + elif args.command == "check-links": + check_links(args.docs_dir, args.output_file) diff --git a/Dokka-plugin-kdoc2json/scripts/verify_package_index.py b/Dokka-plugin-kdoc2json/scripts/verify_package_index.py new file mode 100755 index 00000000..35ff9fc7 --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/verify_package_index.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +Verifies that every object listed in a kdoc-to-json package index.json actually +corresponds to a real documented page: the URL it points to must resolve to an +existing file, and that file's own top-level "dri" must match the index entry's +"dri" exactly. + +This matters because Dokka can group multiple declarations (e.g. several overloads +of the same function name for different receiver types) onto a single PageNode / +output page. JsonRenderer only serializes documentables.first() for such a page, so +every other declaration that shares that page is listed in the package index but +has no actual documented content of its own. +""" +import json +import os +import sys +import argparse + + +def resolve_target_path(package_dir, url): + """Resolve an index entry's url to the local .json file it should point to.""" + if not url: + return None + if url.startswith("http://") or url.startswith("https://"): + return None # external link, not a local file + if url.startswith("unresolved:") or url == "#": + return None + + path = url + if path.endswith(".html"): + path = path[:-5] + ".json" + elif not path.endswith(".json"): + path = path + ".json" + + return os.path.normpath(os.path.join(package_dir, path)) + + +def main(): + parser = argparse.ArgumentParser( + description="Verify every object in a kdoc-to-json package index.json has a corresponding documented page." + ) + parser.add_argument("index_json", help="Path to a package's index.json, or the package directory containing one") + args = parser.parse_args() + + index_path = args.index_json + if os.path.isdir(index_path): + index_path = os.path.join(index_path, "index.json") + + if not os.path.isfile(index_path): + print(f"Error: '{index_path}' not found.", file=sys.stderr) + sys.exit(2) + + package_dir = os.path.dirname(os.path.abspath(index_path)) + + with open(index_path, "r", encoding="utf-8") as f: + index_data = json.load(f) + + member_sections = ["functions", "properties", "classlikes", "typeAliases"] + entries = [] + for section in member_sections: + for entry in index_data.get(section) or []: + entries.append((section, entry)) + + print(f"Package: {index_data.get('name', '?')}") + print(f"Index file: {index_path}") + print(f"Total member entries listed: {len(entries)}") + print() + + file_cache = {} + missing = [] + + for section, entry in entries: + name = entry.get("name", "?") + dri = entry.get("dri") + url = entry.get("url") + + target_path = resolve_target_path(package_dir, url) + if target_path is None: + missing.append((section, name, dri, url, "unresolvable URL")) + continue + + if target_path not in file_cache: + if not os.path.isfile(target_path): + file_cache[target_path] = None + else: + try: + with open(target_path, "r", encoding="utf-8") as f: + file_cache[target_path] = json.load(f) + except Exception: + file_cache[target_path] = None + + target_data = file_cache[target_path] + if target_data is None: + missing.append((section, name, dri, url, "target file missing or unreadable")) + elif target_data.get("dri") != dri: + missing.append((section, name, dri, url, "target file exists but does not document this DRI")) + + print(f"Checked {len(entries)} entries against {len(file_cache)} unique target files.") + print() + + if missing: + print(f"FAILED: {len(missing)} object(s) in the index have no corresponding documented element:") + for section, name, dri, url, reason in missing: + print(f" [{section}] {name} (dri={dri}, url={url}) -- {reason}") + else: + print("SUCCESS: every object in the index corresponds to a documented element.") + + sys.exit(1 if missing else 0) + + +if __name__ == "__main__": + main() diff --git a/Dokka-plugin-kdoc2json/scripts/verify_sourceset_whitelist.py b/Dokka-plugin-kdoc2json/scripts/verify_sourceset_whitelist.py new file mode 100755 index 00000000..0d30878d --- /dev/null +++ b/Dokka-plugin-kdoc2json/scripts/verify_sourceset_whitelist.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Verifies that every output JSON file in a kdoc-to-json rendered documentation folder +complies with a given sourceSet whitelist: any file whose top-level "sourceSets" list +does not intersect the whitelist is reported as a violation. Such a file should have +been omitted by the JsonOutputPlugin's `sourceSetWhitelist` config option, so finding +one here means the plugin was run with a different (or no) whitelist than expected. + +Files with an empty or missing top-level "sourceSets" (e.g. all-types.json, the +multimodule root index.json, or a page whose "sourceSets" field was itself stripped by +`omitFields`) are synthetic/aggregate outputs that aren't tied to a single source set, +and are skipped rather than flagged. +""" +import json +import os +import sys +import argparse + + +def find_json_files(root_dir): + for dirpath, _, filenames in os.walk(root_dir): + for filename in filenames: + if filename.endswith(".json"): + yield os.path.join(dirpath, filename) + + +def check_file(path, whitelist): + """Returns (status, sourceSets, reason). status is one of 'ok', 'violation', 'skipped', 'unreadable'.""" + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + return "unreadable", None, str(e) + + if not isinstance(data, dict): + return "skipped", None, "root is not a JSON object" + + source_sets = data.get("sourceSets") + if not source_sets: + return "skipped", source_sets, "no top-level sourceSets field (synthetic/aggregate output)" + + if any(ss in whitelist for ss in source_sets): + return "ok", source_sets, None + + return "violation", source_sets, None + + +def write_log(output_file, lines): + if not output_file: + return + try: + with open(output_file, "w", encoding="utf-8") as f: + for line in lines: + f.write(line + "\n") + print(f"📄 Results logged to: {output_file}") + except OSError as e: + print(f"⚠️ Failed to write to output file {output_file}: {e}") + + +def main(): + parser = argparse.ArgumentParser( + description="Verify every JSON file in a kdoc-to-json output directory complies with a sourceSet whitelist." + ) + parser.add_argument("docs_dir", help="Path to the rendered JSON documentation directory to scan recursively") + parser.add_argument( + "whitelist", + nargs="+", + help="One or more allowed source set names, matching the values that appear in the output " + "\"sourceSets\" field (e.g. jvm js). Comma-separated values in a single argument are also " + "accepted (e.g. 'jvm,js').", + ) + parser.add_argument("--output-file", "-o", help="Path to a file where violations will be logged.", default=None) + parser.add_argument("--verbose", "-v", action="store_true", help="Print the result for every file checked, not just violations.") + args = parser.parse_args() + + if not os.path.isdir(args.docs_dir): + print(f"Error: '{args.docs_dir}' is not a directory.", file=sys.stderr) + sys.exit(2) + + whitelist = set() + for entry in args.whitelist: + whitelist.update(part.strip() for part in entry.split(",") if part.strip()) + + if not whitelist: + print("Error: whitelist must contain at least one source set name.", file=sys.stderr) + sys.exit(2) + + print(f"Scanning: {args.docs_dir}") + print(f"Whitelist: {sorted(whitelist)}") + print() + + violations = [] + unreadable = [] + checked = 0 + skipped = 0 + + for path in sorted(find_json_files(args.docs_dir)): + rel_path = os.path.relpath(path, args.docs_dir) + status, source_sets, reason = check_file(path, whitelist) + + if status == "unreadable": + unreadable.append((rel_path, reason)) + print(f" [UNREADABLE] {rel_path} -- {reason}") + elif status == "skipped": + skipped += 1 + if args.verbose: + print(f" [SKIPPED] {rel_path} -- {reason}") + else: + checked += 1 + if status == "violation": + violations.append((rel_path, source_sets)) + print(f" [VIOLATION] {rel_path} -- sourceSets={source_sets} not in whitelist") + elif args.verbose: + print(f" [OK] {rel_path} -- sourceSets={source_sets}") + + print() + print(f"Checked {checked} file(s) with a sourceSets field ({skipped} skipped, {len(unreadable)} unreadable).") + + write_log( + args.output_file, + [f"{rel_path}\tsourceSets={source_sets}" for rel_path, source_sets in violations] + + [f"{rel_path}\tUNREADABLE: {reason}" for rel_path, reason in unreadable], + ) + + if violations: + print(f"❌ FAILED: {len(violations)} file(s) violate the sourceSet whitelist.") + if unreadable: + print(f"⚠️ {len(unreadable)} file(s) could not be read/parsed as JSON.") + if not violations and not unreadable: + print("✅ SUCCESS: every file with a sourceSets field complies with the whitelist.") + + sys.exit(1 if (violations or unreadable) else 0) + + +if __name__ == "__main__": + main() diff --git a/Dokka-plugin-kdoc2json/tests/helpers/check_doc_tags.py b/Dokka-plugin-kdoc2json/tests/helpers/check_doc_tags.py new file mode 100755 index 00000000..b9b468a4 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/helpers/check_doc_tags.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Checks for TEST_PLAN.md §4 (Documentation tag & text extraction: +mapDocNodes, extractText). + +Run via tests/test_doc_tags.sh, which regenerates examples/example-data-processor +first. Takes the rendered JSON output directory as argv[1]. + +Not covered here: §4's last row ("Per-source-set documentation") needs an +expect/actual pair across two source sets, which TEST_PLAN.md §1 deferred +(would require converting example-data-processor to Kotlin Multiplatform). +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from checklib import Checker, load + + +def main(): + output_dir = sys.argv[1] + c = Checker() + + safe_divide = load(os.path.join(output_dir, "com.example.testlib", "safe-divide.json")) + tags = safe_divide["documentation"][":/main"] + description = next(t["text"] for t in tags if t["type"] == "Description") + + # --- HTML escaping --- + c.check("< 2" in description, "raw '<' in KDoc prose is escaped to <") + c.check("A & B" in description, "raw '&' in KDoc prose is escaped to &") + c.check("1 < 2" not in description, "the raw unescaped '<' sequence does not leak into the output") + + # --- Nested inline markup --- + c.check("bold" in description, "**bold** maps to bold") + c.check("italic" in description, "*italic* maps to italic") + c.check("1 < 2" in description, "inline `code` maps to , escaped and un-flattened") + + # --- Block-level tags --- + c.check( + "
    val result = safeDivide(10, 2)
    " in description, + "fenced code block round-trips to
    ",
    +    )
    +    c.check("
    " in description, "blockquote markup round-trips to
    ") + c.check("
    • " in description, "bulleted list round-trips to
      • ") + + # --- @see / @throws / custom named tags --- + throws = next(t for t in tags if t["type"] == "Throws") + c.check(throws.get("name") == "ArithmeticException", "@throws captures the exception name via TagWrapperDto.name") + c.check( + "href=" in throws.get("text", ""), + "a [Foo]-style link inside an @throws body resolves to a real url via resolveUrl", + ) + + see = next(t for t in tags if t["type"] == "See") + c.check(see.get("name") == "CustomException", "@see captures the referenced name via TagWrapperDto.name") + # NOTE: `@see [CustomException]` does not currently produce a resolved link/url anywhere on + # this tag. Unlike @throws (whose link comes from an inline [Foo] reference inside its text), + # Dokka's `See` DocTag carries its resolved target on a dedicated `address: DRI?` field, and + # TagWrapperDto has no `url` property for mapDocNodes to put it in. Not asserted as a bug here + # since fixing it needs a DTO/schema change (new TagWrapperDto.url field), not a test. + + # --- @sample --- + samples = [t for t in tags if t["type"] == "Sample"] + c.check(len(samples) == 2, "both @sample tags on safeDivide are present", f"got {len(samples)}") + sample_by_name = {s["name"]: s["text"] for s in samples} + c.check( + sample_by_name.get("com.example.testlib.safeDivideSample") == "val result = safeDivide(10, 2)\nprintln(result)", + "first @sample pulls safeDivideSample's own source", + f"got {sample_by_name.get('com.example.testlib.safeDivideSample')!r}", + ) + c.check( + sample_by_name.get("com.example.testlib.safeDivideSampleAlternate") == "val result = safeDivide(7, 3)\nprintln(result)", + "second @sample pulls safeDivideSampleAlternate's own source (not a repeat of the first)", + f"got {sample_by_name.get('com.example.testlib.safeDivideSampleAlternate')!r}", + ) + + return c.summarize(os.path.basename(__file__)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Dokka-plugin-kdoc2json/tests/helpers/check_dto_mapping.py b/Dokka-plugin-kdoc2json/tests/helpers/check_dto_mapping.py new file mode 100755 index 00000000..bee9577b --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/helpers/check_dto_mapping.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Checks for TEST_PLAN.md §2 (DTO mapping correctness, ModelMapper.mapToDto). + +Run via tests/test_dto_mapping.sh, which regenerates examples/example-data-processor +first. Takes the rendered JSON output directory as argv[1]. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from checklib import Checker, load + + +def path(output_dir, *parts): + return os.path.join(output_dir, *parts) + + +def main(): + output_dir = sys.argv[1] + c = Checker() + + # --- Module -> Package -> Class -> Function nesting --- + module = load(path(output_dir, "index.json")) + c.check(module.get("kind") == "module", "root index.json is a ModuleDto") + package_names = {p.get("name") for p in module.get("packages", [])} + c.check( + {"com.example.testlib", "com.example.utils"} <= package_names, + "root ModuleDto.packages lists both expected packages", + f"got {package_names}", + ) + + testlib_pkg = load(path(output_dir, "com.example.testlib", "index.json")) + c.check(testlib_pkg.get("kind") == "package", "package index.json is a PackageDto") + function_names = {f["name"] for f in testlib_pkg.get("functions", [])} + c.check("safeDivide" in function_names, "package lists its top-level functions", f"got {function_names}") + typealias_names = {t["name"] for t in testlib_pkg.get("typeAliases", [])} + c.check(typealias_names == {"DataMap"}, "package lists its typeAliases", f"got {typealias_names}") + classlike_names = {cl["name"] for cl in testlib_pkg.get("classlikes", [])} + c.check( + {"Level1", "BoundedContainer", "Shape", "Counter", "Provider", "Meta"} <= classlike_names, + "package lists its classlikes", + f"got {classlike_names}", + ) + + # --- Class vs. Interface vs. Enum vs. Object vs. Annotation --- + data_processor = load(path(output_dir, "com.example.utils", "-data-processor", "index.json")) + c.check(data_processor.get("kind") == "class", "DataProcessor kind == class") + c.check("entries" not in data_processor, "ClassDto has no entries key") + + provider = load(path(output_dir, "com.example.testlib", "-provider", "index.json")) + c.check(provider.get("kind") == "interface", "Provider kind == interface") + c.check("entries" not in provider, "InterfaceDto has no entries key") + + level1 = load(path(output_dir, "com.example.testlib", "-level1", "index.json")) + c.check(level1.get("kind") == "object", "Level1 kind == object") + c.check("entries" not in level1, "ObjectDto has no entries key") + + meta = load(path(output_dir, "com.example.testlib", "-meta", "index.json")) + c.check(meta.get("kind") == "annotation", "Meta kind == annotation") + c.check("entries" not in meta, "AnnotationDto has no entries key") + + level3 = load(path(output_dir, "com.example.testlib", "-level1", "-level2", "-level3", "index.json")) + c.check(level3.get("kind") == "enum", "Level3 kind == enum") + c.check(len(level3.get("entries", [])) > 0, "EnumDto.entries is non-empty") + + # --- EnumEntryDto --- + entry_names = {e["name"] for e in level3.get("entries", [])} + c.check(entry_names == {"ACTIVE", "INACTIVE"}, "Level3 has both enum entries", f"got {entry_names}") + for entry in level3.get("entries", []): + docs = entry.get("documentation", {}).get(":/main", []) + has_text = any(tag.get("text") for tag in docs) + c.check(has_text, f"enum entry {entry['name']} carries its own KDoc text") + + # --- Constructor mapping --- + ctor = data_processor["constructors"][0] + c.check(ctor["isConstructor"] is True, "constructor function has isConstructor == true") + regular_fn = next(f for f in data_processor["functions"] if f["name"] == "processRecord") + c.check(regular_fn["isConstructor"] is False, "regular function has isConstructor == false") + + # --- Property with getter/setter --- + counter = load(path(output_dir, "com.example.testlib", "-counter", "index.json")) + count_prop = next(p for p in counter["properties"] if p["name"] == "count") + c.check(count_prop.get("getter") is not None, "var property (Counter.count) has a getter") + c.check(count_prop.get("setter") is not None, "var property (Counter.count) has a setter") + + bounded = load(path(output_dir, "com.example.testlib", "-bounded-container", "index.json")) + value_prop = next(p for p in bounded["properties"] if p["name"] == "value") + c.check(value_prop.get("getter") is not None, "val property (BoundedContainer.value) has a getter") + c.check(value_prop.get("setter") is None, "val property (BoundedContainer.value) has no setter") + + # --- TypeAliasDto --- + data_map = load(path(output_dir, "com.example.testlib", "-data-map", "index.json")) + c.check(data_map.get("kind") == "typeAlias", "DataMap kind == typeAlias") + underlying = data_map["underlyingType"]["main"] + c.check( + underlying.get("kind") == "GenericTypeConstructor" and "Map" in underlying.get("dri", ""), + "DataMap.underlyingType resolves to a GenericTypeConstructorDto for Map, not a raw string", + f"got {underlying}", + ) + + # --- Nested classlikes: breadcrumbs in root-to-leaf order --- + breadcrumb_names = [b["name"] for b in level3.get("breadcrumbs", [])] + c.check( + breadcrumb_names == ["testlib", "com.example.testlib", "Level1", "Level2", "Level3"], + "Level1/Level2/Level3 breadcrumbs are in root-to-leaf order", + f"got {breadcrumb_names}", + ) + c.check(level3["breadcrumbs"][-1]["url"] == "index.json", "leaf breadcrumb url points at the page itself") + + # --- Shallow vs. deep recursion --- + level1_shallow = next(cl for cl in testlib_pkg["classlikes"] if cl["name"] == "Level1") + c.check( + not level1_shallow.get("classlikes") and not level1_shallow.get("functions"), + "Level1 listed shallowly inside the package (no nested members)", + f"got classlikes={level1_shallow.get('classlikes')} functions={level1_shallow.get('functions')}", + ) + c.check( + any(cl["name"] == "Level2" for cl in level1.get("classlikes", [])), + "Level1's own index.json has Level2 fully populated (deep)", + ) + + return c.summarize(os.path.basename(__file__)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Dokka-plugin-kdoc2json/tests/helpers/check_link_postprocessor.py b/Dokka-plugin-kdoc2json/tests/helpers/check_link_postprocessor.py new file mode 100755 index 00000000..36365abd --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/helpers/check_link_postprocessor.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Checks for TEST_PLAN.md §7 (LinkPostProcessor cross-module resolution). + +Run via tests/test_link_postprocessor.sh. Takes the rendered JSON output +directory and the path to that run's captured Gradle log as args. + +Not covered here (need a real multi-module or multiplatform build, deferred to +TEST_PLAN.md §8's kotlin-stdlib stress test per the user's decision when step 3 +started): +- "Relative path depth" for a genuinely cross-module-resolved DRI: verified + empirically that in this single-module fixture, LinkPostProcessor's pass-2 + replace step resolves exactly 0 links ("Successfully resolved 0 cross-module + links!" in the build log) -- every "unresolved:" marker here is either + resolved directly by locationProvider at write time, or is a synthetic + accessor/parent DRI with no page at all, so it's patched straight to "#". +- "Last-writer-wins" for expect/actual: needs two files where the same DRI + legitimately resolves in both. + +Note: synthetic accessor DRIs (getX/setX, constructors, callable parameters) used +to end up here too, but ModelMapper.resolveUrl now falls back to the owning +declaration's own DRI for those, so they resolve to a real link instead (see the +PR review fix for "accessor/param links resolve to dead #"). The remaining +always-unresolvable case used below is a genuinely pageless DRI with no owning +declaration to fall back to: a compiler-internal annotation Kotlin attaches +automatically, which no page (in this module or any external doc link) documents. +""" +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from checklib import Checker, load + + +def main(): + output_dir = sys.argv[1] + gradle_log_path = sys.argv[2] + c = Checker() + + # --- Two-pass index + replace: no lingering "unresolved:" markers --- + offenders = [] + for dirpath, _, files in os.walk(output_dir): + for f in files: + if f.endswith(".json"): + full = os.path.join(dirpath, f) + with open(full, "r", encoding="utf-8", errors="replace") as fh: + if "unresolved:" in fh.read(): + offenders.append(os.path.relpath(full, output_dir)) + c.check( + not offenders, + "no .json file on disk contains the literal string 'unresolved:'", + f"found in: {offenders[:5]}", + ) + + # --- Genuinely unresolvable DRI: patched to "#", and named in the log --- + # Level3 (an enum)'s compiler-generated `name` property picks up @IntrinsicConstEvaluation + # automatically from the Kotlin compiler; it's a compiler-internal annotation with no page + # anywhere (in this module or any external doc link) to link to. + level3 = load(os.path.join(output_dir, "com.example.testlib", "-level1", "-level2", "-level3", "index.json")) + name_property = next((p for p in level3.get("properties", []) if p.get("name") == "name"), None) + annotations = (name_property or {}).get("extras", {}).get("annotations", {}).get(":/main", []) + intrinsic_annotation = next((a for a in annotations if "IntrinsicConstEvaluation" in a.get("dri", "")), None) + c.check( + intrinsic_annotation is not None and intrinsic_annotation.get("url") == "#", + 'a genuinely pageless DRI (the compiler-internal @IntrinsicConstEvaluation annotation) is patched to "#"', + f"got annotations={annotations}", + ) + + with open(gradle_log_path, "r", encoding="utf-8", errors="replace") as f: + log_text = f.read() + match = re.search(r'Failed to resolve (\d+) DRIs \(patched to "#"\):([^\n]*)', log_text) + found = c.check(match is not None, "the build log contains a 'Failed to resolve N DRIs' warning") + if found: + count = int(match.group(1)) + listed = match.group(2) + c.check(count > 0, "the warning reports a positive count of unresolved DRIs", f"got {count}") + c.check( + "kotlin.internal/IntrinsicConstEvaluation" in listed, + "the warning names the specific DRI that ended up patched to \"#\"", + ) + + return c.summarize(os.path.basename(__file__)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Dokka-plugin-kdoc2json/tests/helpers/check_renderer.py b/Dokka-plugin-kdoc2json/tests/helpers/check_renderer.py new file mode 100755 index 00000000..384f001e --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/helpers/check_renderer.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Checks for TEST_PLAN.md §6 (JsonRenderer traversal & index generation). + +Run via tests/test_renderer.sh. Takes two directories as args: the rendered +JSON output dir, and the stock-Dokka-HTML baseline's top-level output dir +(examples/html-baseline/build/dokka/html). The latter nests package/class +pages under a "html-baseline/" (module name) subfolder but keeps the root +module's own index.html one level up, alongside navigation.html/styles/etc, +so both need scanning from this same parent directory. + +Not covered here: "Multimodule index.json" (context.configuration.modules +non-empty) -- needs a real multi-module Dokka build, which this single-module +fixture can't provide. Deferred to TEST_PLAN.md §8's kotlin-stdlib stress test. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from checklib import Checker, load + + +def collect_pages(root, ext, strip_prefix=None): + pages = set() + for dirpath, _, files in os.walk(root): + for f in files: + if f.endswith(ext): + rel = os.path.relpath(os.path.join(dirpath, f), root) + if strip_prefix and rel.startswith(strip_prefix + os.sep): + rel = rel[len(strip_prefix) + 1 :] + pages.add(rel[: -len(ext)]) + return pages + + +def main(): + output_dir = sys.argv[1] + html_baseline_dir = sys.argv[2] + c = Checker() + + # --- package-list --- + with open(os.path.join(output_dir, "package-list"), "r", encoding="utf-8") as f: + lines = [line.rstrip("\n") for line in f] + c.check( + lines[:2] == ["$dokka.format:json-v1$", "$dokka.linkExtension:json$"], + "package-list has the expected header lines", + f"got {lines[:2]}", + ) + packages = lines[2:] + c.check( + {"com.example.testlib", "com.example.utils"} <= set(packages), + "package-list lists the real packages", + f"got {packages}", + ) + c.check(packages == sorted(packages), "package-list packages are sorted", f"got {packages}") + + # --- all-types.json --- + all_types = load(os.path.join(output_dir, "all-types.json")) + types_by_name = {t["name"]: t for t in all_types["types"]} + expected_kinds = { + "BoundedContainer": "class", + "Provider": "interface", + "Level3": "enum", + "Level1": "object", + "Meta": "annotation", + "DataMap": "typeAlias", + } + for name, kind in expected_kinds.items(): + present = c.check(name in types_by_name, f"all-types.json includes {name}") + if present: + c.check( + types_by_name[name]["kind"] == kind, + f"{name}'s kind in all-types.json is '{kind}'", + f"got {types_by_name[name]['kind']}", + ) + names = [t["name"] for t in all_types["types"]] + c.check(names == sorted(names), "all-types.json entries are sorted by name") + + # --- Breadcrumbs at the root and at max depth --- + root_module = load(os.path.join(output_dir, "index.json")) + c.check( + len(root_module.get("breadcrumbs", [])) <= 1, + "root module page has an empty or single-entry breadcrumb list", + f"got {root_module.get('breadcrumbs')}", + ) + + level3 = load(os.path.join(output_dir, "com.example.testlib", "-level1", "-level2", "-level3", "index.json")) + breadcrumb_names = [b["name"] for b in level3.get("breadcrumbs", [])] + c.check( + breadcrumb_names == ["testlib", "com.example.testlib", "Level1", "Level2", "Level3"], + "max-depth page (Level1.Level2.Level3) has a 5-entry breadcrumb list in root-to-leaf order", + f"got {breadcrumb_names}", + ) + + # --- File path parity with Dokka's own HTML layout --- + json_pages = collect_pages(output_dir, ".json") + html_pages = collect_pages(html_baseline_dir, ".html", strip_prefix="html-baseline") + # Known, expected non-page artifacts unique to each renderer: "all-types" is + # our own aggregate index (not a Documentable page), "navigation" is the + # default HTML renderer's sidebar fragment, and everything else at the + # baseline's top level (styles/, scripts/, images/, ui-kit/) is static + # site-template assets our JSON renderer never writes at all. + static_asset_prefixes = ("styles/", "scripts/", "images/", "ui-kit/") + json_comparable = json_pages - {"all-types"} + html_comparable = { + p for p in html_pages if p != "navigation" and not p.startswith(static_asset_prefixes) + } + only_json = sorted(json_comparable - html_comparable) + only_html = sorted(html_comparable - json_comparable) + c.check( + not only_json and not only_html, + "every JSON page path (extension aside) matches a page Dokka's default HTML renderer produces", + f"only in JSON: {only_json[:10]}, only in HTML: {only_html[:10]}", + ) + + return c.summarize(os.path.basename(__file__)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Dokka-plugin-kdoc2json/tests/helpers/check_type_mapping.py b/Dokka-plugin-kdoc2json/tests/helpers/check_type_mapping.py new file mode 100755 index 00000000..5aea06ab --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/helpers/check_type_mapping.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Checks for TEST_PLAN.md §3 (Type/Bound/Projection mapping). + +Run via tests/test_type_mapping.sh, which regenerates examples/example-data-processor +first. Takes the rendered JSON output directory as argv[1]. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from checklib import Checker, load + + +def path(output_dir, *parts): + return os.path.join(output_dir, *parts) + + +def main(): + output_dir = sys.argv[1] + c = Checker() + testlib = path(output_dir, "com.example.testlib") + + # --- Nullable wrapping --- + format_or_default = load(os.path.join(testlib, "format-or-default.json")) + param_type = format_or_default["parameters"][0]["type"] + c.check(param_type.get("kind") == "Nullable", "String? maps to NullableDto, not a flag on the inner bound") + inner = param_type.get("inner", {}) + c.check( + inner.get("kind") == "GenericTypeConstructor" and inner.get("dri", "").startswith("kotlin/String"), + "NullableDto.inner is the underlying String type", + f"got {inner}", + ) + + # --- Generic type with projections --- + copy_items = load(os.path.join(testlib, "copy-items.json")) + source_type = copy_items["parameters"][0]["type"] + c.check( + source_type.get("kind") == "GenericTypeConstructor" and source_type.get("dri", "").startswith("kotlin.collections/List"), + "List maps to a GenericTypeConstructorDto for List", + ) + c.check(len(source_type.get("projections", [])) == 1, "List has exactly one projection") + + # --- Variance --- + c.check(source_type["projections"][0]["kind"] == "Covariance", "'out Number' parameter maps to CovarianceDto") + dest_type = copy_items["parameters"][1]["type"] + c.check(dest_type["projections"][0]["kind"] == "Contravariance", "'in Number' parameter maps to ContravarianceDto") + + bounded = load(os.path.join(testlib, "-bounded-container", "index.json")) + type_param = bounded["generics"][0] + c.check( + type_param["variantTypeParameter"]["kind"] == "Invariance", + "an unannotated type parameter (T) maps to InvarianceDto by default", + ) + + # --- Functional types --- + apply_callbacks = load(os.path.join(testlib, "apply-callbacks.json")) + params_by_name = {p["name"]: p["type"] for p in apply_callbacks["parameters"]} + + plain = params_by_name["plain"] + c.check(plain["kind"] == "FunctionalTypeConstructor", "plain lambda maps to FunctionalTypeConstructorDto") + c.check(plain["isExtensionFunction"] is False and plain["isSuspendable"] is False, "plain lambda: not extension, not suspend") + + extension = params_by_name["extension"] + c.check(extension["isExtensionFunction"] is True and extension["isSuspendable"] is False, "extension lambda: isExtensionFunction true") + + suspending = params_by_name["suspending"] + c.check(suspending["isExtensionFunction"] is False and suspending["isSuspendable"] is True, "suspend lambda: isSuspendable true") + + # --- Java interop --- + current_java_date = load(os.path.join(testlib, "current-java-date.json")) + java_type = current_java_date["type"] + c.check( + java_type.get("kind") in ("GenericTypeConstructor", "JavaObject", "PrimitiveJavaType"), + "java.util.Date maps to one of the Java-interop BoundDto kinds", + f"got kind={java_type.get('kind')}", + ) + c.check(java_type.get("url"), "java.util.Date's type has a resolvable url (external doc link configured)") + c.check("java.util/Date" in java_type.get("dri", ""), "java.util.Date's dri points at the real JDK class") + + # --- Type parameter bounds --- + bound = type_param["bounds"][0] + c.check( + bound.get("kind") == "GenericTypeConstructor" and bound.get("dri", "").startswith("kotlin/Comparable"), + "T : Comparable emits the bound's own DRI, not just the parameter name", + f"got {bound}", + ) + c.check(bound.get("url"), "the bound (Comparable) has a resolvable url") + + return c.summarize(os.path.basename(__file__)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/Dokka-plugin-kdoc2json/tests/helpers/checklib.py b/Dokka-plugin-kdoc2json/tests/helpers/checklib.py new file mode 100755 index 00000000..82973830 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/helpers/checklib.py @@ -0,0 +1,36 @@ +"""Shared assertion plumbing for the tests/helpers/check_*.py scripts. + +These scripts perform the structural JSON assertions for TEST_PLAN.md's §2-§4 +(DTO/type/doc-tag mapping) -- checks that need real JSON parsing rather than +grep, unlike the config-option tests in tests/test_*.sh. Each check_*.py is +invoked by its matching tests/test_*.sh wrapper (which runs run_dokka first), +takes the rendered output directory as argv[1], and exits 0/1 like a normal +test binary. +""" +import json + + +class Checker: + def __init__(self): + self.passed = 0 + self.failed = 0 + + def check(self, condition, desc, detail=""): + if condition: + print(f" PASS: {desc}") + self.passed += 1 + else: + suffix = f" ({detail})" if detail else "" + print(f" FAIL: {desc}{suffix}") + self.failed += 1 + return condition + + def summarize(self, name): + print() + print(f"{name}: {self.passed} passed, {self.failed} failed") + return 0 if self.failed == 0 else 1 + + +def load(path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) diff --git a/Dokka-plugin-kdoc2json/tests/lib.sh b/Dokka-plugin-kdoc2json/tests/lib.sh new file mode 100755 index 00000000..44a881f3 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/lib.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Shared harness for the kdoc-to-json config-option tests. Each test_*.sh script +# sources this file, calls publish_plugin once, then calls run_dokka one or more +# times with a JSON config string to regenerate examples/example-data-processor's +# docs under that config and assert on the resulting output. +set -uo pipefail + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$TESTS_DIR/.." && pwd)" +PLUGIN_DIR="$ROOT_DIR/kdoc-to-json" +EXAMPLE_DIR="$ROOT_DIR/examples/example-data-processor" +OUTPUT_DIR="$EXAMPLE_DIR/build/dokka/html" + +TMP_DIR="$(mktemp -d /tmp/kdoc2json_test.XXXXXX)" +trap 'rm -rf "$TMP_DIR"' EXIT + +PASS_COUNT=0 +FAIL_COUNT=0 + +# Publishes the plugin under test to mavenLocal so the example project can pick it +# up. Skipped when run_all.sh has already done this for the whole suite. +publish_plugin() { + if [[ "${KDOC2JSON_SKIP_PUBLISH:-}" == "1" ]]; then + return 0 + fi + local log="$TMP_DIR/publish.log" + echo "==> Publishing kdoc-to-json to mavenLocal..." >&2 + if ! (cd "$PLUGIN_DIR" && ./gradlew --console=plain publishToMavenLocal) >"$log" 2>&1; then + echo "FATAL: failed to publish kdoc-to-json to mavenLocal" >&2 + cat "$log" >&2 + exit 1 + fi +} + +# run_dokka '' regenerates examples/example-data-processor's docs with +# the given plugin config. Wipes any previous output first, so afterwards +# $OUTPUT_DIR reflects only this run. Aborts the whole test script (not just the +# current assertion) if the Dokka build itself fails, since that means the harness +# is broken rather than the config option under test. +# After a successful call, LAST_GRADLE_LOG holds the path to that run's captured +# Gradle output -- useful for tests asserting on build-log content (e.g. the +# "Failed to resolve N DRIs" warning), not just the written JSON files. +LAST_GRADLE_LOG="" + +run_dokka() { + local config_json="$1" + local config_file="$TMP_DIR/config-$RANDOM.json" + local gradle_log="$TMP_DIR/gradle-$RANDOM.log" + printf '%s' "$config_json" >"$config_file" + + rm -rf "$EXAMPLE_DIR/build/dokka" + + if ! (cd "$EXAMPLE_DIR" && KDOC2JSON_TEST_CONFIG="$config_file" ./gradlew --console=plain dokkaGenerate) >"$gradle_log" 2>&1; then + echo "FATAL: dokkaGenerate failed for config: $config_json" >&2 + cat "$gradle_log" >&2 + exit 1 + fi + LAST_GRADLE_LOG="$gradle_log" +} + +# run_dokka_expect_failure '' is run_dokka's counterpart for tests +# that assert the build SHOULD fail (e.g. a genuinely malformed config, or a +# classDiscriminator collision). Never aborts the script on a Dokka failure -- +# instead records the outcome in LAST_EXIT_CODE (0 or 1) and the log path in +# LAST_GRADLE_LOG, leaving the assertion itself to the caller. +LAST_EXIT_CODE="" + +run_dokka_expect_failure() { + local config_json="$1" + local config_file="$TMP_DIR/config-$RANDOM.json" + local gradle_log="$TMP_DIR/gradle-$RANDOM.log" + printf '%s' "$config_json" >"$config_file" + + rm -rf "$EXAMPLE_DIR/build/dokka" + + if (cd "$EXAMPLE_DIR" && KDOC2JSON_TEST_CONFIG="$config_file" ./gradlew --console=plain dokkaGenerate) >"$gradle_log" 2>&1; then + LAST_EXIT_CODE=0 + else + LAST_EXIT_CODE=1 + fi + LAST_GRADLE_LOG="$gradle_log" +} + +# run_dokka_no_plugin_config runs dokkaGenerate with KDOC2JSON_NO_PLUGIN_CONFIG=1, +# which build.gradle.kts uses to skip registering any pluginsConfiguration entry +# at all -- distinct from an empty/malformed one, which still registers +# *something*. Aborts the script on failure, like run_dokka. +run_dokka_no_plugin_config() { + local gradle_log="$TMP_DIR/gradle-$RANDOM.log" + + rm -rf "$EXAMPLE_DIR/build/dokka" + + if ! (cd "$EXAMPLE_DIR" && KDOC2JSON_NO_PLUGIN_CONFIG=1 ./gradlew --console=plain dokkaGenerate) >"$gradle_log" 2>&1; then + echo "FATAL: dokkaGenerate failed with no plugin config registered" >&2 + cat "$gradle_log" >&2 + exit 1 + fi + LAST_GRADLE_LOG="$gradle_log" +} + +# Returns a path inside the test's tmp dir that does not yet exist, suitable for +# passing as a `logFile` config value when the assertion cares whether the plugin +# itself creates the file. +unique_tmp_path() { + mktemp -u "$TMP_DIR/$1.XXXXXX" +} + +pass() { + echo " PASS: $1" + PASS_COUNT=$((PASS_COUNT + 1)) +} + +fail() { + echo " FAIL: $1" + FAIL_COUNT=$((FAIL_COUNT + 1)) +} + +assert_file_exists() { + local path="$1" desc="$2" + if [[ -f "$path" ]]; then + pass "$desc" + else + fail "$desc (file not found: $path)" + fi +} + +assert_file_not_exists() { + local path="$1" desc="$2" + if [[ ! -f "$path" ]]; then + pass "$desc" + else + fail "$desc (file unexpectedly exists: $path)" + fi +} + +assert_contains() { + local path="$1" pattern="$2" desc="$3" + if grep -qF -- "$pattern" "$path" 2>/dev/null; then + pass "$desc" + else + fail "$desc (pattern not found: '$pattern' in $path)" + fi +} + +assert_not_contains() { + local path="$1" pattern="$2" desc="$3" + if ! grep -qF -- "$pattern" "$path" 2>/dev/null; then + pass "$desc" + else + fail "$desc (pattern unexpectedly found: '$pattern' in $path)" + fi +} + +assert_eq() { + local actual="$1" expected="$2" desc="$3" + if [[ "$actual" == "$expected" ]]; then + pass "$desc" + else + fail "$desc (expected '$expected', got '$actual')" + fi +} + +assert_no_local_html_urls() { + local path="$1" desc="$2" + local hits + hits=$(grep -oE '"url":"[^"]*\.html"' "$path" 2>/dev/null | grep -v '"url":"http' || true) + if [[ -z "$hits" ]]; then + pass "$desc" + else + fail "$desc (found: $(echo "$hits" | head -3 | tr '\n' ' '))" + fi +} + +assert_gt() { + local actual="$1" threshold="$2" desc="$3" + if [[ "$actual" -gt "$threshold" ]]; then + pass "$desc" + else + fail "$desc (expected > $threshold, got $actual)" + fi +} + +# Counts lines in a file, correctly handling a final line with no trailing newline +# (which `wc -l` would otherwise undercount) -- needed to distinguish compact +# single-line JSON from prettyPrint's indented multi-line output. +line_count() { + awk 'END { print NR }' "$1" 2>/dev/null || echo 0 +} + +summarize_and_exit() { + echo + echo "$(basename "$0"): $PASS_COUNT passed, $FAIL_COUNT failed" + if [[ "$FAIL_COUNT" -gt 0 ]]; then + exit 1 + fi + exit 0 +} diff --git a/Dokka-plugin-kdoc2json/tests/run_all.sh b/Dokka-plugin-kdoc2json/tests/run_all.sh new file mode 100755 index 00000000..1d60aea3 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/run_all.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Runs every config-option test in this directory against +# examples/example-data-processor and prints an overall summary. +set -uo pipefail + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$TESTS_DIR/lib.sh" + +publish_plugin +export KDOC2JSON_SKIP_PUBLISH=1 + +overall_pass=0 +overall_fail=0 +failed_scripts=() + +for test_script in "$TESTS_DIR"/test_*.sh; do + name="$(basename "$test_script")" + echo + echo "########## $name ##########" + if "$test_script"; then + overall_pass=$((overall_pass + 1)) + else + overall_fail=$((overall_fail + 1)) + failed_scripts+=("$name") + fi +done + +echo +echo "================================================" +echo "Test scripts: $overall_pass passed, $overall_fail failed" +if [[ "$overall_fail" -gt 0 ]]; then + echo "Failed: ${failed_scripts[*]}" + exit 1 +fi +exit 0 diff --git a/Dokka-plugin-kdoc2json/tests/test_class_discriminator.sh b/Dokka-plugin-kdoc2json/tests/test_class_discriminator.sh new file mode 100755 index 00000000..7b4308a0 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_class_discriminator.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Verifies the `classDiscriminator` config option renames the polymorphic type key +# (default "kind") to whatever key name is configured. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +SAMPLE="$OUTPUT_DIR/com.example.utils/-data-processor/index.json" + +publish_plugin + +echo "=== classDiscriminator=\"kind\" (default): the \"kind\" key is used ===" +run_dokka '{ "classDiscriminator": "kind" }' +assert_contains "$SAMPLE" '"kind":"class"' "default discriminator key \"kind\" present" + +echo "=== classDiscriminator=\"elementType\": key renamed, old \"kind\" key gone ===" +run_dokka '{ "classDiscriminator": "elementType" }' +assert_not_contains "$SAMPLE" '"kind"' "old \"kind\" key no longer present" +assert_contains "$SAMPLE" '"elementType":"class"' "custom discriminator key \"elementType\" used instead" + +summarize_and_exit diff --git a/Dokka-plugin-kdoc2json/tests/test_doc_tags.sh b/Dokka-plugin-kdoc2json/tests/test_doc_tags.sh new file mode 100755 index 00000000..59d1bf3b --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_doc_tags.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Verifies TEST_PLAN.md §4: documentation tag & text extraction +# (mapDocNodes, extractText) against the example-data-processor fixture +# (including Advanced.kt from step 1). Structural JSON checks live in +# helpers/check_doc_tags.py since they need real parsing, not grep. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +publish_plugin +run_dokka '{ "omitNulls": false, "replaceHtmlExtension": true }' + +python3 "$TESTS_DIR/helpers/check_doc_tags.py" "$OUTPUT_DIR" diff --git a/Dokka-plugin-kdoc2json/tests/test_dto_mapping.sh b/Dokka-plugin-kdoc2json/tests/test_dto_mapping.sh new file mode 100755 index 00000000..c4513191 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_dto_mapping.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Verifies TEST_PLAN.md §2: DTO mapping correctness (ModelMapper.mapToDto) +# against the example-data-processor fixture (including Advanced.kt from +# step 1). Structural JSON checks live in helpers/check_dto_mapping.py since +# they need real parsing, not grep. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +publish_plugin +run_dokka '{ "omitNulls": false, "replaceHtmlExtension": true }' + +python3 "$TESTS_DIR/helpers/check_dto_mapping.py" "$OUTPUT_DIR" diff --git a/Dokka-plugin-kdoc2json/tests/test_link_postprocessor.sh b/Dokka-plugin-kdoc2json/tests/test_link_postprocessor.sh new file mode 100755 index 00000000..a997cb3b --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_link_postprocessor.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Verifies TEST_PLAN.md §7: LinkPostProcessor cross-module resolution. +# +# Not covered: "relative path depth" for a genuinely cross-module-resolved DRI, +# and "last-writer-wins" for expect/actual -- both need a real multi-module or +# multiplatform build. Confirmed empirically that this single-module fixture +# makes LinkPostProcessor's pass-2 replace step resolve exactly 0 links (every +# "unresolved:" marker here is either resolved directly by locationProvider at +# write time, or is permanently unresolvable). Deferred to TEST_PLAN.md §8's +# kotlin-stdlib stress test, per the user's decision when step 3 started. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +publish_plugin +run_dokka '{ "omitNulls": false, "replaceHtmlExtension": true }' + +python3 "$TESTS_DIR/helpers/check_link_postprocessor.py" "$OUTPUT_DIR" "$LAST_GRADLE_LOG" diff --git a/Dokka-plugin-kdoc2json/tests/test_log_file.sh b/Dokka-plugin-kdoc2json/tests/test_log_file.sh new file mode 100755 index 00000000..00ce2bfe --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_log_file.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Verifies the `logFile` config option: when set, the plugin's log messages are +# written to that path; when unset (the default), no such file is created. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +publish_plugin + +echo "=== logFile set to a custom path: file is created and contains plugin log lines ===" +LOG_PATH="$(unique_tmp_path log_file_set)" +run_dokka "{ \"logFile\": \"$LOG_PATH\" }" +assert_file_exists "$LOG_PATH" "custom log file was created" +assert_contains "$LOG_PATH" 'JSON Plugin' "log file contains plugin log lines" + +echo "=== logFile unset (default): no log file appears at that same path ===" +rm -f "$LOG_PATH" +run_dokka '{}' +assert_file_not_exists "$LOG_PATH" "no log file written when logFile is not configured" + +summarize_and_exit diff --git a/Dokka-plugin-kdoc2json/tests/test_log_level.sh b/Dokka-plugin-kdoc2json/tests/test_log_level.sh new file mode 100755 index 00000000..665bc831 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_log_level.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Verifies the `logLevel` config option filters which severities get written to +# the plugin's log file: "debug" writes everything, "info" suppresses debug +# lines, and "error" (with no actual errors in a clean build) writes nothing at +# all -- exercising every rung of PluginLogger's level filter. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +publish_plugin + +echo "=== logLevel=debug: DEBUG lines are written ===" +LOG_DEBUG="$(unique_tmp_path log_debug)" +run_dokka "{ \"logLevel\": \"debug\", \"logFile\": \"$LOG_DEBUG\" }" +assert_contains "$LOG_DEBUG" 'JSON Plugin DEBUG' "DEBUG lines present at logLevel=debug" + +echo "=== logLevel=info: DEBUG lines suppressed, INFO lines still present ===" +LOG_INFO="$(unique_tmp_path log_info)" +run_dokka "{ \"logLevel\": \"info\", \"logFile\": \"$LOG_INFO\" }" +assert_not_contains "$LOG_INFO" 'JSON Plugin DEBUG' "DEBUG lines suppressed at logLevel=info" +assert_contains "$LOG_INFO" 'JSON Plugin INFO' "INFO lines present at logLevel=info" + +echo "=== logLevel=error: no PluginLogger output at all on a clean, error-free build ===" +LOG_ERROR="$(unique_tmp_path log_error)" +run_dokka "{ \"logLevel\": \"error\", \"logFile\": \"$LOG_ERROR\" }" +assert_file_not_exists "$LOG_ERROR" "no log file written at logLevel=error when nothing errors" + +summarize_and_exit diff --git a/Dokka-plugin-kdoc2json/tests/test_omit_fields.sh b/Dokka-plugin-kdoc2json/tests/test_omit_fields.sh new file mode 100755 index 00000000..eab17af5 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_omit_fields.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Verifies the `omitFields` config option strips exactly the listed JSON keys +# (recursively) from the output, and leaves other fields untouched. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +SAMPLE="$OUTPUT_DIR/com.example.utils/-data-processor/index.json" + +publish_plugin + +echo "=== omitFields=[] (default): sources and documentation are present ===" +run_dokka '{ "omitFields": [] }' +assert_contains "$SAMPLE" '"sources"' "sources field present by default" +assert_contains "$SAMPLE" '"documentation"' "documentation field present by default" + +echo "=== omitFields=[\"sources\", \"documentation\"]: both keys stripped everywhere ===" +run_dokka '{ "omitFields": ["sources", "documentation"] }' +assert_not_contains "$SAMPLE" '"sources"' "sources field stripped" +assert_not_contains "$SAMPLE" '"documentation"' "documentation field stripped" +assert_contains "$SAMPLE" '"name":"DataProcessor"' "unrelated fields (e.g. name) are left untouched" + +echo "=== omitFields=[\"sources\"]: only the named field is stripped ===" +run_dokka '{ "omitFields": ["sources"] }' +assert_not_contains "$SAMPLE" '"sources"' "sources field stripped" +assert_contains "$SAMPLE" '"documentation"' "documentation field NOT stripped when not listed" + +summarize_and_exit diff --git a/Dokka-plugin-kdoc2json/tests/test_omit_nulls.sh b/Dokka-plugin-kdoc2json/tests/test_omit_nulls.sh new file mode 100755 index 00000000..cbde99a4 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_omit_nulls.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Verifies the `omitNulls` config option deeply strips null values, empty +# strings, empty arrays, and empty objects from the output. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +# Matches a JSON value of null, "", [], or {} -- what omitNulls should remove. +# Compact output has no space after the colon while prettyPrint output does, so +# the whitespace after ":" is optional here to match either style. +NULL_OR_EMPTY_PATTERN=':[[:space:]]*(null|""|\[\]|\{\})' + +publish_plugin + +echo "=== omitNulls=false (default): at least one null/empty value exists somewhere ===" +run_dokka '{ "omitNulls": false }' +hits=$(grep -rlE "$NULL_OR_EMPTY_PATTERN" "$OUTPUT_DIR" --include='*.json' 2>/dev/null | wc -l | tr -d ' ') +assert_gt "$hits" 0 "found null/empty values with omitNulls=false ($hits file(s))" + +echo "=== omitNulls=true: no null/empty values remain anywhere in the output ===" +run_dokka '{ "omitNulls": true }' +hits=$(grep -rlE "$NULL_OR_EMPTY_PATTERN" "$OUTPUT_DIR" --include='*.json' 2>/dev/null | wc -l | tr -d ' ') +assert_eq "$hits" "0" "no null/empty values found with omitNulls=true" + +summarize_and_exit diff --git a/Dokka-plugin-kdoc2json/tests/test_pretty_print.sh b/Dokka-plugin-kdoc2json/tests/test_pretty_print.sh new file mode 100755 index 00000000..6a62f4bd --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_pretty_print.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Verifies the `prettyPrint` config option switches the written JSON between +# compact single-line output (default) and indented multi-line output. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +SAMPLE="$OUTPUT_DIR/com.example.utils/-data-processor/index.json" + +publish_plugin + +echo "=== prettyPrint=false (default): compact single-line JSON ===" +run_dokka '{ "prettyPrint": false }' +lines=$(line_count "$SAMPLE") +assert_eq "$lines" "1" "output file is a single line when compact" + +echo "=== prettyPrint=true: indented multi-line JSON ===" +run_dokka '{ "prettyPrint": true }' +lines=$(line_count "$SAMPLE") +assert_gt "$lines" 1 "output file spans multiple lines when pretty-printed ($lines lines)" +assert_contains "$SAMPLE" ' "kind"' "output uses indentation, not just newlines" + +summarize_and_exit diff --git a/Dokka-plugin-kdoc2json/tests/test_renderer.sh b/Dokka-plugin-kdoc2json/tests/test_renderer.sh new file mode 100755 index 00000000..5ce07e56 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_renderer.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Verifies TEST_PLAN.md §6: JsonRenderer traversal & index generation. +# +# Not covered: "Multimodule index.json" -- needs a real multi-module Dokka +# build, deferred to TEST_PLAN.md §8's kotlin-stdlib stress test rather than +# building a second fixture just for this row (per the user's decision when +# step 3 started). +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +HTML_BASELINE_DIR="$ROOT_DIR/examples/html-baseline" + +publish_plugin +run_dokka '{ "omitNulls": false, "replaceHtmlExtension": true }' + +echo "==> Building stock-Dokka HTML baseline for file-path-parity comparison..." >&2 +rm -rf "$HTML_BASELINE_DIR/build/dokka" +if ! (cd "$HTML_BASELINE_DIR" && ./gradlew --console=plain dokkaGenerate) >"$TMP_DIR/html_baseline.log" 2>&1; then + echo "FATAL: html-baseline dokkaGenerate failed" >&2 + cat "$TMP_DIR/html_baseline.log" >&2 + exit 1 +fi + +python3 "$TESTS_DIR/helpers/check_renderer.py" "$OUTPUT_DIR" "$HTML_BASELINE_DIR/build/dokka/html" diff --git a/Dokka-plugin-kdoc2json/tests/test_replace_html_extension.sh b/Dokka-plugin-kdoc2json/tests/test_replace_html_extension.sh new file mode 100755 index 00000000..92439823 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_replace_html_extension.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Verifies the `replaceHtmlExtension` config option: when true, every internal +# relative "url" field in the JSON output should end in .json instead of .html. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +SAMPLE="$OUTPUT_DIR/com.example.utils/-data-processor/index.json" + +publish_plugin + +echo "=== replaceHtmlExtension=true: internal urls should end in .json ===" +run_dokka '{ "replaceHtmlExtension": true }' +assert_contains "$SAMPLE" '"url":"index.json"' "self url rewritten to .json" +assert_no_local_html_urls "$SAMPLE" "no internal .html urls remain (external stdlib links may still end in .html)" + +echo "=== replaceHtmlExtension=false (default): internal urls should end in .html ===" +run_dokka '{ "replaceHtmlExtension": false }' +assert_contains "$SAMPLE" '"url":"index.html"' "self url left as .html" +assert_not_contains "$SAMPLE" '.json"' "no .json urls appear when the option is off" + +summarize_and_exit diff --git a/Dokka-plugin-kdoc2json/tests/test_robustness.sh b/Dokka-plugin-kdoc2json/tests/test_robustness.sh new file mode 100755 index 00000000..d509ef83 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_robustness.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Verifies TEST_PLAN.md §9: robustness / edge cases. +# +# The "malformed plugin config" row turned out to only be partially true -- +# see the two scenarios below marked "expected to fail". Dokka's own Gradle +# plugin deserializes the raw jsonEncode() string directly against +# JsonPluginConfig (via Jackson, client-side) before our worker/renderer code +# ever runs. That means: +# - valid JSON with only an unknown extra key survives (both Dokka's own +# decoder and our manual ignoreUnknownKeys fallback tolerate it), but +# - genuinely invalid JSON syntax, and valid JSON with a wrong-typed known +# field, both fail the WHOLE GRADLE BUILD at that upfront layer -- never +# reaching the try/catch in JsonRenderer.render() at all. +# There's nothing in this plugin's own code to fix for those last two: the +# failure happens one layer above it, in Dokka's Gradle plugin itself. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +SAMPLE="$OUTPUT_DIR/com.example.testlib/-bounded-container/index.json" + +publish_plugin + +echo "=== Unknown extra config key: build succeeds, known keys still applied ===" +run_dokka '{ "omitNulls": true, "totallyUnknownKey": "whatever" }' +if grep -qE ':[[:space:]]*(null|""|\[\]|\{\})' "$SAMPLE"; then + fail "omitNulls=true is still applied when an unrelated unknown key is present" +else + pass "omitNulls=true is still applied when an unrelated unknown key is present" +fi + +echo "=== Genuinely malformed JSON syntax: build fails (expected -- see header comment) ===" +run_dokka_expect_failure '{ this is not valid json at all !!! ' +assert_eq "$LAST_EXIT_CODE" "1" "build fails on syntactically invalid config JSON" +assert_contains "$LAST_GRADLE_LOG" "Unexpected character" "failure is a JSON parse error, not an unrelated crash" + +echo "=== Wrong-typed known field: build fails (expected -- see header comment) ===" +run_dokka_expect_failure '{ "omitNulls": "not-a-boolean" }' +assert_eq "$LAST_EXIT_CODE" "1" "build fails when a known field has the wrong JSON type" +assert_contains "$LAST_GRADLE_LOG" "JsonPluginConfig[\"omitNulls\"]" "failure clearly names the offending field" + +echo "=== No plugin config registered at all: build succeeds using JsonPluginConfig() defaults ===" +run_dokka_no_plugin_config +assert_contains "$LAST_GRADLE_LOG" "No JSON config found in pluginsConfiguration" "the fallback-to-defaults warning is logged" +lines=$(line_count "$SAMPLE") +assert_eq "$lines" "1" "output is compact (prettyPrint default false) when no config is registered" +assert_contains "$SAMPLE" '"url":"index.html"' "urls are left as .html (replaceHtmlExtension default false) when no config is registered" + +echo "=== Empty/near-empty module (package with zero public declarations): build succeeds ===" +run_dokka '{ "omitNulls": false, "replaceHtmlExtension": true }' +assert_file_exists "$OUTPUT_DIR/package-list" "package-list is still written" +assert_not_contains "$OUTPUT_DIR/package-list" "com.example.emptypkg" "the internal-only package is cleanly skipped (Dokka's own skipEmptyPackages), not left half-written" +assert_file_exists "$OUTPUT_DIR/index.json" "root module index.json is still written" +if python3 -c " +import json, sys +d = json.load(open('$OUTPUT_DIR/index.json')) +sys.exit(0 if d.get('kind') == 'module' and isinstance(d.get('packages'), list) else 1) +"; then + pass "root module index.json is still valid JSON with the expected shape" +else + fail "root module index.json is still valid JSON with the expected shape" +fi + +echo "=== classDiscriminator collision (set to an existing field name): build fails with a clear error ===" +run_dokka_expect_failure '{ "classDiscriminator": "name" }' +assert_eq "$LAST_EXIT_CODE" "1" "build fails when classDiscriminator collides with a real field name" +assert_contains "$LAST_GRADLE_LOG" "conflicts with JSON class discriminator" "the error clearly names the discriminator collision, not silent corruption" + +summarize_and_exit diff --git a/Dokka-plugin-kdoc2json/tests/test_source_set_whitelist.sh b/Dokka-plugin-kdoc2json/tests/test_source_set_whitelist.sh new file mode 100755 index 00000000..e92b8abc --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_source_set_whitelist.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Verifies the `sourceSetWhitelist` config option: documentables outside the +# whitelist are omitted from the output entirely, and a whitelist that matches +# every source set behaves like no filtering at all. Reuses +# scripts/verify_sourceset_whitelist.py for the "matches" case, since that's +# exactly the tool it exists to exercise. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +# example-data-processor is a single-source-set (JVM-only) project; Dokka names +# that source set "main". +ALL_TYPES="$OUTPUT_DIR/all-types.json" +PACKAGE_LIST="$OUTPUT_DIR/package-list" + +publish_plugin + +echo "=== sourceSetWhitelist=[] (default): no filtering, all-types.json present ===" +run_dokka '{ "sourceSetWhitelist": [] }' +assert_file_exists "$ALL_TYPES" "all-types.json generated with no whitelist" +assert_contains "$ALL_TYPES" '"name":"DataProcessor"' "DataProcessor present with no whitelist" + +echo "=== sourceSetWhitelist=[\"main\"] (matches the project's only source set): nothing filtered ===" +run_dokka '{ "sourceSetWhitelist": ["main"] }' +assert_file_exists "$ALL_TYPES" "all-types.json generated when whitelist matches" +assert_contains "$ALL_TYPES" '"name":"DataProcessor"' "DataProcessor present when whitelist matches" +if python3 "$ROOT_DIR/scripts/verify_sourceset_whitelist.py" "$OUTPUT_DIR" main >"$TMP_DIR/verify.log" 2>&1; then + pass "verify_sourceset_whitelist.py confirms no violations" +else + fail "verify_sourceset_whitelist.py reported violations: $(cat "$TMP_DIR/verify.log")" +fi + +echo "=== sourceSetWhitelist=[\"does-not-exist\"] (matches nothing): everything omitted ===" +run_dokka '{ "sourceSetWhitelist": ["does-not-exist"] }' +assert_file_not_exists "$ALL_TYPES" "all-types.json not generated when nothing passes the whitelist" +assert_file_exists "$PACKAGE_LIST" "package-list header is still written" +assert_not_contains "$PACKAGE_LIST" 'com.example' "package-list has no packages when nothing passes the whitelist" + +summarize_and_exit diff --git a/Dokka-plugin-kdoc2json/tests/test_type_mapping.sh b/Dokka-plugin-kdoc2json/tests/test_type_mapping.sh new file mode 100755 index 00000000..29277769 --- /dev/null +++ b/Dokka-plugin-kdoc2json/tests/test_type_mapping.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Verifies TEST_PLAN.md §3: Type/Bound/Projection mapping against the +# example-data-processor fixture (including Advanced.kt from step 1). +# Structural JSON checks live in helpers/check_type_mapping.py since they +# need real parsing, not grep. +set -uo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +publish_plugin +run_dokka '{ "omitNulls": false, "replaceHtmlExtension": true }' + +python3 "$TESTS_DIR/helpers/check_type_mapping.py" "$OUTPUT_DIR"