Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/port-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -306,18 +306,18 @@ jobs:
if: matrix.port == 'ts'
working-directory: port
run: bun install --frozen-lockfile
- if: matrix.port == 'java'
- if: matrix.port == 'java' || matrix.port == 'kotlin' || matrix.port == 'scala'
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
with:
distribution: temurin
# The repository's Gradle conventions compile and run on JDK 25.
java-version: '25'
- if: matrix.port == 'java'
- if: matrix.port == 'java' || matrix.port == 'kotlin' || matrix.port == 'scala'
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6
with:
validate-wrappers: true
cache-disabled: true
- if: matrix.port == 'dotnet'
- if: matrix.port == 'dotnet' || matrix.port == 'fsharp'
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
global-json-file: port/global.json
Expand Down
1 change: 1 addition & 0 deletions infra/cloudfront-function.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ async function handler(event) {
*/
const ASSET_EXTENSIONS = {
html: 1, htm: 1, xml: 1, txt: 1, json: 1, js: 1, mjs: 1, map: 1, css: 1,
java: 1, kt: 1, scala: 1,
svg: 1, png: 1, jpg: 1, jpeg: 1, gif: 1, webp: 1, avif: 1, ico: 1,
woff: 1, woff2: 1, ttf: 1, otf: 1, eot: 1,
md: 1, pdf: 1, zip: 1, gz: 1, wasm: 1, pf_meta: 1, pf_fragment: 1,
Expand Down
25 changes: 19 additions & 6 deletions notes/adding-a-port.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,25 @@ ownership together while giving each package its own routes, artwork,
installation commands, and navigation position. They have no companion
MCP server or workspace CLI.

For a wrapper with `referenceKind: 'guide'`, keep native API links and authored
guides in `port-documentation.ts`. Do not add an empty shared API model.
`stage-port-docs.mjs` stages those guides and records their source revision.
Ordinary shell builds use the committed guide caches; source-bound builds read
the selected checkout. Refresh a wrapper's cache after verifying its native
documentation and examples:
Each library has its own extracted API model. Kotlin includes public KDoc
declarations and generated coroutine extensions. Scala includes Scaladoc,
opaque types, companions and generated extensions. F# reads the public `.fsi`
files listed in its project through the pinned SDK's FSharp.Compiler.Service.
Private declarations stay out of the reference; malformed public syntax fails
extraction. Generated source files travel with the API model so their source
links resolve at the documented version.

Refresh the API model with its native tools available. Kotlin and Scala need
the source project's JDK and Gradle; F# needs its .NET SDK:

```console
$ node scripts/gen-api-model.mjs --port kotlin
```

`stage-port-docs.mjs` stages authored guides from `port-documentation.ts` and
records their source revision. Ordinary shell builds use committed caches;
source-bound builds extract both the API and guides from the selected checkout.
Refresh the guide cache after verifying its documentation and examples:

```console
$ node scripts/stage-port-docs.mjs --port kotlin --refresh-cache
Expand Down
3 changes: 2 additions & 1 deletion packages/api-model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.1",
"private": true,
"type": "module",
"description": "One normalized API model for ten languages, extracted from native documentation artifacts and source.",
"description": "A shared API model for language ports, extracted from native documentation artifacts and source.",
"exports": {
".": "./src/index.ts"
},
Expand All @@ -13,6 +13,7 @@
"type-check": "oxlint --type-aware --type-check -A all --tsconfig tsconfig.json --ignore-pattern '**/*.test.ts' src"
},
"dependencies": {
"tree-sitter-scala": "0.24.0",
"tree-sitter-wasms": "catalog:",
"web-tree-sitter": "catalog:"
},
Expand Down
168 changes: 168 additions & 0 deletions packages/api-model/scripts/extract-fsharp.fsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
#r "FSharp.Compiler.Service.dll"

open System
open System.IO
open System.Text.Json
open System.Text.RegularExpressions
open System.Xml.Linq
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.Diagnostics
open FSharp.Compiler.Syntax
open FSharp.Compiler.Text
open FSharp.Compiler.Xml

// Run through the source repository's pinned SDK, which supplies this parser.
type Parameter = { name: string; ``type``: string; optional: bool }
type Declaration = {
id: string
name: string
kind: string
parent: string
file: string
line: int
signature: string
parameters: Parameter array
returns: string
documentation: string
namespaces: string array
}

let declarations = ResizeArray<Declaration>()
let checker = FSharpChecker.Create()
let publicAccess = function None | Some (SynAccess.Public _) -> true | _ -> false
let qualify owner name = if String.IsNullOrEmpty owner then name else owner + "." + name
let xml (doc: PreXmlDoc) = doc.ToXmlDoc(false, None).GetXmlText()
let compact text = Regex.Replace(text, @"\s+", " ").Trim()

let parseFile file =
let mutable namespaces: string list = []
let lines = File.ReadAllLines file
let source (range: range) =
[ for row in range.StartLine .. range.EndLine do
let line = lines[row - 1]
let first = if row = range.StartLine then range.StartColumn else 0
let last = if row = range.EndLine then range.EndColumn else line.Length
yield line.Substring(first, last - first) ]
|> String.concat "\n"
let add owner name kind (range: range) signature parameters returns doc =
declarations.Add {
id = qualify owner name; name = name; kind = kind; parent = owner
file = file; line = range.StartLine; signature = signature
parameters = parameters; returns = returns; documentation = xml doc
namespaces = Array.ofList namespaces
}
let parameter position = function
| SynType.SignatureParameter(optional = optional; id = name; usedType = value) ->
{ name = name |> Option.map _.idText |> Option.defaultValue $"arg{position}"
``type`` = compact (source value.Range); optional = optional }
| value ->
{ name = $"arg{position}"; ``type`` = compact (source value.Range); optional = false }
let rec signature (parameters: Parameter list) = function
| SynType.WithGlobalConstraints(typeName = value) -> signature parameters value
| SynType.Fun(argType = argument; returnType = returns) ->
signature (parameters @ [parameter (parameters.Length + 1) argument]) returns
| returns -> Array.ofList parameters, compact (source returns.Range)
let emitValue owner prefix (SynValSig(ident = SynIdent(name, _); synType = value;
xmlDoc = doc; accessibility = access; range = range)) =
let access = match access with SynValSigAccess.Single access | SynValSigAccess.GetSet(access, _, _) -> access
if publicAccess access then
let parameters, returns = signature [] value
let kind = if prefix = "member" then "method" elif parameters.Length > 0 then "function" else "constant"
let written = prefix + " " + source (Range.mkRange file name.idRange.Start range.End)
add owner name.idText kind name.idRange written parameters returns doc
let componentInfo (SynComponentInfo(longId = names; xmlDoc = doc; accessibility = access; range = range)) =
String.concat "." (names |> List.map _.idText), doc, access, range
let rec emitType owner (SynTypeDefnSig(typeInfo = info; typeRepr = repr; members = members;
range = declarationRange; trivia = trivia)) =
let name, doc, access, range = componentInfo info
if publicAccess access then
let kind =
match repr with
| SynTypeDefnSigRepr.Simple(repr = SynTypeDefnSimpleRepr.Union _) -> "enum"
| SynTypeDefnSigRepr.Simple(repr = SynTypeDefnSimpleRepr.Record _) -> "struct"
| SynTypeDefnSigRepr.Simple(repr = SynTypeDefnSimpleRepr.TypeAbbrev _) -> "typealias"
| _ -> "class"
let headerEnd = trivia.EqualsRange |> Option.map _.Start |> Option.defaultValue declarationRange.End
let header = "type " + (source (Range.mkRange file range.Start headerEnd)).Trim()
let written = match repr with
| SynTypeDefnSigRepr.Simple(repr = SynTypeDefnSimpleRepr.TypeAbbrev(rhsType = value)) -> header + " = " + source value.Range
| _ -> header
add owner name kind range written [||] "" doc
let parent = qualify owner name
match repr with
| SynTypeDefnSigRepr.Simple(repr = SynTypeDefnSimpleRepr.Union(accessibility = access; unionCases = cases)) when publicAccess access ->
for SynUnionCase(ident = SynIdent(name, _); caseType = caseType; xmlDoc = doc; accessibility = access; range = range) in cases do
if publicAccess access then
let parameters =
match caseType with
| SynUnionCaseKind.Fields fields -> fields |> List.mapi (fun index (SynField(idOpt = name; fieldType = value)) ->
{ name = name |> Option.map _.idText |> Option.defaultValue $"arg{index + 1}"
``type`` = compact (source value.Range); optional = false }) |> Array.ofList
| SynUnionCaseKind.FullType(fullType = value) -> fst (signature [] value)
add parent name.idText "constant" name.idRange (source (Range.mkRange file name.idRange.Start range.End)) parameters parent doc
| SynTypeDefnSigRepr.Simple(repr = SynTypeDefnSimpleRepr.Record(accessibility = access; recordFields = fields)) when publicAccess access ->
for field in fields do emitField parent field
| SynTypeDefnSigRepr.ObjectModel(memberSigs = members) ->
for memberSig in members do emitMember parent memberSig
| _ -> ()
for memberSig in members do emitMember parent memberSig
and emitField owner (SynField(idOpt = name; fieldType = value; xmlDoc = doc; accessibility = access; range = range)) =
match name with
| Some name when publicAccess access ->
add owner name.idText "property" range (name.idText + ": " + source value.Range) [||] (compact (source value.Range)) doc
| _ -> ()
and emitMember owner = function
| SynMemberSig.Member(memberSig = value) -> emitValue owner "member" value
| SynMemberSig.ValField(field = field) -> emitField owner field
| SynMemberSig.NestedType(nestedType = nested) -> emitType owner nested
| SynMemberSig.Interface _ | SynMemberSig.Inherit _ -> ()
let rec emitModule owner = function
| SynModuleSigDecl.Val(valSig = value) -> emitValue owner "val" value
| SynModuleSigDecl.Types(types = types) -> for value in types do emitType owner value
| SynModuleSigDecl.NestedModule(moduleInfo = info; moduleDecls = members) ->
let name, doc, access, range = componentInfo info
if publicAccess access then
add owner name "module" range ("module " + name) [||] "" doc
let outer = namespaces
for memberSig in members do emitModule (qualify owner name) memberSig
namespaces <- outer
| SynModuleSigDecl.NamespaceFragment(fragment) -> emitNamespace fragment
| SynModuleSigDecl.Open(range = range) ->
let opened = Regex.Match(source range, @"^open\s+([\w.]+)\s*$")
if not opened.Success then failwithf "Unsupported F# open declaration: %s" (source range)
namespaces <- namespaces @ [opened.Groups[1].Value]
| SynModuleSigDecl.HashDirective _ -> ()
| other -> failwithf "Unsupported public F# signature declaration: %A" other
and emitNamespace (SynModuleOrNamespaceSig(longId = names; decls = members; accessibility = access)) =
if publicAccess access then
namespaces <- []
let owner = String.concat "." (names |> List.map _.idText)
for memberSig in members do emitModule owner memberSig
let options = { FSharpParsingOptions.Default with SourceFiles = [|file|] }
let result = checker.ParseFile(file, SourceText.ofString (File.ReadAllText file), options) |> Async.RunSynchronously
let errors = result.Diagnostics |> Array.filter (fun error -> error.Severity = FSharpDiagnosticSeverity.Error)
if errors.Length > 0 then failwith (errors |> Array.map string |> String.concat "\n")
match result.ParseTree with
| ParsedInput.SigFile(ParsedSigFileInput(contents = modules)) -> for value in modules do emitNamespace value
| _ -> failwith $"Expected an F# public signature file: {file}"

let files = fsi.CommandLineArgs |> Array.skip 1
if files.Length = 0 then failwith "Pass the public .fsi files to extract."
let signatures = files |> Array.collect (fun file ->
if Path.GetExtension file = ".fsproj" then
XDocument.Load(file).Descendants(XName.Get "Compile")
|> Seq.choose (fun item ->
let includePath = item.Attribute(XName.Get "Include")
if isNull includePath || not (includePath.Value.EndsWith ".fsi") then None
else Some (Path.Combine(Path.GetDirectoryName file, includePath.Value)))
|> Seq.toArray
else [|file|])
for file in signatures do parseFile (Path.GetFullPath file)
if declarations.Count = 0 then failwith "No public F# declarations were extracted."
let result = {|
schema = 1
port = "fsharp"
compiler = typeof<FSharpChecker>.Assembly.GetName().Version.ToString()
declarations = declarations.ToArray()
|}
printfn "%s" (JsonSerializer.Serialize result)
54 changes: 53 additions & 1 deletion packages/api-model/src/builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,62 @@ const swift = (page: string) => `https://developer.apple.com/documentation/swift
const mdn = (page: string) =>
`https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/${page}`
const ts = (page: string) => `https://www.typescriptlang.org/docs/handbook/2/${page}`
const kotlin = (page: string) => `https://kotlinlang.org/api/core/kotlin-stdlib/${page}/`
const scala = (page: string) => `https://www.scala-lang.org/api/3.x/${page}.html`
const fsharp = (page: string) => `https://fsharp.github.io/fsharp-core-docs/reference/${page}.html`

export const BUILTINS: Record<string, Record<string, string>> = {
kotlin: {
String: kotlin('kotlin/-string'), Boolean: kotlin('kotlin/-boolean'),
Int: kotlin('kotlin/-int'), Long: kotlin('kotlin/-long'),
Unit: kotlin('kotlin/-unit'), Any: kotlin('kotlin/-any'),
List: kotlin('kotlin.collections/-list'), Map: kotlin('kotlin.collections/-map'),
Iterable: kotlin('kotlin.collections/-iterable'),
Flow: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-flow/',
StateFlow: 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/',
'collect()': 'https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/collect.html',
},
scala: {
String: scala('scala/Predef$'), Boolean: scala('scala/Boolean'),
Int: scala('scala/Int'), Long: scala('scala/Long'), Unit: scala('scala/Unit'),
Option: scala('scala/Option'), Either: scala('scala/util/Either'),
Vector: scala('scala/collection/immutable/Vector'),
List: scala('scala/collection/immutable/List'),
IterableOnce: scala('scala/collection/IterableOnce'),
'scala.concurrent.duration.FiniteDuration': scala('scala/concurrent/duration/FiniteDuration'),
ExecutionContext: scala('scala/concurrent/ExecutionContext'),
'scala.util.Using.resource': scala('scala/util/Using$'),
IO: 'https://typelevel.org/cats-effect/api/3.x/cats/effect/IO.html',
Resource: 'https://typelevel.org/cats-effect/api/3.x/cats/effect/kernel/Resource.html',
},
fsharp: {
Result: fsharp('fsharp-core-fsharpresult-2'),
option: fsharp('fsharp-core-fsharpoption-1'),
list: fsharp('fsharp-collections-fsharplist-1'),
seq: dotnet('system.collections.generic.ienumerable-1'),
string: dotnet('system.string'), bool: dotnet('system.boolean'),
int: dotnet('system.int32'), int64: dotnet('system.int64'),
Task: dotnet('system.threading.tasks.task'),
CancellationToken: dotnet('system.threading.cancellationtoken'),
IReadOnlyList: dotnet('system.collections.generic.ireadonlylist-1'),
IAsyncEnumerable: dotnet('system.collections.generic.iasyncenumerable-1'),
None: fsharp('fsharp-core-fsharpoption-1'),
Some: fsharp('fsharp-core-fsharpoption-1'),
Seq: fsharp('fsharp-collections-seqmodule'),
'Seq.filter': `${fsharp('fsharp-collections-seqmodule')}#filter`,
Async: fsharp('fsharp-control-fsharpasync'),
'Async.AwaitTask': `${fsharp('fsharp-control-fsharpasync')}#AwaitTask`,
'Async.StartAsTask': `${fsharp('fsharp-control-fsharpasync')}#StartAsTask`,
TaskCanceledException: dotnet('system.threading.tasks.taskcanceledexception'),
'Task.WhenAll': dotnet('system.threading.tasks.task.whenall'),
},
dotnet: {
bool: dotnet('system.boolean'),
Boolean: dotnet('system.boolean'),
string: dotnet('system.string'),
String: dotnet('system.string'),
char: dotnet('system.char'),
Char: dotnet('system.char'),
int: dotnet('system.int32'),
Int32: dotnet('system.int32'),
long: dotnet('system.int64'),
Expand Down Expand Up @@ -216,6 +265,7 @@ export const BUILTINS: Record<string, Record<string, string>> = {
Readonly: ts('..%2Futility-types.html#readonlytype'),
},
java: {
ExtensionContext: 'https://docs.junit.org/current/api/org.junit.jupiter.api/org/junit/jupiter/api/extension/ExtensionContext.html',
boolean: 'https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.2.5',
int: 'https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.2.1',
long: 'https://docs.oracle.com/javase/specs/jls/se21/html/jls-4.html#jls-4.2.1',
Expand All @@ -240,5 +290,7 @@ export const BUILTINS: Record<string, Record<string, string>> = {

/** The documentation URL for a language's own type, if this is one. */
export function builtinHref(port: string, name: string): string | undefined {
return BUILTINS[port]?.[name]
if (!Object.hasOwn(BUILTINS, port)) return undefined
const types = BUILTINS[port]!
return Object.hasOwn(types, name) ? types[name] : undefined
}
Loading
Loading