Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 8 additions & 4 deletions notes/adding-a-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,16 +167,20 @@ $ escript packages/api-model/scripts/export-erlang.escript \
../libtmux-erlang/_build/docs/api.json
```

The artifact records the source commit, tracked input hashes and OTP version.
The artifact records the source commit, tracked input hashes, OTP version and
exporter source digest.
It retains native module, function, type and callback documentation, typespecs,
nominal types, supporting non-exported types and exported native records.
Source locations follow included headers. Opaque declarations expose their
name and parameters. Hidden modules and members stay out. Dirty sources,
missing compiler chunks and ambiguous source paths fail the export.

This artifact is preparation for an Erlang model adapter. Erlang has no shared
site routes or package destinations until that adapter and the port's delivery
checks are complete. The compiler fixture runs in the outer test loop; it
`extractErlang` maps the artifact to the shared API model. The compiler supplies
callable parameters, return types, overload clauses and quoted atom spellings.
The model retains native arities, supporting types and distinct type/callback
identities. Native `m:`, `t:` and `c:` references resolve within their module.
Erlang has no shared site routes or package destinations until the port's
delivery checks are complete. The compiler fixture runs in the outer test loop; it
requires OTP 29 and Rebar3 on CI and skips locally when that toolchain is absent.

### Wrapper libraries
Expand Down
58 changes: 53 additions & 5 deletions packages/api-model/scripts/export-erlang.escript
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
main([Root0, AppName, Output0]) ->
Output = filename:absname(Output0),
try
ExporterDigest = exporter_digest(),
"29" = erlang:system_info(otp_release),
Root = filename:absname(Root0),
ok = file:set_cwd(Root),
Expand All @@ -28,8 +29,9 @@ main([Root0, AppName, Output0]) ->
"source revision changed while compiling"),
Artifact = #{schema => 1, port => <<"erlang">>,
source => #{repository => Repository, revision => Revision, files => Files},
exporter => #{name => <<"Erlang EEP-48 and abstract code">>, version => 1,
otp => otp_version(), columns => 60},
exporter => #{name => <<"Erlang EEP-48 and abstract code">>, version => 2,
otp => otp_version(), columns => 60,
source_sha256 => ExporterDigest},
package => #{name => bin(AppName), version => bin(proplists:get_value(vsn, Properties))},
modules => Public},
ok = filelib:ensure_dir(Output),
Expand Down Expand Up @@ -60,10 +62,10 @@ module(Name, Ebin, Files) ->
Records = lists:append([Rs || {attribute, _, export_record, Rs} <- Forms]),
Members = [member(E, Located, Types) || E <- lists:sort(Entries),
public(E, Exports)],
#{name => bin(Name), doc => documentation(Doc),
#{name => bin(Name), display_name => atom_text(Name), doc => documentation(Doc),
source => location(File, maps:get(source_anno, Meta, 1)),
members => Members,
records => [#{name => bin(R), declaration => pretty(F),
records => [#{name => bin(R), display_name => atom_text(R), declaration => pretty(F),
source => location(RecordFile, Anno)} ||
{RecordFile, F = {attribute, Anno, native_record, {R, _}}} <- Located,
lists:member(R, Records)]};
Expand All @@ -87,14 +89,60 @@ member({{Kind, Name, Arity}, _, Signatures, Doc, Meta}, Located, Types) ->
[nominal] -> nominal;
_ -> public
end,
#{kind => Kind, name => bin(Name), arity => Arity,
#{kind => Kind, name => bin(Name), display_name => atom_text(Name), arity => Arity,
signatures => Signatures, doc => documentation(Doc),
specs => callable_specs(Kind, Name, Arity, Located, Declarations),
since => metadata(since, Meta), deprecated => metadata(deprecated, Meta),
visibility => Visibility,
exported => Kind =/= type orelse lists:member({Name, Arity}, Types),
source => member_location(Kind, Name, Arity, Located, Declarations),
declarations => [declaration_text(F) || {_, F} <- Declarations]}.

callable_specs(type, _, _, _, _) -> [];
callable_specs(Kind, Name, Arity, Located, Declarations) ->
Patterns = case [Args || {_, {function, _, N, A, [{clause, _, Args, _, _} | _]}} <- Located,
N =:= Name, A =:= Arity] of
[Args] -> Args;
[] -> lists:duplicate(Arity, none)
end,
Attribute = case Kind of function -> spec; callback -> callback end,
[spec_clause(Clause, Patterns, Attribute, Anno, Name, Arity)
|| {_, {attribute, Anno, AttributeName, {{Name1, Arity1}, Clauses}}} <- Declarations,
AttributeName =:= Attribute, Name1 =:= Name, Arity1 =:= Arity, Clause <- Clauses].

spec_clause(Clause, Patterns, Attribute, Anno, Name, Arity) ->
Fun = case Clause of
{type, _, bounded_fun, [Function, _]} -> Function;
_ -> Clause
end,
{type, _, 'fun', [{type, _, product, Parameters}, Return]} = Fun,
require(length(Parameters) =:= Arity, "compiler typespec arity mismatch"),
#{parameters => [parameter(Type, Pattern, Index)
|| {Type, Pattern, Index} <- lists:zip3(Parameters, Patterns, lists:seq(1, Arity))],
returns => type_text(Return),
text => pretty({attribute, Anno, Attribute, {{Name, Arity}, [Clause]}})}.

parameter({ann_type, _, [{var, _, Name}, Type]}, _, _) ->
#{name => bin(Name), type => type_text(Type)};
parameter(Type, Pattern, Index) ->
Name = case Pattern of
{var, _, Variable} when Variable =/= '_' -> bin(Variable);
{match, _, {var, _, Variable}, _} when Variable =/= '_' -> bin(Variable);
{match, _, _, {var, _, Variable}} when Variable =/= '_' -> bin(Variable);
_ -> <<"Argument", (integer_to_binary(Index))/binary>>
end,
#{name => Name, type => type_text(Type)}.

type_text(Type) ->
[_, Body] = binary:split(pretty({attribute, 0, type, {doc_type, Type, []}}), <<"::">>),
trim(binary:part(Body, 0, byte_size(Body) - 1)).

atom_text(Name) -> unicode:characters_to_binary(erl_pp:expr({atom, 0, Name}, [{encoding, unicode}])).

exporter_digest() ->
{ok, Source} = file:read_file(escript:script_name()),
binary:encode_hex(crypto:hash(sha256, Source), lowercase).

member_location(function, Name, Arity, Located, _) ->
[{File, {function, Anno, Name, Arity, _}}] =
[{F, Form} || {F, Form = {function, _, N, A, _}} <- Located,
Expand Down
2 changes: 2 additions & 0 deletions packages/api-model/src/doc/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ const SYNTAX: Record<string, ReadonlySet<string>> = {
julia: new Set(['markdown-link', 'documenter']),
// ExDoc links a Markdown destination written as code: [the server](`LibTmux.Server`).
elixir: new Set(['markdown-link', 'exdoc']),
erlang: new Set(['markdown-link', 'exdoc']),
powershell: new Set(['markdown-link']),
}

Expand Down Expand Up @@ -604,6 +605,7 @@ function tokenizeRun(text: string, lang?: string): DocSpan[] {
: syntax.has('docc') && code.ticks === 2
? { kind: 'ref', role: 'any', target: doccTarget(code.text), label: code.text }
: { kind: 'code', text: code.text }
if (lang === 'erlang' && span.kind === 'ref') span.label = span.label.replace(/^[mtc]:/, '')
hits.push({ start: code.start, end: code.end, span })
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/api-model/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export { extractLua } from './languages/lua.ts'
export { extractPowerShell } from './languages/powershell.ts'
export { extractJulia } from './languages/julia.ts'
export { ELIXIR_DECLARATION_COLUMNS, extractElixir } from './languages/elixir.ts'
export { extractErlang } from './languages/erlang.ts'
export { extractFSharp, extractFSharpArtifact } from './languages/fsharp.ts'
export { extractProject } from './project.ts'
export { resolvePublicIds } from './reexports.ts'
Expand Down
1 change: 1 addition & 0 deletions packages/api-model/src/inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const DOMAIN: Record<PortSlug, string> = {
powershell: 'std',
julia: 'std',
elixir: 'std',
erlang: 'std',
cxx: 'cpp',
ts: 'js',
rs: 'std',
Expand Down
177 changes: 177 additions & 0 deletions packages/api-model/src/languages/erlang.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { parseMarkdownDocFull } from '../doc/markdown.ts'
import type { ApiModel, ApiSymbol, DocBlock, Modifier, Signature } from '../model.ts'

interface Source { file: string; line: number }
interface Spec { parameters: { name: string; type: string }[]; returns: string; text: string }
interface Member {
name: string
display_name: string
kind: 'function' | 'callback' | 'type'
arity: number
doc: string | null
since: string | null
deprecated: string | null
visibility: 'public' | 'opaque' | 'nominal'
exported: boolean
signatures: string[]
declarations: string[]
specs: Spec[]
source: Source
}
interface Module {
name: string
display_name: string
doc: string | null
source: Source
members: Member[]
records: { name: string; display_name: string; declaration: string; source: Source }[]
}
interface Artifact {
schema: number
port: string
source: { repository: string; revision: string; files: { path: string; sha256: string }[] }
exporter: { name: string; version: number; columns: number; otp: string; source_sha256: string }
package: { name: string; version: string }
modules: Module[]
}

function assertArtifact(input: unknown, expectedRevision?: string): Artifact {
const artifact = input as Artifact
if (artifact?.schema !== 1 || artifact.port !== 'erlang' || artifact.exporter?.version !== 2 ||
artifact.exporter.columns !== 60 || !/^[a-f0-9]{64}$/.test(artifact.exporter.source_sha256 ?? '') ||
!Array.isArray(artifact.modules) || !artifact.modules.length ||
!/^[a-f0-9]{40}$/.test(artifact.source?.revision ?? '') ||
!Array.isArray(artifact.source?.files) || !artifact.source.files.length ||
!artifact.source.repository || !artifact.package?.name || !artifact.package.version) {
throw new Error('Erlang compiler artifact has an unsupported schema or source identity')
}
if (expectedRevision && artifact.source.revision !== expectedRevision) {
throw new Error('Erlang artifact source revision differs from the selected checkout')
}
const files = new Set(artifact.source.files.map((file) => {
if (!file.path || file.path.startsWith('/') || file.path.split('/').includes('..') ||
!/^[a-f0-9]{64}$/.test(file.sha256)) throw new Error('Erlang artifact has invalid source inputs')
return file.path
}))
if (files.size !== artifact.source.files.length) throw new Error('Erlang artifact repeats a source input')
const location = (source: Source) => {
if (!source || !files.has(source.file) || !Number.isInteger(source.line) || source.line < 1) {
throw new Error('Erlang artifact lost a declaration source')
}
}
const seen = new Set<string>()
const unique = (id: string) => {
if (seen.has(id)) throw new Error(`Erlang artifact repeats ${id}`)
seen.add(id)
}
for (const module of artifact.modules) {
if (!module.display_name || !Array.isArray(module.members) || !Array.isArray(module.records)) {
throw new Error('Erlang artifact lost a module declaration')
}
unique(module.display_name)
location(module.source)
for (const member of module.members) {
if (!['function', 'callback', 'type'].includes(member.kind) || !member.display_name ||
!Number.isInteger(member.arity) || member.arity < 0 || member.arity > 255 ||
!member.declarations?.length || !Array.isArray(member.specs) ||
typeof member.exported !== 'boolean' || !['public', 'opaque', 'nominal'].includes(member.visibility)) {
throw new Error('Erlang artifact lost a member declaration')
}
unique(memberId(module, member))
location(member.source)
if (member.visibility === 'opaque' && member.declarations.some(opaqueBody)) {
throw new Error('Erlang artifact exposes an opaque type body')
}
if (member.kind !== 'type' && (!Array.isArray(member.signatures) ||
!member.signatures.length || member.signatures.some((head) => typeof head !== 'string' || !head.trim()) ||
!member.specs.length || member.specs.some((spec) => !spec ||
!Array.isArray(spec.parameters) || spec.parameters.length !== member.arity ||
spec.parameters.some((parameter) => !parameter ||
typeof parameter.name !== 'string' || !parameter.name.trim() ||
typeof parameter.type !== 'string' || !parameter.type.trim()) ||
typeof spec.returns !== 'string' || !spec.returns.trim() ||
typeof spec.text !== 'string' || !spec.text.trim()))) {
throw new Error('Erlang artifact lost its native callable signature')
}
}
for (const record of module.records) {
if (!record.display_name || !record.declaration) throw new Error('Erlang artifact lost a native record declaration')
unique(recordId(module, record.display_name))
location(record.source)
}
}
return artifact
}

/** A body delimiter inside a compiler-quoted atom is part of its name. */
function opaqueBody(text: string): boolean {
let quoted = false
for (let at = 0; at < text.length; at++) {
const character = text[at]
if (quoted && character === '\\') { at++; continue }
if (character === "'") { quoted = !quoted; continue }
if (!quoted && character === ':' && text[at + 1] === ':') return true
}
return quoted
}

const memberId = (module: Module, member: Member): string =>
`${member.kind === 'type' ? 't:' : member.kind === 'callback' ? 'c:' : ''}${module.display_name}:${member.display_name}/${member.arity}`
const recordId = (module: Module, name: string): string => `#${module.display_name}:${name}{}`

