- lagoon.ValidateRequest ports Laravel 9 request validation: wildcard expansion, implicit-rule stop, bail, sometimes/nullable/blank skipping, size messages split by type and character-counted string lengths - ParseRules, In, CustomRule, UploadedFile and ErrorKeys for rule tables - pl/en lagoon::validation catalogs ported verbatim from WinterCMS - lagoon.Validate answers a numeric range failure with the bound that failed (min, max or numeric between) instead of always max
733 lines
19 KiB
Go
733 lines
19 KiB
Go
package lagoon
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/textproto"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// RequestRule is one attribute of a request rule table: the attribute name,
|
|
// which may contain `*` wildcard segments (posts.*.title), and its rules in
|
|
// the order Laravel runs them.
|
|
type RequestRule struct {
|
|
Field string
|
|
Rules []Rule
|
|
}
|
|
|
|
// Rule is one parsed validation rule: a named Laravel rule with its
|
|
// parameters, or a closure built with CustomRule. Build rules with
|
|
// ParseRules, In or CustomRule; the zero Rule is ignored.
|
|
type Rule struct {
|
|
name string
|
|
args []string
|
|
custom func(attribute string, value any) (message string, failed bool)
|
|
re *compiledRegex
|
|
}
|
|
|
|
// Name returns the snake-case rule name (required, max, regex) or "custom"
|
|
// for a CustomRule.
|
|
func (r Rule) Name() string {
|
|
if r.custom != nil {
|
|
return "custom"
|
|
}
|
|
return r.name
|
|
}
|
|
|
|
// Args returns the rule parameters as written after the colon.
|
|
func (r Rule) Args() []string {
|
|
return append([]string(nil), r.args...)
|
|
}
|
|
|
|
// CustomRule wraps a closure rule, the Go form of a PHP `function
|
|
// ($attribute, $value, $fail)` rule. It runs in its declaration position,
|
|
// only when the value is present (it is not implicit), and a failure adds
|
|
// the returned message as written, after the :attribute placeholder is
|
|
// replaced, exactly as Winter adds a closure's $fail message.
|
|
func CustomRule(fn func(attribute string, value any) (message string, failed bool)) Rule {
|
|
if fn == nil {
|
|
panic("lagoon: CustomRule with a nil func")
|
|
}
|
|
return Rule{custom: fn}
|
|
}
|
|
|
|
// In is the Go form of Laravel's Rule::in: the value must equal one of
|
|
// values. Use it when a value contains a comma or a quote.
|
|
func In(values ...string) Rule {
|
|
return Rule{name: "in", args: append([]string(nil), values...)}
|
|
}
|
|
|
|
// UploadedFile is an uploaded file offered to the file rules (file, image,
|
|
// mimes and the size rules, which measure it in kilobytes). Open returns the
|
|
// content; the rules read at most the first 512 bytes to detect the type.
|
|
type UploadedFile struct {
|
|
Filename string
|
|
Size int64
|
|
Header textproto.MIMEHeader
|
|
Open func() (io.ReadCloser, error)
|
|
}
|
|
|
|
// UploadedFileFromHeader adapts a parsed multipart file part.
|
|
func UploadedFileFromHeader(fh *multipart.FileHeader) UploadedFile {
|
|
if fh == nil {
|
|
return UploadedFile{}
|
|
}
|
|
return UploadedFile{
|
|
Filename: fh.Filename,
|
|
Size: fh.Size,
|
|
Header: fh.Header,
|
|
Open: func() (io.ReadCloser, error) {
|
|
return fh.Open()
|
|
},
|
|
}
|
|
}
|
|
|
|
// ValidateRequest validates decoded request input with Laravel 9 request
|
|
// validation semantics and returns Laravel's errors object (attribute to
|
|
// ordered messages), or nil when the input passes. The error return is for
|
|
// failures that are not the client's fault (a database error in exists:).
|
|
//
|
|
// The semantics follow Illuminate\Validation\Validator: `*` segments expand
|
|
// against the input (posts.0.title, posts.1.title; a wildcard with nothing to
|
|
// expand adds no attribute); rules run in order; a non-implicit rule runs only
|
|
// when the attribute is present and is skipped for a blank string, for null
|
|
// under nullable, and for an absent key under sometimes; an attribute stops
|
|
// after a failed implicit rule (required, present, filled, accepted) and,
|
|
// under bail, after any failure. Messages come from the lagoon::validation
|
|
// catalog in the request locale; size rules pick the numeric, file, array or
|
|
// string message by the attribute's type.
|
|
func ValidateRequest(ctx context.Context, tx *gorm.DB, input map[string]any, rules []RequestRule, tr *phrasebook.Translator) (map[string][]string, error) {
|
|
v := &requestValidator{ctx: ctx, tx: tx, data: input, tr: tr}
|
|
if v.data == nil {
|
|
v.data = map[string]any{}
|
|
}
|
|
if err := v.explode(rules); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, attr := range v.order {
|
|
ruleset := v.rules[attr]
|
|
for _, rule := range ruleset {
|
|
if err := v.validateAttribute(attr, rule); err != nil {
|
|
return nil, err
|
|
}
|
|
if v.shouldStop(attr) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(v.messages) == 0 {
|
|
return nil, nil
|
|
}
|
|
return v.messages, nil
|
|
}
|
|
|
|
// ErrorKeys returns the attributes of errs in the order Laravel's message bag
|
|
// holds them for rules: explicit attributes in declaration order, then the
|
|
// attributes each wildcard rule expanded to, rule by rule, in input order
|
|
// (array indexes ascending). Callers that compare a 422 body byte for byte
|
|
// use it to emit the errors object in PHP's key order.
|
|
func ErrorKeys(errs map[string][]string, rules []RequestRule) []string {
|
|
type ranked struct {
|
|
key string
|
|
group int
|
|
path []string
|
|
}
|
|
explicit := map[string]int{}
|
|
for i, rr := range rules {
|
|
if !strings.Contains(rr.Field, "*") {
|
|
if _, ok := explicit[rr.Field]; !ok {
|
|
explicit[rr.Field] = i
|
|
}
|
|
}
|
|
}
|
|
out := make([]ranked, 0, len(errs))
|
|
for key := range errs {
|
|
group := len(rules) * 2
|
|
if i, ok := explicit[key]; ok {
|
|
group = i
|
|
} else {
|
|
for i, rr := range rules {
|
|
if strings.Contains(rr.Field, "*") && wildcardMatches(rr.Field, key) {
|
|
group = len(rules) + i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
out = append(out, ranked{key: key, group: group, path: strings.Split(key, ".")})
|
|
}
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].group != out[j].group {
|
|
return out[i].group < out[j].group
|
|
}
|
|
return pathLess(out[i].path, out[j].path)
|
|
})
|
|
keys := make([]string, len(out))
|
|
for i, r := range out {
|
|
keys[i] = r.key
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func pathLess(a, b []string) bool {
|
|
for i := 0; i < len(a) && i < len(b); i++ {
|
|
if a[i] == b[i] {
|
|
continue
|
|
}
|
|
ai, aerr := strconv.Atoi(a[i])
|
|
bi, berr := strconv.Atoi(b[i])
|
|
if aerr == nil && berr == nil {
|
|
return ai < bi
|
|
}
|
|
return a[i] < b[i]
|
|
}
|
|
return len(a) < len(b)
|
|
}
|
|
|
|
// wildcardMatches reports whether key is an expansion of pattern: every `*`
|
|
// stands for one non-empty segment.
|
|
func wildcardMatches(pattern, key string) bool {
|
|
ps := strings.Split(pattern, ".")
|
|
ks := strings.Split(key, ".")
|
|
if len(ps) != len(ks) {
|
|
return false
|
|
}
|
|
for i := range ps {
|
|
if ps[i] == "*" {
|
|
if ks[i] == "" {
|
|
return false
|
|
}
|
|
continue
|
|
}
|
|
if ps[i] != ks[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type requestValidator struct {
|
|
ctx context.Context
|
|
tx *gorm.DB
|
|
data map[string]any
|
|
tr *phrasebook.Translator
|
|
order []string
|
|
rules map[string][]Rule
|
|
primary map[string]string // expanded attribute -> wildcard pattern
|
|
messages map[string][]string
|
|
failed map[string]map[string]bool
|
|
}
|
|
|
|
// explode builds the attribute order Laravel's ValidationRuleParser gives:
|
|
// explicit attributes keep their declaration position (a later explicit
|
|
// declaration of an expanded key replaces its rules), and wildcard
|
|
// expansions are appended in expansion order.
|
|
func (v *requestValidator) explode(rules []RequestRule) error {
|
|
v.rules = map[string][]Rule{}
|
|
v.primary = map[string]string{}
|
|
seen := map[string]bool{}
|
|
for _, rr := range rules {
|
|
if rr.Field == "" {
|
|
return fmt.Errorf("lagoon: request rule with an empty field")
|
|
}
|
|
if seen[rr.Field] {
|
|
return fmt.Errorf("lagoon: duplicate request rule field %q", rr.Field)
|
|
}
|
|
seen[rr.Field] = true
|
|
v.order = append(v.order, rr.Field)
|
|
v.rules[rr.Field] = cleanRules(rr.Rules)
|
|
}
|
|
for _, rr := range rules {
|
|
if !strings.Contains(rr.Field, "*") {
|
|
v.rules[rr.Field] = cleanRules(rr.Rules)
|
|
continue
|
|
}
|
|
for _, key := range expandWildcard(v.data, rr.Field) {
|
|
if _, ok := v.rules[key]; ok {
|
|
v.rules[key] = append(v.rules[key], cleanRules(rr.Rules)...)
|
|
} else {
|
|
v.order = append(v.order, key)
|
|
v.rules[key] = cleanRules(rr.Rules)
|
|
}
|
|
if _, ok := v.primary[key]; !ok {
|
|
v.primary[key] = rr.Field
|
|
}
|
|
}
|
|
delete(v.rules, rr.Field)
|
|
v.order = removeString(v.order, rr.Field)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cleanRules(rules []Rule) []Rule {
|
|
out := make([]Rule, 0, len(rules))
|
|
for _, r := range rules {
|
|
if r.name == "" && r.custom == nil {
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func removeString(list []string, s string) []string {
|
|
out := list[:0]
|
|
for _, x := range list {
|
|
if x != s {
|
|
out = append(out, x)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
type expandedKey struct {
|
|
key string
|
|
leaf bool
|
|
}
|
|
|
|
// expandWildcard lists the concrete attributes a wildcard pattern stands for
|
|
// in data, in the order Laravel's ValidationData gathers them: attributes that
|
|
// are leaves of the dotted input first, then the rest, each in input order.
|
|
// Literal segments after the last `*` always produce the attribute (its value
|
|
// may be missing); a missing or scalar node before a `*` produces nothing.
|
|
func expandWildcard(data map[string]any, pattern string) []string {
|
|
segs := strings.Split(pattern, ".")
|
|
last := -1
|
|
for i, s := range segs {
|
|
if s == "*" {
|
|
last = i
|
|
}
|
|
}
|
|
var found []expandedKey
|
|
var walk func(node any, present bool, i int, prefix string)
|
|
walk = func(node any, present bool, i int, prefix string) {
|
|
if i == len(segs) {
|
|
found = append(found, expandedKey{key: prefix, leaf: !present || isDotLeaf(node)})
|
|
return
|
|
}
|
|
seg := segs[i]
|
|
if seg == "*" {
|
|
for _, ch := range children(node) {
|
|
walk(ch.value, true, i+1, joinPath(prefix, ch.key))
|
|
}
|
|
return
|
|
}
|
|
child, ok := childOf(node, seg)
|
|
if i > last {
|
|
walk(child, ok, i+1, joinPath(prefix, seg))
|
|
return
|
|
}
|
|
if !ok {
|
|
return
|
|
}
|
|
walk(child, true, i+1, joinPath(prefix, seg))
|
|
}
|
|
walk(data, true, 0, "")
|
|
out := make([]string, 0, len(found))
|
|
seen := map[string]bool{}
|
|
for _, pass := range []bool{true, false} {
|
|
for _, f := range found {
|
|
if f.leaf != pass || seen[f.key] {
|
|
continue
|
|
}
|
|
seen[f.key] = true
|
|
out = append(out, f.key)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func joinPath(prefix, seg string) string {
|
|
if prefix == "" {
|
|
return seg
|
|
}
|
|
return prefix + "." + seg
|
|
}
|
|
|
|
// isDotLeaf reports whether Arr::dot would emit the value as one key: any
|
|
// scalar and any empty array.
|
|
func isDotLeaf(v any) bool {
|
|
return len(children(v)) == 0
|
|
}
|
|
|
|
type childEntry struct {
|
|
key string
|
|
value any
|
|
}
|
|
|
|
// children lists an array's elements or a map's entries. Go maps carry no
|
|
// insertion order, so map entries come sorted by key.
|
|
func children(v any) []childEntry {
|
|
switch t := v.(type) {
|
|
case []any:
|
|
out := make([]childEntry, len(t))
|
|
for i, x := range t {
|
|
out[i] = childEntry{key: strconv.Itoa(i), value: x}
|
|
}
|
|
return out
|
|
case map[string]any:
|
|
keys := make([]string, 0, len(t))
|
|
for k := range t {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
out := make([]childEntry, len(keys))
|
|
for i, k := range keys {
|
|
out[i] = childEntry{key: k, value: t[k]}
|
|
}
|
|
return out
|
|
case []string:
|
|
out := make([]childEntry, len(t))
|
|
for i, x := range t {
|
|
out[i] = childEntry{key: strconv.Itoa(i), value: x}
|
|
}
|
|
return out
|
|
case []map[string]any:
|
|
out := make([]childEntry, len(t))
|
|
for i, x := range t {
|
|
out[i] = childEntry{key: strconv.Itoa(i), value: x}
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func childOf(v any, seg string) (any, bool) {
|
|
switch t := v.(type) {
|
|
case map[string]any:
|
|
x, ok := t[seg]
|
|
return x, ok
|
|
case []any:
|
|
i, err := strconv.Atoi(seg)
|
|
if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg {
|
|
return nil, false
|
|
}
|
|
return t[i], true
|
|
case []string:
|
|
i, err := strconv.Atoi(seg)
|
|
if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg {
|
|
return nil, false
|
|
}
|
|
return t[i], true
|
|
case []map[string]any:
|
|
i, err := strconv.Atoi(seg)
|
|
if err != nil || i < 0 || i >= len(t) || strconv.Itoa(i) != seg {
|
|
return nil, false
|
|
}
|
|
return t[i], true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// lookup is Arr::get/Arr::has over the dotted attribute.
|
|
func (v *requestValidator) lookup(attr string) (any, bool) {
|
|
var node any = v.data
|
|
for _, seg := range strings.Split(attr, ".") {
|
|
x, ok := childOf(node, seg)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
node = x
|
|
}
|
|
return node, true
|
|
}
|
|
|
|
func (v *requestValidator) hasRule(attr string, names ...string) bool {
|
|
for _, r := range v.rules[attr] {
|
|
for _, n := range names {
|
|
if r.custom == nil && r.name == n {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (v *requestValidator) validateAttribute(attr string, rule Rule) error {
|
|
value, present := v.lookup(attr)
|
|
if !v.isValidatable(rule, attr, value, present) {
|
|
return nil
|
|
}
|
|
if rule.custom != nil {
|
|
msg, failed := rule.custom(attr, value)
|
|
if failed {
|
|
v.addMessage(attr, v.replaceAttribute(msg, attr))
|
|
v.markFailed(attr, "custom")
|
|
}
|
|
return nil
|
|
}
|
|
ok, err := v.passes(rule, attr, value, present)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
v.addFailure(attr, rule, value)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (v *requestValidator) isValidatable(rule Rule, attr string, value any, present bool) bool {
|
|
implicit := rule.isImplicit()
|
|
// presentOrRuleIsImplicit
|
|
if s, ok := value.(string); ok && phpTrim(s) == "" && present {
|
|
if !implicit {
|
|
return false
|
|
}
|
|
} else if !present && !implicit {
|
|
return false
|
|
}
|
|
// passesOptionalCheck
|
|
if v.hasRule(attr, "sometimes") && !present {
|
|
return false
|
|
}
|
|
// isNotNullIfMarkedAsNullable
|
|
if !implicit && v.hasRule(attr, "nullable") && present && value == nil {
|
|
return false
|
|
}
|
|
// hasNotFailedPreviousRuleIfPresenceRule
|
|
if rule.custom == nil && (rule.name == "exists" || rule.name == "unique") && len(v.messages[attr]) > 0 {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (v *requestValidator) shouldStop(attr string) bool {
|
|
if v.hasRule(attr, "bail") {
|
|
return len(v.messages[attr]) > 0
|
|
}
|
|
if !v.hasRule(attr, implicitRuleNames...) {
|
|
return false
|
|
}
|
|
for name := range v.failed[attr] {
|
|
if isImplicitName(name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (v *requestValidator) addMessage(attr, msg string) {
|
|
if v.messages == nil {
|
|
v.messages = map[string][]string{}
|
|
}
|
|
v.messages[attr] = append(v.messages[attr], msg)
|
|
}
|
|
|
|
func (v *requestValidator) markFailed(attr, rule string) {
|
|
if v.failed == nil {
|
|
v.failed = map[string]map[string]bool{}
|
|
}
|
|
if v.failed[attr] == nil {
|
|
v.failed[attr] = map[string]bool{}
|
|
}
|
|
v.failed[attr][rule] = true
|
|
}
|
|
|
|
func (v *requestValidator) addFailure(attr string, rule Rule, value any) {
|
|
msg := v.message(attr, rule.name)
|
|
msg = v.replaceAttribute(msg, attr)
|
|
msg = replaceInput(msg, value)
|
|
msg = replaceIndexes(msg, attr)
|
|
msg = v.replaceRule(msg, attr, rule)
|
|
v.addMessage(attr, msg)
|
|
v.markFailed(attr, rule.name)
|
|
}
|
|
|
|
const catalogPrefix = "lagoon::validation."
|
|
|
|
// message is Winter's FormatsMessages::getMessage: a custom line for the
|
|
// attribute and rule, then the typed line of a size rule, then the rule line.
|
|
func (v *requestValidator) message(attr, rule string) string {
|
|
if s, ok := v.line("custom." + attr + "." + rule); ok {
|
|
return s
|
|
}
|
|
if sizeRuleNames[rule] {
|
|
if s, ok := v.line(rule + "." + v.attributeType(attr)); ok {
|
|
return s
|
|
}
|
|
return "validation." + rule + "." + v.attributeType(attr)
|
|
}
|
|
if s, ok := v.line(rule); ok {
|
|
return s
|
|
}
|
|
return "validation." + rule
|
|
}
|
|
|
|
func (v *requestValidator) line(key string) (string, bool) {
|
|
if v.tr == nil {
|
|
return "", false
|
|
}
|
|
full := catalogPrefix + key
|
|
if !v.tr.Has(full) {
|
|
return "", false
|
|
}
|
|
s := v.tr.Get(v.ctx, full, nil)
|
|
if s == full {
|
|
return "", false
|
|
}
|
|
return s, true
|
|
}
|
|
|
|
func (v *requestValidator) attributeType(attr string) string {
|
|
switch {
|
|
case v.hasRule(attr, numericRuleNames...):
|
|
return "numeric"
|
|
case v.hasRule(attr, "array"):
|
|
return "array"
|
|
}
|
|
if val, ok := v.lookup(attr); ok {
|
|
if _, isFile := asUploadedFile(val); isFile {
|
|
return "file"
|
|
}
|
|
}
|
|
return "string"
|
|
}
|
|
|
|
// displayableAttribute is Laravel's getDisplayableAttribute: a catalog
|
|
// attribute name for the attribute or its wildcard pattern, the raw name of
|
|
// an expanded attribute, else the snake-cased name with spaces.
|
|
func (v *requestValidator) displayableAttribute(attr string) string {
|
|
names := []string{attr}
|
|
primary, expanded := v.primary[attr]
|
|
if expanded && primary != attr {
|
|
names = append(names, primary)
|
|
}
|
|
for _, n := range names {
|
|
if s, ok := v.line("attributes." + n); ok {
|
|
return s
|
|
}
|
|
}
|
|
if expanded {
|
|
return attr
|
|
}
|
|
return strings.ReplaceAll(laravelSnake(attr), "_", " ")
|
|
}
|
|
|
|
func (v *requestValidator) replaceAttribute(msg, attr string) string {
|
|
name := v.displayableAttribute(attr)
|
|
return strings.NewReplacer(
|
|
":attribute", name,
|
|
":ATTRIBUTE", strings.ToUpper(name),
|
|
":Attribute", phpUcfirst(name),
|
|
).Replace(msg)
|
|
}
|
|
|
|
func (v *requestValidator) replaceRule(msg, attr string, rule Rule) string {
|
|
switch rule.name {
|
|
case "between":
|
|
return strings.NewReplacer(":min", argAt(rule.args, 0), ":max", argAt(rule.args, 1)).Replace(msg)
|
|
case "min":
|
|
return strings.ReplaceAll(msg, ":min", argAt(rule.args, 0))
|
|
case "max":
|
|
return strings.ReplaceAll(msg, ":max", argAt(rule.args, 0))
|
|
case "size":
|
|
return strings.ReplaceAll(msg, ":size", argAt(rule.args, 0))
|
|
case "in", "not_in", "mimes":
|
|
return strings.ReplaceAll(msg, ":values", strings.Join(rule.args, ", "))
|
|
case "after", "after_or_equal", "before", "before_or_equal":
|
|
arg := argAt(rule.args, 0)
|
|
if _, ok := parseDateArg(arg); !ok {
|
|
return strings.ReplaceAll(msg, ":date", v.displayableAttribute(arg))
|
|
}
|
|
return strings.ReplaceAll(msg, ":date", arg)
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func argAt(args []string, i int) string {
|
|
if i < len(args) {
|
|
return args[i]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// replaceInput replaces :input with a scalar value, as Laravel does.
|
|
func replaceInput(msg string, value any) string {
|
|
if !strings.Contains(msg, ":input") {
|
|
return msg
|
|
}
|
|
s, ok := phpScalarString(value)
|
|
if !ok {
|
|
return msg
|
|
}
|
|
return strings.ReplaceAll(msg, ":input", s)
|
|
}
|
|
|
|
// replaceIndexes replaces :index and :position (zero- and one-based) with
|
|
// the first numeric segment of the attribute.
|
|
func replaceIndexes(msg, attr string) string {
|
|
if !strings.Contains(msg, ":index") && !strings.Contains(msg, ":position") {
|
|
return msg
|
|
}
|
|
for _, seg := range strings.Split(attr, ".") {
|
|
if n, err := strconv.Atoi(seg); err == nil {
|
|
msg = strings.ReplaceAll(msg, ":index", strconv.Itoa(n))
|
|
msg = strings.ReplaceAll(msg, ":position", strconv.Itoa(n+1))
|
|
break
|
|
}
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// laravelSnake ports Str::snake with the underscore delimiter.
|
|
func laravelSnake(s string) string {
|
|
if isCtypeLower(s) {
|
|
return s
|
|
}
|
|
s = phpUcwords(s)
|
|
var b strings.Builder
|
|
first := true
|
|
for _, r := range s {
|
|
if r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == '\f' || r == '\v' {
|
|
continue
|
|
}
|
|
if !first && r >= 'A' && r <= 'Z' {
|
|
b.WriteByte('_')
|
|
}
|
|
b.WriteRune(r)
|
|
first = false
|
|
}
|
|
return strings.ToLower(b.String())
|
|
}
|
|
|
|
func isCtypeLower(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < 'a' || s[i] > 'z' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func phpUcwords(s string) string {
|
|
b := []byte(s)
|
|
start := true
|
|
for i, c := range b {
|
|
if start && c >= 'a' && c <= 'z' {
|
|
b[i] = c - 'a' + 'A'
|
|
}
|
|
start = c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '\f' || c == '\v'
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// phpUcfirst ports Str::ucfirst, which upper-cases the first character
|
|
// multibyte-safely.
|
|
func phpUcfirst(s string) string {
|
|
r, size := utf8.DecodeRuneInString(s)
|
|
if size == 0 || r == utf8.RuneError {
|
|
return s
|
|
}
|
|
return strings.ToUpper(string(r)) + s[size:]
|
|
}
|