Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f86c1a4
api-directory(feat[ports]) Read Erlang, OCaml and Common Lisp directo…
tony Nov 14, 2026
34940f8
concepts(fix[ocaml]) Map the OCaml server to the Client module
tony Nov 14, 2026
beb205a
lisp(feat[members]) List a client's operations as members of its class
tony Nov 14, 2026
bc459ef
concepts(test[ocaml]) Expect the Client module as the OCaml server
tony Nov 14, 2026
a88013a
stage-port-docs(fix[html-header]) Stage a README whose header and bad…
tony Nov 14, 2026
42be26a
erlang(chore[pin]) Pin Erlang to the tmux_server capability directory
tony Nov 14, 2026
8dae303
ocaml(chore[pin]) Pin OCaml to the Client capability directory
tony Nov 14, 2026
dc87fa9
lisp(chore[pin]) Pin Common Lisp to the client class capability direc…
tony Nov 14, 2026
491dd5a
api-directory(test[ports]) Check the pinned Erlang, OCaml and Lisp mo…
tony Nov 14, 2026
649394a
destinations(chore[evidence]) Drop the asyncio link the Lisp guide no…
tony Nov 14, 2026
46b9375
badges(feat[cpan]) Render CPAN version badges from the registry data
tony Nov 14, 2026
7cea5e5
ports(fix[installs]) Take the Java BOM and Swift range from the registry
tony Nov 14, 2026
6ab0984
raku(fix[member-order]) Rank Server members by the concepts they real…
tony Nov 14, 2026
a3ba4d5
raku(fix[prose]) Link code spans in descriptions to the member of the…
tony Nov 14, 2026
1f59f16
api(fix[style]) Keep the gap below a fenced block in a description
tony Nov 14, 2026
5751724
api-directory(feat[ports]) Read Raku, Perl and Clojure directory refe…
tony Nov 14, 2026
534bcac
raku(fix[prose]) Resolve a call written with arguments to the method …
tony Nov 14, 2026
d610140
raku(chore[pin]) Pin Raku to the Server capability directory
tony Nov 14, 2026
8a7cf8b
perl(chore[pin]) Pin Perl to the LibTmux::Server capability directory
tony Nov 14, 2026
8167d91
clojure(chore[pin]) Pin Clojure to the libtmux.core capability directory
tony Nov 14, 2026
723beb3
api-directory(test[ports]) Check the pinned Raku, Perl and Clojure mo…
tony Nov 14, 2026
91b5889
raku(fix[markdown]) Link Raku API names in the Markdown twin as the p…
tony Nov 14, 2026
3a52aaf
raku(fix[prose]) Resolve a bare code span only from the class it desc…
tony Nov 14, 2026
cd4b4fc
rehype-site-root(fix[badges]) Serve an HTML header's badges from the …
tony Nov 14, 2026
75400c2
Merge fix/reference-directories into fix/raku-perl-clojure-reference
tony Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 21 additions & 9 deletions packages/api-model/src/concepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4053,7 +4053,7 @@ for (const [id, binding] of Object.entries(ERLANG_CONCEPTS)) {
CONCEPTS['list-session-panes']!.absent!.julia = 'Traverse `windows(session)` and `panes(window)` in the captured snapshot. `panes(snapshot)` has server-wide scope.'

const OCAML_CONCEPTS: Record<string, { symbol: string; note?: string; variants?: string[] }> = {
server: { symbol: 'Libtmux_unix.Client.t', variants: ['Libtmux_eio.Client.t'], note: 'A client is a live, generation-bound server connection. Endpoint describes a socket without connecting; closing a client releases its resources.' },
server: { symbol: 'Libtmux_unix.Client', variants: ['Libtmux_eio.Client'], note: 'A client is a live, generation-bound server connection. Endpoint describes a socket without connecting; closing a client releases its resources.' },
session: { symbol: 'Libtmux.Session.t' },
window: { symbol: 'Libtmux.Window.t', note: 'A global window can have several session placements. Window_link holds the session, index and current flag.' },
pane: { symbol: 'Libtmux.Pane.t' },
Expand Down Expand Up @@ -4144,12 +4144,13 @@ for (const [id, binding] of Object.entries(CLOJURE_CONCEPTS)) {
concept.symbols.clojure = binding.symbol
if (binding.note) (concept.notes ??= {}).clojure = binding.note
}
const RAKU_CONCEPTS: Record<string, { symbol: string; note?: string }> = {
const RAKU_SEARCH_NOTE = 'Explicit acquisition returns a local, replayable selection; the pushdown policy decides which predicates tmux evaluates and which run locally.'
const RAKU_CONCEPTS: Record<string, { symbol: string; note?: string; variants?: string[] }> = {
server: { symbol: 'LibTmux::Server' },
session: { symbol: 'LibTmux::Model::Session' },
window: { symbol: 'LibTmux::Model::Window', note: 'A physical window may have multiple WindowLink placements, including distinct indexes in one session.' },
pane: { symbol: 'LibTmux::Model::Pane' },
snapshot: { symbol: 'LibTmux::Server.snapshot', note: 'Explicit I/O captures an observation interval. Existing views and relationships remain local.' },
snapshot: { symbol: 'LibTmux::Server.snapshot', variants: ['LibTmux::Server.snapshot-async'], note: 'Explicit I/O captures an observation interval. Existing views and relationships remain local.' },
'list-sessions': { symbol: 'LibTmux::Model::Snapshot.sessions' },
'list-server-windows': { symbol: 'LibTmux::Model::Snapshot.windows', note: 'Physical windows occur once; window-links preserves placements.' },
'list-server-panes': { symbol: 'LibTmux::Model::Snapshot.panes' },
Expand All @@ -4167,14 +4168,19 @@ const RAKU_CONCEPTS: Record<string, { symbol: string; note?: string }> = {
'kill-server': { symbol: 'LibTmux::Server.kill-server', note: 'Explicitly terminates the observed daemon. Owned callers still close to join cleanup.' },
'server-from-env': { symbol: 'LibTmux::Server.from-env', note: 'Explicitly imports TMUX environment data, verifies the daemon PID and binds that incarnation.' },
'has-session': { symbol: 'LibTmux::Server.has-session', note: 'Checks one exact ID or name; acquisition and identity failures still throw.' },
'run-shell': { symbol: 'LibTmux::Server.run-shell', note: 'Background acknowledgement does not establish job completion.' },
'run-shell': { symbol: 'LibTmux::Server.run-shell', variants: ['LibTmux::Server.run-shell-async'], note: 'Background acknowledgement does not establish job completion.' },
'if-shell': { symbol: 'LibTmux::Server.if-shell', note: 'Branches use bounded Invocation values; the condition explicitly selects shell or FORMAT semantics.' },
'source-file': { symbol: 'LibTmux::Server.source-file', note: 'Sources a literal pathname; parse-only validates without executing commands.' },
'wait-for': { symbol: 'LibTmux::Server.wait-for', note: 'Waits, signals, locks or unlocks a named channel within the Server deadline.' },
'server-cmd': { symbol: 'LibTmux::Server.command' },
'attach-session': { symbol: 'LibTmux::Server.attach', note: 'Returns a separately owned attachment with an explicit lifecycle.' },
'server-version': { symbol: 'LibTmux::Server.capabilities', note: 'Client and observed daemon versions are distinct capability inputs.' },
'chain-commands': { symbol: 'LibTmux::Server.run-batch', note: 'Returns per-operation evidence and partial results; this is not a transaction.' },
'wait-for': { symbol: 'LibTmux::Server.wait-for', variants: ['LibTmux::Server.wait-for-async'], note: 'Waits, signals, locks or unlocks a named channel within the Server deadline.' },
'server-cmd': { symbol: 'LibTmux::Server.command', variants: ['LibTmux::Server.command-async'] },
'attach-session': { symbol: 'LibTmux::Server.attach', variants: ['LibTmux::Server.attach-async', 'LibTmux::Server.open-control', 'LibTmux::Server.open-control-async'], note: 'Returns a separately owned attachment with an explicit lifecycle.' },
'server-version': { symbol: 'LibTmux::Server.capabilities', variants: ['LibTmux::Server.capabilities-async'], note: 'Client and observed daemon versions are distinct capability inputs.' },
'chain-commands': { symbol: 'LibTmux::Server.run-batch', variants: ['LibTmux::Server.run-batch-async'], note: 'Returns per-operation evidence and partial results; this is not a transaction.' },
// `query` takes the entity as an argument and `query-panes` and its siblings call it, so it realises each search.
'search-sessions': { symbol: 'LibTmux::Server.query-sessions', variants: ['LibTmux::Server.query', 'LibTmux::Server.query-async'], note: RAKU_SEARCH_NOTE },
'search-server-windows': { symbol: 'LibTmux::Server.query-windows', variants: ['LibTmux::Server.query', 'LibTmux::Server.query-async'], note: RAKU_SEARCH_NOTE },
'search-server-panes': { symbol: 'LibTmux::Server.query-panes', variants: ['LibTmux::Server.query', 'LibTmux::Server.query-async'], note: RAKU_SEARCH_NOTE },
'global-options': { symbol: 'LibTmux::Server.options', note: 'Pass :scope<session> or :scope<window> for the defaults new sessions and windows inherit.' },
}
for (const [owner, bindings] of Object.entries({
SessionRef: { 'new-window': 'new-window', 'kill-session': 'kill', 'rename-session': 'rename', 'session-cmd': 'command',
Expand Down Expand Up @@ -4216,11 +4222,17 @@ for (const [concept, method] of Object.entries({ 'list-buffers': 'list', 'show-b
RAKU_CONCEPTS[concept] = { symbol: `LibTmux::Configuration::Buffers.${method}`, note: 'Buffer content is byte data; file I/O remains a separate caller operation.' }
}
for (const [concept, method] of Object.entries({ 'list-keys': 'list', 'bind-key': 'bind', 'unbind-key': 'unbind' })) RAKU_CONCEPTS[concept] = { symbol: `LibTmux::Configuration::KeyBindings.${method}` }
// Other ports map their Server accessor for each of these to the same concept; in Raku it returns the facade that realises it.
// `key-bindings` stays unmapped: no port maps a Server accessor to `list-keys`, and that concept ranks as a listing.
for (const [concept, accessor] of Object.entries({ 'server-options': 'options', 'server-hooks': 'hooks', 'server-environment': 'environment', 'list-buffers': 'buffers' })) {
(RAKU_CONCEPTS[concept]!.variants ??= []).push(`LibTmux::Server.${accessor}`)
}
for (const [id, binding] of Object.entries(RAKU_CONCEPTS)) {
const concept = CONCEPTS[id]
if (!concept) throw new Error(`concepts: Raku maps unknown concept ${id}`)
concept.symbols.raku = binding.symbol
if (binding.note) (concept.notes ??= {}).raku = binding.note
if (binding.variants) (concept.variants ??= {}).raku = binding.variants
}
for (const [id, reason] of Object.entries({
'workspace-description': 'Workspace functionality is outside the Raku core library scope.',
Expand Down
5 changes: 3 additions & 2 deletions packages/api-model/src/doc/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,9 @@ const SYNTAX: Record<string, ReadonlySet<string>> = {
py: new Set(['rest', 'citation']),
// Pod::Simple exports local POD targets as roles and external targets as Markdown links.
perl: new Set(['pod', 'markdown-link']),
// The RakuDoc adapter resolves L<> links into roles; C<> stays literal code.
raku: new Set(['rest-role', 'markdown-link', 'literal-code']),
// The RakuDoc adapter resolves L<> links into roles. The exporter writes C<> as a Markdown span, `start` or ` start `, which
// names a member of the class being described like any other port's code span; a doubled span stays literal.
raku: new Set(['rest-role', 'markdown-link']),
rs: new Set(['intra-doc']),
go: new Set(['go-link']),
java: new Set(['jsdoc']),
Expand Down
10 changes: 8 additions & 2 deletions packages/api-model/src/languages/lisp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,19 @@ export function extractCommonLisp(input: unknown, revision?: string): ApiModel {
kind: 'module', modifiers: [], signatures: [], product: pkg.product, package: pkg.system,
publicOwner: pkg.name, apiScope: 'exported', doc: documentation(pkg.documentation), source: source(pkg.source),
})
// A function whose first required parameter is the client operates on it, so the client's page lists it as a
// member, as other ports' server pages list their methods.
const client = pkg.symbols.find((symbol) => symbol.kind === 'class' && symbol.name === 'client')?.id
for (const symbol of pkg.symbols) {
const params = symbol.signature ? parameters(symbol.signature) : []
const operation = client && symbol.kind === 'function' && !symbol.modifiers.includes('macro') &&
params[0]?.name.toLowerCase() === 'client' && !params[0].optional && !params[0].variadic
symbols.push({
id: symbol.id, publicId: symbol.id, qualifiedName: `${pkg.name}:${symbol.name}`,
namespace: pkg.name, name: symbol.name, kind: symbol.kind as SymbolKind,
modifiers: symbol.modifiers as Modifier[], parent: pkg.name,
modifiers: symbol.modifiers as Modifier[], parent: operation ? client : pkg.name,
signatures: [
...(symbol.signature ? [{ raw: symbol.signature, params: parameters(symbol.signature) }] : []),
...(symbol.signature ? [{ raw: symbol.signature, params }] : []),
...(symbol.methods ?? []).map((method) => methodSignature(symbol.id, method, names)),
],
product: pkg.product, package: pkg.system, publicOwner: pkg.name, apiScope: 'exported',
Expand Down
56 changes: 52 additions & 4 deletions packages/api-model/src/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ export function isFilePath(text: string): boolean {
* (`send_keys`, `sendKeys`, `Server::kill`) is an identifier: it says which declaration it means, and keeps its link.
*/
const plainWord = /^[a-z][a-z0-9]*$/
/** A bare Raku name: one word, with the hyphens a Raku identifier takes, and no qualifier, sigil, adverb or call. */
const rakuBareName = /^[A-Za-z_][\w-]*$/

interface TypeSpan {
text: string
Expand Down Expand Up @@ -735,7 +737,49 @@ export class SymbolIndex {
const parameter = this.typeParameterLink(target, context)
if (parameter) return parameter
}
return role === 'typeparam' ? undefined : this.resolveSymbol(target, role, context)
if (role === 'typeparam') return undefined
const hit = this.resolveSymbol(target, role, context)
return this.lang === 'raku' && role === 'any' && context && rakuBareName.test(target) ? this.rakuMember(target, hit, context) : hit
}

/**
* What a bare Raku name in a description means: a member of the class being described, or of the class a module is
* named for, and no member of another class that happens to share its name. `bell` on the page of `LibTmux::Catalogue`,
* a list of tmux format names, is a format name and not `WindowLink.bell`; `where` on the page of `LibTmux::Selection`
* is `Selection::Selection.where`. A type, a module and a function keep the way they resolve. A name that no class in
* scope declares is plain code. An identifier, with a hyphen or a capital (`to-json`, `ACCEPTS`), also names a member
* of a class declared directly in the module being described, as it names one declaration; a plain word does not, as
* `reset` on the page of `LibTmux::Model` is a shell command and not `PaneRef.reset`.
*/
private rakuMember(name: string, hit: LinkTarget | undefined, context: ApiSymbol): LinkTarget | undefined {
const member = hit?.symbol
const owner = member?.parent ? this.byDeclared.get(member.parent) : undefined
if (hit && (!member || TYPE_KINDS.has(member.kind) || member.kind === 'module' || !owner || !TYPE_KINDS.has(owner.kind))) return hit
const scope = this.rakuScope(context, !plainWord.test(name))
if (owner && scope.has(owner.id)) return hit
// The scope may declare the name although the global lookup picked another class's, or was ambiguous.
const declared = [...scope].flatMap((id) => {
const found = this.byPublic.get(`${this.byDeclared.get(id)?.publicId ?? id}.${name}`)
return found && !TYPE_KINDS.has(found.kind) ? [found] : []
})
return declared.length === 1 ? { href: this.hrefFor(declared[0]!), external: false, symbol: declared[0] } : undefined
}

/**
* The declarations a bare Raku name is looked up in: the symbol and what encloses it, and the class a module is named
* for. With `nested`, the classes declared directly in the symbol too, when it is a module.
*/
private rakuScope(context: ApiSymbol, nested = false): Set<string> {
const owners = this.ownersOf(context)
const scope = new Set(owners)
for (const id of owners) {
const module = this.byDeclared.get(id)
if (module?.kind !== 'module') continue
for (const child of this.membersByOwner.get(id) ?? []) {
if (TYPE_KINDS.has(child.kind) && (child.name === module.name || (nested && id === context.id))) scope.add(child.id)
}
}
return scope
}

/** Resolve odoc's namespaces from the containing module outward, without a global suffix guess. */
Expand Down Expand Up @@ -803,6 +847,8 @@ export class SymbolIndex {
return { href: `${this.hrefFor(owner).split('#')[0]}#${encodeURIComponent(docSectionId(owner, section))}`, external: false, symbol: owner }
}
if (this.lang === 'erlang') return this.resolveErlang(target, role, context)
// A Raku call names its method before the parenthesis, whatever the arguments or adverbs: `has-session(:name)`.
if (this.lang === 'raku') target = target.replace(/^([A-Za-z_][\w-]*(?:(?:::|\.)[A-Za-z_][\w-]*)*)\(.*\)$/s, '$1')
const relative = target.startsWith('.')
const goType = this.lang === 'go' && role === 'class'
if (this.lang === 'ruby') {
Expand Down Expand Up @@ -1194,8 +1240,10 @@ export class SymbolIndex {
const resolved = this.resolve(span.target, span.role, context)
const lone = /^([A-Za-z_$][\w$]*)(\(.*\))?$/s.exec(span.target)
const link = lone && this.isAnotherOwnersMember(lone[1]!, resolved, context, this.isCall(lone[2] ?? '')) ? undefined : resolved
// A `typeparam` reference that names nothing in scope is a literal, not a lookup of a global.
return !link && span.role === 'typeparam' ? { kind: 'code' as const, text: span.label } : { ...span, link }
// A `typeparam` reference that names nothing in scope is a literal, not a lookup of a global. So is a Raku span that names
// nothing whole: a name has hyphens and an adverb a leading colon (`:deadline(Instant)`), which the literal tokenizer reads as
// operators between other names, so it would link `deadline` to the member of another class.
return !link && (span.role === 'typeparam' || this.lang === 'raku') ? { kind: 'code' as const, text: span.label } : { ...span, link }
})
}

Expand Down Expand Up @@ -1237,7 +1285,7 @@ export class SymbolIndex {
if (!owner || !TYPE_KINDS.has(owner.kind)) return false
// A page of the public API links nothing into the library's internals.
if ((this.isInternal(member) || this.isInternal(owner)) && !this.isInternal(context)) return true
const chain = this.ownersOf(context)
const chain = this.lang === 'raku' ? this.rakuScope(context) : this.ownersOf(context)
if (chain.has(owner.id)) return false
// A language whose ids this index does not walk by `.` may hold the member in an enclosing type as well.
if ((this.byName.get(member.name) ?? []).some((other) => other.parent !== undefined && chain.has(other.parent))) return false
Expand Down
43 changes: 36 additions & 7 deletions packages/api-model/src/member-order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,31 @@ export interface MemberSignals {
readonly inheritedParents: ReadonlyMap<string, ReadonlySet<string>>
/** Guide pages that discuss a member, by public id. */
readonly mentions: ReadonlyMap<string, number>
/** The suffix that names a method's promise-returning twin on this port, when it has one. */
readonly twinSuffix?: string
}

/**
* The suffix that spells a method's promise-returning twin, per port.
*
* Raku declares `snapshot` and `snapshot-async` side by side. A reader looks for the twin where the original is, so a
* twin has the signals of its original unless it has some of its own. Other ports name twins differently
* (`xAsync`, `x_async`) or not at all, and `close-async` there is an unrelated name.
*/
const TWIN_SUFFIX: Readonly<Record<string, string>> = { raku: '-async' }

/** A map whose entry for a twin falls back to the entry for its original: no entry is invented for a twin that does not exist. */
class TwinMap<V> extends Map<string, V> {
private readonly suffix: string | undefined
constructor(suffix: string | undefined) {
super()
this.suffix = suffix
}
override get(id: string): V | undefined {
const own = super.get(id)
return own !== undefined || !this.suffix || !id.endsWith(this.suffix) ? own : super.get(id.slice(0, -this.suffix.length))
}
override has(id: string): boolean { return this.get(id) !== undefined }
}

export const MEMBER_TIERS = [
Expand All @@ -53,11 +78,12 @@ export function memberSignals(
mentions: readonly Mention[] = [],
concepts: Record<string, Concept> = CONCEPTS,
): MemberSignals {
const conceptPorts = new Map<string, number>()
const conceptIds = new Map<string, string>()
const listingConcepts = new Map<string, string>()
const conceptOrder = new Map<string, number>()
const parentObjects = new Map<string, { level: number; variant: number }>()
const twinSuffix = TWIN_SUFFIX[port]
const conceptPorts = new TwinMap<number>(twinSuffix)
const conceptIds = new TwinMap<string>(twinSuffix)
const listingConcepts = new TwinMap<string>(twinSuffix)
const conceptOrder = new TwinMap<number>(twinSuffix)
const parentObjects = new TwinMap<{ level: number; variant: number }>(twinSuffix)
const inheritedParents = new Map<string, Set<string>>()
for (const [index, [id, concept]] of Object.entries(concepts).entries()) {
const symbol = concept.symbols[port]
Expand All @@ -81,9 +107,9 @@ export function memberSignals(
}
}
}
const counts = new Map<string, number>()
const counts = new TwinMap<number>(twinSuffix)
for (const m of mentions) if (m.port === port) counts.set(m.symbol, (counts.get(m.symbol) ?? 0) + 1)
return { conceptPorts, conceptIds, listingConcepts, conceptOrder, parentObjects, inheritedParents, mentions: counts }
return { conceptPorts, conceptIds, listingConcepts, conceptOrder, parentObjects, inheritedParents, mentions: counts, twinSuffix }
}

const idOf = (s: ApiSymbol) => s.publicId ?? s.id
Expand Down Expand Up @@ -211,6 +237,8 @@ export function memberTier(s: ApiSymbol, signals: MemberSignals): MemberTier {
* `new_session`. Every port implements `new-session`; fewer bind keys.
*/
export function compareMembers(signals: MemberSignals): (a: ApiSymbol, b: ApiSymbol) => number {
// Equal signals put a twin by its original; ordering by the original's name keeps another method from landing between them.
const base = (s: ApiSymbol) => signals.twinSuffix && s.name.endsWith(signals.twinSuffix) ? s.name.slice(0, -signals.twinSuffix.length) : s.name
const tier = new Map<ApiSymbol, number>()
const rank = (s: ApiSymbol) => {
let t = tier.get(s)
Expand All @@ -230,6 +258,7 @@ export function compareMembers(signals: MemberSignals): (a: ApiSymbol, b: ApiSym
(signals.conceptOrder.get(idOf(a)) ?? Infinity) - (signals.conceptOrder.get(idOf(b)) ?? Infinity) ||
(memberTier(a, signals) === 'listing' ? listingRank(a, signals) - listingRank(b, signals) : 0) ||
(signals.mentions.get(idOf(b)) ?? 0) - (signals.mentions.get(idOf(a)) ?? 0) ||
base(a).localeCompare(base(b)) ||
a.name.localeCompare(b.name) ||
idOf(a).localeCompare(idOf(b))
}
Loading
Loading