function documentation(text: string | null, since?: string | null, deprecated?: string | null): DocBlock | undefined {
const doc = text?.trim() ? parseMarkdownDocFull(text, 'erlang').doc : undefined
return doc || since || deprecated
? { ...(doc ?? { summary: '' }), ...(since ? { since } : {}), ...(deprecated ? { deprecated } : {}) }
: undefined
}

/** Map compiler declarations without guessing types or parsing Erlang source in JavaScript. */
export function extractErlang(input: unknown, expectedRevision?: string): ApiModel {
const artifact = assertArtifact(input, expectedRevision)
const source = (location: Source): ApiSymbol['source'] => ({
...location, repo: artifact.source.repository, revision: artifact.source.revision,
extractedRevision: artifact.source.revision,
})
const common = { package: artifact.package.name, product: 'core' as const }
const symbols: ApiSymbol[] = []
for (const module of artifact.modules) {
const owner = module.display_name
symbols.push({ id: owner, publicId: owner, qualifiedName: owner, name: owner,
kind: 'module', modifiers: [], signatures: [], apiScope: 'exported',
...common, doc: documentation(module.doc), source: source(module.source) })
for (const member of module.members) {
const type = member.kind === 'type'
const modifiers: Modifier[] = [
...(member.visibility === 'opaque' ? ['opaque' as const] : []),
...(member.kind === 'callback' ? ['abstract' as const] : []),
...(member.deprecated ? ['deprecated' as const] : []),
...(member.specs.length > 1 ? ['overload' as const] : []),
]
const signatures: Signature[] = type
? member.declarations.map((raw) => ({ raw, params: [] }))
: member.specs.map((spec) => ({
raw: member.kind === 'callback' ? spec.text : `${member.signatures[0]}\n${spec.text}`,
params: spec.parameters, returns: spec.returns,
}))
const id = memberId(module, member)
symbols.push({ id, publicId: id, qualifiedName: `${owner}:${member.display_name}/${member.arity}`,
name: `${member.display_name}/${member.arity}`, namespace: owner, parent: owner,
kind: type ? 'typealias' : 'function', modifiers, signatures,
...common, apiScope: member.exported ? 'exported' : 'supporting',
doc: documentation(member.doc, member.since, member.deprecated), source: source(member.source) })
}
for (const record of module.records) {
const id = recordId(module, record.display_name)
symbols.push({ id, publicId: id, qualifiedName: id, name: `#${record.display_name}{}`,
namespace: owner, parent: owner, kind: 'struct', modifiers: [],
signatures: [{ raw: record.declaration, params: [] }], ...common,
apiScope: 'exported', source: source(record.source) })
}
}
return { port: 'erlang', repo: artifact.source.repository, revision: artifact.source.revision,
extractor: `@libtmux/api-model@0.0.1 (${artifact.exporter.name} v${artifact.exporter.version})`,
nativeProvenance: { files: artifact.source.files, exporter: artifact.exporter },
sources: [{ ...common, version: artifact.package.version, repo: artifact.source.repository,
revision: artifact.source.revision, extractedRevision: artifact.source.revision }], symbols }
}
20 changes: 20 additions & 0 deletions packages/api-model/src/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -716,6 +716,7 @@ export class SymbolIndex {
}

