|
| 1 | +// Vendored from https://github.com/denoland/std/blob/main/_tools/check_docs.ts |
| 2 | +// Copyright 2018-2026 the Deno authors. MIT license. |
| 3 | + |
| 4 | +import { |
| 5 | + type ClassConstructorDef, |
| 6 | + type ClassMethodDef, |
| 7 | + type ClassPropertyDef, |
| 8 | + doc, |
| 9 | + type DocNode, |
| 10 | + type DocNodeBase, |
| 11 | + type DocNodeClass, |
| 12 | + type DocNodeFunction, |
| 13 | + type DocNodeModuleDoc, |
| 14 | + type JsDoc, |
| 15 | + type JsDocTagDocRequired, |
| 16 | + type JsDocTagParam, |
| 17 | + type Location, |
| 18 | + type TsTypeDef, |
| 19 | +} from "@deno/doc"; |
| 20 | +import { distinctBy } from "@std/collections/distinct-by"; |
| 21 | + |
| 22 | +type DocNodeWithJsDoc<T = DocNodeBase> = T & { |
| 23 | + jsDoc: JsDoc; |
| 24 | +}; |
| 25 | + |
| 26 | +const TS_SNIPPET_REGEXP = /```ts[\s\S]*?```/g; |
| 27 | +const ASSERTION_IMPORT_REGEXP = |
| 28 | + /from "@std\/(assert(\/[a-z-]+)?|expect(\/[a-z-]+)?|testing\/(mock|snapshot|types))"/g; |
| 29 | +const NEWLINE = "\n"; |
| 30 | +const diagnostics: DocumentError[] = []; |
| 31 | + |
| 32 | +class DocumentError extends Error { |
| 33 | + constructor( |
| 34 | + message: string, |
| 35 | + document: { location: Location }, |
| 36 | + ) { |
| 37 | + super(message, { |
| 38 | + cause: `${document.location.filename}:${document.location.line}`, |
| 39 | + }); |
| 40 | + this.name = this.constructor.name; |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +function assert( |
| 45 | + condition: boolean, |
| 46 | + message: string, |
| 47 | + document: { location: Location }, |
| 48 | +): asserts condition { |
| 49 | + if (!condition) { |
| 50 | + diagnostics.push(new DocumentError(message, document)); |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +function isVoidOrPromiseVoid(returnType: TsTypeDef) { |
| 55 | + return isVoid(returnType) || |
| 56 | + (returnType.kind === "typeRef" && |
| 57 | + returnType.typeRef.typeName === "Promise" && |
| 58 | + returnType.typeRef.typeParams?.length === 1 && |
| 59 | + isVoid(returnType.typeRef.typeParams[0]!)); |
| 60 | +} |
| 61 | + |
| 62 | +function isTypeAsserts(returnType: TsTypeDef) { |
| 63 | + return returnType.kind === "typePredicate" && |
| 64 | + returnType.typePredicate.asserts; |
| 65 | +} |
| 66 | + |
| 67 | +function isVoid(returnType: TsTypeDef) { |
| 68 | + return returnType.kind === "keyword" && returnType.keyword === "void"; |
| 69 | +} |
| 70 | + |
| 71 | +function assertHasReturnTag(document: { jsDoc: JsDoc; location: Location }) { |
| 72 | + const tag = document.jsDoc.tags?.find((tag) => tag.kind === "return"); |
| 73 | + assert( |
| 74 | + tag !== undefined, |
| 75 | + "Symbol must have a @return or @returns tag", |
| 76 | + document, |
| 77 | + ); |
| 78 | + if (tag === undefined) return; |
| 79 | + assert( |
| 80 | + tag.doc !== undefined, |
| 81 | + "@return tag must have a description", |
| 82 | + document, |
| 83 | + ); |
| 84 | +} |
| 85 | + |
| 86 | +function assertHasParamDefinition( |
| 87 | + document: DocNodeWithJsDoc<DocNodeFunction | ClassMethodDef>, |
| 88 | + param: JsDocTagParam, |
| 89 | +) { |
| 90 | + const paramDoc = document.functionDef.params.find((paramDoc) => { |
| 91 | + if (paramDoc.kind === "identifier") { |
| 92 | + return paramDoc.name === param.name; |
| 93 | + } else if (paramDoc.kind === "rest" && paramDoc.arg.kind === "identifier") { |
| 94 | + return paramDoc.arg.name === param.name; |
| 95 | + } else if ( |
| 96 | + paramDoc.kind === "assign" && paramDoc.left.kind === "identifier" |
| 97 | + ) { |
| 98 | + return paramDoc.left.name === param.name; |
| 99 | + } |
| 100 | + return false; |
| 101 | + }); |
| 102 | + |
| 103 | + assert( |
| 104 | + paramDoc !== undefined, |
| 105 | + `@param ${param.name} must have a corresponding function parameter definition.`, |
| 106 | + document, |
| 107 | + ); |
| 108 | +} |
| 109 | + |
| 110 | +function assertHasParamTag( |
| 111 | + document: { jsDoc: JsDoc; location: Location }, |
| 112 | + param: string, |
| 113 | +) { |
| 114 | + const tag = document.jsDoc.tags?.find((tag) => |
| 115 | + tag.kind === "param" && tag.name === param |
| 116 | + ); |
| 117 | + assert( |
| 118 | + tag !== undefined, |
| 119 | + `Symbol must have a @param tag for ${param}`, |
| 120 | + document, |
| 121 | + ); |
| 122 | + if (tag === undefined) return; |
| 123 | + assert( |
| 124 | + // @ts-ignore doc is defined |
| 125 | + tag.doc !== undefined, |
| 126 | + `@param tag for ${param} must have a description`, |
| 127 | + document, |
| 128 | + ); |
| 129 | +} |
| 130 | + |
| 131 | +function assertHasSnippets( |
| 132 | + doc: string, |
| 133 | + document: { jsDoc: JsDoc; location: Location }, |
| 134 | +) { |
| 135 | + const snippets = doc.match(TS_SNIPPET_REGEXP); |
| 136 | + assert( |
| 137 | + snippets !== null, |
| 138 | + "@example tag must have a TypeScript code snippet", |
| 139 | + document, |
| 140 | + ); |
| 141 | + if (snippets === null) return; |
| 142 | + for (let snippet of snippets) { |
| 143 | + const delim = snippet.split(NEWLINE)[0]; |
| 144 | + snippet = snippet.split(NEWLINE).slice(1, -1).join(NEWLINE); |
| 145 | + if (!(delim?.includes("no-assert") || delim?.includes("ignore"))) { |
| 146 | + assert( |
| 147 | + snippet.match(ASSERTION_IMPORT_REGEXP) !== null, |
| 148 | + "Snippet must contain assertion from `@std/assert`, `@std/expect` or `@std/testing`", |
| 149 | + document, |
| 150 | + ); |
| 151 | + } |
| 152 | + } |
| 153 | +} |
| 154 | + |
| 155 | +function assertHasExampleTag( |
| 156 | + document: { jsDoc: JsDoc; location: Location }, |
| 157 | +) { |
| 158 | + const exampleTags = document.jsDoc.tags?.filter((tag) => |
| 159 | + tag.kind === "example" |
| 160 | + ) as JsDocTagDocRequired[]; |
| 161 | + assert(exampleTags?.length > 0, "Symbol must have an @example tag", document); |
| 162 | + if (exampleTags === undefined) return; |
| 163 | + for (const tag of exampleTags) { |
| 164 | + assert( |
| 165 | + tag.doc !== undefined, |
| 166 | + "@example tag must have a title and TypeScript code snippet", |
| 167 | + document, |
| 168 | + ); |
| 169 | + if (tag.doc === undefined) continue; |
| 170 | + assert( |
| 171 | + !tag.doc.startsWith("```ts"), |
| 172 | + "@example tag must have a title", |
| 173 | + document, |
| 174 | + ); |
| 175 | + assertHasSnippets(tag.doc, document); |
| 176 | + } |
| 177 | +} |
| 178 | + |
| 179 | +function assertHasTypeParamTags( |
| 180 | + document: { jsDoc: JsDoc; location: Location }, |
| 181 | + typeParamName: string, |
| 182 | +) { |
| 183 | + const tag = document.jsDoc.tags?.find((tag) => |
| 184 | + tag.kind === "template" && tag.name === typeParamName |
| 185 | + ); |
| 186 | + assert( |
| 187 | + tag !== undefined, |
| 188 | + `Symbol must have a @typeParam tag for ${typeParamName}`, |
| 189 | + document, |
| 190 | + ); |
| 191 | + if (tag === undefined) return; |
| 192 | + assert( |
| 193 | + // @ts-ignore doc is defined |
| 194 | + tag.doc !== undefined, |
| 195 | + `@typeParam tag for ${typeParamName} must have a description`, |
| 196 | + document, |
| 197 | + ); |
| 198 | +} |
| 199 | + |
| 200 | +function assertFunctionDocs( |
| 201 | + document: DocNodeWithJsDoc<DocNodeFunction | ClassMethodDef>, |
| 202 | +) { |
| 203 | + for (const param of document.functionDef.params) { |
| 204 | + if (param.kind === "identifier") { |
| 205 | + assertHasParamTag(document, param.name); |
| 206 | + } |
| 207 | + if (param.kind === "rest" && param.arg.kind === "identifier") { |
| 208 | + assertHasParamTag(document, param.arg.name); |
| 209 | + } |
| 210 | + if (param.kind === "assign" && param.left.kind === "identifier") { |
| 211 | + assertHasParamTag(document, param.left.name); |
| 212 | + } |
| 213 | + } |
| 214 | + |
| 215 | + const documentedParams = document.jsDoc.tags?.filter(( |
| 216 | + tag, |
| 217 | + ): tag is JsDocTagParam => tag.kind === "param" && !tag.name.includes(".")) ?? |
| 218 | + []; |
| 219 | + for (const param of documentedParams) { |
| 220 | + assertHasParamDefinition(document, param); |
| 221 | + } |
| 222 | + |
| 223 | + for (const typeParam of document.functionDef.typeParams) { |
| 224 | + assertHasTypeParamTags(document, typeParam.name); |
| 225 | + } |
| 226 | + if ( |
| 227 | + document.functionDef.returnType !== undefined && |
| 228 | + !isVoidOrPromiseVoid(document.functionDef.returnType) && |
| 229 | + !isTypeAsserts(document.functionDef.returnType) |
| 230 | + ) { |
| 231 | + assertHasReturnTag(document); |
| 232 | + } |
| 233 | + assertHasExampleTag(document); |
| 234 | +} |
| 235 | + |
| 236 | +function assertClassDocs(document: DocNodeWithJsDoc<DocNodeClass>) { |
| 237 | + for (const typeParam of document.classDef.typeParams) { |
| 238 | + assertHasTypeParamTags(document, typeParam.name); |
| 239 | + } |
| 240 | + if (!document.jsDoc.tags?.some((tag) => tag.kind === "example")) { |
| 241 | + assertHasExampleTag(document); |
| 242 | + } |
| 243 | + |
| 244 | + for (const property of document.classDef.properties) { |
| 245 | + if (property.jsDoc === undefined) continue; |
| 246 | + assert( |
| 247 | + property.accessibility === undefined, |
| 248 | + "Do not use `public`, `protected`, or `private` fields in classes", |
| 249 | + property, |
| 250 | + ); |
| 251 | + assertClassPropertyDocs( |
| 252 | + property as DocNodeWithJsDoc<ClassPropertyDef>, |
| 253 | + ); |
| 254 | + } |
| 255 | + for (const method of document.classDef.methods) { |
| 256 | + if (method.jsDoc === undefined) continue; |
| 257 | + assert( |
| 258 | + method.accessibility === undefined, |
| 259 | + "Do not use `public`, `protected`, or `private` methods in classes", |
| 260 | + document, |
| 261 | + ); |
| 262 | + assertFunctionDocs(method as DocNodeWithJsDoc<ClassMethodDef>); |
| 263 | + } |
| 264 | + for (const constructor of document.classDef.constructors) { |
| 265 | + if (constructor.jsDoc === undefined) continue; |
| 266 | + assert( |
| 267 | + constructor.accessibility === undefined, |
| 268 | + "Do not use `public`, `protected`, or `private` constructors in classes", |
| 269 | + constructor, |
| 270 | + ); |
| 271 | + assertConstructorDocs( |
| 272 | + constructor as DocNodeWithJsDoc<ClassConstructorDef>, |
| 273 | + ); |
| 274 | + } |
| 275 | +} |
| 276 | + |
| 277 | +function assertClassPropertyDocs( |
| 278 | + property: DocNodeWithJsDoc<ClassPropertyDef>, |
| 279 | +) { |
| 280 | + assertHasExampleTag(property); |
| 281 | +} |
| 282 | + |
| 283 | +function assertConstructorDocs( |
| 284 | + constructor: DocNodeWithJsDoc<ClassConstructorDef>, |
| 285 | +) { |
| 286 | + for (const param of constructor.params) { |
| 287 | + assert( |
| 288 | + param.accessibility === undefined, |
| 289 | + "Do not use `public`, `protected`, or `private` parameters in constructors", |
| 290 | + constructor, |
| 291 | + ); |
| 292 | + if (param.kind === "identifier") { |
| 293 | + assertHasParamTag(constructor, param.name); |
| 294 | + } |
| 295 | + if (param.kind === "assign" && param.left.kind === "identifier") { |
| 296 | + assertHasParamTag(constructor, param.left.name); |
| 297 | + } |
| 298 | + } |
| 299 | +} |
| 300 | + |
| 301 | +function assertModuleDoc(document: DocNodeWithJsDoc<DocNodeModuleDoc>) { |
| 302 | + assertHasSnippets(document.jsDoc.doc!, document); |
| 303 | +} |
| 304 | + |
| 305 | +function assertHasDeprecationDesc(document: DocNodeWithJsDoc<DocNode>) { |
| 306 | + const tags = document.jsDoc?.tags; |
| 307 | + if (!tags) return; |
| 308 | + for (const tag of tags) { |
| 309 | + if (tag.kind !== "deprecated") continue; |
| 310 | + assert( |
| 311 | + tag.doc !== undefined, |
| 312 | + "@deprecated tag must have a description", |
| 313 | + document, |
| 314 | + ); |
| 315 | + } |
| 316 | +} |
| 317 | + |
| 318 | +function resolve( |
| 319 | + specifier: string, |
| 320 | + referrer: string, |
| 321 | +) { |
| 322 | + return (specifier.startsWith("./") || specifier.startsWith("../")) |
| 323 | + ? new URL(specifier, referrer).href |
| 324 | + : import.meta.resolve(specifier); |
| 325 | +} |
| 326 | + |
| 327 | +async function assertDocs(specifiers: string[]) { |
| 328 | + const docs = await doc(specifiers, { resolve }); |
| 329 | + for (const d of Object.values(docs).flat()) { |
| 330 | + if (d.jsDoc === undefined || d.declarationKind !== "export") continue; |
| 331 | + |
| 332 | + const document = d as DocNodeWithJsDoc<DocNode>; |
| 333 | + assertHasDeprecationDesc(document); |
| 334 | + switch (document.kind) { |
| 335 | + case "moduleDoc": { |
| 336 | + if (document.location.filename.endsWith("/mod.ts")) { |
| 337 | + assertModuleDoc(document); |
| 338 | + } |
| 339 | + break; |
| 340 | + } |
| 341 | + case "function": { |
| 342 | + assertFunctionDocs(document); |
| 343 | + break; |
| 344 | + } |
| 345 | + case "class": { |
| 346 | + assertClassDocs(document); |
| 347 | + break; |
| 348 | + } |
| 349 | + } |
| 350 | + } |
| 351 | +} |
| 352 | + |
| 353 | +export async function checkDocs(specifiers: string[]) { |
| 354 | + const { success, stderr } = await new Deno.Command(Deno.execPath(), { |
| 355 | + args: ["doc", "--lint", ...specifiers], |
| 356 | + stdin: "inherit", |
| 357 | + stdout: "inherit", |
| 358 | + stderr: "piped", |
| 359 | + }).output(); |
| 360 | + if (!success) { |
| 361 | + throw new Error(new TextDecoder().decode(stderr)); |
| 362 | + } |
| 363 | + |
| 364 | + await assertDocs(specifiers); |
| 365 | + |
| 366 | + if (diagnostics.length > 0) { |
| 367 | + const errors = distinctBy(diagnostics, (e) => e.message + e.cause); |
| 368 | + for (const error of errors) { |
| 369 | + // deno-lint-ignore no-console |
| 370 | + console.error( |
| 371 | + `%c[error] %c${error.message} %cat ${error.cause}`, |
| 372 | + "color: red", |
| 373 | + "", |
| 374 | + "color: gray", |
| 375 | + ); |
| 376 | + } |
| 377 | + |
| 378 | + // deno-lint-ignore no-console |
| 379 | + console.log(`%c${errors.length} errors found`, "color: red"); |
| 380 | + Deno.exit(1); |
| 381 | + } |
| 382 | +} |
0 commit comments