|
| 1 | +# SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +defmodule Opsm.Registries.Betlang do |
| 4 | + @moduledoc """ |
| 5 | + Betlang package registry API client. |
| 6 | +
|
| 7 | + Betlang is a hard real-time systems language with static worst-case |
| 8 | + execution time (WCET) analysis, deterministic garbage collection, and |
| 9 | + formal timing proofs built into the type system. |
| 10 | +
|
| 11 | + Package manager: `bet` CLI (nextgen-languages/betlang/tools/bet-pkg, Rust) |
| 12 | + Manifest format: `betlang.toml` or `opsm.toml` ([package] forth = "betlang") |
| 13 | + Registry: https://packages.betlang.dev/api/v1 (planned — git fallback now) |
| 14 | + Language ID: betlang |
| 15 | + Source `:forth` atom: `:betlang` |
| 16 | +
|
| 17 | + Packages live under the hyperpolymath org until the central registry |
| 18 | + is deployed. Prefer `opsm.toml` with `forth = "betlang"`. |
| 19 | + """ |
| 20 | + |
| 21 | + alias Opsm.Types.{ManifestFormat, ResolvedPackage} |
| 22 | + alias Opsm.Verified.Http, as: VerifiedHttp |
| 23 | + |
| 24 | + @base_url "https://packages.betlang.dev/api/v1" |
| 25 | + @fallback_mode :git |
| 26 | + |
| 27 | + @known_packages [ |
| 28 | + %{ |
| 29 | + name: "betlang-rt", |
| 30 | + url: "https://github.com/hyperpolymath/betlang", |
| 31 | + path: "runtime", |
| 32 | + description: "Betlang real-time runtime — deterministic scheduler, WCET guarantees, static allocation" |
| 33 | + }, |
| 34 | + %{ |
| 35 | + name: "betlang-hal", |
| 36 | + url: "https://github.com/hyperpolymath/betlang", |
| 37 | + path: "hal", |
| 38 | + description: "Hardware Abstraction Layer for Betlang — GPIO, timers, interrupts, DMA" |
| 39 | + }, |
| 40 | + %{ |
| 41 | + name: "betlang-rtos", |
| 42 | + url: "https://github.com/hyperpolymath/betlang", |
| 43 | + path: "rtos", |
| 44 | + description: "RTOS integration bindings for Betlang — FreeRTOS, Zephyr, ThreadX adapters" |
| 45 | + }, |
| 46 | + %{ |
| 47 | + name: "betlang-timing", |
| 48 | + url: "https://github.com/hyperpolymath/betlang", |
| 49 | + path: "timing", |
| 50 | + description: "Static timing analysis primitives — WCET types, deadline proofs, jitter bounds" |
| 51 | + }, |
| 52 | + %{ |
| 53 | + name: "betlang-std", |
| 54 | + url: "https://github.com/hyperpolymath/betlang", |
| 55 | + path: "stdlib", |
| 56 | + description: "Betlang standard library — deterministic collections, no-alloc I/O, safe concurrency" |
| 57 | + }, |
| 58 | + %{ |
| 59 | + name: "betlang-test", |
| 60 | + url: "https://github.com/hyperpolymath/betlang", |
| 61 | + path: "test", |
| 62 | + description: "Real-time test framework for Betlang — timing-accurate property testing" |
| 63 | + }, |
| 64 | + %{ |
| 65 | + name: "betlang-spark", |
| 66 | + url: "https://github.com/hyperpolymath/betlang", |
| 67 | + path: "spark-bridge", |
| 68 | + description: "SPARK/Ada interoperability layer for Betlang — formal proof bridging" |
| 69 | + } |
| 70 | + ] |
| 71 | + |
| 72 | + def fetch_package(name, version \\ "latest") do |
| 73 | + case @fallback_mode do |
| 74 | + :git -> fetch_from_git(name, version) |
| 75 | + :registry -> fetch_from_registry(name, version) |
| 76 | + end |
| 77 | + end |
| 78 | + |
| 79 | + def search(query, opts \\ []) do |
| 80 | + case @fallback_mode do |
| 81 | + :git -> search_curated(query, opts) |
| 82 | + :registry -> search_registry(query, opts) |
| 83 | + end |
| 84 | + end |
| 85 | + |
| 86 | + def exists?(name) do |
| 87 | + case @fallback_mode do |
| 88 | + :git -> curated_exists?(name) |
| 89 | + :registry -> registry_exists?(name) |
| 90 | + end |
| 91 | + end |
| 92 | + |
| 93 | + def versions(name) do |
| 94 | + case @fallback_mode do |
| 95 | + :git -> git_versions(name) |
| 96 | + :registry -> registry_versions(name) |
| 97 | + end |
| 98 | + end |
| 99 | + |
| 100 | + defp fetch_from_registry(name, version) do |
| 101 | + url = "#{@base_url}/packages/#{URI.encode(name)}" |
| 102 | + case VerifiedHttp.get_json(url, receive_timeout: 10_000) do |
| 103 | + {:ok, body} -> |
| 104 | + target = if version == "latest", do: body["latest_version"], else: version |
| 105 | + {:ok, parse_registry_package(body, target)} |
| 106 | + {:error, reason} -> {:error, reason} |
| 107 | + end |
| 108 | + end |
| 109 | + |
| 110 | + defp search_registry(query, opts) do |
| 111 | + limit = Keyword.get(opts, :limit, 20) |
| 112 | + url = "#{@base_url}/packages?q=#{URI.encode(query)}&limit=#{limit}" |
| 113 | + case VerifiedHttp.get_json(url, receive_timeout: 10_000) do |
| 114 | + {:ok, body} when is_list(body) -> {:ok, Enum.map(body, &parse_search_result/1)} |
| 115 | + {:error, reason} -> {:error, reason} |
| 116 | + end |
| 117 | + end |
| 118 | + |
| 119 | + defp registry_exists?(name) do |
| 120 | + case VerifiedHttp.get("#{@base_url}/packages/#{URI.encode(name)}", receive_timeout: 5_000) do |
| 121 | + {:ok, _} -> true |
| 122 | + _ -> false |
| 123 | + end |
| 124 | + end |
| 125 | + |
| 126 | + defp registry_versions(name) do |
| 127 | + url = "#{@base_url}/packages/#{URI.encode(name)}/versions" |
| 128 | + case VerifiedHttp.get_json(url, receive_timeout: 10_000) do |
| 129 | + {:ok, body} when is_list(body) -> {:ok, Enum.map(body, & &1["version"])} |
| 130 | + {:error, reason} -> {:error, reason} |
| 131 | + end |
| 132 | + end |
| 133 | + |
| 134 | + defp fetch_from_git(name, version) do |
| 135 | + case find_curated(name) do |
| 136 | + {:ok, pkg_info} -> fetch_git_manifest(pkg_info, version) |
| 137 | + :not_found -> try_github_locations(name, version) |
| 138 | + end |
| 139 | + end |
| 140 | + |
| 141 | + defp search_curated(query, _opts) do |
| 142 | + q = String.downcase(query) |
| 143 | + results = Enum.filter(@known_packages, fn p -> |
| 144 | + String.contains?(String.downcase("#{p.name} #{p.description}"), q) |
| 145 | + end) |
| 146 | + {:ok, Enum.map(results, &curated_to_resolved(&1, "latest"))} |
| 147 | + end |
| 148 | + |
| 149 | + defp curated_exists?(name), do: match?({:ok, _}, find_curated(name)) |
| 150 | + |
| 151 | + defp git_versions(name) do |
| 152 | + case find_curated(name) do |
| 153 | + {:ok, pkg_info} -> |
| 154 | + tags_url = |
| 155 | + pkg_info.url |
| 156 | + |> String.replace("https://github.com/", "https://api.github.com/repos/") |
| 157 | + |> Kernel.<>("/tags") |
| 158 | + case VerifiedHttp.get_json(tags_url, receive_timeout: 10_000) do |
| 159 | + {:ok, tags} when is_list(tags) -> |
| 160 | + versions = Enum.map(tags, & &1["name"]) |> Enum.reject(&is_nil/1) |
| 161 | + {:ok, if(versions == [], do: ["main"], else: versions)} |
| 162 | + _ -> {:ok, ["main"]} |
| 163 | + end |
| 164 | + :not_found -> {:error, :not_found} |
| 165 | + end |
| 166 | + end |
| 167 | + |
| 168 | + defp try_github_locations(name, version) do |
| 169 | + urls = [ |
| 170 | + "https://github.com/hyperpolymath/#{name}", |
| 171 | + "https://github.com/hyperpolymath/betlang-#{name}" |
| 172 | + ] |
| 173 | + Enum.find_value(urls, {:error, :not_found}, fn url -> |
| 174 | + case fetch_git_manifest(%{name: name, url: url, description: nil}, version) do |
| 175 | + {:ok, pkg} -> {:ok, pkg} |
| 176 | + _ -> nil |
| 177 | + end |
| 178 | + end) |
| 179 | + end |
| 180 | + |
| 181 | + defp fetch_git_manifest(pkg_info, version) do |
| 182 | + branch = if version in ["latest", "main"], do: "main", else: version |
| 183 | + path = Map.get(pkg_info, :path) |
| 184 | + base = pkg_info.url |
| 185 | + |
| 186 | + for_manifest = fn manifest_name -> |
| 187 | + url = if path, do: "#{base}/raw/#{branch}/#{path}/#{manifest_name}", |
| 188 | + else: "#{base}/raw/#{branch}/#{manifest_name}" |
| 189 | + case VerifiedHttp.get(url, receive_timeout: 10_000) do |
| 190 | + {:ok, %{body: text}} -> {:ok, text} |
| 191 | + _ -> :not_found |
| 192 | + end |
| 193 | + end |
| 194 | + |
| 195 | + with :not_found <- for_manifest.("opsm.toml"), |
| 196 | + :not_found <- for_manifest.("betlang.toml") do |
| 197 | + {:ok, curated_to_resolved(pkg_info, version)} |
| 198 | + else |
| 199 | + {:ok, toml_text} -> parse_betlang_toml(toml_text, pkg_info, version) |
| 200 | + end |
| 201 | + end |
| 202 | + |
| 203 | + defp parse_betlang_toml(toml_text, pkg_info, version) do |
| 204 | + fields = extract_toml_section(toml_text, "package") |
| 205 | + pkg_name = fields["name"] || pkg_info.name |
| 206 | + |
| 207 | + pkg = %ResolvedPackage{ |
| 208 | + package: pkg_name, |
| 209 | + version: fields["version"] || version, |
| 210 | + forth: :betlang, |
| 211 | + registry_url: pkg_info.url, |
| 212 | + tarball_url: "#{pkg_info.url}/archive/#{version}.tar.gz", |
| 213 | + checksum: nil, |
| 214 | + checksum_algo: :sha256, |
| 215 | + manifest: %ManifestFormat{ |
| 216 | + name: pkg_name, |
| 217 | + version: fields["version"] || version, |
| 218 | + description: fields["description"] || pkg_info[:description], |
| 219 | + license: fields["license"] || "PMPL-1.0-or-later", |
| 220 | + homepage: fields["homepage"] || pkg_info.url, |
| 221 | + repository: fields["repository"] || pkg_info.url, |
| 222 | + authors: parse_toml_array(fields["authors"]) || default_authors(), |
| 223 | + keywords: parse_toml_array(fields["keywords"]) || default_keywords(), |
| 224 | + dependencies: %{}, |
| 225 | + dev_dependencies: %{}, |
| 226 | + source_forth: :betlang, |
| 227 | + raw_manifest: fields |
| 228 | + }, |
| 229 | + attestations: [], |
| 230 | + resolved_deps: [] |
| 231 | + } |
| 232 | + {:ok, pkg} |
| 233 | + end |
| 234 | + |
| 235 | + defp curated_to_resolved(pkg_info, version) do |
| 236 | + %ResolvedPackage{ |
| 237 | + package: pkg_info.name, |
| 238 | + version: version, |
| 239 | + forth: :betlang, |
| 240 | + registry_url: pkg_info.url, |
| 241 | + tarball_url: "#{pkg_info.url}/archive/#{version}.tar.gz", |
| 242 | + checksum: nil, |
| 243 | + checksum_algo: :sha256, |
| 244 | + manifest: %ManifestFormat{ |
| 245 | + name: pkg_info.name, |
| 246 | + version: version, |
| 247 | + description: pkg_info.description, |
| 248 | + license: "PMPL-1.0-or-later", |
| 249 | + homepage: pkg_info.url, |
| 250 | + repository: pkg_info.url, |
| 251 | + authors: default_authors(), |
| 252 | + keywords: default_keywords(), |
| 253 | + dependencies: %{}, |
| 254 | + dev_dependencies: %{}, |
| 255 | + source_forth: :betlang, |
| 256 | + raw_manifest: %{"registry" => "betlang-curated"} |
| 257 | + }, |
| 258 | + attestations: [], |
| 259 | + resolved_deps: [] |
| 260 | + } |
| 261 | + end |
| 262 | + |
| 263 | + defp parse_registry_package(data, version) do |
| 264 | + %ResolvedPackage{ |
| 265 | + package: data["name"], |
| 266 | + version: version, |
| 267 | + forth: :betlang, |
| 268 | + registry_url: @base_url, |
| 269 | + tarball_url: "#{@base_url}/packages/#{data["name"]}/#{version}/download", |
| 270 | + checksum: data["checksum"], |
| 271 | + checksum_algo: :sha256, |
| 272 | + manifest: %ManifestFormat{ |
| 273 | + name: data["name"], |
| 274 | + version: version, |
| 275 | + description: data["description"], |
| 276 | + license: data["license"], |
| 277 | + homepage: data["homepage"], |
| 278 | + repository: data["repository"], |
| 279 | + authors: data["authors"] || [], |
| 280 | + keywords: data["keywords"] || [], |
| 281 | + dependencies: data["dependencies"] || %{}, |
| 282 | + dev_dependencies: data["dev_dependencies"] || %{}, |
| 283 | + source_forth: :betlang, |
| 284 | + raw_manifest: data |
| 285 | + }, |
| 286 | + attestations: data["attestations"] || [], |
| 287 | + resolved_deps: [] |
| 288 | + } |
| 289 | + end |
| 290 | + |
| 291 | + defp parse_search_result(r) do |
| 292 | + %{name: r["name"], version: r["latest_version"], |
| 293 | + description: r["description"], downloads: r["downloads"] || 0} |
| 294 | + end |
| 295 | + |
| 296 | + defp find_curated(name) do |
| 297 | + case Enum.find(@known_packages, &(&1.name == name)) do |
| 298 | + nil -> :not_found |
| 299 | + pkg -> {:ok, pkg} |
| 300 | + end |
| 301 | + end |
| 302 | + |
| 303 | + defp extract_toml_section(toml_text, section_name) do |
| 304 | + {_inside, fields} = |
| 305 | + String.split(toml_text, "\n") |
| 306 | + |> Enum.reduce({false, %{}}, fn line, {inside, acc} -> |
| 307 | + t = String.trim(line) |
| 308 | + cond do |
| 309 | + t == "[#{section_name}]" -> {true, acc} |
| 310 | + String.starts_with?(t, "[") -> {false, acc} |
| 311 | + inside -> |
| 312 | + case String.split(t, " = ", parts: 2) do |
| 313 | + [k, v] -> {true, Map.put(acc, String.trim(k), String.trim(v, "\""))} |
| 314 | + _ -> {inside, acc} |
| 315 | + end |
| 316 | + true -> {inside, acc} |
| 317 | + end |
| 318 | + end) |
| 319 | + fields |
| 320 | + end |
| 321 | + |
| 322 | + defp parse_toml_array(nil), do: nil |
| 323 | + defp parse_toml_array(str) when is_binary(str) do |
| 324 | + str |
| 325 | + |> String.trim("[") |> String.trim("]") |
| 326 | + |> String.split(",") |
| 327 | + |> Enum.map(&(&1 |> String.trim() |> String.trim("\""))) |
| 328 | + |> Enum.reject(&(&1 == "")) |
| 329 | + end |
| 330 | + |
| 331 | + defp default_authors, do: ["Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>"] |
| 332 | + defp default_keywords, do: ["betlang", "real-time", "wcet", "embedded"] |
| 333 | +end |
0 commit comments