private resolveSymbol(target: string, role: string, context?: ApiSymbol): LinkTarget | undefined {
if (this.lang === 'erlang') return this.resolveErlang(target, role, context)
const relative = target.startsWith('.')
const goType = this.lang === 'go' && role === 'class'
if (this.lang === 'ruby') {
Expand Down Expand Up @@ -948,6 +949,25 @@ export class SymbolIndex {
return undefined
}

/** Resolve compiler-qualified Erlang identities within the native namespace. */
private resolveErlang(target: string, role: string, context?: ApiSymbol): LinkTarget | undefined {
const prefix = /^(?:([mtc]):)?/.exec(target)![1]
const body = prefix ? target.slice(2) : target
const wanted = prefix === 'm' ? body : target
const kinds = ROLE_KINDS[role] ?? []
const matches = (symbol: ApiSymbol | undefined) => symbol &&
(!kinds.length || kinds.includes(symbol.kind)) &&
(prefix === 'm' ? symbol.kind === 'module' : prefix === 't' ? symbol.kind === 'typealias'
: prefix === 'c' ? symbol.modifiers.includes('abstract') : !symbol.id.startsWith('t:') && !symbol.id.startsWith('c:'))
let hit = this.byDeclared.get(wanted)
if (!matches(hit) && prefix !== 'm') {
let owner = context
while (owner?.parent) owner = this.byDeclared.get(owner.parent)
if (owner?.kind === 'module') hit = this.byDeclared.get(`${prefix ? `${prefix}:` : ''}${owner.id}:${body}`)
}
return matches(hit) ? { href: this.hrefFor(hit!), external: false, symbol: hit } : undefined
}

/** The module a declaration belongs to: itself, or its parent. */
private elixirOwner(context?: ApiSymbol): ApiSymbol | undefined {
return context?.parent ? this.byDeclared.get(context.parent) : context
Expand Down
2 changes: 1 addition & 1 deletion packages/api-model/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

/** Language libraries, by the slug the site uses. */
export type PortSlug = 'py' | 'ruby' | 'lua' | 'ts' | 'rs' | 'go' | 'java' | 'csharp' | 'cxx' | 'swift' | 'kotlin' | 'scala' | 'fsharp' | 'powershell' | 'julia' | 'elixir'
export type PortSlug = 'py' | 'ruby' | 'lua' | 'ts' | 'rs' | 'go' | 'java' | 'csharp' | 'cxx' | 'swift' | 'kotlin' | 'scala' | 'fsharp' | 'powershell' | 'julia' | 'elixir' | 'erlang'

export type ApiProduct = 'core' | 'workspace' | 'mcp'

Expand Down
Loading
Loading