///|
fn is_ws(c : UInt16) -> Bool {
c == ' ' || c == '\t' || c == '\r' || c == '\n'
}
///|
fn trim(s : String) -> String {
let n = s.length()
let mut a = 0
let mut b = n
while a < b && is_ws(s[a]) {
a = a + 1
}
while b > a && is_ws(s[b - 1]) {
b = b - 1
}
s[a:b].to_owned()
}
///|
fn lines(s : String) -> Array[String] {
let out : Array[String] = []
let n = s.length()
let mut start = 0
for i = 0; i <= n; i = i + 1 {
if i == n || s[i] == '\n' {
out.push(s[start:i].to_owned())
start = i + 1
}
}
out
}
///|
fn starts_with(s : String, prefix : String) -> Bool {
let pn = prefix.length()
if s.length() < pn {
return false
}
for i = 0; i < pn; i = i + 1 {
if s[i] != prefix[i] {
return false
}
}
true
}
///|
/// Index of the first `ch` in `s` at or after `from`, or `-1` if absent.
fn index_of(s : String, ch : UInt16, from : Int) -> Int {
let n = s.length()
for i = from; i < n; i = i + 1 {
if s[i] == ch {
return i
}
}
-1
}
///|
/// Map a `.api` field type onto its MoonBit spelling (← goctl's Go type set):
/// scalars (`string`→`String`, `int`→`Int`, `int64`→`Int64`, …), slices
/// (`[]T`→`Array[T]`), and maps (`map[K]V`→`Map[K, V]`). An unrecognised name is
/// passed through verbatim, so a spec may already reference a MoonBit type or one
/// of its own `type` blocks.
fn map_type(raw : String) -> String {
let t = trim(raw)
if starts_with(t, "*") {
return map_type(t[1:].to_owned()) + "?"
}
if starts_with(t, "[]") {
return "Array[" + map_type(t[2:].to_owned()) + "]"
}
if starts_with(t, "map[") {
let close = index_of(t, ']', 4)
if close > 4 {
let key = t[4:close].to_owned()
let value = t[close + 1:].to_owned()
return "Map[" + map_type(key) + ", " + map_type(value) + "]"
}
}
match t {
"string" => "String"
"bool" => "Bool"
"int" | "int8" | "int16" | "int32" | "rune" => "Int"
"int64" => "Int64"
"uint" | "uint8" | "uint16" | "uint32" => "UInt"
"uint64" => "UInt64"
"byte" => "Byte"
"bytes" => "Bytes"
"float32" => "Float"
"float" | "float64" | "double" => "Double"
_ => t
}
}
///|
/// Whitespace-split a line into tokens, keeping a `"quoted string"` as one token
/// (with the quotes stripped).
fn tokens(s : String) -> Array[String] {
let out : Array[String] = []
let n = s.length()
let mut i = 0
while i < n {
while i < n && is_ws(s[i]) {
i = i + 1
}
if i >= n {
break
}
if s[i] == '"' {
let start = i + 1
let mut j = start
while j < n && s[j] != '"' {
j = j + 1
}
out.push(s[start:j].to_owned())
i = j + 1
} else {
let start = i
while i < n && is_ws(s[i]) == false {
i = i + 1
}
out.push(s[start:i].to_owned())
}
}
out
}
///|
/// One route in a service spec: HTTP verb, path pattern, handler name, and an
/// optional summary. A modern goctl route (`verb /path (Req) returns (Resp)`)
/// also carries its request/response type names (its `@doc` fills `summary`);
/// both are empty for the legacy inline form (`verb /path handler "summary"`).
pub(all) struct Route {
verb : String
path : String
handler : String
summary : String
req : String
resp : String
}
///|
/// One field of a `type` block: its name, the (already MoonBit-mapped) type, and
/// the raw back-tick struct tag (`json:"…"`/`path`/`form`/`header`, empty when
/// absent) that carries the wire name and binding location.
pub(all) struct Field {
name : String
type_ : String
tag : String
}
///|
/// A named message schema declared by a `type Name { field: Type … }` block,
/// generated into a MoonBit struct (goctl's request/response types).
pub(all) struct TypeDef {
name : String
fields : Array[Field]
}
///|
/// A parsed `.api` service specification.
pub(all) struct Spec {
service : String
routes : Array[Route]
types : Array[TypeDef]
info : Array[(String, String)]
}
///|
/// Parse a `.api` service spec. Grammar (one statement per line):
/// ```
/// service greet {
/// get /ping ping "health check"
/// get /users/:id get_user
/// post /users create_user "create a user"
/// }
///
/// type User {
/// id: int64
/// name: string
/// }
/// ```
/// A `type` block declares a message schema (its fields become a MoonBit struct);
/// field types use goctl's Go spellings (`string`, `int64`, `[]T`, `map[K]V`, …).
/// Blank lines and `//` comments are ignored.
///|
/// Index of the first occurrence of `needle` in `hay`, or `-1`.
///|
/// goctl only reads a line as a route when it opens with an HTTP verb; anything
/// else at that nesting level is an annotation or a brace.
fn is_http_verb(t : String) -> Bool {
match t.to_lower() {
"get"
| "head"
| "post"
| "put"
| "patch"
| "delete"
| "options"
| "connect"
| "trace" => true
_ => false
}
}
///|
/// What is left of `s` after its first `n` whitespace-separated tokens.
fn rest_after(s : String, n : Int) -> String {
let len = s.length()
let mut i = 0
let mut seen = 0
while seen < n && i < len {
while i < len && is_ws(s[i]) {
i = i + 1
}
while i < len && is_ws(s[i]) == false {
i = i + 1
}
seen = seen + 1
}
trim(s[i:].to_owned())
}
///|
/// The text inside the first `( … )` pair of `s`, or "" when there is none.
fn paren_body(s : String) -> String {
let lp = index_of(s, '(', 0)
if lp < 0 {
return ""
}
let rp = index_of(s, ')', lp + 1)
if rp <= lp + 1 {
return ""
}
trim(s[lp + 1:rp].to_owned())
}
///|
fn index_of_str(hay : String, needle : String) -> Int {
let hn = hay.length()
let nn = needle.length()
if nn == 0 {
return 0
}
for i = 0; i + nn <= hn; i = i + 1 {
let mut ok = true
for j = 0; j < nn; j = j + 1 {
if hay[i + j] != needle[j] {
ok = false
break
}
}
if ok {
return i
}
}
-1
}
///|
/// The field's serialized name: the `json` struct-tag name (with any
/// `,optional`/`,omitempty` option stripped) when the tag carries one, else the
/// field's own name.
pub fn Field::json_name(self : Field) -> String {
let key = "json:\""
let at = index_of_str(self.tag, key)
if at < 0 {
return self.name
}
let start = at + key.length()
let end = index_of(self.tag, '"', start)
if end < 0 {
return self.name
}
let raw = self.tag[start:end].to_owned()
let comma = index_of(raw, ',', 0)
if comma >= 0 {
trim(raw[0:comma].to_owned())
} else {
trim(raw)
}
}
///|
/// Parse one `type`-block field line into a `Field`. Accepts goctl's Go-struct
/// form `Name Type` and moonctl's colon form `name: type`, each with an optional
/// trailing back-tick struct tag. Returns `None` for a line with no `name type`
/// pair (an embedded type or stray token), which the caller skips.
fn parse_type_field(line : String) -> Field? {
let mut decl = line
let mut tag = ""
let bt = index_of(line, '`', 0)
if bt >= 0 {
let close = index_of(line, '`', bt + 1)
if close > bt {
tag = line[bt + 1:close].to_owned()
decl = trim(line[0:bt].to_owned())
}
}
let colon = index_of(decl, ':', 0)
if colon > 0 {
let name = trim(decl[0:colon].to_owned())
let type_ = map_type(decl[colon + 1:].to_owned())
return Some({ name, type_, tag, })
}
let toks = tokens(decl)
if toks.length() >= 2 {
return Some({ name: toks[0], type_: map_type(toks[1]), tag, })
}
None
}
///|
/// Read a goctl-style .api description into a Spec: the service name, its routes,
/// the type blocks, and the info block. A line that matches nothing is skipped
/// rather than raising — a spec is written by hand, and half of one should still
/// generate what it does describe.
pub fn parse(source : String) -> Spec {
let mut service = "app"
let routes : Array[Route] = []
let types : Array[TypeDef] = []
let info : Array[(String, String)] = []
let mut current : TypeDef? = None
let mut in_info = false
let mut in_type_group = false
let mut pending_handler = ""
let mut pending_doc = ""
for line in lines(source) {
let s = trim(line)
if s == "" || starts_with(s, "//") {
continue
}
// An `info ( key: "value" … )` block (← goctl's `info(...)`): its members feed
// the OpenAPI `info` object. `syntax = "v1"` is recorded and skipped so it is
// not mistaken for a route.
if in_info {
if starts_with(s, ")") {
in_info = false
} else {
let colon = index_of(s, ':', 0)
if colon > 0 {
let key = trim(s[0:colon].to_owned())
let mut val = trim(s[colon + 1:].to_owned())
if val.length() >= 2 && val[0] == '"' && val[val.length() - 1] == '"' {
val = val[1:val.length() - 1].to_owned()
}
info.push((key, val))
}
}
continue
}
if starts_with(s, "syntax") {
continue
}
if starts_with(s, "info") && index_of(s, '(', 0) >= 0 {
in_info = true
continue
}
match current {
Some(td) => {
if s == "}" {
types.push(td)
current = None
} else {
match parse_type_field(s) {
Some(f) => td.fields.push(f)
None => ()
}
}
continue
}
None => ()
}
// A grouped `type ( … )` block declares each member as a bare `Name {` line;
// the single `type Name {` form is handled below.
if in_type_group {
if starts_with(s, ")") {
in_type_group = false
} else {
let toks = tokens(s)
if toks.length() >= 1 && toks[0] != "{" {
current = Some({ name: toks[0], fields: [], })
}
}
continue
}
if starts_with(s, "service ") {
let toks = tokens(s)
if toks.length() >= 2 {
service = toks[1]
}
} else if starts_with(s, "type ") || starts_with(s, "type(") {
let toks = tokens(s)
if starts_with(trim(s[4:].to_owned()), "(") {
in_type_group = true
} else if toks.length() >= 2 {
current = Some({ name: toks[1], fields: [], })
}
} else if starts_with(s, "@doc") {
let dq = index_of(s, '"', 0)
if dq >= 0 {
let close = index_of(s, '"', dq + 1)
if close > dq {
pending_doc = s[dq + 1:close].to_owned()
}
}
} else if starts_with(s, "@handler") {
let toks = tokens(s)
if toks.length() >= 2 {
pending_handler = toks[1]
}
} else if starts_with(s, "@") {
// Other annotations (`@server(...)` groups, handled by the layered generator)
// are not routes — skip so they are never mistaken for one.
continue
} else if s == "}" {
continue
} else {
// A route is `verb /path`, optionally carrying `(Req)`, `returns (Resp)`, or
// both; the legacy form instead names its handler inline and may add a quoted
// summary. A line whose first token is not an HTTP verb is not a route, so it
// is skipped rather than guessed at.
let toks = tokens(s)
if toks.length() >= 2 && is_http_verb(toks[0]) {
let rest = rest_after(s, 2)
let mut req = ""
let mut resp = ""
let mut handler = pending_handler
let mut summary = pending_doc
if starts_with(rest, "(") {
let rp = index_of(rest, ')', 1)
if rp > 1 {
req = trim(rest[1:rp].to_owned())
}
if rp >= 0 {
let tail = trim(rest[rp + 1:].to_owned())
if starts_with(tail, "returns") {
resp = paren_body(tail)
}
}
} else if starts_with(rest, "returns") {
resp = paren_body(rest)
} else if rest != "" {
let lt = tokens(rest)
if lt.length() >= 1 {
handler = lt[0]
}
if lt.length() >= 2 {
summary = lt[1]
}
}
if handler == "" {
handler = toks[0] + "_handler"
}
routes.push({
verb: toks[0],
path: toks[1],
handler,
summary,
req,
resp,
})
pending_handler = ""
pending_doc = ""
}
}
}
{ service, routes, types, info, }
}
///|
fn quote(s : String) -> String {
"\"" + s + "\""
}
///|
/// Generate compilable moonapi scaffolding from a spec: a MoonBit struct for
/// every `type` block, a `build_app` that wires every route to its handler, plus
/// a stub for each handler. The routing part depends only on `Lfan-ke/moonapi`
/// and `Lfan-ke/moonasgi`; the emitted schemas are dependency-free.
///
/// Pass `template` to render the spec through the runtime template engine (see
/// `generate_with`) instead of the built-in generator; omit it for the default
/// scaffold.
pub fn generate(spec : Spec, template? : String) -> String raise TemplateError {
match template {
Some(src) => generate_with(spec, src)
None => generate_builtin(spec)
}
}
///|
fn generate_builtin(spec : Spec) -> String {
let mut out = "// Code generated by moonctl. DO NOT EDIT.\n\n"
for t in spec.types {
out = out +
"///|\n/// `" +
t.name +
"` schema (generated from the `.api` `type` block).\npub(all) struct " +
t.name +
" {\n"
for f in t.fields {
out = out + " " + to_snake(f.name) + " : " + f.type_ + "\n"
}
out = out + "}\n\n"
}
out = out +
"///|\n/// Build the " +
spec.service +
" application with its routes wired to handlers.\n"
out = out + "pub fn build_app() -> @moonapi.App {\n"
out = out + " let app = @moonapi.App::new()\n"
for r in spec.routes {
// moonapi's `ApiHandler` is a *raising* fn-type, and under moonc 0.10.5 a pure
// named fn no longer coerces to one. Register each handler through an arrow
// closure — a closure infers the raise effect, so a stub that never raises and a
// filled-in handler that raises an `HttpException` are both accepted unchanged.
let handler = "ctx => " + r.handler + "(ctx)"
let head = " app." + r.verb + "(" + quote(r.path) + ", " + handler
out = out +
(if r.summary != "" {
head + ", summary=" + quote(r.summary) + ")\n"
} else {
head + ")\n"
})
}
out = out + " app\n}\n\n"
for r in spec.routes {
out = out +
"///|\npub fn " +
r.handler +
"(_ctx : @moonapi.Context) -> @moonasgi.Response {\n @moonapi.text(200, " +
quote("TODO: " + r.handler) +
")\n}\n\n"
}
out
}