package lua import ( "fmt" golua "github.com/aarzilli/golua/lua" "sort" "strings" ) // The declarative decoder. // // Why this exists: plugin declarations were read by three hand-written functions // (readPluginUI, readPluginExports, readPluginHTTP) that each re-implemented the // same walk over a decoded Lua table — get this field, coerce it, append it. The // field names lived in the .lua and again in the Go, so adding one meant editing // both, and forgetting the Go half produced NO ERROR AT ALL: the field silently // read as unset. // // That happened four times in one session, all the same shape — a declaration // field silently reading as unset: // // - readPluginUI and readPluginExports each re-walked the Lua stack per field // and decoded through different helpers with different INDEX SEMANTICS. // - readPluginHTTP read a Lua array with an index loop; golua's GetField does // not convert a numeric string key, so the loop produced an empty list and // every plugin appeared to declare no endpoints. // - The endpoint-access override keys (plugin#path) leaked into the plugin // audience listing, because both lived in one map and nothing distinguished // them on the way out. // // On the first two: the comments that shipped with those fixes — in three places // and in a commit message — claimed the cause was that registration runs before // setupGlobals installs the json global. That was never verified and is FALSE. // Measured: setupGlobals runs at vm.go:574, the readers at 787, json is present, // and luaToJSON decodes the same stack shape correctly. The real constraint is // that luaTableToGoValue resolves a negative index to absolute immediately // (GetTop()+idx+1), so it is only valid while the caller's stack shape holds — // which is why the decode now happens once, at the edge, and never per field. // // // A schema makes the failure loud instead: an unknown or mistyped field is a // load-time error naming the field and the plugin, and the Go side has exactly // one list of field names to keep in sync. // // The schema is deliberately data, not reflection or struct tags. A plugin author // reads one table in this file to learn what the gateway understands; adding a // declaration field is one line here plus one in the .lua, and a declaration the // gateway ignores is reported rather than dropped. // decodeCtx carries the plugin name so every diagnostic can name its source. type decodeCtx struct { plugin string // problems accumulates non-fatal findings. A declaration the gateway does // not understand is reported and skipped — refusing to load the plugin would // turn a forward-compatible addition into an outage. problems []string } func (c *decodeCtx) warnf(format string, args ...interface{}) { c.problems = append(c.problems, fmt.Sprintf(format, args...)) } // strictKeys reports keys present in m that the schema does not describe. // // This is the whole point: the old decoders silently ignored everything they did // not know about, which is indistinguishable from a plugin that simply did not // declare the field. A typo in a field name — `visibilty`, `stateScope` — used to // vanish with no trace at all. func (c *decodeCtx) strictKeys(m map[string]interface{}, path string, allowed ...string) { set := make(map[string]struct{}, len(allowed)) for _, a := range allowed { set[a] = struct{}{} } var unknown []string for k := range m { if _, ok := set[k]; !ok { unknown = append(unknown, k) } } if len(unknown) == 0 { return } sort.Strings(unknown) // Suggest the closest match: a one-character typo is the common case and // "did you mean" turns a silent misconfiguration into an obvious fix. for _, u := range unknown { if near := closestField(u, allowed); near != "" { c.warnf("%s: unknown field %q (did you mean %q?)", path, u, near) } else { c.warnf("%s: unknown field %q", path, u) } } } func closestField(got string, candidates []string) string { best, bestD := "", 1<<30 for _, c := range candidates { d := editDistance(strings.ToLower(got), strings.ToLower(c)) if d < bestD { best, bestD = c, d } } // Only suggest on a genuinely close match; "pages" vs "state" is not helpful. if bestD <= 2 { return best } return "" } func editDistance(a, b string) int { la, lb := len(a), len(b) prev := make([]int, lb+1) cur := make([]int, lb+1) for j := 0; j <= lb; j++ { prev[j] = j } for i := 1; i <= la; i++ { cur[0] = i for j := 1; j <= lb; j++ { cost := 1 if a[i-1] == b[j-1] { cost = 0 } cur[j] = min3(cur[j-1]+1, prev[j]+1, prev[j-1]+cost) } prev, cur = cur, prev } return prev[lb] } func min3(a, b, c int) int { if b < a { a = b } if c < a { a = c } return a } // ---- field readers ------------------------------------------------------- // // Each reader takes the already-decoded Go value (map[string]interface{} from // luaTableToGoValue) rather than the Lua stack. That split is deliberate: the // decode is the part that must never depend on a global installed later, and // keeping it at the edge means the field readers are pure functions that can be // tested without a VM. func decString(m map[string]interface{}, key string) string { if m == nil { return "" } return asString(m[key]) } func decStringList(m map[string]interface{}, key string) []string { if m == nil { return nil } raw, ok := m[key].([]interface{}) if !ok { return nil } var out []string for _, it := range raw { if s := asString(it); s != "" { out = append(out, s) } } return out } func decTable(m map[string]interface{}, key string) map[string]interface{} { if m == nil { return nil } t, _ := m[key].(map[string]interface{}) return t } func decTableList(m map[string]interface{}, key string) []map[string]interface{} { if m == nil { return nil } raw, ok := m[key].([]interface{}) if !ok { return nil } var out []map[string]interface{} for _, it := range raw { if t, ok := it.(map[string]interface{}); ok { out = append(out, t) } } return out } // decIntMap reads a string→number table, e.g. state_scope.depth = { global = 1 }. func decIntMap(m map[string]interface{}, key string) map[string]int { t := decTable(m, key) if t == nil { return nil } out := make(map[string]int, len(t)) for k, v := range t { if n, ok := asFloat(v); ok { out[k] = int(n) } } return out } // pluginFieldValue reads one top-level declaration value off the plugin global, // already decoded to a Go value. // // Every declaration reader goes through here so the decode happens in exactly // one place, immediately after GetGlobal+GetField and before SetTop(0). // // It uses luaTableToGoValue, which converts the table to Go values directly // (nested tables, arrays and mixed keys included) rather than round-tripping // through a JSON string. // // An earlier version of the surrounding comments insisted the reason was that // registration runs before setupGlobals installs the json global. Measured, that // is false: setupGlobals is at vm.go:574, the readers at 787, and json IS on the // stack. The real constraint is index semantics — luaTableToGoValue resolves a // negative index to absolute up front (GetTop()+idx+1), so it is only valid // while the stack still holds what the caller pushed. Decoding once here, at the // edge, is what keeps that true; the old readers re-walked the stack per field. func pluginFieldValue(L *golua.State, field string) (interface{}, bool) { L.GetGlobal(pluginGlobal) if L.IsNil(-1) { L.SetTop(0) return nil, false } L.GetField(-1, field) if L.Type(-1) == golua.LUA_TNIL { L.SetTop(0) return nil, false } raw := luaTableToGoValue(L, -1) L.SetTop(0) return raw, raw != nil }