// `api validate`: the checks a `.api` spec has to pass before it is worth
// generating from (← goctl's `goctl api validate`). The parser already refuses a
// line it cannot read; what is left are the mistakes a parser cannot see — a route
// pointing at a type nobody declared, two handlers under one name, a `:id` segment
// with no field to fill it. Each finding names the rule that caught it, so a
// toolchain can turn one class of finding off without turning the rest off.
///|
/// How bad a finding is: `Error` means the generated code would not compile or
/// would not match the spec, `Warning` means it would compile but probably is not
/// what the author meant.
pub(all) enum Severity {
Error
Warning
} derive(Eq)
///|
pub extend Severity with Eq::{equal, not_equal}
///|
/// One finding of `validate`: how bad it is, the `rule` that produced it, the
/// `at` — the route, type or field it is about — and a sentence saying what is
/// wrong.
pub(all) struct Diagnostic {
severity : Severity
rule : String
at : String
message : String
}
///|
pub impl Show for Diagnostic with fn output(self, logger) {
logger.write_string(
match self.severity {
Error => "error"
Warning => "warning"
},
)
logger.write_string(" [" + self.rule + "] " + self.at + ": " + self.message)
}
///|
/// `to_string` is how a report line is built, so it is spelled out rather than left
/// to the implicit promotion the compiler now warns about.
pub extend Diagnostic with Show::{to_string, output}
///|
/// Whether any finding is an `Error` — what makes `mctl api validate` exit non-zero.
pub fn has_errors(diagnostics : Array[Diagnostic]) -> Bool {
for d in diagnostics {
if d.severity == Error {
return true
}
}
false
}
///|
/// The names MoonBit will not take as an identifier. A handler named one of these
/// compiles into a function whose name the language rejects, so it is an error and
/// not a warning.
fn is_keyword(name : String) -> Bool {
match name {
"as"
| "async"
| "break"
| "catch"
| "const"
| "continue"
| "deriving"
| "else"
| "enum"
| "extern"
| "false"
| "fn"
| "for"
| "guard"
| "if"
| "impl"
| "in"
| "init"
| "is"
| "let"
| "main"
| "match"
| "mut"
| "not"
| "open"
| "panic"
| "priv"
| "pub"
| "raise"
| "return"
| "self"
| "struct"
| "test"
| "trait"
| "true"
| "try"
| "type"
| "while"
| "with" => true
_ => false
}
}
///|
/// The scalar spellings a field type may end at without naming another `type`
/// block. Anything else is a reference, and `validate` insists it is declared.
fn is_scalar(t : String) -> Bool {
match t {
"String"
| "Bool"
| "Int"
| "Int32"
| "Int64"
| "UInt"
| "UInt32"
| "UInt64"
| "Byte"
| "Bytes"
| "Double"
| "Float"
| "Unit"
| "Json" => true
_ => false
}
}
///|
/// The index of the `", "` between a `Map[K, V]`'s two halves, or `-1`.
fn top_comma(s : String) -> Int32 {
let mut depth = 0
for i = 0; i < s.length(); i = i + 1 {
let c = s[i]
if c == '[' {
depth = depth + 1
} else if c == ']' {
depth = depth - 1
} else if c == ',' && depth == 0 {
return i
}
}
-1
}
///|
/// Collect the `type` blocks a MoonBit type spelling refers to: `User` names one,
/// `Array[User]` and `Map[String, User]` name the same one, `String` names none.
fn collect_refs(t : String, out : Array[String]) -> Unit {
let s = @text.trim(t)
if s == "" {
return
}
if @text.ends_with(s, "?") {
collect_refs(s[0:s.length() - 1].to_owned(), out)
return
}
if @text.starts_with(s, "Array[") && @text.ends_with(s, "]") {
collect_refs(s[6:s.length() - 1].to_owned(), out)
return
}
if @text.starts_with(s, "Map[") && @text.ends_with(s, "]") {
let body = s[4:s.length() - 1].to_owned()
let comma = top_comma(body)
if comma >= 0 {
collect_refs(body[0:comma].to_owned(), out)
collect_refs(body[comma + 1:].to_owned(), out)
}
return
}
let mapped = map_type(s)
if is_scalar(mapped) == false {
out.push(mapped)
}
}
///|
/// Whether `types` declares `name`.
fn declares(types : Array[TypeDef], name : String) -> Bool {
for t in types {
if t.name == name {
return true
}
}
false
}
///|
/// The `:name` segments of a route path, in order.
fn path_params(path : String) -> Array[String] {
let out : Array[String] = []
for seg in @text.path_segments(path) {
if seg.length() > 0 && seg[0] == ':' {
out.push(seg[1:].to_owned())
}
}
out
}
///|
/// The fields a route request type carries, resolving an embedded type; empty when
/// the route names no request type or names one the spec does not declare.
fn request_fields(spec : Spec, route : Route) -> Array[Field] {
if route.req == "" {
return []
}
match find_type(spec.types, route.req) {
None => []
Some(t) => flat_fields(t, spec.types)
}
}
///|
/// A finding, built once so every rule reports in the same shape.
fn finding(
severity : Severity,
rule : String,
at : String,
message : String,
) -> Diagnostic {
{ severity, rule, at, message, }
}
///|
/// Check a parsed spec: the routes, the types and the places one names the other.
/// Findings come in spec order — routes first, then types — so a report reads the
/// way the file does.
pub fn validate(spec : Spec) -> Array[Diagnostic] {
let out : Array[Diagnostic] = []
let types = spec.types
// routes: one handler per name, one handler per verb + path
let handlers : Map[String, Bool] = Map([])
let paths : Map[String, Bool] = Map([])
for r in spec.routes {
let at = r.verb + " " + r.path
if handlers.contains(r.handler) {
out.push(
finding(
Error,
"duplicate-handler",
at,
"handler \"" + r.handler + "\" is declared by more than one route",
),
)
}
handlers[r.handler] = true
let key = r.verb + " " + r.path
if paths.contains(key) {
out.push(
finding(
Error,
"duplicate-route",
at,
"the same verb and path is declared twice",
),
)
}
paths[key] = true
if is_keyword(r.handler) {
out.push(
finding(
Error,
"reserved-handler",
at,
"\"" +
r.handler +
"\" is a MoonBit keyword, so the generated function cannot carry it",
),
)
}
if r.req != "" && declares(types, r.req) == false {
out.push(
finding(
Error,
"unknown-type",
at,
"request type \"" + r.req + "\" is not declared",
),
)
}
if r.resp != "" && declares(types, r.resp) == false {
out.push(
finding(
Error,
"unknown-type",
at,
"response type \"" + r.resp + "\" is not declared",
),
)
}
let fields = request_fields(spec, r)
for param in path_params(r.path) {
let mut bound = false
for f in fields {
if f.json_name() == param || f.mbt_name() == param {
bound = true
}
}
if r.req != "" && declares(types, r.req) && bound == false {
out.push(
finding(
Warning,
"path-param",
at,
"`:" +
param +
"` has no field named \"" +
param +
"\" in " +
r.req +
", so nothing fills it",
),
)
}
}
}
if spec.service != "" && spec.routes.length() == 0 {
out.push(
finding(
Warning,
"empty-service",
"service " + spec.service,
"the service declares no route",
),
)
}
let used_groups : Map[String, Bool] = Map([])
for r in spec.routes {
match r.group {
Some(g) => used_groups[g.name] = true
None => ()
}
}
for g in spec.groups {
if used_groups.contains(g.name) == false {
out.push(
finding(
Warning,
"unused-group",
"@server(" + g.name + ")",
"the group annotates no route",
),
)
}
}
// types: one declaration per name, one field per name, every reference declared
let declared : Map[String, Bool] = Map([])
for t in types {
if declared.contains(t.name) {
out.push(
finding(
Error,
"duplicate-type",
"type " + t.name,
"\"" + t.name + "\" is declared more than once",
),
)
}
declared[t.name] = true
}
for t in types {
// A bare `Base` line inlines another block: it names that block the same way a
// field type does, so an undeclared embed is the same `unknown-type` finding.
for e in t.embeds {
if declared.contains(e) == false {
out.push(
finding(
Error,
"unknown-type",
"type " + t.name,
"embedded type \"" + e + "\" is not declared",
),
)
}
}
let fields = flat_fields(t, types)
if fields.length() == 0 {
out.push(
finding(
Warning,
"empty-type",
"type " + t.name,
"the block declares no field",
),
)
}
let names : Map[String, Bool] = Map([])
for f in fields {
let at = "type " + t.name + "." + f.name
if names.contains(f.name) {
out.push(
finding(
Error,
"duplicate-field",
at,
"the field is declared twice in the same block",
),
)
}
names[f.name] = true
let refs : Array[String] = []
collect_refs(f.type_, refs)
for r in refs {
if declared.contains(r) == false {
out.push(
finding(
Error,
"unknown-type",
at,
"field type \"" + r + "\" is not declared",
),
)
}
}
}
}
for t in types {
let mut used = false
for r in spec.routes {
if r.req == t.name || r.resp == t.name {
used = true
}
}
for other in types {
if other.name == t.name {
continue
}
for e in other.embeds {
if e == t.name {
used = true
}
}
for f in flat_fields(other, types) {
let refs : Array[String] = []
collect_refs(f.type_, refs)
for r in refs {
if r == t.name {
used = true
}
}
}
}
if used == false {
out.push(
finding(
Warning,
"unused-type",
"type " + t.name,
"no route and no other type refers to it",
),
)
}
}
out
}
///|
/// Validate a spec given as source. A spec the parser cannot read is reported as
/// the one finding it is — `syntax`, naming the line — rather than raising, so a
/// caller checks a file the same way whether it is broken or merely wrong.
pub fn validate_source(source : String) -> Array[Diagnostic] {
let spec = parse(source) catch {
Syntax(line~, msg~) =>
return [finding(Error, "syntax", "line " + line.to_string(), msg)]
Missing(file~, path~) =>
return [
finding(Error, "import", file, "cannot read import \"" + path + "\""),
]
}
validate(spec)
}
///|
/// Validate one file of a spec together with everything it imports, `files` being
/// every source the walk has read keyed by the path it was read from. Without this a
/// spec that embeds a type from another file is told that the type does not exist —
/// true of the file on its own, false of the spec it belongs to.
pub fn validate_files(
source : String,
files : Map[String, String],
from~ : String = "",
) -> Array[Diagnostic] {
let spec = parse_all(source, files, from~) catch {
Syntax(line~, msg~) =>
return [finding(Error, "syntax", "line " + line.to_string(), msg)]
Missing(file~, path~) =>
return [
finding(Error, "import", file, "cannot read import \"" + path + "\""),
]
}
validate(spec)
}