Files
zopu-code/repos/effect/packages/tools/jsdocs/src/Jsdocs.ts
T

3365 lines
118 KiB
TypeScript

/*
AI prompt for writing Effect public API JSDoc:
This package owns Effect public API JSDoc parsing, extraction, and model generation.
*/
import * as Effect from "effect/Effect"
import { globSync } from "glob"
import * as crypto from "node:crypto"
import * as fs from "node:fs"
import * as path from "node:path"
import * as ts from "typescript"
type ExportBucket = "value" | "type"
type DocScope = "module" | "declaration" | "namespace" | "namespace-declaration" | "member"
type Result<A, E> =
| { readonly _tag: "Success"; readonly value: A }
| { readonly _tag: "Failure"; readonly error: E }
interface SourceFileWithParseDiagnostics extends ts.SourceFile {
readonly parseDiagnostics: ReadonlyArray<ts.Diagnostic>
}
/**
* Result type returned by the JSDoc parser helpers.
*
* @category utility types
* @since 4.0.0
*/
export type JSDocResult<A, E> = Result<A, E>
/**
* Diagnostic emitted when a JSDoc block does not follow the standard shape.
*
* @category models
* @since 4.0.0
*/
export interface JSDocDiagnostic {
readonly code: string
readonly message: string
}
/**
* Parse error containing all diagnostics collected for a JSDoc block or file.
*
* @category models
* @since 4.0.0
*/
export interface JSDocParseError {
readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
}
/**
* Parsed `@see` tag text and any inline links it contains.
*
* @category models
* @since 4.0.0
*/
export interface ParsedSeeTag {
readonly text: string
readonly links: ReadonlyArray<ParsedInlineLink>
}
/**
* TypeScript symbol target resolved for a parsed inline JSDoc link.
*
* @category models
* @since 4.0.0
*/
export interface ParsedInlineLinkSymbol {
readonly file: string
readonly range: readonly [number, number]
}
/**
* Parsed inline JSDoc link target and optional display text.
*
* @category models
* @since 4.0.0
*/
export interface ParsedInlineLink {
readonly raw: string
readonly target: string
readonly text: string | null
readonly range?: readonly [number, number]
readonly symbol?: ParsedInlineLinkSymbol
}
/**
* Parsed JSDoc description sections.
*
* @category models
* @since 4.0.0
*/
export interface ParsedDescription {
readonly short: string
readonly whenToUse: string | null
readonly details: string | null
readonly gotchas: string | null
}
/**
* Parsed example section from a JSDoc block.
*
* @category models
* @since 4.0.0
*/
export interface ParsedExample {
readonly title: string
readonly body: string | null
readonly code: string
}
/**
* Raw module-level JSDoc block collected from the top of a checked file.
*
* @category models
* @since 4.0.0
*/
export interface ParsedModuleJSDoc {
readonly raw: string
readonly range: readonly [number, number]
}
interface ParsedModuleTags {
readonly since: string
readonly deprecated: string | null
readonly see: ReadonlyArray<ParsedSeeTag>
}
/**
* Parsed tags required for a root public declaration.
*
* @category models
* @since 4.0.0
*/
export interface ParsedDeclarationTags {
readonly category: string
readonly since: string
readonly deprecated: string | null
readonly see: ReadonlyArray<ParsedSeeTag>
}
/**
* Parsed tags allowed for namespace JSDoc.
*
* @category models
* @since 4.0.0
*/
export interface ParsedNamespaceTags {
readonly category: string | null
readonly since: string
readonly deprecated: string | null
readonly see: ReadonlyArray<ParsedSeeTag>
}
/**
* Parsed tags allowed for documented members.
*
* @category models
* @since 4.0.0
*/
export interface ParsedMemberTags {
readonly since: string | null
readonly default: string | null
readonly deprecated: string | null
readonly see: ReadonlyArray<ParsedSeeTag>
}
/**
* Parsed documented member within a declaration or another member.
*
* @category models
* @since 4.0.0
*/
export interface ParsedMember {
readonly name: string
readonly signature: string | null
readonly range: readonly [number, number]
readonly description: ParsedDescription
readonly examples: ReadonlyArray<ParsedExample>
readonly tags: ParsedMemberTags
readonly members: ReadonlyArray<ParsedMember>
}
/**
* Parsed root declaration exported from a checked file.
*
* @category models
* @since 4.0.0
*/
export interface ParsedRootDeclaration {
readonly name: string
readonly bucket: ExportBucket
readonly signature: string | null
readonly range: readonly [number, number]
readonly description: ParsedDescription
readonly examples: ReadonlyArray<ParsedExample>
readonly tags: ParsedDeclarationTags
readonly members: ReadonlyArray<ParsedMember>
}
/**
* Parsed type declaration exported from inside a namespace.
*
* @category models
* @since 4.0.0
*/
export interface ParsedNamespaceDeclaration {
readonly name: string
readonly signature: string | null
readonly range: readonly [number, number]
readonly description: ParsedDescription
readonly examples: ReadonlyArray<ParsedExample>
readonly tags: ParsedNamespaceTags
readonly members: ReadonlyArray<ParsedMember>
}
/**
* Parsed namespace and its documented exported type declarations.
*
* @category models
* @since 4.0.0
*/
export interface ParsedNamespace {
readonly name: string
readonly signature: string | null
readonly range: readonly [number, number]
readonly description: ParsedDescription
readonly examples: ReadonlyArray<ParsedExample>
readonly tags: ParsedNamespaceTags
readonly declarations: ReadonlyArray<ParsedNamespaceDeclaration>
readonly namespaces: ReadonlyArray<ParsedNamespace>
}
/**
* Parsed public JSDoc data collected from one checked file.
*
* @category models
* @since 4.0.0
*/
export interface ParsedJSDocFile {
readonly moduleJSDoc?: ParsedModuleJSDoc
readonly declarations: ReadonlyArray<ParsedRootDeclaration>
readonly namespaces: ReadonlyArray<ParsedNamespace>
}
/**
* Barrel import metadata for a public module included in the JSDoc dump.
*
* @category models
* @since 4.0.0
*/
export type ParsedJSDocBarrelImport =
| {
readonly type: "namespace"
readonly module: string
readonly name: string
}
| {
readonly type: "flat"
readonly module: string
}
/**
* Import specifiers for a public module included in the JSDoc dump.
*
* @category models
* @since 4.0.0
*/
export interface ParsedJSDocImports {
readonly module: string
readonly barrel: ParsedJSDocBarrelImport | null
readonly flatNames: ReadonlyArray<string>
}
export type JSDocApiKind = "root-declaration" | "namespace" | "namespace-declaration" | "member"
export type JSDocApiImportGuidance =
| {
readonly style: "namespace-barrel"
readonly importDeclaration: string
readonly usage: string
}
| {
readonly style: "named-barrel"
readonly importDeclaration: string
readonly usage: string
}
| {
readonly style: "named-module"
readonly importDeclaration: string
readonly usage: string
}
export type JSDocApiSeeLinkResolution =
| {
readonly _tag: "Resolved"
readonly apiId: string
readonly apiFqn: string
}
| {
readonly _tag: "Unresolved"
readonly reason: "not-found" | "ambiguous"
readonly candidates: ReadonlyArray<string>
}
export interface JSDocApiSeeLink extends ParsedInlineLink {
readonly resolution: JSDocApiSeeLinkResolution
}
export interface JSDocApiSeeTag {
readonly text: string
readonly links: ReadonlyArray<JSDocApiSeeLink>
}
export interface JSDocApiTags {
readonly category: string | null
readonly since: string | null
readonly deprecated: string | null
readonly default: string | null
}
export interface JSDocApi {
readonly id: string
readonly kind: JSDocApiKind
readonly moduleName: string
readonly apiName: string
readonly localName: string
readonly signature: string | null
readonly parentApiName: string | null
readonly apiFqn: string
readonly hasFqnCollision: boolean
readonly bucket: ExportBucket | null
readonly importable: boolean
readonly namespacePath: ReadonlyArray<string>
readonly memberPath: ReadonlyArray<string>
readonly source: {
readonly file: string
readonly path: ReadonlyArray<string | number>
readonly range: readonly [number, number]
}
readonly description: ParsedDescription
readonly examples: ReadonlyArray<ParsedExample>
readonly tags: JSDocApiTags
readonly see: ReadonlyArray<JSDocApiSeeTag>
readonly importGuidance: JSDocApiImportGuidance | null
}
interface ParsedBarrelExports {
readonly namespace: ReadonlyMap<string, string>
readonly flat: ReadonlySet<string>
readonly named: ReadonlyMap<string, ReadonlySet<string>>
}
/**
* Parsed public JSDoc data paired with the normalized source file path.
*
* @category models
* @since 4.0.0
*/
export interface ParsedJSDocFileDumpEntry extends ParsedJSDocFile {
readonly file: string
readonly imports: ParsedJSDocImports
}
interface AstNode {
readonly type: string
readonly range: [number, number]
readonly [key: string]: any
}
interface JSDocTag {
readonly name: string
value: string
readonly line: number
}
interface JSDocBlock {
readonly range: [number, number]
readonly raw: string
readonly lines: Array<string>
readonly tags: Array<JSDocTag>
readonly parsed?: ParsedCoreJSDoc
readonly diagnostics: Array<JSDocDiagnostic>
readonly internal: boolean
}
interface ParsedCoreJSDoc {
readonly description: ParsedDescription
readonly examples: ReadonlyArray<ParsedExample>
readonly tags: ReadonlyArray<JSDocTag>
}
interface ProgramCacheEntry {
readonly program?: ts.Program
readonly error?: string
reported: boolean
}
interface PackageMetadata {
readonly root: string
readonly sourceRoot: string
readonly name: string
readonly exports: unknown
}
interface PackageExportMatch {
readonly key: string
readonly value: unknown
readonly star: string | null
readonly specificity: number
readonly prefixLength: number
}
interface ParsedConfigResult {
readonly parsed?: ts.ParsedCommandLine
readonly error?: string
}
const programCache = new Map<string, ProgramCacheEntry>()
const packageMetadataCache = new Map<string, Result<PackageMetadata, string>>()
const barrelExportCache = new Map<string, Result<ParsedBarrelExports, string>>()
const tagOrder = new Map([
["deprecated", 0],
["default", 1],
["see", 2],
["category", 3],
["since", 4]
])
const signatureTypeFormatFlags = ts.TypeFormatFlags.NoTruncation |
ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType
const stableSemverRegex = /^\d+\.\d+\.\d+$/
const urlRegex = /^https?:\/\//
function diagnostic(code: string, message: string): JSDocDiagnostic {
return { code, message }
}
function normalizePathName(filePath: string): string {
return filePath.replaceAll(path.sep, "/")
}
function normalizePackagePath(filePath: string): string {
return normalizePathName(filePath).replace(/^\.\//, "./")
}
function escapeRegExp(value: string): string {
return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&")
}
function globToRegExp(glob: string): RegExp {
let source = "^"
for (let index = 0; index < glob.length; index++) {
const char = glob[index]
const next = glob[index + 1]
if (char === "*") {
if (next === "*") {
source += ".*"
index++
} else {
source += "[^/]*"
}
} else if (char === "?") {
source += "[^/]"
} else {
source += escapeRegExp(char)
}
}
return new RegExp(`${source}$`)
}
/**
* Creates a predicate that checks whether a filename is included by the configured JSDoc globs.
*
* @category constructors
* @since 4.0.0
*/
export function createJSDocFileMatcher(options: {
readonly cwd: string
readonly include?: ReadonlyArray<string>
readonly exclude?: ReadonlyArray<string>
}): (filename: string) => boolean {
const include = options.include?.map(globToRegExp)
const exclude = options.exclude?.map(globToRegExp)
return (filename) => {
const normalizedFilename = normalizePathName(filename)
const relativeFilename = normalizePathName(path.relative(options.cwd, filename))
const matches = (regexp: RegExp) => regexp.test(normalizedFilename) || regexp.test(relativeFilename)
if (include !== undefined && !include.some(matches)) {
return false
}
if (exclude !== undefined && exclude.some(matches)) {
return false
}
return true
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function findPackageRoot(filename: string): string | undefined {
let directory = path.dirname(path.resolve(filename))
while (true) {
const packageJsonPath = path.join(directory, "package.json")
if (fs.existsSync(packageJsonPath)) {
return directory
}
const parent = path.dirname(directory)
if (parent === directory) {
return undefined
}
directory = parent
}
}
function readPackageMetadata(root: string): Result<PackageMetadata, string> {
const normalizedRoot = path.resolve(root)
const cached = packageMetadataCache.get(normalizedRoot)
if (cached !== undefined) {
return cached
}
const packageJsonPath = path.join(normalizedRoot, "package.json")
let parsed: unknown
try {
parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
} catch (error) {
const result = {
_tag: "Failure",
error: `unable to read package.json at ${packageJsonPath}: ${String(error)}`
} as const
packageMetadataCache.set(normalizedRoot, result)
return result
}
if (!isRecord(parsed)) {
const result = { _tag: "Failure", error: `package.json at ${packageJsonPath} must be an object` } as const
packageMetadataCache.set(normalizedRoot, result)
return result
}
if (typeof parsed.name !== "string" || parsed.name.length === 0) {
const result = {
_tag: "Failure",
error: `package.json at ${packageJsonPath} must include a non-empty name`
} as const
packageMetadataCache.set(normalizedRoot, result)
return result
}
if (parsed.exports === undefined) {
const result = { _tag: "Failure", error: `package.json at ${packageJsonPath} must include exports` } as const
packageMetadataCache.set(normalizedRoot, result)
return result
}
const result = {
_tag: "Success",
value: {
root: normalizedRoot,
sourceRoot: path.join(normalizedRoot, "src"),
name: parsed.name,
exports: parsed.exports
}
} as const
packageMetadataCache.set(normalizedRoot, result)
return result
}
function parseBarrelExports(indexPath: string): Result<ParsedBarrelExports, string> {
const normalizedIndexPath = path.resolve(indexPath)
const cached = barrelExportCache.get(normalizedIndexPath)
if (cached !== undefined) {
return cached
}
let source: string
try {
source = fs.readFileSync(normalizedIndexPath, "utf8")
} catch (error) {
const result = {
_tag: "Failure",
error: `unable to read barrel file ${normalizedIndexPath}: ${String(error)}`
} as const
barrelExportCache.set(normalizedIndexPath, result)
return result
}
const sourceFile = ts.createSourceFile(normalizedIndexPath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const namespace = new Map<string, string>()
const flat = new Set<string>()
const named = new Map<string, Set<string>>()
for (const statement of sourceFile.statements) {
if (
ts.isExportDeclaration(statement) &&
statement.moduleSpecifier !== undefined &&
ts.isStringLiteralLike(statement.moduleSpecifier)
) {
const moduleSpecifier = normalizePathName(statement.moduleSpecifier.text)
if (statement.exportClause === undefined) {
flat.add(moduleSpecifier)
} else if (ts.isNamespaceExport(statement.exportClause)) {
namespace.set(moduleSpecifier, statement.exportClause.name.text)
} else if (ts.isNamedExports(statement.exportClause)) {
const names = named.get(moduleSpecifier) ?? new Set<string>()
for (const specifier of statement.exportClause.elements) {
names.add(specifier.name.text)
}
named.set(moduleSpecifier, names)
}
}
}
const result = { _tag: "Success", value: { namespace, flat, named } } as const
barrelExportCache.set(normalizedIndexPath, result)
return result
}
function getPackageExportMatch(exports: unknown, subpath: string): PackageExportMatch | undefined {
if (!isRecord(exports)) {
return subpath === "." ? { key: ".", value: exports, star: null, specificity: 1, prefixLength: 1 } : undefined
}
if (Object.hasOwn(exports, subpath)) {
return {
key: subpath,
value: exports[subpath],
star: null,
specificity: Number.MAX_SAFE_INTEGER,
prefixLength: subpath.length
}
}
const matches: Array<PackageExportMatch> = []
for (const [key, value] of Object.entries(exports)) {
const starIndex = key.indexOf("*")
if (!key.startsWith(".") || starIndex === -1) {
continue
}
const prefix = key.slice(0, starIndex)
const suffix = key.slice(starIndex + 1)
if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) {
continue
}
matches.push({
key,
value,
star: subpath.slice(prefix.length, subpath.length - suffix.length),
specificity: prefix.length + suffix.length,
prefixLength: prefix.length
})
}
return matches.sort((a, b) =>
b.specificity - a.specificity || b.prefixLength - a.prefixLength || b.key.length - a.key.length
)[0]
}
function packageExportTargetMatches(value: unknown, star: string | null, expectedTarget: string): boolean {
if (typeof value === "string") {
const target = normalizePackagePath(star === null ? value : value.replaceAll("*", star))
return target === expectedTarget
}
if (value === null || value === undefined) {
return false
}
if (Array.isArray(value)) {
return value.some((item) => packageExportTargetMatches(item, star, expectedTarget))
}
if (isRecord(value)) {
return Object.values(value).some((item) => packageExportTargetMatches(item, star, expectedTarget))
}
return false
}
function isPackageSubpathExported(
packageExports: unknown,
subpath: string,
expectedTarget: string
): boolean {
const match = getPackageExportMatch(packageExports, subpath)
return match !== undefined && packageExportTargetMatches(match.value, match.star, expectedTarget)
}
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child)
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}
function packageSpecifier(packageName: string, subpath: string): string {
return subpath === "" ? packageName : `${packageName}/${subpath}`
}
function resolveBarrelImport(
metadata: PackageMetadata,
filename: string
): Result<{ readonly barrel: ParsedJSDocBarrelImport | null; readonly flatNames: ReadonlyArray<string> }, string> {
let directory = path.dirname(filename)
while (isPathInside(metadata.sourceRoot, directory)) {
const indexPath = path.join(directory, "index.ts")
if (indexPath !== filename && fs.existsSync(indexPath)) {
const expectedExport = `./${normalizePathName(path.relative(directory, filename))}`
const parsed = parseBarrelExports(indexPath)
if (parsed._tag === "Failure") {
return parsed
}
const barrelSubpath = normalizePathName(path.relative(metadata.sourceRoot, directory))
const exportSubpath = barrelSubpath === "" ? "." : `./${barrelSubpath}`
const exportTarget = barrelSubpath === "" ? "./src/index.ts" : `./src/${barrelSubpath}/index.ts`
if (isPackageSubpathExported(metadata.exports, exportSubpath, exportTarget)) {
const moduleSpecifier = packageSpecifier(metadata.name, barrelSubpath)
const flatNames = Array.from(parsed.value.named.get(expectedExport) ?? [])
const namespace = parsed.value.namespace.get(expectedExport)
if (namespace !== undefined) {
return {
_tag: "Success",
value: { barrel: { type: "namespace", module: moduleSpecifier, name: namespace }, flatNames }
}
}
if (parsed.value.flat.has(expectedExport)) {
return {
_tag: "Success",
value: { barrel: { type: "flat", module: moduleSpecifier }, flatNames }
}
}
if (flatNames.length > 0) {
return {
_tag: "Success",
value: { barrel: { type: "flat", module: moduleSpecifier }, flatNames }
}
}
}
}
if (directory === metadata.sourceRoot) {
break
}
directory = path.dirname(directory)
}
return { _tag: "Success", value: { barrel: null, flatNames: [] } }
}
function resolveJSDocImports(
cwd: string,
filename: string
): Result<ParsedJSDocImports, string> {
const absoluteFilename = path.resolve(filename)
const packageRoot = findPackageRoot(absoluteFilename)
const relativeFilename = normalizePathName(path.relative(cwd, absoluteFilename))
if (packageRoot === undefined) {
return { _tag: "Failure", error: `no package.json found for ${relativeFilename}` }
}
const metadata = readPackageMetadata(packageRoot)
if (metadata._tag === "Failure") {
return metadata
}
const relativeToSourceRoot = normalizePathName(path.relative(metadata.value.sourceRoot, absoluteFilename))
if (!isPathInside(metadata.value.sourceRoot, absoluteFilename)) {
return {
_tag: "Failure",
error: `${relativeFilename} is not under ${normalizePathName(path.relative(cwd, metadata.value.sourceRoot))}`
}
}
if (path.basename(absoluteFilename) === "index.ts") {
return {
_tag: "Failure",
error: `${relativeFilename} is a barrel file; exclude it from jsdocs`
}
}
if (path.extname(absoluteFilename) !== ".ts" || absoluteFilename.endsWith(".d.ts")) {
return {
_tag: "Failure",
error: `${relativeFilename} is not a TypeScript source module`
}
}
const moduleSubpath = relativeToSourceRoot.slice(0, -".ts".length)
const moduleExportSubpath = `./${moduleSubpath}`
const moduleExportTarget = `./src/${relativeToSourceRoot}`
if (!isPackageSubpathExported(metadata.value.exports, moduleExportSubpath, moduleExportTarget)) {
return {
_tag: "Failure",
error: `package.json exports do not expose ${
packageSpecifier(metadata.value.name, moduleSubpath)
} for ${relativeFilename}`
}
}
const barrel = resolveBarrelImport(metadata.value, absoluteFilename)
if (barrel._tag === "Failure") {
return barrel
}
return {
_tag: "Success",
value: {
module: packageSpecifier(metadata.value.name, moduleSubpath),
barrel: barrel.value.barrel,
flatNames: barrel.value.flatNames
}
}
}
export function getSourceText(context: {
readonly sourceCode: { readonly text?: string; getText(node?: unknown): string }
}): string {
return context.sourceCode.text ?? context.sourceCode.getText()
}
export function getCwd(context: { readonly cwd?: string; getCwd?: () => string }): string {
return context.cwd ?? context.getCwd?.() ?? process.cwd()
}
function skipWhitespace(source: string, end: number): number {
while (end > 0 && /\s/.test(source[end - 1])) {
end--
}
return end
}
function isSkippableDirectiveComment(line: string): boolean {
const trimmed = line.trim()
return trimmed.startsWith("// @ts-expect-error") ||
trimmed.startsWith("// @ts-ignore") ||
trimmed.startsWith("// @effect-diagnostics") ||
trimmed.startsWith("// eslint-disable-next-line") ||
trimmed.startsWith("// oxlint-disable-next-line")
}
function skipDirectiveComments(source: string, end: number): number {
while (end > 0) {
const lineStart = source.lastIndexOf("\n", end - 1) + 1
if (!isSkippableDirectiveComment(source.slice(lineStart, end))) {
return end
}
end = skipWhitespace(source, lineStart)
}
return end
}
export function findLeadingJSDoc(
source: string,
node: AstNode,
ignoredRange?: [number, number]
): JSDocBlock | undefined {
const end = skipDirectiveComments(source, skipWhitespace(source, node.range[0]))
if (!source.slice(0, end).endsWith("*/")) {
return undefined
}
const start = source.lastIndexOf("/*", end)
if (start === -1 || source[start + 2] !== "*") {
return undefined
}
const range: [number, number] = [start, end]
if (ignoredRange !== undefined && range[0] === ignoredRange[0] && range[1] === ignoredRange[1]) {
return undefined
}
return parseJSDocBlock(source.slice(start, end), range)
}
/**
* Parses one raw JSDoc block into the standard description, example, and tag structure.
*
* **Details**
*
* The block must have one short description paragraph, optional `When to use`, `Details`, and `Gotchas` sections in that order, then any example sections before tags. Example titles must be unique after trimming and lowercasing, each example must contain exactly one non-empty TypeScript code fence, and boundaries between sections, examples, and tags must use exactly one blank line.
*
* **Example** (Parsing a block)
*
* ```ts
* const rawBlock = [
* "/" + "**",
* " * A value.",
* " *",
* " * @category models",
* " * @since 4.0.0",
* " *" + "/"
* ].join("\n")
* const result = parseJSDoc(rawBlock)
* ```
*
* @category parsing
* @since 4.0.0
*/
export function parseJSDoc(raw: string): Result<ParsedCoreJSDoc, JSDocParseError> {
const block = parseJSDocBlock(raw, [0, raw.length])
if (block.internal) {
return { _tag: "Failure", error: { diagnostics: [diagnostic("internal", "Internal JSDoc blocks are ignored")] } }
}
if (block.diagnostics.length > 0 || block.parsed === undefined) {
return { _tag: "Failure", error: { diagnostics: block.diagnostics } }
}
return { _tag: "Success", value: block.parsed }
}
function parseLooseJSDocBlock(raw: string, range: [number, number]): JSDocBlock {
const body = raw.replace(/^\/\*\*/, "").replace(/\*\/$/, "")
const rawLines = body.split(/\r\n|\r|\n/)
const lines = rawLines.map((line) => line.replace(/^\s*\* ?/, "").trimEnd())
const normalized = trimStructuralBlankLines(lines)
const tags = parseTags(normalized)
const internal = tags.some((tag) => tag.name === "internal")
return { range, raw, lines: normalized, tags, diagnostics: [], internal }
}
function parseJSDocBlock(raw: string, range: [number, number]): JSDocBlock {
const base = parseLooseJSDocBlock(raw, range)
const { lines: normalized, tags, internal } = base
if (internal) {
return base
}
const result = parseCoreJSDoc(normalized, tags)
const block: JSDocBlock = {
...base,
range,
raw,
lines: normalized,
tags,
diagnostics: result._tag === "Failure" ? Array.from(result.error.diagnostics) : [],
internal
}
return result._tag === "Success" ? { ...block, parsed: result.value } : block
}
function trimStructuralBlankLines(lines: Array<string>): Array<string> {
let start = 0
let end = lines.length
if (lines[start]?.trim() === "") {
start++
}
if (end > start && lines[end - 1]?.trim() === "") {
end--
}
return lines.slice(start, end)
}
function parseTags(lines: Array<string>): Array<JSDocTag> {
const tags: Array<JSDocTag> = []
let current: JSDocTag | undefined
let inFence = false
for (let index = 0; index < lines.length; index++) {
const trimmed = lines[index].trim()
if (trimmed.startsWith("```")) {
inFence = !inFence
current = undefined
continue
}
if (inFence) {
continue
}
const match = /^@([A-Za-z][\w-]*)(?:\s+(.*))?$/.exec(trimmed)
if (match) {
current = { name: match[1], value: match[2]?.trim() ?? "", line: index }
tags.push(current)
continue
}
if (current !== undefined && trimmed !== "") {
current.value = current.value === "" ? trimmed : `${current.value}\n${trimmed}`
} else if (trimmed === "") {
current = undefined
}
}
return tags
}
function parseCoreJSDoc(lines: Array<string>, tags: Array<JSDocTag>): Result<ParsedCoreJSDoc, JSDocParseError> {
const diagnostics: Array<JSDocDiagnostic> = []
const firstTagLine = tags[0]?.line ?? lines.length
const content = lines.slice(0, firstTagLine)
if (lines.length === 0) {
diagnostics.push(diagnostic("missing-description", "JSDoc must include a short description"))
}
if (content.at(-1)?.trim() === "" && tags.length > 0) {
// exactly one blank before tags is validated by ensuring the last content line is blank and the previous is not blank
if (content.length < 2 || content[content.length - 2]?.trim() === "") {
diagnostics.push(
diagnostic("invalid-spacing", "JSDoc tags must be separated from description content by exactly one blank line")
)
}
} else if (tags.length > 0 && content.length > 0) {
diagnostics.push(
diagnostic("invalid-spacing", "JSDoc tags must be separated from description content by exactly one blank line")
)
}
if (content[0]?.trim() === "") {
diagnostics.push(diagnostic("leading-blank", "JSDoc must not start with a blank line"))
}
const contentWithoutTagBlank = tags.length > 0 && content.at(-1)?.trim() === "" ? content.slice(0, -1) : content
if (contentWithoutTagBlank.at(-1)?.trim() === "") {
diagnostics.push(diagnostic("trailing-blank", "JSDoc description must not end with a blank line"))
}
const parsedContent = parseDescriptionContent(contentWithoutTagBlank)
diagnostics.push(...parsedContent.diagnostics)
if (diagnostics.length > 0 || parsedContent.value === undefined) {
return { _tag: "Failure", error: { diagnostics: uniqueDiagnostics(diagnostics) } }
}
return {
_tag: "Success",
value: {
description: parsedContent.value.description,
examples: parsedContent.value.examples,
tags
}
}
}
function uniqueDiagnostics(
diagnostics: ReadonlyArray<JSDocDiagnostic>
): ReadonlyArray<JSDocDiagnostic> {
const seen = new Set<string>()
return diagnostics.filter((item) => {
const key = `${item.code}:${item.message}`
if (seen.has(key)) {
return false
}
seen.add(key)
return true
})
}
const standardHeadings = ["**When to use**", "**Details**", "**Gotchas**"] as const
type StandardHeading = typeof standardHeadings[number]
const whenToUsePrefixes = ["Use to", "Use when", "Use as", "Use with"] as const
interface ParsedContentResult {
readonly value?: {
readonly description: ParsedDescription
readonly examples: ReadonlyArray<ParsedExample>
}
readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
}
function parseDescriptionContent(lines: Array<string>): ParsedContentResult {
const diagnostics: Array<JSDocDiagnostic> = []
const sections: Record<StandardHeading, string | null> = {
"**When to use**": null,
"**Details**": null,
"**Gotchas**": null
}
const examples: Array<ParsedExample> = []
const exampleTitles = new Set<string>()
let index = 0
let currentSectionOrder = -1
let examplesStarted = false
while (index < lines.length && lines[index].trim() !== "" && !isHeadingLine(lines[index])) {
if (isForbiddenMarkdownHeading(lines[index], false)) {
diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions"))
}
index++
}
const shortLines = lines.slice(0, index)
if (shortLines.length === 0 || joinBody(shortLines).trim() === "") {
diagnostics.push(diagnostic("missing-description", "JSDoc must include a short description"))
}
if (index < lines.length && lines[index].trim() === "") {
const next = lines[index + 1]
if (next !== undefined && !isHeadingLine(next)) {
diagnostics.push(diagnostic("multiple-description-paragraphs", "JSDoc short description must be one paragraph"))
}
}
while (index < lines.length) {
if (lines[index].trim() !== "") {
diagnostics.push(diagnostic("invalid-spacing", "JSDoc sections must be separated by exactly one blank line"))
break
}
if (lines[index + 1]?.trim() === "") {
diagnostics.push(diagnostic("invalid-spacing", "JSDoc sections must be separated by exactly one blank line"))
while (lines[index + 1]?.trim() === "") index++
}
index++
if (index >= lines.length) {
break
}
const line = lines[index].trim()
if (line.startsWith("**Example**")) {
examplesStarted = true
const parsed = parseExample(lines, index)
diagnostics.push(...parsed.diagnostics)
if (parsed.example !== undefined) {
const key = parsed.example.title.trim().toLowerCase()
if (exampleTitles.has(key)) {
diagnostics.push(diagnostic("duplicate-example", `Duplicate example title: ${parsed.example.title.trim()}`))
}
exampleTitles.add(key)
examples.push(parsed.example)
}
index = parsed.nextIndex
continue
}
const heading = standardHeadings.find((candidate) => candidate === line)
if (heading !== undefined) {
if (examplesStarted) {
diagnostics.push(diagnostic("section-after-example", `${heading} must appear before examples`))
}
const order = standardHeadings.indexOf(heading)
if (order <= currentSectionOrder) {
diagnostics.push(diagnostic("section-out-of-order", `${heading} is out of order or duplicated`))
}
currentSectionOrder = Math.max(currentSectionOrder, order)
if (sections[heading] !== null) {
diagnostics.push(diagnostic("duplicate-section", `${heading} may appear at most once`))
}
const parsed = parseSection(lines, index)
diagnostics.push(...parsed.diagnostics)
sections[heading] = parsed.body
index = parsed.nextIndex
continue
}
if (isNearMissHeading(line)) {
diagnostics.push(diagnostic("invalid-heading", `Invalid JSDoc section heading: ${line}`))
index++
continue
}
if (isBoldOnlyLine(line)) {
diagnostics.push(diagnostic("unknown-heading", `Unknown JSDoc section heading: ${line}`))
index++
continue
}
if (isForbiddenMarkdownHeading(line, false)) {
diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions"))
index++
continue
}
diagnostics.push(
diagnostic("invalid-description", "JSDoc description content must appear under a standard section heading")
)
index++
}
const whenToUse = sections["**When to use**"]
if (whenToUse !== null && !hasWhenToUsePrefix(whenToUse)) {
diagnostics.push(
diagnostic(
"when-to-use-format",
`**When to use** must start with one of: ${whenToUsePrefixes.map((prefix) => `\`${prefix}\``).join(", ")}`
)
)
}
if (diagnostics.length > 0) {
return { diagnostics }
}
return {
value: {
description: {
short: joinBody(shortLines),
whenToUse: sections["**When to use**"],
details: sections["**Details**"],
gotchas: sections["**Gotchas**"]
},
examples
},
diagnostics
}
}
function hasWhenToUsePrefix(text: string): boolean {
const trimmed = text.trimStart()
return whenToUsePrefixes.some((prefix) => trimmed === prefix || trimmed.startsWith(`${prefix} `))
}
function parseSection(lines: Array<string>, headingIndex: number): {
readonly body: string | null
readonly nextIndex: number
readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
} {
const diagnostics: Array<JSDocDiagnostic> = []
if (lines[headingIndex + 1]?.trim() !== "") {
diagnostics.push(
diagnostic("invalid-spacing", `${lines[headingIndex].trim()} must be followed by exactly one blank line`)
)
}
let index = headingIndex + 2
const bodyStart = index
let inFence = false
while (index < lines.length) {
const trimmed = lines[index].trim()
if (trimmed.startsWith("```")) {
if (trimmed === "```ts") {
diagnostics.push(diagnostic("loose-ts-fence", "TypeScript examples must use **Example** (Title) sections"))
}
inFence = !inFence
}
if (!inFence && trimmed === "" && isHeadingLine(lines[index + 1])) {
break
}
if (!inFence && trimmed !== "" && isForbiddenMarkdownHeading(trimmed, false)) {
diagnostics.push(diagnostic("invalid-heading", "Markdown headings are not allowed in JSDoc descriptions"))
}
if (
!inFence && isBoldOnlyLine(trimmed) && !standardHeadings.includes(trimmed as StandardHeading) &&
!trimmed.startsWith("**Example**")
) {
diagnostics.push(diagnostic("unknown-heading", `Unknown JSDoc section heading: ${trimmed}`))
}
index++
}
const bodyLines = lines.slice(bodyStart, index)
if (joinBody(bodyLines).trim() === "") {
diagnostics.push(diagnostic("empty-section", `${lines[headingIndex].trim()} must have a non-empty body`))
}
if (bodyLines.at(-1)?.trim() === "") {
diagnostics.push(diagnostic("invalid-spacing", "Section bodies must not end with extra blank lines"))
}
return { body: joinBody(bodyLines), nextIndex: index, diagnostics }
}
function parseExample(lines: Array<string>, headingIndex: number): {
readonly example?: ParsedExample
readonly nextIndex: number
readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
} {
const diagnostics: Array<JSDocDiagnostic> = []
const heading = lines[headingIndex].trim()
const match = /^\*\*Example\*\* \((.+)\)$/.exec(heading)
if (!match || match[1].trim() === "") {
diagnostics.push(diagnostic("malformed-example", "TypeScript examples must use **Example** (Title)"))
}
if (lines[headingIndex + 1]?.trim() !== "") {
diagnostics.push(diagnostic("invalid-spacing", "Example headings must be followed by exactly one blank line"))
}
let index = headingIndex + 2
const bodyStart = index
let fenceIndex = -1
while (index < lines.length) {
const trimmed = lines[index].trim()
if (trimmed === "```ts") {
fenceIndex = index
break
}
if (trimmed.startsWith("```")) {
diagnostics.push(diagnostic("malformed-example", "Examples may only contain one TypeScript code fence"))
}
if (trimmed === "" && isHeadingLine(lines[index + 1])) {
break
}
if (trimmed.startsWith("@")) {
break
}
index++
}
if (fenceIndex === -1) {
diagnostics.push(diagnostic("malformed-example", "Examples must include a non-empty ```ts fence"))
return { nextIndex: index, diagnostics }
}
const bodyLines = lines.slice(bodyStart, fenceIndex)
const nonEmptyBody = joinBody(bodyLines).trim() !== ""
if (nonEmptyBody && bodyLines.at(-1)?.trim() !== "") {
diagnostics.push(
diagnostic("invalid-spacing", "Example prose must be separated from code by exactly one blank line")
)
}
const prose = nonEmptyBody ? joinBody(bodyLines.slice(0, bodyLines.at(-1)?.trim() === "" ? -1 : undefined)) : null
index = fenceIndex + 1
const codeStart = index
while (index < lines.length && lines[index].trim() !== "```") {
if (lines[index].trim() === "```ts") {
diagnostics.push(diagnostic("malformed-example", "Examples must contain exactly one TypeScript code fence"))
}
index++
}
const codeLines = lines.slice(codeStart, index)
if (index >= lines.length) {
diagnostics.push(diagnostic("malformed-example", "Examples must close the TypeScript code fence"))
}
if (joinBody(codeLines).trim() === "") {
diagnostics.push(diagnostic("malformed-example", "Examples must include non-empty TypeScript code"))
}
index++
if (index < lines.length && lines[index].trim() !== "" && !lines[index].trim().startsWith("@")) {
diagnostics.push(
diagnostic("invalid-spacing", "Examples must be separated from following content by exactly one blank line")
)
}
const result = { nextIndex: index, diagnostics }
return match
? { ...result, example: { title: match[1].trim(), body: prose, code: joinBody(codeLines) } }
: result
}
function parseModuleExamples(block: JSDocBlock): {
readonly examples: ReadonlyArray<ParsedExample>
readonly diagnostics: ReadonlyArray<JSDocDiagnostic>
} {
const diagnostics: Array<JSDocDiagnostic> = []
const examples: Array<ParsedExample> = []
const exampleTitles = new Set<string>()
const firstTagLine = block.tags[0]?.line ?? block.lines.length
const lines = block.lines.slice(0, firstTagLine)
let index = 0
while (index < lines.length) {
const trimmed = lines[index].trim()
if (trimmed.startsWith("**Example**")) {
const parsed = parseExample(lines, index)
diagnostics.push(...parsed.diagnostics)
if (parsed.example !== undefined) {
const key = parsed.example.title.trim().toLowerCase()
if (exampleTitles.has(key)) {
diagnostics.push(diagnostic("duplicate-example", `Duplicate example title: ${parsed.example.title.trim()}`))
}
exampleTitles.add(key)
examples.push(parsed.example)
}
index = Math.max(parsed.nextIndex, index + 1)
continue
}
if (trimmed.startsWith("```")) {
diagnostics.push(
diagnostic("loose-ts-fence", "Code fences in module JSDoc must use **Example** (Title) sections")
)
index++
while (index < lines.length && !lines[index].trim().startsWith("```")) index++
if (index < lines.length) index++
continue
}
index++
}
return { examples, diagnostics }
}
function joinBody(lines: ReadonlyArray<string>): string {
let start = 0
let end = lines.length
while (start < end && lines[start]?.trim() === "") start++
while (end > start && lines[end - 1]?.trim() === "") end--
return lines.slice(start, end).join("\n")
}
function isHeadingLine(line: string | undefined): boolean {
if (line === undefined) return false
const trimmed = line.trim()
return standardHeadings.includes(trimmed as StandardHeading) || trimmed.startsWith("**Example**") ||
isNearMissHeading(trimmed) || isBoldOnlyLine(trimmed) || isForbiddenMarkdownHeading(trimmed, false)
}
function isNearMissHeading(line: string): boolean {
return /^\*\*(When to use|When To Use|Details|Gotchas).*\*\*/.test(line) &&
!standardHeadings.includes(line as StandardHeading)
}
function isBoldOnlyLine(line: string): boolean {
return /^\*\*[^*]+\*\*(?:\s*)$/.test(line)
}
function isForbiddenMarkdownHeading(line: string, inFence: boolean): boolean {
return !inFence && /^#{1,6}\s+/.test(line.trim())
}
function parseSeeTag(value: string): ParsedSeeTag {
return { text: value, links: extractParsedInlineLinks(value) }
}
function extractParsedInlineLinks(source: string): ReadonlyArray<ParsedInlineLink> {
const links: Array<ParsedInlineLink> = []
let index = source.indexOf("{@link")
while (index !== -1) {
if (!isInlineLinkStart(source, index)) {
index = source.indexOf("{@link", index + 1)
continue
}
const end = source.indexOf("}", index + "{@link".length)
if (end === -1) {
break
}
const raw = source.slice(index, end + 1)
const content = raw.slice("{@link".length, -1).trim()
const pipeIndex = content.indexOf("|")
const beforeLabel = pipeIndex === -1 ? content : content.slice(0, pipeIndex).trim()
const target = beforeLabel.split(/\s+/)[0] ?? ""
const text = pipeIndex === -1
? beforeLabel.slice(target.length).trim() || null
: content.slice(pipeIndex + 1).trim() || null
links.push({ raw, target, text })
index = source.indexOf("{@link", end + 1)
}
return links
}
function buildTags(
scope: DocScope,
tags: ReadonlyArray<JSDocTag>
): Result<ParsedModuleTags | ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags, JSDocParseError> {
const diagnostics: Array<JSDocDiagnostic> = []
const allowed = scope === "declaration"
? new Set(["deprecated", "see", "category", "since"])
: scope === "member"
? new Set(["deprecated", "default", "see", "since"])
: scope === "module"
? new Set(["deprecated", "see", "since"])
: new Set(["deprecated", "see", "category", "since"])
let previousOrder = -1
const values = new Map<string, Array<string>>()
for (const tag of tags) {
if (tag.name === "internal") {
continue
}
if (tag.name === "example") {
diagnostics.push(
diagnostic("forbidden-tag", "@example is not allowed; use a canonical **Example** (Title) section")
)
continue
}
if (!allowed.has(tag.name)) {
diagnostics.push(
diagnostic(
"forbidden-tag",
`@${tag.name} is not allowed in ${scope === "namespace-declaration" ? "namespace" : scope} JSDoc`
)
)
continue
}
const order = tagOrder.get(tag.name) ?? previousOrder
if (order < previousOrder) {
diagnostics.push(diagnostic("tag-out-of-order", `@${tag.name} is out of order in JSDoc`))
}
previousOrder = Math.max(previousOrder, order)
values.set(tag.name, [...values.get(tag.name) ?? [], tag.value.trim()])
}
const singletonTags = scope === "member" ? ["deprecated", "default", "since"] : ["deprecated", "category", "since"]
for (const tag of singletonTags) {
if ((values.get(tag)?.length ?? 0) > 1) {
diagnostics.push(diagnostic("duplicate-tag", `JSDoc blocks may contain at most one @${tag} tag`))
}
}
const see = values.get("see") ?? []
for (const value of see) {
if (value === "") {
diagnostics.push(diagnostic("empty-tag", "@see must include a value"))
}
}
const deprecated = values.get("deprecated")?.[0] ?? null
if (deprecated === "") diagnostics.push(diagnostic("empty-tag", "@deprecated must include a message"))
const since = values.get("since")?.[0] ?? null
if ((scope === "declaration" || scope === "namespace" || scope === "namespace-declaration") && since === null) {
diagnostics.push(
diagnostic(
"missing-tag",
scope === "declaration" ? "Public JSDoc must include @since" : "Namespace JSDoc must include @since"
)
)
}
if (scope === "module" && since === null) {
diagnostics.push(diagnostic("missing-tag", "Module JSDoc must include @since"))
}
if (since !== null && !stableSemverRegex.test(since)) {
diagnostics.push(diagnostic("invalid-since", "@since must be a stable semver version like 1.2.3"))
}
if (scope === "declaration") {
const category = values.get("category")?.[0] ?? null
if (category === null) diagnostics.push(diagnostic("missing-tag", "Public JSDoc must include @category"))
if (category === "") diagnostics.push(diagnostic("empty-tag", "@category must include a value"))
if (diagnostics.length > 0 || category === null || since === null) {
return { _tag: "Failure", error: { diagnostics } }
}
return { _tag: "Success", value: { category, since, deprecated, see: see.map(parseSeeTag) } }
}
if (scope === "member") {
const defaultValue = values.get("default")?.[0] ?? null
if (defaultValue === "") diagnostics.push(diagnostic("empty-tag", "@default must include a value"))
if (diagnostics.length > 0) return { _tag: "Failure", error: { diagnostics } }
return { _tag: "Success", value: { since, default: defaultValue, deprecated, see: see.map(parseSeeTag) } }
}
if (scope === "module") {
if (diagnostics.length > 0 || since === null) return { _tag: "Failure", error: { diagnostics } }
return { _tag: "Success", value: { since, deprecated, see: see.map(parseSeeTag) } }
}
const category = values.get("category")?.[0] ?? null
if (category === "") diagnostics.push(diagnostic("empty-tag", "@category must include a value"))
if (diagnostics.length > 0 || since === null) return { _tag: "Failure", error: { diagnostics } }
return { _tag: "Success", value: { category, since, deprecated, see: see.map(parseSeeTag) } }
}
function formatDiagnostic(diagnostic: ts.Diagnostic): string {
return ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")
}
function parseTsConfig(tsconfigPath: string): ParsedConfigResult {
const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile)
if (config.error !== undefined) {
return { error: `Unable to read TypeScript config ${tsconfigPath}: ${formatDiagnostic(config.error)}` }
}
const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, path.dirname(tsconfigPath))
if (parsed.errors.length > 0) {
return {
error: `Unable to parse TypeScript config ${tsconfigPath}: ${parsed.errors.map(formatDiagnostic).join("; ")}`
}
}
return { parsed }
}
function collectTsConfigFiles(tsconfigPath: string, seen: Set<string>, fileNames: Set<string>): ParsedConfigResult {
if (seen.has(tsconfigPath)) return {}
seen.add(tsconfigPath)
const result = parseTsConfig(tsconfigPath)
if (result.error !== undefined || result.parsed === undefined) return result
for (const fileName of result.parsed.fileNames) fileNames.add(fileName)
for (const reference of result.parsed.projectReferences ?? []) {
const referenceResult = collectTsConfigFiles(ts.resolveProjectReferencePath(reference), seen, fileNames)
if (referenceResult.error !== undefined) return referenceResult
}
return result
}
export function getProgram(tsconfigPath: string): ProgramCacheEntry {
const cached = programCache.get(tsconfigPath)
if (cached !== undefined) return cached
const fileNames = new Set<string>()
const result = collectTsConfigFiles(tsconfigPath, new Set(), fileNames)
if (result.error !== undefined || result.parsed === undefined) {
const entry = { error: result.error ?? `Unable to parse TypeScript config ${tsconfigPath}`, reported: false }
programCache.set(tsconfigPath, entry)
return entry
}
const program = ts.createProgram(Array.from(fileNames), result.parsed.options)
const entry = { program, reported: false }
programCache.set(tsconfigPath, entry)
return entry
}
function resolveJSDocLinkSymbol(
link: ts.JSDocLink,
checker: ts.TypeChecker
): ts.Symbol | undefined {
return link.name === undefined ? undefined : checker.getSymbolAtLocation(link.name)
}
function isInlineLinkStart(source: string, index: number): boolean {
if (!source.startsWith("{@link", index)) return false
const next = source[index + "{@link".length]
return next === undefined || /\s|}/.test(next)
}
function hasInlineLink(block: JSDocBlock): boolean {
return block.lines.some((line) => {
let index = line.indexOf("{@link")
while (index !== -1) {
if (isInlineLinkStart(line, index)) return true
index = line.indexOf("{@link", index + 1)
}
return false
})
}
function extractInlineLinkTarget(linkText: string): string {
const content = linkText.slice("{@link".length, linkText.endsWith("}") ? -1 : undefined).trim()
const pipeIndex = content.indexOf("|")
const beforeLabel = pipeIndex === -1 ? content : content.slice(0, pipeIndex).trim()
return beforeLabel.split(/\s+/)[0] ?? ""
}
function malformedInlineLinks(block: JSDocBlock): Array<string> {
const source = block.lines.join("\n")
const malformed: Array<string> = []
let index = source.indexOf("{@link")
while (index !== -1) {
if (!isInlineLinkStart(source, index)) {
index = source.indexOf("{@link", index + 1)
continue
}
const end = source.indexOf("}", index + "{@link".length)
const text = end === -1 ? source.slice(index).split(/\r?\n/, 1)[0] : source.slice(index, end + 1)
if (end === -1 || extractInlineLinkTarget(text) === "") malformed.push(text)
index = source.indexOf("{@link", end === -1 ? index + 1 : end + 1)
}
return malformed
}
function collectJSDocLinks(sourceFile: ts.SourceFile, block: JSDocBlock): Array<ts.JSDocLink> {
const links: Array<ts.JSDocLink> = []
function inspectJSDoc(node: ts.Node) {
for (const jsdoc of (node as { readonly jsDoc?: ReadonlyArray<ts.JSDoc> }).jsDoc ?? []) {
if (jsdoc.pos !== block.range[0] || jsdoc.end !== block.range[1]) continue
ts.forEachChild(jsdoc, function visit(child) {
if (ts.isJSDocLink(child)) links.push(child)
ts.forEachChild(child, visit)
})
}
}
function visit(node: ts.Node) {
inspectJSDoc(node)
ts.forEachChild(node, visit)
}
visit(sourceFile)
return links
}
function collectJSDocSeeLinks(sourceFile: ts.SourceFile, block: JSDocBlock): Array<ts.JSDocLink> {
const links: Array<ts.JSDocLink> = []
function inspectJSDoc(node: ts.Node) {
for (const jsdoc of (node as { readonly jsDoc?: ReadonlyArray<ts.JSDoc> }).jsDoc ?? []) {
if (jsdoc.pos !== block.range[0] || jsdoc.end !== block.range[1]) continue
ts.forEachChild(jsdoc, function visit(child) {
if (ts.isJSDocSeeTag(child)) {
ts.forEachChild(child, function visitSeeTag(seeTagChild) {
if (ts.isJSDocLink(seeTagChild)) links.push(seeTagChild)
ts.forEachChild(seeTagChild, visitSeeTag)
})
return
}
ts.forEachChild(child, visit)
})
}
}
function visit(node: ts.Node) {
inspectJSDoc(node)
ts.forEachChild(node, visit)
}
visit(sourceFile)
return links
}
function resolvedSymbolTarget(symbol: ts.Symbol, checker: ts.TypeChecker): ts.Symbol {
return (symbol.flags & ts.SymbolFlags.Alias) === 0 ? symbol : checker.getAliasedSymbol(symbol)
}
function isLocalSourceFile(cwd: string, sourceFile: ts.SourceFile): boolean {
if (sourceFile.isDeclarationFile) return false
const relative = path.relative(cwd, sourceFile.fileName)
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
}
function symbolDeclarations(symbol: ts.Symbol, checker: ts.TypeChecker): ReadonlyArray<ts.Declaration> {
const target = resolvedSymbolTarget(symbol, checker)
const declarations = new Set<ts.Declaration>()
if (target.valueDeclaration !== undefined) declarations.add(target.valueDeclaration)
for (const declaration of target.declarations ?? []) declarations.add(declaration)
return Array.from(declarations)
}
function symbolHasPublicJSDoc(symbol: ts.Symbol, checker: ts.TypeChecker): boolean {
return symbolDeclarations(symbol, checker).some((declaration) => {
const block = getNodeJSDoc(declaration)
return block !== undefined && !block.internal && block.parsed !== undefined
})
}
function isMemberDeclaration(declaration: ts.Declaration): boolean {
return ts.isPropertySignature(declaration) || ts.isMethodSignature(declaration) ||
ts.isPropertyDeclaration(declaration) || ts.isMethodDeclaration(declaration) ||
ts.isGetAccessorDeclaration(declaration) || ts.isSetAccessorDeclaration(declaration)
}
function undocumentedSeeLinkTarget(
symbol: ts.Symbol,
checker: ts.TypeChecker,
cwd: string
): ts.Declaration | undefined {
const localDeclarations = symbolDeclarations(symbol, checker).filter((declaration) =>
isMemberDeclaration(declaration) && isLocalSourceFile(cwd, declaration.getSourceFile())
)
if (localDeclarations.length === 0 || symbolHasPublicJSDoc(symbol, checker)) return undefined
return localDeclarations[0]
}
function inlineLinkSymbol(
symbol: ts.Symbol,
cwd: string,
checker: ts.TypeChecker
): ParsedInlineLinkSymbol | undefined {
const target = resolvedSymbolTarget(symbol, checker)
const declaration = target.valueDeclaration ?? target.declarations?.[0]
if (declaration === undefined) return undefined
return {
file: normalizeFile(cwd, declaration.getSourceFile().fileName),
range: nodeRange(declaration)
}
}
function attachSeeLinkSymbolsFromSourceFile<
T extends ParsedModuleTags | ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags
>(
tags: T,
sourceFile: ts.SourceFile,
block: JSDocBlock,
linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
): T {
const resolved = new Map<
string,
Array<{ readonly range: readonly [number, number]; readonly symbol?: ParsedInlineLinkSymbol }>
>()
for (const link of collectJSDocLinks(sourceFile, block)) {
const text = link.getText(sourceFile)
const target = extractInlineLinkTarget(text)
if (target === "" || urlRegex.test(target)) continue
const symbol = resolveJSDocLinkSymbol(link, linkContext.checker)
const info = symbol === undefined ? undefined : inlineLinkSymbol(symbol, linkContext.cwd, linkContext.checker)
const items = resolved.get(text) ?? []
items.push(info === undefined ? { range: [link.pos, link.end] } : { range: [link.pos, link.end], symbol: info })
resolved.set(text, items)
}
return {
...tags,
see: tags.see.map((tag) => ({
...tag,
links: tag.links.map((link) => {
const items = resolved.get(link.raw)
const info = items?.shift()
if (info === undefined) return link
return info.symbol === undefined
? { ...link, range: info.range }
: { ...link, range: info.range, symbol: info.symbol }
})
}))
}
}
function attachSeeLinkSymbols<T extends ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags>(
tags: T,
node: ts.Node,
block: JSDocBlock,
linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
): T {
return attachSeeLinkSymbolsFromSourceFile(tags, node.getSourceFile(), block, linkContext)
}
export interface JSDocModelDiagnostic extends JSDocDiagnostic {
readonly range: readonly [number, number]
}
export interface JSDocModelFile extends ParsedJSDocFile {
readonly file: string
readonly hash: string
readonly diagnostics: ReadonlyArray<JSDocModelDiagnostic>
readonly imports?: ParsedJSDocImports
}
export interface JSDocModel {
readonly version: 2
readonly generatedBy: "@effect/jsdocs"
readonly generatedAt: string
readonly inputHash?: string
readonly files: ReadonlyArray<JSDocModelFile>
readonly apis: ReadonlyArray<JSDocApi>
}
export interface JSDocConfig {
readonly tsconfig: string
readonly include: ReadonlyArray<string>
readonly exclude?: ReadonlyArray<string>
readonly output: string
}
export interface ExtractJSDocsOptions extends JSDocConfig {
readonly cwd?: string
}
function addInputFile(files: Set<string>, filename: string) {
const normalized = path.resolve(filename)
if (fs.existsSync(normalized) && fs.statSync(normalized).isFile()) {
files.add(normalized)
}
}
export function computeJSDocInputHash(options: ExtractJSDocsOptions): string {
const cwd = path.resolve(options.cwd ?? process.cwd())
const hash = crypto.createHash("sha256")
const files = new Set<string>()
hash.update(JSON.stringify({
tsconfig: options.tsconfig,
include: options.include,
exclude: options.exclude ?? [],
output: options.output
}))
addInputFile(files, path.join(cwd, "jsdocs.config.json"))
addInputFile(files, path.resolve(cwd, options.tsconfig))
for (
const filename of globSync([...options.include], {
cwd,
absolute: true,
nodir: true,
ignore: ["**/node_modules/**"]
})
) {
addInputFile(files, filename)
}
for (
const filename of globSync([
"package.json",
"packages/**/package.json",
"tsconfig*.json",
"packages/**/tsconfig*.json"
], {
cwd,
absolute: true,
nodir: true,
ignore: ["**/node_modules/**"]
})
) {
addInputFile(files, filename)
}
for (const filename of Array.from(files).sort()) {
hash.update("\0")
hash.update(normalizeFile(cwd, filename))
hash.update("\0")
hash.update(hashSource(fs.readFileSync(filename, "utf8")))
}
return hash.digest("hex")
}
function isIdentifierName(value: string): boolean {
return /^[$A-Z_a-z][$\w]*$/.test(value)
}
function renderInlineLinks(text: string): string {
return text.replace(/\{@link\s+([^}]+)\}/g, (_match, content: string) => {
const pipeIndex = content.indexOf("|")
const beforeLabel = pipeIndex === -1 ? content.trim() : content.slice(0, pipeIndex).trim()
const target = beforeLabel.split(/\s+/)[0] ?? ""
const label = pipeIndex === -1
? beforeLabel.slice(target.length).trim() || target
: content.slice(pipeIndex + 1).trim() || target
return `\`${label.replace(/:var$/, "")}\``
})
}
function renderDescription(description: ParsedDescription): ParsedDescription {
return {
short: renderInlineLinks(description.short),
whenToUse: description.whenToUse === null ? null : renderInlineLinks(description.whenToUse),
details: description.details === null ? null : renderInlineLinks(description.details),
gotchas: description.gotchas === null ? null : renderInlineLinks(description.gotchas)
}
}
function renderExamples(examples: ReadonlyArray<ParsedExample>): ReadonlyArray<ParsedExample> {
return examples.map((example) => ({
...example,
body: example.body === null ? null : renderInlineLinks(example.body)
}))
}
function apiTags(
tags: ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags
): JSDocApiTags {
return {
category: "category" in tags ? tags.category : null,
since: tags.since,
deprecated: tags.deprecated,
default: "default" in tags ? tags.default : null
}
}
function unresolvedSeeTag(tag: ParsedSeeTag): JSDocApiSeeTag {
return {
text: renderInlineLinks(tag.text),
links: tag.links.map((link) => ({
...link,
resolution: { _tag: "Unresolved", reason: "not-found", candidates: [] }
}))
}
}
function unresolvedSee(tags: ReadonlyArray<ParsedSeeTag>): ReadonlyArray<JSDocApiSeeTag> {
return tags.map(unresolvedSeeTag)
}
function apiId(kind: JSDocApiKind, bucket: ExportBucket | null, apiFqn: string): string {
return `${kind}:${bucket ?? "none"}:${apiFqn}`
}
function importGuidance(
imports: ParsedJSDocImports,
kind: JSDocApiKind,
bucket: ExportBucket | null,
apiName: string
): JSDocApiImportGuidance | null {
if (kind !== "root-declaration" || bucket !== "value" || !isIdentifierName(apiName)) {
return null
}
if (imports.flatNames.includes(apiName) && imports.barrel !== null) {
return {
style: "named-barrel",
importDeclaration: `import { ${apiName} } from "${imports.barrel.module}"`,
usage: apiName
}
}
if (imports.barrel?.type === "namespace") {
if (!isIdentifierName(imports.barrel.name)) return null
return {
style: "namespace-barrel",
importDeclaration: `import { ${imports.barrel.name} } from "${imports.barrel.module}"`,
usage: `${imports.barrel.name}.${apiName}`
}
}
if (imports.barrel?.type === "flat") {
return {
style: "named-barrel",
importDeclaration: `import { ${apiName} } from "${imports.barrel.module}"`,
usage: apiName
}
}
return {
style: "named-module",
importDeclaration: `import { ${apiName} } from "${imports.module}"`,
usage: apiName
}
}
function makeApi(input: {
readonly file: JSDocModelFile
readonly imports: ParsedJSDocImports
readonly kind: JSDocApiKind
readonly bucket: ExportBucket | null
readonly apiName: string
readonly localName: string
readonly signature: string | null
readonly parentApiName: string | null
readonly namespacePath: ReadonlyArray<string>
readonly memberPath: ReadonlyArray<string>
readonly sourcePath: ReadonlyArray<string | number>
readonly sourceRange: readonly [number, number]
readonly description: ParsedDescription
readonly examples: ReadonlyArray<ParsedExample>
readonly tags: ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags
}): JSDocApi {
const apiFqn = `${input.imports.module}.${input.apiName}`
return {
id: apiId(input.kind, input.bucket, apiFqn),
kind: input.kind,
moduleName: input.imports.module,
apiName: input.apiName,
localName: input.localName,
signature: input.signature,
parentApiName: input.parentApiName,
apiFqn,
hasFqnCollision: false,
bucket: input.bucket,
importable: input.kind === "root-declaration",
namespacePath: input.namespacePath,
memberPath: input.memberPath,
source: {
file: input.file.file,
path: input.sourcePath,
range: input.sourceRange
},
description: renderDescription(input.description),
examples: renderExamples(input.examples),
tags: apiTags(input.tags),
see: unresolvedSee(input.tags.see),
importGuidance: importGuidance(input.imports, input.kind, input.bucket, input.apiName)
}
}
function buildJSDocApis(files: ReadonlyArray<JSDocModelFile>): ReadonlyArray<JSDocApi> {
const apis: Array<JSDocApi> = []
const addMembers = (
file: JSDocModelFile,
imports: ParsedJSDocImports,
members: ReadonlyArray<ParsedMember>,
parentApiName: string,
namespacePath: ReadonlyArray<string>,
memberPath: ReadonlyArray<string>,
sourcePath: ReadonlyArray<string | number>
) => {
members.forEach((member, index) => {
const nextMemberPath = [...memberPath, member.name]
const apiName = `${parentApiName}.${nextMemberPath.join(".")}`
apis.push(makeApi({
file,
imports,
kind: "member",
bucket: null,
apiName,
localName: member.name,
signature: member.signature,
parentApiName,
namespacePath,
memberPath: nextMemberPath,
sourcePath: [...sourcePath, "members", index],
sourceRange: member.range,
description: member.description,
examples: member.examples,
tags: member.tags
}))
addMembers(file, imports, member.members, parentApiName, namespacePath, nextMemberPath, [
...sourcePath,
"members",
index
])
})
}
const addNamespaces = (
file: JSDocModelFile,
imports: ParsedJSDocImports,
namespaces: ReadonlyArray<ParsedNamespace>,
namespacePath: ReadonlyArray<string>,
sourcePath: ReadonlyArray<string | number>
) => {
namespaces.forEach((namespace, index) => {
const nextNamespacePath = [...namespacePath, namespace.name]
const namespaceApiName = nextNamespacePath.join(".")
apis.push(makeApi({
file,
imports,
kind: "namespace",
bucket: null,
apiName: namespaceApiName,
localName: namespace.name,
signature: namespace.signature,
parentApiName: namespacePath.length === 0 ? null : namespacePath.join("."),
namespacePath: nextNamespacePath,
memberPath: [],
sourcePath: [...sourcePath, index],
sourceRange: namespace.range,
description: namespace.description,
examples: namespace.examples,
tags: namespace.tags
}))
namespace.declarations.forEach((declaration, declarationIndex) => {
const apiName = `${namespaceApiName}.${declaration.name}`
apis.push(makeApi({
file,
imports,
kind: "namespace-declaration",
bucket: "type",
apiName,
localName: declaration.name,
signature: declaration.signature,
parentApiName: namespaceApiName,
namespacePath: nextNamespacePath,
memberPath: [],
sourcePath: [...sourcePath, index, "declarations", declarationIndex],
sourceRange: declaration.range,
description: declaration.description,
examples: declaration.examples,
tags: declaration.tags
}))
addMembers(file, imports, declaration.members, apiName, nextNamespacePath, [], [
...sourcePath,
index,
"declarations",
declarationIndex
])
})
addNamespaces(file, imports, namespace.namespaces, nextNamespacePath, [...sourcePath, index, "namespaces"])
})
}
for (const file of files) {
if (file.imports === undefined) continue
const imports = file.imports
file.declarations.forEach((declaration, index) => {
apis.push(makeApi({
file,
imports,
kind: "root-declaration",
bucket: declaration.bucket,
apiName: declaration.name,
localName: declaration.name,
signature: declaration.signature,
parentApiName: null,
namespacePath: [],
memberPath: [],
sourcePath: ["declarations", index],
sourceRange: declaration.range,
description: declaration.description,
examples: declaration.examples,
tags: declaration.tags
}))
addMembers(file, imports, declaration.members, declaration.name, [], [], ["declarations", index])
})
addNamespaces(file, imports, file.namespaces, [], ["namespaces"])
}
return resolveJSDocApiSeeLinks(markFqnCollisions(apis))
}
function markFqnCollisions(apis: ReadonlyArray<JSDocApi>): ReadonlyArray<JSDocApi> {
const counts = new Map<string, number>()
for (const api of apis) counts.set(api.apiFqn, (counts.get(api.apiFqn) ?? 0) + 1)
return apis.map((api) => ({ ...api, hasFqnCollision: (counts.get(api.apiFqn) ?? 0) > 1 }))
}
function normalizeSeeTarget(target: string): ReadonlyArray<string> {
const withoutKind = target.replace(/:[A-Za-z]+$/, "")
const out = [withoutKind]
if (withoutKind.endsWith("_")) out.push(withoutKind.slice(0, -1))
return Array.from(new Set(out.filter((item) => item.length > 0)))
}
function sourceRangeKey(file: string, range: readonly [number, number]): string {
return `${file}:${range[0]}:${range[1]}`
}
function rangeContains(outer: readonly [number, number], inner: readonly [number, number]): boolean {
return outer[0] <= inner[0] && inner[1] <= outer[1]
}
function rangeOverlaps(self: readonly [number, number], that: readonly [number, number]): boolean {
return self[0] <= that[1] && that[0] <= self[1]
}
function rangeWidth(range: readonly [number, number]): number {
return range[1] - range[0]
}
function seeTargetNames(link: ParsedInlineLink): ReadonlySet<string> {
const names = new Set<string>()
const add = (target: string) => {
for (const normalized of normalizeSeeTarget(target)) {
const parts = normalized.split(".").filter((part) => part.length > 0)
for (const part of parts) names.add(part)
if (parts.length > 0) names.add(parts.join("."))
}
}
add(link.target)
if (link.text !== null) add(link.text)
return names
}
function apiMatchesSeeTarget(api: JSDocApi, names: ReadonlySet<string>): boolean {
if (names.size === 0) return true
if (names.has(api.localName) || names.has(api.apiName)) return true
for (const name of names) {
if (api.apiName.endsWith(`.${name}`)) return true
}
return false
}
function createJSDocApiSeeLinkResolver(apis: ReadonlyArray<JSDocApi>) {
const byModuleAndName = new Map<string, JSDocApi>()
const byLocalName = new Map<string, Array<JSDocApi>>()
const bySourceRange = new Map<string, JSDocApi>()
const bySourceFile = new Map<string, Array<JSDocApi>>()
for (const api of apis) {
byModuleAndName.set(`${api.moduleName}:${api.apiName}`, api)
bySourceRange.set(sourceRangeKey(api.source.file, api.source.range), api)
const sourceApis = bySourceFile.get(api.source.file) ?? []
sourceApis.push(api)
bySourceFile.set(api.source.file, sourceApis)
for (const name of new Set([api.apiName, api.localName])) {
const existing = byLocalName.get(name) ?? []
existing.push(api)
byLocalName.set(name, existing)
}
}
const resolveSymbol = (link: ParsedInlineLink): JSDocApiSeeLinkResolution | undefined => {
if (link.symbol === undefined) return undefined
const symbol = link.symbol
const exact = bySourceRange.get(sourceRangeKey(symbol.file, symbol.range))
if (exact !== undefined) return { _tag: "Resolved", apiId: exact.id, apiFqn: exact.apiFqn }
const names = seeTargetNames(link)
const candidates = (bySourceFile.get(symbol.file) ?? []).filter((api) =>
apiMatchesSeeTarget(api, names) &&
(rangeContains(symbol.range, api.source.range) ||
rangeContains(api.source.range, symbol.range) ||
rangeOverlaps(symbol.range, api.source.range))
).sort((self, that) => rangeWidth(self.source.range) - rangeWidth(that.source.range))
const unique = Array.from(new Map(candidates.map((candidate) => [candidate.id, candidate])).values())
if (unique.length === 1) return { _tag: "Resolved", apiId: unique[0].id, apiFqn: unique[0].apiFqn }
if (unique.length > 1) {
return { _tag: "Unresolved", reason: "ambiguous", candidates: unique.map((candidate) => candidate.id) }
}
const sameFileByName = (bySourceFile.get(symbol.file) ?? []).filter((api) => apiMatchesSeeTarget(api, names))
const uniqueByName = Array.from(new Map(sameFileByName.map((candidate) => [candidate.id, candidate])).values())
if (uniqueByName.length === 1) {
return { _tag: "Resolved", apiId: uniqueByName[0].id, apiFqn: uniqueByName[0].apiFqn }
}
if (uniqueByName.length > 1) {
return { _tag: "Unresolved", reason: "ambiguous", candidates: uniqueByName.map((candidate) => candidate.id) }
}
return undefined
}
return (
context: { readonly moduleName: string; readonly namespacePath: ReadonlyArray<string> },
link: ParsedInlineLink
): JSDocApiSeeLinkResolution => {
const symbolResolution = resolveSymbol(link)
if (symbolResolution !== undefined) return symbolResolution
const targets = new Set<string>()
for (const target of normalizeSeeTarget(link.target)) targets.add(target)
if (link.text !== null) {
for (const target of normalizeSeeTarget(link.text)) targets.add(target)
}
for (const target of targets) {
const sameModule = byModuleAndName.get(`${context.moduleName}:${target}`)
if (sameModule !== undefined) return { _tag: "Resolved", apiId: sameModule.id, apiFqn: sameModule.apiFqn }
if (!target.includes(".") && context.namespacePath.length > 0) {
const sameNamespace = byModuleAndName.get(
`${context.moduleName}:${[...context.namespacePath, target].join(".")}`
)
if (sameNamespace !== undefined) {
return { _tag: "Resolved", apiId: sameNamespace.id, apiFqn: sameNamespace.apiFqn }
}
}
}
const candidates = Array.from(targets).flatMap((target) => byLocalName.get(target) ?? [])
const unique = Array.from(new Map(candidates.map((candidate) => [candidate.id, candidate])).values())
if (unique.length === 1) return { _tag: "Resolved", apiId: unique[0].id, apiFqn: unique[0].apiFqn }
if (unique.length > 1) {
return { _tag: "Unresolved", reason: "ambiguous", candidates: unique.map((candidate) => candidate.id) }
}
return { _tag: "Unresolved", reason: "not-found", candidates: [] }
}
}
function resolveJSDocApiSeeLinks(apis: ReadonlyArray<JSDocApi>): ReadonlyArray<JSDocApi> {
const resolve = createJSDocApiSeeLinkResolver(apis)
return apis.map((api) => ({
...api,
see: api.see.map((tag) => ({
...tag,
links: tag.links.map((link) => ({ ...link, resolution: resolve(api, link) }))
}))
}))
}
function sameDiagnostic(self: JSDocModelDiagnostic, that: JSDocModelDiagnostic): boolean {
return self.code === that.code && self.message === that.message &&
self.range[0] === that.range[0] && self.range[1] === that.range[1]
}
function unresolvedPublicSeeDiagnostic(link: JSDocApiSeeLink): JSDocModelDiagnostic | undefined {
if (link.resolution._tag === "Resolved" || link.range === undefined) return undefined
const message = link.resolution.reason === "ambiguous"
? `@see link ${link.raw} is ambiguous; it must resolve to exactly one public JSDoc API. Candidates: ${
link.resolution.candidates.join(", ")
}`
: `@see link ${link.raw} does not resolve to a public JSDoc API. Check that the target is exported and has valid public JSDoc.`
return { ...diagnostic("public-see-target", message), range: link.range }
}
function appendPublicSeeDiagnostics(
files: ReadonlyArray<JSDocModelFile>,
apis: ReadonlyArray<JSDocApi>
): { readonly files: ReadonlyArray<JSDocModelFile>; readonly added: boolean } {
const diagnosticsByFile = new Map(files.map((file) => [file.file, [...file.diagnostics]]))
let added = false
for (const api of apis) {
const diagnostics = diagnosticsByFile.get(api.source.file)
if (diagnostics === undefined) continue
for (const tag of api.see) {
for (const link of tag.links) {
const item = unresolvedPublicSeeDiagnostic(link)
if (item === undefined || diagnostics.some((diagnostic) => sameDiagnostic(diagnostic, item))) continue
diagnostics.push(item)
added = true
}
}
}
if (!added) return { files, added }
return {
added,
files: files.map((file) => ({
...file,
diagnostics: diagnosticsByFile.get(file.file) ?? file.diagnostics
}))
}
}
function moduleSeeTags(
file: JSDocModelFile,
sourceFile: ts.SourceFile,
linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
): ReadonlyArray<JSDocApiSeeTag> {
if (file.moduleJSDoc === undefined) return []
const block = parseLooseJSDocBlock(file.moduleJSDoc.raw, [file.moduleJSDoc.range[0], file.moduleJSDoc.range[1]])
if (block.internal) return []
const tags = buildTags("module", block.tags)
const moduleTags: ParsedModuleTags = tags._tag === "Success"
? tags.value as ParsedModuleTags
: {
since: "0.0.0",
deprecated: null,
see: block.tags.filter((tag) => tag.name === "see" && tag.value.trim() !== "").map((tag) =>
parseSeeTag(tag.value.trim())
)
}
const withSymbols = attachSeeLinkSymbolsFromSourceFile(moduleTags, sourceFile, block, linkContext)
return unresolvedSee(withSymbols.see)
}
function appendModulePublicSeeDiagnostics(
files: ReadonlyArray<JSDocModelFile>,
apis: ReadonlyArray<JSDocApi>,
context: {
readonly sourceFilesByFile: ReadonlyMap<string, ts.SourceFile>
readonly checker: ts.TypeChecker
readonly entry: ProgramCacheEntry
readonly cwd: string
}
): { readonly files: ReadonlyArray<JSDocModelFile>; readonly added: boolean } {
const diagnosticsByFile = new Map(files.map((file) => [file.file, [...file.diagnostics]]))
const resolve = createJSDocApiSeeLinkResolver(apis)
let added = false
for (const file of files) {
if (file.imports === undefined || file.moduleJSDoc === undefined) continue
const sourceFile = context.sourceFilesByFile.get(file.file)
const diagnostics = diagnosticsByFile.get(file.file)
if (sourceFile === undefined || diagnostics === undefined) continue
for (const tag of moduleSeeTags(file, sourceFile, context)) {
for (const link of tag.links) {
const item = unresolvedPublicSeeDiagnostic({
...link,
resolution: resolve({ moduleName: file.imports.module, namespacePath: [] }, link)
})
if (item === undefined || diagnostics.some((diagnostic) => sameDiagnostic(diagnostic, item))) continue
diagnostics.push(item)
added = true
}
}
}
if (!added) return { files, added }
return {
added,
files: files.map((file) => ({
...file,
diagnostics: diagnosticsByFile.get(file.file) ?? file.diagnostics
}))
}
}
function buildJSDocApisWithPublicSeeDiagnostics(
files: ReadonlyArray<JSDocModelFile>,
context?: {
readonly sourceFilesByFile: ReadonlyMap<string, ts.SourceFile>
readonly checker: ts.TypeChecker
readonly entry: ProgramCacheEntry
readonly cwd: string
}
): {
readonly files: ReadonlyArray<JSDocModelFile>
readonly apis: ReadonlyArray<JSDocApi>
} {
let current = files
for (let index = 0; index < 5; index++) {
const apis = buildJSDocApis(current)
const publicSee = appendPublicSeeDiagnostics(current, apis)
const moduleSee = context === undefined
? { files: publicSee.files, added: false }
: appendModulePublicSeeDiagnostics(publicSee.files, apis, context)
const next = { files: moduleSee.files, added: publicSee.added || moduleSee.added }
if (!next.added) return { files: current, apis }
current = next.files
}
return { files: current, apis: buildJSDocApis(current) }
}
interface ExampleImportPolicy {
readonly allowedNamedBySource: ReadonlyMap<string, ReadonlySet<string>>
readonly namespaceModuleBySource: ReadonlyMap<string, { readonly barrelModule: string; readonly name: string }>
readonly flatModuleBySource: ReadonlyMap<
string,
{ readonly barrelModule: string; readonly names: ReadonlySet<string> }
>
readonly namedModuleBySource: ReadonlyMap<string, ReadonlySet<string>>
}
function createExampleImportPolicy(files: ReadonlyArray<JSDocModelFile>): ExampleImportPolicy {
const allowedNamed = new Map<string, Set<string>>()
const namespaceModuleBySource = new Map<string, { readonly barrelModule: string; readonly name: string }>()
const flatModuleBySource = new Map<string, { readonly barrelModule: string; readonly names: ReadonlySet<string> }>()
const namedModuleBySource = new Map<string, ReadonlySet<string>>()
const addAllowed = (source: string, name: string) => {
const names = allowedNamed.get(source) ?? new Set<string>()
names.add(name)
allowedNamed.set(source, names)
}
for (const file of files) {
if (file.imports === undefined) continue
const rootValues = file.declarations.filter((declaration) =>
declaration.bucket === "value" && isIdentifierName(declaration.name)
).map((declaration) => declaration.name)
if (file.imports.barrel?.type === "namespace") {
addAllowed(file.imports.barrel.module, file.imports.barrel.name)
for (const name of file.imports.flatNames) addAllowed(file.imports.barrel.module, name)
namespaceModuleBySource.set(file.imports.module, {
barrelModule: file.imports.barrel.module,
name: file.imports.barrel.name
})
continue
}
if (file.imports.barrel?.type === "flat") {
for (const name of new Set([...rootValues, ...file.imports.flatNames])) {
addAllowed(file.imports.barrel.module, name)
}
flatModuleBySource.set(file.imports.module, {
barrelModule: file.imports.barrel.module,
names: new Set([...rootValues, ...file.imports.flatNames])
})
continue
}
for (const name of rootValues) addAllowed(file.imports.module, name)
namedModuleBySource.set(file.imports.module, new Set(rootValues))
}
return { allowedNamedBySource: allowedNamed, namespaceModuleBySource, flatModuleBySource, namedModuleBySource }
}
function importSpecifierName(specifier: ts.ImportSpecifier): string {
return specifier.propertyName?.text ?? specifier.name.text
}
function validateExampleImports(
code: string,
policy: ExampleImportPolicy
): ReadonlyArray<JSDocDiagnostic> {
const diagnostics: Array<JSDocDiagnostic> = []
const sourceFile = ts.createSourceFile("example.ts", code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
for (const statement of sourceFile.statements) {
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteralLike(statement.moduleSpecifier)) continue
if (statement.importClause?.isTypeOnly) continue
const source = statement.moduleSpecifier.text
const namedAllowed = policy.allowedNamedBySource.get(source)
const namespaceModule = policy.namespaceModuleBySource.get(source)
const flatModule = policy.flatModuleBySource.get(source)
const namedModule = policy.namedModuleBySource.get(source)
const clause = statement.importClause
if (clause === undefined) continue
if (namespaceModule !== undefined) {
diagnostics.push(diagnostic(
"example-import-style",
`JSDoc examples should use \`import { ${namespaceModule.name} } from "${namespaceModule.barrelModule}"\` instead of importing from "${source}"`
))
continue
}
if (flatModule !== undefined) {
diagnostics.push(diagnostic(
"example-import-style",
`JSDoc examples should import ${
Array.from(flatModule.names).map((name) => `\`${name}\``).join(", ")
} from "${flatModule.barrelModule}" instead of "${source}"`
))
continue
}
if (clause.namedBindings !== undefined && ts.isNamespaceImport(clause.namedBindings)) {
if (namedAllowed !== undefined || namedModule !== undefined) {
diagnostics.push(diagnostic("example-import-style", `JSDoc examples should use named imports from "${source}"`))
}
continue
}
if (clause.name !== undefined && (namedAllowed !== undefined || namedModule !== undefined)) {
diagnostics.push(
diagnostic("example-import-style", `JSDoc examples should not use default imports from "${source}"`)
)
}
if (clause.namedBindings !== undefined && ts.isNamedImports(clause.namedBindings) && namedAllowed !== undefined) {
for (const specifier of clause.namedBindings.elements) {
if (specifier.isTypeOnly) continue
const imported = importSpecifierName(specifier)
if (!namedAllowed.has(imported)) {
diagnostics.push(diagnostic(
"example-import-style",
`JSDoc example import \`${imported}\` from "${source}" is not a canonical public API import`
))
}
}
}
}
return diagnostics
}
function appendExampleImportDiagnostics(files: ReadonlyArray<JSDocModelFile>): ReadonlyArray<JSDocModelFile> {
const policy = createExampleImportPolicy(files)
const checkExamples = (
examples: ReadonlyArray<ParsedExample>,
range: readonly [number, number] = [0, 0] as const
): ReadonlyArray<JSDocModelDiagnostic> =>
examples.flatMap((example) => validateExampleImports(example.code, policy).map((item) => ({ ...item, range })))
const checkModuleExamples = (moduleJSDoc: ParsedModuleJSDoc | undefined): ReadonlyArray<JSDocModelDiagnostic> => {
if (moduleJSDoc === undefined) return []
const block = parseLooseJSDocBlock(moduleJSDoc.raw, [moduleJSDoc.range[0], moduleJSDoc.range[1]])
if (block.internal) return []
return checkExamples(parseModuleExamples(block).examples, moduleJSDoc.range)
}
const checkMembers = (members: ReadonlyArray<ParsedMember>): ReadonlyArray<JSDocModelDiagnostic> =>
members.flatMap((member) => [
...checkExamples(member.examples),
...checkMembers(member.members)
])
const checkNamespaces = (namespaces: ReadonlyArray<ParsedNamespace>): ReadonlyArray<JSDocModelDiagnostic> =>
namespaces.flatMap((namespace) => [
...checkExamples(namespace.examples),
...namespace.declarations.flatMap((declaration) => [
...checkExamples(declaration.examples),
...checkMembers(declaration.members)
]),
...checkNamespaces(namespace.namespaces)
])
return files.map((file) => {
const diagnostics = [
...file.diagnostics,
...checkModuleExamples(file.moduleJSDoc),
...file.declarations.flatMap((declaration) => [
...checkExamples(declaration.examples),
...checkMembers(declaration.members)
]),
...checkNamespaces(file.namespaces)
]
return diagnostics.length === file.diagnostics.length
? file
: { ...file, diagnostics: uniqueModelDiagnostics(diagnostics) }
})
}
function uniqueModelDiagnostics(
diagnostics: ReadonlyArray<JSDocModelDiagnostic>
): ReadonlyArray<JSDocModelDiagnostic> {
const seen = new Set<string>()
return diagnostics.filter((item) => {
const key = `${item.code}:${item.message}:${item.range[0]}:${item.range[1]}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
function nodeRange(node: ts.Node): [number, number] {
return [node.getStart(), node.getEnd()]
}
function normalizeFile(cwd: string, filename: string): string {
return normalizePathName(path.relative(cwd, filename))
}
function hashSource(source: string): string {
return crypto.createHash("sha256").update(source).digest("hex")
}
function getNodeJSDoc(node: ts.Node): JSDocBlock | undefined {
const jsDocs = (node as { readonly jsDoc?: ReadonlyArray<ts.JSDoc> }).jsDoc
const jsDoc = jsDocs?.[jsDocs.length - 1]
if (jsDoc === undefined) return undefined
const source = node.getSourceFile().text
return parseJSDocBlock(source.slice(jsDoc.pos, jsDoc.end), [jsDoc.pos, jsDoc.end])
}
function addModelDiagnostics(
out: Array<JSDocModelDiagnostic>,
range: [number, number],
diagnostics: ReadonlyArray<JSDocDiagnostic>
) {
for (const item of diagnostics) out.push({ ...item, range })
}
function addJSDocLinkDiagnostics(
sourceFile: ts.SourceFile,
block: JSDocBlock,
diagnostics: Array<JSDocModelDiagnostic>,
linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
) {
if (!hasInlineLink(block)) return
for (const link of malformedInlineLinks(block)) {
diagnostics.push({ ...diagnostic("malformed-link", `Malformed JSDoc inline link: ${link}`), range: block.range })
}
for (const link of collectJSDocLinks(sourceFile, block)) {
const text = link.getText(sourceFile)
const target = extractInlineLinkTarget(text)
if (target === "") continue
if (urlRegex.test(target)) {
diagnostics.push({
...diagnostic("url-link", `JSDoc inline link must target a TypeScript symbol: ${text}`),
range: [link.pos, link.end]
})
} else if (resolveJSDocLinkSymbol(link, linkContext.checker) === undefined) {
diagnostics.push({
...diagnostic("unresolved-link", `Unresolved JSDoc inline link: ${text}`),
range: [link.pos, link.end]
})
}
}
for (const link of collectJSDocSeeLinks(sourceFile, block)) {
const text = link.getText(sourceFile)
const target = extractInlineLinkTarget(text)
if (target === "" || urlRegex.test(target)) continue
const symbol = resolveJSDocLinkSymbol(link, linkContext.checker)
if (symbol === undefined) continue
if (undocumentedSeeLinkTarget(symbol, linkContext.checker, linkContext.cwd) !== undefined) {
diagnostics.push({
...diagnostic("undocumented-see-target", `@see link target must have public JSDoc: ${text}`),
range: [link.pos, link.end]
})
}
}
}
function parseDocumentedTs(
node: ts.Node,
scope: DocScope,
diagnostics: Array<JSDocModelDiagnostic>,
required = true,
linkContext?: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
) {
const block = getNodeJSDoc(node)
if (block?.internal) return undefined
if (block === undefined) {
if (required) {
diagnostics.push({
...diagnostic("missing-jsdoc", scope === "member" ? "Member JSDoc is required" : "Public JSDoc is required"),
range: nodeRange(node)
})
}
return undefined
}
addModelDiagnostics(diagnostics, block.range, block.diagnostics)
if (block.parsed === undefined) return undefined
const tags = buildTags(scope, block.parsed.tags)
if (tags._tag === "Failure") {
addModelDiagnostics(diagnostics, block.range, tags.error.diagnostics)
return undefined
}
const parsedTags = tags.value as ParsedDeclarationTags | ParsedNamespaceTags | ParsedMemberTags
if (linkContext !== undefined) addJSDocLinkDiagnostics(node.getSourceFile(), block, diagnostics, linkContext)
return {
core: block.parsed,
tags: linkContext === undefined ? parsedTags : attachSeeLinkSymbols(parsedTags, node, block, linkContext)
}
}
function hasExportModifier(node: ts.Node): boolean {
return ts.canHaveModifiers(node) &&
(ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ?? false)
}
function hasDefaultModifier(node: ts.Node): boolean {
return ts.canHaveModifiers(node) &&
(ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ?? false)
}
function declarationName(node: ts.Node): string | undefined {
if (
(ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node) || ts.isClassDeclaration(node) ||
ts.isFunctionDeclaration(node) || ts.isModuleDeclaration(node)) && node.name !== undefined
) return node.name.text
if (ts.isVariableStatement(node)) {
return node.declarationList.declarations.map((declaration) => {
if (ts.isIdentifier(declaration.name)) return declaration.name.text
if (ts.isObjectBindingPattern(declaration.name)) {
return declaration.name.elements.map((element) =>
element.propertyName?.getText(element.getSourceFile()) ?? element.name.getText(element.getSourceFile())
).join(", ")
}
return ""
}).filter(Boolean).join(", ")
}
return undefined
}
function publicDeclarationRange(node: ts.Node, name: string): readonly [number, number] {
if (ts.isVariableStatement(node)) {
const declaration = node.declarationList.declarations.find((item) =>
ts.isIdentifier(item.name) && item.name.text === name
)
if (declaration !== undefined) return nodeRange(declaration)
}
return nodeRange(node)
}
function bucketOfTs(node: ts.Node): ExportBucket | undefined {
if (ts.isVariableStatement(node) || ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) return "value"
if (ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node)) return "type"
return undefined
}
function normalizeSignature(cwd: string, text: string): string | null {
const normalizedCwd = normalizePathName(cwd)
const signature = text.replaceAll(normalizedCwd, ".").replaceAll(cwd, ".").replace(/\r\n?/g, "\n").replace(
/[ \t]+$/gm,
""
).trim()
return signature === "" ? null : signature
}
function displaySignature(text: string): string {
return text.replace(/import\((?:"[^"]+"|'[^']+')\)\.([A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*)/g, "$1")
}
function parseableSignature(cwd: string, text: string): string | null {
const signature = normalizeSignature(cwd, displaySignature(text))
if (signature === null) return null
const source = ts.createSourceFile(
"signature.ts",
signature,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
) as SourceFileWithParseDiagnostics
return source.parseDiagnostics.length === 0 ? signature : null
}
function signatureLocalName(name: string): string {
const sanitized = name.replace(/[^A-Za-z0-9_$]/g, "_")
return `_${sanitized === "" ? "api" : sanitized}`
}
function signatureExportName(name: string): string {
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name)
}
function signatureAlias(name: string, text: string): string {
const localName = signatureLocalName(name)
return `declare const ${localName}: ${text}
export { ${localName} as ${signatureExportName(name)} }`
}
function signatureParameterDeclaration(parameter: ts.Symbol): ts.ParameterDeclaration | undefined {
const declaration = parameter.valueDeclaration ?? parameter.declarations?.[0]
return declaration !== undefined && ts.isParameter(declaration) ? declaration : undefined
}
function signatureParameterName(parameter: ts.Symbol): string {
const declaration = signatureParameterDeclaration(parameter)
if (declaration !== undefined && ts.isIdentifier(declaration.name)) return declaration.name.text
return parameter.getName()
}
function signatureFunctionThisKind(type: ts.Type, checker: ts.TypeChecker, location: ts.Node): string {
const thisParameter = checker.getSignaturesOfType(type, ts.SignatureKind.Call)[0]?.thisParameter
if (thisParameter === undefined) return "none"
const thisType = checker.typeToString(
checker.getTypeOfSymbolAtLocation(thisParameter, location),
location,
signatureTypeFormatFlags
)
return thisType === "unassigned" ? "unassigned" : "self"
}
function isSignatureFunctionKind(kind: string): boolean {
return kind.startsWith("body:") || kind.startsWith("function:")
}
function signatureTransformCountKind(count: number): string {
if (count <= 3) return String(count)
if (count <= 7) return "4"
if (count <= 15) return "8"
return "16"
}
function signatureParameterKind(
parameter: ts.Symbol | undefined,
checker: ts.TypeChecker,
location: ts.Node
): string {
if (parameter === undefined) return "none"
const declaration = signatureParameterDeclaration(parameter)
if (declaration?.dotDotDotToken !== undefined) return "rest"
const name = signatureParameterName(parameter).replace(/_$/, "").toLowerCase()
const type = checker.getTypeOfSymbolAtLocation(parameter, declaration ?? location)
if (name === "self" || name === "that") return "self"
if (name === "body") return `body:${signatureFunctionThisKind(type, checker, location)}`
if (name === "name" || name === "spanname") return "name"
if (name.includes("refinement")) return "refinement"
if (name.includes("predicate")) return "predicate"
if (name === "options" || name === "option" || name === "opts" || name === "config" || name === "args") {
return "options"
}
if (name === "f" || name === "fn" || name === "callback") {
return `function:${signatureFunctionThisKind(type, checker, location)}`
}
if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length > 0) {
return `function:${signatureFunctionThisKind(type, checker, location)}`
}
if ((type.flags & ts.TypeFlags.StringLike) !== 0) return "primitive:string"
if ((type.flags & ts.TypeFlags.NumberLike) !== 0) return "primitive:number"
if ((type.flags & ts.TypeFlags.BooleanLike) !== 0) return "primitive:boolean"
if ((type.flags & ts.TypeFlags.BigIntLike) !== 0) return "primitive:bigint"
if ((type.flags & ts.TypeFlags.ESSymbolLike) !== 0) return "primitive:symbol"
if ((type.flags & ts.TypeFlags.Object) !== 0 || checker.getPropertiesOfType(type).length > 0) return "object"
return "other"
}
function signatureTailKind(signature: ts.Signature, checker: ts.TypeChecker, location: ts.Node): string {
if (signature.parameters.length <= 1) return "none"
const firstKind = signatureParameterKind(signature.parameters[0], checker, location)
const tailKinds = signature.parameters.slice(1).map((parameter) =>
signatureParameterKind(parameter, checker, location)
)
if (firstKind === "options") {
const hasTransformTail = tailKinds.length > 1 && tailKinds.slice(1).every(isSignatureFunctionKind)
return `${tailKinds[0]}:${hasTransformTail ? "transform" : "none"}`
}
if (tailKinds.every(isSignatureFunctionKind)) {
return firstKind.startsWith("body:")
? "transform"
: `transform:${signatureTransformCountKind(tailKinds.length)}`
}
return tailKinds[0]
}
function signatureShapeKey(signature: ts.Signature, checker: ts.TypeChecker, location: ts.Node): string {
const hasRest = signature.parameters.some((parameter) =>
signatureParameterDeclaration(parameter)?.dotDotDotToken !== undefined
)
const returnType = checker.getReturnTypeOfSignature(signature)
const returnMode = checker.getSignaturesOfType(returnType, ts.SignatureKind.Call).length > 0 ? "curried" : "direct"
return [
hasRest ? "rest" : "fixed",
signatureParameterKind(signature.parameters[0], checker, location),
signatureTailKind(signature, checker, location),
returnMode
].join(":")
}
function representativeSignatures(
signatures: ReadonlyArray<ts.Signature>,
checker: ts.TypeChecker,
location: ts.Node
): ReadonlyArray<ts.Signature> {
const seen = new Set<string>()
const out: Array<ts.Signature> = []
for (const signature of signatures) {
const key = signatureShapeKey(signature, checker, location)
if (seen.has(key)) continue
seen.add(key)
out.push(signature)
}
return out
}
function functionSignature(
name: string,
symbol: ts.Symbol,
location: ts.Node,
checker: ts.TypeChecker,
cwd: string
): string | null {
const target = resolvedSymbolTarget(symbol, checker)
const type = checker.getTypeOfSymbolAtLocation(target, location)
const signatures = checker.getSignaturesOfType(type, ts.SignatureKind.Call)
if (signatures.length === 0) return null
const callSignatures: Array<string> = []
for (const signature of representativeSignatures(signatures, checker, location)) {
const text = normalizeSignature(
cwd,
checker.signatureToString(signature, location, signatureTypeFormatFlags, ts.SignatureKind.Call)
)
if (text === null) return null
callSignatures.push(text)
}
const declaration = parseableSignature(
cwd,
callSignatures.map((text) => `declare function ${name}${text}`).join("\n")
)
if (declaration !== null) return declaration
const callSignatureAlias = parseableSignature(
cwd,
signatureAlias(
name,
`{
${callSignatures.map((text) => ` ${text};`).join("\n")}
}`
)
)
if (callSignatureAlias !== null) return callSignatureAlias
const typeText = normalizeSignature(cwd, checker.typeToString(type, location, signatureTypeFormatFlags))
if (typeText === null) return null
return parseableSignature(cwd, `declare const ${name}: ${typeText}`) ??
parseableSignature(cwd, signatureAlias(name, typeText))
}
function symbolSignature(
name: string,
bucket: ExportBucket,
symbol: ts.Symbol,
location: ts.Node,
checker: ts.TypeChecker,
cwd: string
): string | null {
if (bucket !== "value") return null
const target = resolvedSymbolTarget(symbol, checker)
const type = checker.getTypeOfSymbolAtLocation(target, location)
if (checker.getSignaturesOfType(type, ts.SignatureKind.Call).length === 0) return null
return functionSignature(name, symbol, location, checker, cwd)
}
function declarationSignature(
name: string,
bucket: ExportBucket,
node: ts.Node,
checker: ts.TypeChecker,
cwd: string
): string | null {
if (ts.isVariableStatement(node)) {
const declaration = node.declarationList.declarations.find((item) =>
ts.isIdentifier(item.name) && item.name.text === name
)
if (declaration === undefined) return null
const symbol = checker.getSymbolAtLocation(declaration.name)
return symbol === undefined ? null : symbolSignature(name, bucket, symbol, declaration.name, checker, cwd)
}
const nameNode = ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node) ||
ts.isTypeAliasDeclaration(node)
? node.name
: undefined
if (nameNode === undefined) return null
const symbol = checker.getSymbolAtLocation(nameNode)
return symbol === undefined ? null : symbolSignature(name, bucket, symbol, nameNode, checker, cwd)
}
function exportSpecifierSignature(
name: string,
bucket: ExportBucket,
specifier: ts.ExportSpecifier,
checker: ts.TypeChecker,
cwd: string
): string | null {
const symbol = checker.getSymbolAtLocation(specifier.name) ??
(specifier.propertyName === undefined ? undefined : checker.getSymbolAtLocation(specifier.propertyName))
return symbol === undefined ? null : symbolSignature(name, bucket, symbol, specifier.name, checker, cwd)
}
function parseMembersFromTsType(
type: ts.TypeNode | undefined,
diagnostics: Array<JSDocModelDiagnostic>,
linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
): Array<ParsedMember> {
if (!type) return []
if (ts.isTypeLiteralNode(type)) return parseTsMembers(type.members, diagnostics, linkContext)
if (ts.isUnionTypeNode(type) || ts.isIntersectionTypeNode(type)) {
return type.types.flatMap((item) => parseMembersFromTsType(item, diagnostics, linkContext))
}
if (ts.isParenthesizedTypeNode(type) || ts.isTypeOperatorNode(type)) {
return parseMembersFromTsType(type.type, diagnostics, linkContext)
}
if (ts.isArrayTypeNode(type)) return parseMembersFromTsType(type.elementType, diagnostics, linkContext)
if (ts.isConditionalTypeNode(type)) {
return [
...parseMembersFromTsType(type.checkType, diagnostics, linkContext),
...parseMembersFromTsType(type.extendsType, diagnostics, linkContext),
...parseMembersFromTsType(type.trueType, diagnostics, linkContext),
...parseMembersFromTsType(type.falseType, diagnostics, linkContext)
]
}
if (ts.isTypeReferenceNode(type)) {
return type.typeArguments?.flatMap((item) => parseMembersFromTsType(item, diagnostics, linkContext)) ?? []
}
if (ts.isMappedTypeNode(type)) return parseMembersFromTsType(type.type, diagnostics, linkContext)
if (ts.isIndexedAccessTypeNode(type)) {
return [
...parseMembersFromTsType(type.objectType, diagnostics, linkContext),
...parseMembersFromTsType(type.indexType, diagnostics, linkContext)
]
}
if (ts.isFunctionTypeNode(type) || ts.isConstructorTypeNode(type)) {
return parseMembersFromTsType(type.type, diagnostics, linkContext)
}
return []
}
function memberName(member: ts.TypeElement | ts.ClassElement): string {
if (
ts.isPropertySignature(member) || ts.isMethodSignature(member) || ts.isPropertyDeclaration(member) ||
ts.isMethodDeclaration(member) || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)
) {
const name = member.name
if (ts.isComputedPropertyName(name)) return name.expression.getText(member.getSourceFile())
return name.getText(member.getSourceFile()).replace(/^[']|[']$/g, "")
}
return ""
}
function memberType(member: ts.TypeElement | ts.ClassElement): ts.TypeNode | undefined {
if (
ts.isPropertySignature(member) || ts.isMethodSignature(member) || ts.isPropertyDeclaration(member) ||
ts.isMethodDeclaration(member) || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)
) return member.type
return undefined
}
function parseTsMembers(
members: ts.NodeArray<ts.TypeElement | ts.ClassElement>,
diagnostics: Array<JSDocModelDiagnostic>,
linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
): Array<ParsedMember> {
const out: Array<ParsedMember> = []
for (const member of members) {
if (
ts.isCallSignatureDeclaration(member) || ts.isConstructSignatureDeclaration(member) ||
ts.isIndexSignatureDeclaration(member) || member.kind === ts.SyntaxKind.Constructor
) continue
const documented = parseDocumentedTs(member, "member", diagnostics, false, linkContext)
if (documented === undefined) continue
const name = memberName(member)
if (name === "") continue
const nested = parseMembersFromTsType(memberType(member), diagnostics, linkContext)
out.push({
name,
signature: null,
range: nodeRange(member),
description: documented.core.description,
examples: documented.core.examples,
tags: documented.tags as ParsedMemberTags,
members: nested
})
}
return out
}
function parseDeclarationMembersTs(
node: ts.Node,
diagnostics: Array<JSDocModelDiagnostic>,
linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
): Array<ParsedMember> {
if (ts.isInterfaceDeclaration(node)) return parseTsMembers(node.members, diagnostics, linkContext)
if (ts.isClassDeclaration(node)) return parseTsMembers(node.members, diagnostics, linkContext)
if (ts.isTypeAliasDeclaration(node)) return parseMembersFromTsType(node.type, diagnostics, linkContext)
if (ts.isVariableStatement(node)) {
return node.declarationList.declarations.flatMap((d) => parseMembersFromTsType(d.type, diagnostics, linkContext))
}
if (ts.isFunctionDeclaration(node)) return parseMembersFromTsType(node.type, diagnostics, linkContext)
return []
}
function hasDeclareModifier(node: ts.Node): boolean {
return ts.canHaveModifiers(node) &&
(ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.DeclareKeyword) ?? false)
}
function parseNamespaceTs(
node: ts.ModuleDeclaration,
diagnostics: Array<JSDocModelDiagnostic>,
linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string },
isInsideDeclare = false
): ParsedNamespace | undefined {
if (!ts.isIdentifier(node.name)) return undefined
if (!isInsideDeclare && !hasDeclareModifier(node)) {
diagnostics.push({
...diagnostic("namespace-declare", "Namespaces must be declared with declare namespace"),
range: nodeRange(node)
})
return undefined
}
const documented = parseDocumentedTs(node, "namespace", diagnostics, true, linkContext)
if (documented === undefined) return undefined
const declarations: Array<ParsedNamespaceDeclaration> = []
const namespaces: Array<ParsedNamespace> = []
if (node.body !== undefined && ts.isModuleBlock(node.body)) {
for (const statement of node.body.statements) {
if (!hasExportModifier(statement)) continue
if (ts.isModuleDeclaration(statement)) {
const nested = parseNamespaceTs(statement, diagnostics, linkContext, true)
if (nested !== undefined) namespaces.push(nested)
continue
}
if (ts.isEnumDeclaration(statement)) {
diagnostics.push({ ...diagnostic("enum", "Enums are not allowed"), range: nodeRange(statement) })
continue
}
const bucket = bucketOfTs(statement)
if (bucket !== "type") {
diagnostics.push({
...diagnostic("namespace-value", "Namespace exports must be type declarations"),
range: nodeRange(statement)
})
continue
}
const nestedDocumented = parseDocumentedTs(statement, "namespace-declaration", diagnostics, true, linkContext)
if (nestedDocumented === undefined) continue
const name = declarationName(statement)
if (name === undefined || name.length === 0) {
diagnostics.push({
...diagnostic("missing-name", "Namespace declaration name could not be determined"),
range: nodeRange(statement)
})
continue
}
declarations.push({
name,
signature: null,
range: publicDeclarationRange(statement, name),
description: nestedDocumented.core.description,
examples: nestedDocumented.core.examples,
tags: nestedDocumented.tags as ParsedNamespaceTags,
members: parseDeclarationMembersTs(statement, diagnostics, linkContext)
})
}
}
return {
name: node.name.text,
signature: null,
range: nodeRange(node),
description: documented.core.description,
examples: documented.core.examples,
tags: documented.tags as ParsedNamespaceTags,
declarations,
namespaces
}
}
function skipLeadingIgnoredTrivia(source: string): number {
let index = source.charCodeAt(0) === 0xfeff ? 1 : 0
while (index < source.length) {
while (index < source.length && /\s/.test(source[index])) index++
if (!source.startsWith("//", index)) return index
const lineEnd = source.indexOf("\n", index)
const end = lineEnd === -1 ? source.length : lineEnd + 1
if (!isSkippableDirectiveComment(source.slice(index, end))) return index
index = end
}
return index
}
function sourceFileTopLevelJSDoc(sourceFile: ts.SourceFile): ParsedModuleJSDoc | undefined {
const source = sourceFile.text
const start = skipLeadingIgnoredTrivia(source)
if (!source.startsWith("/**", start)) return undefined
const end = source.indexOf("*/", start + 3)
if (end === -1) return undefined
const range = [start, end + 2] as const
const firstStatement = sourceFile.statements[0]
if (firstStatement !== undefined) {
const jsDocs = (firstStatement as { readonly jsDoc?: ReadonlyArray<ts.JSDoc> }).jsDoc ?? []
if (
jsDocs.some((jsDoc) => jsDoc.pos === range[0] && jsDoc.end === range[1]) &&
!ts.isImportDeclaration(firstStatement) &&
!ts.isImportEqualsDeclaration(firstStatement)
) return undefined
}
return { raw: source.slice(range[0], range[1]), range }
}
function parseModuleJSDocTs(
sourceFile: ts.SourceFile,
moduleJSDoc: ParsedModuleJSDoc,
diagnostics: Array<JSDocModelDiagnostic>,
linkContext: { readonly checker: ts.TypeChecker; readonly entry: ProgramCacheEntry; readonly cwd: string }
): ParsedModuleJSDoc | undefined {
const block = parseLooseJSDocBlock(moduleJSDoc.raw, [moduleJSDoc.range[0], moduleJSDoc.range[1]])
if (block.internal) return undefined
const tags = buildTags("module", block.tags)
if (tags._tag === "Failure") addModelDiagnostics(diagnostics, block.range, tags.error.diagnostics)
const examples = parseModuleExamples(block)
addModelDiagnostics(diagnostics, block.range, examples.diagnostics)
addJSDocLinkDiagnostics(sourceFile, block, diagnostics, linkContext)
return moduleJSDoc
}
function parseSourceFileDocs(
cwd: string,
sourceFile: ts.SourceFile,
checker: ts.TypeChecker,
entry: ProgramCacheEntry
): { readonly parsed: ParsedJSDocFile; readonly diagnostics: ReadonlyArray<JSDocModelDiagnostic> } {
const diagnostics: Array<JSDocModelDiagnostic> = []
const declarations: Array<ParsedRootDeclaration> = []
const namespaces: Array<ParsedNamespace> = []
const linkContext = { checker, entry, cwd }
const checkedFunctionOverloads = new Set<string>()
const moduleJSDoc = sourceFileTopLevelJSDoc(sourceFile)
const parsedModuleJSDoc = moduleJSDoc === undefined
? undefined
: parseModuleJSDocTs(sourceFile, moduleJSDoc, diagnostics, linkContext)
for (const statement of sourceFile.statements) {
if (ts.isExportAssignment(statement)) continue
if (ts.isExportDeclaration(statement)) {
if (statement.exportClause === undefined) continue
if (ts.isNamedExports(statement.exportClause) && statement.exportClause.elements.length === 0) {
diagnostics.push({
...diagnostic("empty-export", "Empty export declarations are not allowed"),
range: nodeRange(statement)
})
}
if (ts.isNamedExports(statement.exportClause)) {
for (const specifier of statement.exportClause.elements) {
const documented = parseDocumentedTs(specifier, "declaration", diagnostics, true, linkContext)
if (documented === undefined) continue
declarations.push({
name: specifier.name.text,
bucket: statement.isTypeOnly || specifier.isTypeOnly ? "type" : "value",
signature: exportSpecifierSignature(
specifier.name.text,
statement.isTypeOnly || specifier.isTypeOnly ? "type" : "value",
specifier,
checker,
cwd
),
range: nodeRange(specifier),
description: documented.core.description,
examples: documented.core.examples,
tags: documented.tags as ParsedDeclarationTags,
members: []
})
}
}
continue
}
if (!hasExportModifier(statement) || hasDefaultModifier(statement)) continue
if (ts.isModuleDeclaration(statement)) {
const namespace = parseNamespaceTs(statement, diagnostics, linkContext)
if (namespace !== undefined) namespaces.push(namespace)
continue
}
if (ts.isEnumDeclaration(statement)) {
diagnostics.push({ ...diagnostic("enum", "Enums are not allowed"), range: nodeRange(statement) })
continue
}
const bucket = bucketOfTs(statement)
if (bucket === undefined) continue
if (ts.isFunctionDeclaration(statement) && statement.name !== undefined) {
const name = statement.name.text
if (statement.body === undefined) {
if (checkedFunctionOverloads.has(name)) continue
checkedFunctionOverloads.add(name)
} else if (checkedFunctionOverloads.has(name)) {
continue
}
}
const documented = parseDocumentedTs(statement, "declaration", diagnostics, true, linkContext)
if (documented === undefined) continue
const name = declarationName(statement)
if (name === undefined || name.length === 0) {
diagnostics.push({
...diagnostic("missing-name", "Root declaration name could not be determined"),
range: nodeRange(statement)
})
continue
}
declarations.push({
name,
bucket,
signature: declarationSignature(name, bucket, statement, checker, cwd),
range: publicDeclarationRange(statement, name),
description: documented.core.description,
examples: documented.core.examples,
tags: documented.tags as ParsedDeclarationTags,
members: parseDeclarationMembersTs(statement, diagnostics, linkContext)
})
}
return {
parsed: {
...(parsedModuleJSDoc === undefined ? {} : { moduleJSDoc: parsedModuleJSDoc }),
declarations,
namespaces
},
diagnostics: uniqueDiagnostics(diagnostics) as Array<JSDocModelDiagnostic>
}
}
export function loadJSDocConfig(cwd = process.cwd(), configPath = "jsdocs.config.json"): JSDocConfig {
const absolute = path.resolve(cwd, configPath)
const parsed = JSON.parse(fs.readFileSync(absolute, "utf8")) as JSDocConfig
return parsed
}
export function extractJSDocsSync(options: ExtractJSDocsOptions): JSDocModel {
const cwd = path.resolve(options.cwd ?? process.cwd())
const tsconfigPath = path.resolve(cwd, options.tsconfig)
const entry = getProgram(tsconfigPath)
if (entry.error !== undefined || entry.program === undefined) {
throw new Error(entry.error ?? `Unable to read ${tsconfigPath}`)
}
const program = entry.program
const checker = program.getTypeChecker()
const sourceByPath = new Map(program.getSourceFiles().map((file) => [path.resolve(file.fileName), file]))
const sourceFilesByFile = new Map(program.getSourceFiles().map((file) => [normalizeFile(cwd, file.fileName), file]))
const files = globSync([...options.include], {
cwd,
absolute: true,
nodir: true,
ignore: [...options.exclude ?? []]
}).sort()
const modelFiles: Array<JSDocModelFile> = []
for (const filename of files) {
const source = fs.readFileSync(filename, "utf8")
const file = normalizeFile(cwd, filename)
const hash = hashSource(source)
const sourceFile = sourceByPath.get(path.resolve(filename))
if (sourceFile === undefined) {
modelFiles.push({
file,
hash,
diagnostics: [{ ...diagnostic("tsconfig", `${file} is not included in ${options.tsconfig}`), range: [0, 0] }],
declarations: [],
namespaces: []
})
continue
}
const result = parseSourceFileDocs(cwd, sourceFile, checker, entry)
if (
result.diagnostics.length === 0 && result.parsed.declarations.length === 0 &&
result.parsed.namespaces.length === 0 && result.parsed.moduleJSDoc === undefined
) continue
const fileDiagnostics = [...result.diagnostics]
let importsValue: ParsedJSDocImports | undefined
if (
result.parsed.declarations.length > 0 || result.parsed.namespaces.length > 0 ||
result.parsed.moduleJSDoc !== undefined
) {
const imports = resolveJSDocImports(cwd, filename)
if (imports._tag === "Success") importsValue = imports.value
else {
fileDiagnostics.push({
...diagnostic(
"imports",
`Unable to resolve jsdocs imports: ${imports.error}. Add the missing barrel/package export or exclude this file from jsdocs.`
),
range: [0, 0]
})
}
}
modelFiles.push({
file,
hash,
diagnostics: fileDiagnostics,
...(importsValue === undefined ? {} : { imports: importsValue }),
...result.parsed
})
}
const filesWithExampleDiagnostics = appendExampleImportDiagnostics(modelFiles)
const withPublicSeeDiagnostics = buildJSDocApisWithPublicSeeDiagnostics(filesWithExampleDiagnostics, {
sourceFilesByFile,
checker,
entry,
cwd
})
return {
version: 2,
generatedBy: "@effect/jsdocs",
generatedAt: new Date().toISOString(),
inputHash: computeJSDocInputHash(options),
files: withPublicSeeDiagnostics.files,
apis: withPublicSeeDiagnostics.apis
}
}
export const extractJSDocs = (options: ExtractJSDocsOptions): Effect.Effect<JSDocModel> =>
Effect.sync(() => extractJSDocsSync(options))
export function writeJSDocModel(cwd: string, output: string, model: JSDocModel) {
const filename = path.resolve(cwd, output)
fs.mkdirSync(path.dirname(filename), { recursive: true })
fs.writeFileSync(filename, `${JSON.stringify(model, null, 2)}\n`)
}
export function readJSDocModel(filename: string): Result<JSDocModel, string> {
if (!fs.existsSync(filename)) return { _tag: "Failure", error: "missing" }
try {
const parsed = JSON.parse(fs.readFileSync(filename, "utf8")) as JSDocModel
if (parsed.version !== 2) return { _tag: "Failure", error: "Unsupported jsdocs model version" }
if (!Array.isArray(parsed.files)) return { _tag: "Failure", error: "Invalid jsdocs model: files must be an array" }
if (!Array.isArray(parsed.apis)) return { _tag: "Failure", error: "Invalid jsdocs model: apis must be an array" }
return { _tag: "Success", value: parsed }
} catch (error) {
return { _tag: "Failure", error: `Invalid jsdocs model: ${error instanceof Error ? error.message : String(error)}` }
}
}
export function sourceHash(source: string): string {
return hashSource(source)
}