package uconfig import ( "bytes" "crypto/sha1" "encoding/json" "errors" "fmt" "io" "math" "net/http" "os" "os/exec" "path/filepath" "reflect" "regexp" "strconv" "strings" "sync" "time" "unsafe" "github.com/pyke369/golang-support/rcache" ) type UConfig struct { input string config any hash string cache map[string]any separator string cacheLock sync.RWMutex sync.RWMutex } type replacer struct { search *regexp.Regexp replace string loop bool } var ( escaped = "{}[],#/*;:= " unescaper = regexp.MustCompile(`@\d+@`) // match escaped characters (to reverse previous escaping) expander = regexp.MustCompile(`{{([<=|@&!\-\+_])\s*([^{}]*?)\s*}}`) // match external content macros sizer = regexp.MustCompile(`^(\d+(?:\.\d*)?)\s*([KMGTP]?)(B?)$`) // match size value duration1 = regexp.MustCompile(`(\d+)(Y|MO|D|H|MN|S|MS|US)?`) // match duration value form1 (free) duration2 = regexp.MustCompile(`^(?:(\d+):)?(\d{2}):(\d{2})(?:\.(\d{1,3}))?$`) // match duration value form2 (timecode) replacers = []replacer{ replacer{regexp.MustCompile("(?m)^(.*?)(?:#|//).*?$"), `$1`, false}, // remove # and // commented portions replacer{regexp.MustCompile(`/\*[^\*]*\*/`), ``, true}, // remove /* */ commented portions replacer{regexp.MustCompile(`(?m)^\s+`), ``, false}, // trim leading spaces replacer{regexp.MustCompile(`(?m)\s+$`), ``, false}, // trim trailing spaces replacer{regexp.MustCompile(`(?m)^(\S+)\s+([^{}\[\],;:=]+);$`), "$1 = $2;", false}, // add missing key-value separators replacer{regexp.MustCompile(`(?m);$`), `,`, false}, // replace ; line terminators by , replacer{regexp.MustCompile(`(\S+?)\s*[:=]`), `$1:`, false}, // replace = key-value separators by : replacer{regexp.MustCompile(`([}\]])(\s*)([^,}\]\s])`), `$1,$2$3`, false}, // add missing objects/arrays , separators replacer{regexp.MustCompile("(?m)(^[^:]+:.+?[^,])$"), `$1,`, false}, // add missing values trailing , seperators replacer{regexp.MustCompile(`(?m)(^[^\[{][^:\[{]+)\s+([\[{])`), `$1:$2`, true}, // add missing key-(object/array-)value separator replacer{regexp.MustCompile(`(?m)^([^":{}\[\]]+)`), `"$1"`, false}, // add missing quotes around keys replacer{regexp.MustCompile("([:,\\[\\s]+)([^\",\\[\\]{}\\s\n\r]+?)(\\s*[,\\]}])"), `$1"$2"$3`, false}, // add missing quotes around values replacer{regexp.MustCompile("\"[\r\n]"), "\",\n", false}, // add still issing objects/arrays , separators replacer{regexp.MustCompile(`"\s*(.+?)\s*"`), `"$1"`, false}, // trim leading and trailing spaces in quoted strings replacer{regexp.MustCompile(`,+(\s*[}\]])`), `$1`, false}, // remove objets/arrays last element extra , } ) func escape(input string) string { var output []byte instring := false for index := 0; index < len(input); index++ { if input[index:index+1] == `"` && (index == 0 || input[index-1:index] != `\`) { instring = !instring } if instring { offset := strings.IndexAny(escaped, input[index:index+1]) if offset >= 0 { output = append(output, []byte(fmt.Sprintf("@%02d@", offset))...) } else { output = append(output, input[index:index+1]...) } } else { output = append(output, input[index:index+1]...) } } return string(output) } func unescape(input string) string { return unescaper.ReplaceAllStringFunc(input, func(a string) string { offset, _ := strconv.Atoi(a[1:3]) if offset < len(escaped) { return escaped[offset : offset+1] } return "" }) } func reduce(input any) { if input != nil { switch reflect.TypeOf(input).Kind() { case reflect.Map: for key := range input.(map[string]any) { var parts []string for _, value := range strings.Split(key, " ") { if value != "" { parts = append(parts, value) } } if len(parts) > 1 { if input.(map[string]any)[parts[0]] == nil || reflect.TypeOf(input.(map[string]any)[parts[0]]).Kind() != reflect.Map { input.(map[string]any)[parts[0]] = make(map[string]any) } input.(map[string]any)[parts[0]].(map[string]any)[parts[1]] = input.(map[string]any)[key] delete(input.(map[string]any), key) } } for _, value := range input.(map[string]any) { reduce(value) } case reflect.Slice: for index := 0; index < len(input.([]any)); index++ { reduce(input.([]any)[index]) } } } } func New(input string, inline ...bool) (*UConfig, error) { config := &UConfig{ input: input, config: nil, separator: ".", } return config, config.Load(input, inline...) } func (c *UConfig) Reload(inline ...bool) error { return c.Load(c.input, inline...) } func (c *UConfig) SetSeparator(separator string) { c.separator = separator } func (c *UConfig) Load(input string, inline ...bool) error { c.Lock() base, _ := os.Getwd() content := fmt.Sprintf("/*base:%s*/\n", base) if len(inline) > 0 && inline[0] { content += input } else { content += fmt.Sprintf("{{<%s}}", input) } for { indexes := expander.FindStringSubmatchIndex(content) if indexes == nil { content = escape(content) for index := 0; index < len(replacers); index++ { mcontent := "" for mcontent != content { mcontent = content content = replacers[index].search.ReplaceAllString(content, replacers[index].replace) if !replacers[index].loop { break } } } content = unescape(strings.Trim(content, " \n,")) break } expanded := "" arguments := strings.Split(content[indexes[4]:indexes[5]], " ") if start := strings.LastIndex(content[0:indexes[2]], "/*base:"); start >= 0 { if end := strings.Index(content[start+7:indexes[2]], "*/"); end > 0 { base = content[start+7 : start+7+end] } } switch content[indexes[2]:indexes[3]] { case "<": if arguments[0][0:1] != "/" { arguments[0] = fmt.Sprintf("%s/%s", base, arguments[0]) } nbase := "" if elements, err := filepath.Glob(arguments[0]); err == nil { for _, element := range elements { if mcontent, err := os.ReadFile(element); err == nil { nbase = filepath.Dir(element) expanded += string(mcontent) } } } if nbase != "" && strings.Contains(expanded, "\n") { expanded = fmt.Sprintf("/*base:%s*/\n%s\n/*base:%s*/\n", nbase, expanded, base) } case "=": if elements, err := filepath.Glob(arguments[0]); err == nil { for _, element := range elements { if mcontent, err := os.ReadFile(element); err == nil { for _, line := range strings.Split(string(mcontent), "\n") { line = strings.TrimSpace(line) if (len(line) >= 1 && line[0] != '#') || (len(line) >= 2 && line[0] != '/' && line[1] != '/') { expanded += fmt.Sprintf("\"%s\"\n", line) } } } } } case "|": if arguments[0][0:1] != "/" { arguments[0] = fmt.Sprintf("%s/%s", base, arguments[0]) } nbase := "" if elements, err := filepath.Glob(arguments[0]); err == nil { for _, element := range elements { if element[0:1] != "/" { element = fmt.Sprintf("%s/%s", base, element) } if mcontent, err := exec.Command(element, strings.Join(arguments[1:], " ")).Output(); err == nil { nbase = filepath.Dir(element) expanded += string(mcontent) } } } if nbase != "" && strings.Contains(expanded, "\n") { expanded = fmt.Sprintf("/*base:%s*/\n%s\n/*base:%s*/\n", nbase, expanded, base) } case "@": requester := http.Client{ Timeout: time.Duration(5 * time.Second), } if response, err := requester.Get(arguments[0]); err == nil { if (response.StatusCode / 100) == 2 { if mcontent, err := io.ReadAll(response.Body); err == nil { expanded += string(mcontent) } } } case "&": expanded += os.Getenv(arguments[0]) case "!": if matcher := rcache.Get(fmt.Sprintf(`(?i)^--?(no-?)?(?:%s)(?:(=)(.+))?$`, arguments[0])); matcher != nil { for index := 1; index < len(os.Args); index++ { option := os.Args[index] if option == "--" { break } if captures := matcher.FindStringSubmatch(option); captures != nil { if captures[2] == "=" { expanded = captures[3] } else { if index == len(os.Args)-1 || strings.HasPrefix(os.Args[index+1], "-") { expanded = "true" if captures[1] != "" { expanded = "false" } } else { expanded = os.Args[index+1] } } break } } } case "-": if index := strings.Index(os.Args[0], "-"); index >= 0 { expanded = strings.ToLower(os.Args[0][index+1:]) } case "+": if arguments[0][0:1] != "/" { arguments[0] = fmt.Sprintf("%s/%s", base, arguments[0]) } if elements, err := filepath.Glob(arguments[0]); err == nil { for _, element := range elements { element = filepath.Base(element) expanded += fmt.Sprintf("%s ", strings.TrimSuffix(element, filepath.Ext(element))) } } expanded = strings.TrimSpace(expanded) case "_": expanded = filepath.Base(input) } content = fmt.Sprintf("%s%s%s", content[0:indexes[0]], expanded, content[indexes[1]:]) } var config any if err := json.Unmarshal([]byte(content), &config); err != nil { if err := json.Unmarshal([]byte("{"+content+"}"), &config); err != nil { if syntax, ok := err.(*json.SyntaxError); ok && syntax.Offset < int64(len(content)) { if start := strings.LastIndex(content[:syntax.Offset], "\n") + 1; start >= 0 { line := strings.Count(content[:start], "\n") + 1 c.Unlock() return fmt.Errorf("uconfig: %s at line %d near %s", syntax, line, content[start:syntax.Offset]) } } c.Unlock() return err } } c.config = config c.hash = fmt.Sprintf("%x", sha1.Sum([]byte(content))) c.cache = map[string]any{} reduce(c.config) c.Unlock() return nil } func (c *UConfig) Loaded() bool { c.RLock() defer c.RUnlock() return !(c.config == nil) } func (c *UConfig) Hash() string { c.RLock() defer c.RUnlock() return c.hash } func (c *UConfig) String() string { if c.config != nil { config := &bytes.Buffer{} encoder := json.NewEncoder(config) encoder.SetEscapeHTML(false) encoder.SetIndent("", " ") if encoder.Encode(c.config) == nil { return config.String() } } return "{}" } func (c *UConfig) Path(input ...string) string { total, count, separator := 0, 0, len(c.separator) for _, value := range input { if length := len(value); length > 0 { total += length count++ } } if total == 0 { return "" } total += (count - 1) * separator result, offset := make([]byte, total), 0 for _, value := range input { if length := len(value); length > 0 { if offset > 0 { copy(result[offset:], c.separator) offset += separator } copy(result[offset:], value) offset += length } } return unsafe.String(unsafe.SliceData(result), total) } func (c *UConfig) Base(path string) string { parts := strings.Split(path, c.separator) return parts[len(parts)-1] } func (c *UConfig) GetPaths(path string) []string { var ( current any = c.config paths []string = []string{} ) c.RLock() prefix := "" if current == nil || path == "" { c.RUnlock() return paths } c.cacheLock.RLock() if c.cache[path] != nil { if paths, ok := c.cache[path].([]string); ok { c.cacheLock.RUnlock() c.RUnlock() return paths } } c.cacheLock.RUnlock() if path != "" { prefix = c.separator for _, part := range strings.Split(path, c.separator) { kind := reflect.TypeOf(current).Kind() index, err := strconv.Atoi(part) if (kind == reflect.Slice && (err != nil || index < 0 || index >= len(current.([]any)))) || (kind != reflect.Slice && kind != reflect.Map) { c.cacheLock.Lock() c.cache[path] = paths c.cacheLock.Unlock() c.RUnlock() return paths } if kind == reflect.Slice { current = current.([]any)[index] } else { if current = current.(map[string]any)[strings.TrimSpace(part)]; current == nil { c.cacheLock.Lock() c.cache[path] = paths c.cacheLock.Unlock() c.RUnlock() return paths } } } } switch reflect.TypeOf(current).Kind() { case reflect.Slice: for index := 0; index < len(current.([]any)); index++ { paths = append(paths, fmt.Sprintf("%s%s%d", path, prefix, index)) } case reflect.Map: for key := range current.(map[string]any) { paths = append(paths, fmt.Sprintf("%s%s%s", path, prefix, key)) } } c.cacheLock.Lock() c.cache[path] = paths c.cacheLock.Unlock() c.RUnlock() return paths } func (c *UConfig) value(path string) (string, error) { var current any = c.config c.RLock() if current == nil || path == "" { c.RUnlock() return "", errors.New(`uconfig: invalid parameter`) } c.cacheLock.RLock() if c.cache[path] != nil { if current, ok := c.cache[path].(bool); ok && !current { c.cacheLock.RUnlock() c.RUnlock() return "", errors.New(`uconfig: invalid path`) } if current, ok := c.cache[path].(string); ok { c.cacheLock.RUnlock() c.RUnlock() return current, nil } } c.cacheLock.RUnlock() for _, part := range strings.Split(path, c.separator) { kind := reflect.TypeOf(current).Kind() index, err := strconv.Atoi(part) if (kind == reflect.Slice && (err != nil || index < 0 || index >= len(current.([]any)))) || (kind != reflect.Slice && kind != reflect.Map) { c.cacheLock.Lock() c.cache[path] = false c.cacheLock.Unlock() c.RUnlock() return "", errors.New(`uconfig: invalid path`) } if kind == reflect.Slice { current = current.([]any)[index] } else { if current = current.(map[string]any)[strings.TrimSpace(part)]; current == nil { c.cacheLock.Lock() c.cache[path] = false c.cacheLock.Unlock() c.RUnlock() return "", errors.New(`uconfig: invalid path`) } } } if reflect.TypeOf(current).Kind() == reflect.String { c.cacheLock.Lock() c.cache[path] = current.(string) c.cacheLock.Unlock() c.RUnlock() return current.(string), nil } c.cacheLock.Lock() c.cache[path] = false c.cacheLock.Unlock() c.RUnlock() return "", errors.New(`uconfig: invalid path`) } func (c *UConfig) GetBoolean(path string, fallback ...bool) bool { if value, err := c.value(path); err == nil { if value = strings.ToLower(strings.TrimSpace(value)); value == "1" || value == "on" || value == "yes" || value == "true" { return true } return false } if len(fallback) > 0 { return fallback[0] } return false } func (c *UConfig) GetStrings(path string) []string { list := []string{} for _, path := range c.GetPaths(path) { list = append(list, c.GetString(path, "")) } return list } func (c *UConfig) GetString(path string, fallback ...string) string { if value, err := c.value(path); err == nil { return value } if len(fallback) > 0 { return fallback[0] } return "" } func (c *UConfig) GetStringMatch(path string, fallback, match string) string { return c.GetStringMatchCaptures(path, fallback, match)[0] } func (c *UConfig) GetStringMatchCaptures(path string, fallback, match string) []string { value, err := c.value(path) if err != nil { return []string{fallback} } if match != "" { if matcher := rcache.Get(match); matcher != nil { if matches := matcher.FindStringSubmatch(value); matches != nil { return matches } else { return []string{fallback} } } else { return []string{fallback} } } return []string{value} } func (c *UConfig) GetInteger(path string, fallback int64) int64 { return c.GetIntegerBounds(path, fallback, math.MinInt64, math.MaxInt64) } func (c *UConfig) GetIntegerBounds(path string, fallback, min, max int64) int64 { value, err := c.value(path) if err != nil { return fallback } nvalue, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64) if err != nil { return fallback } if nvalue < min { nvalue = min } if nvalue > max { nvalue = max } return nvalue } func (c *UConfig) GetFloat(path string, fallback float64) float64 { return c.GetFloatBounds(path, fallback, -math.MaxFloat64, math.MaxFloat64) } func (c *UConfig) GetFloatBounds(path string, fallback, min, max float64) float64 { value, err := c.value(path) if err != nil { return fallback } nvalue, err := strconv.ParseFloat(strings.TrimSpace(value), 64) if err != nil { return fallback } return math.Max(math.Min(nvalue, max), min) } func (c *UConfig) GetSize(path string, fallback int64) int64 { return c.GetSizeBounds(path, fallback, math.MinInt64, math.MaxInt64) } func (c *UConfig) GetSizeBounds(path string, fallback, min, max int64) int64 { value, err := c.value(path) if err != nil { return fallback } nvalue := int64(0) if matches := sizer.FindStringSubmatch(strings.TrimSpace(strings.ToUpper(value))); matches != nil { fvalue, err := strconv.ParseFloat(matches[1], 64) if err != nil { return fallback } scale := float64(1000) if matches[3] == "B" { scale = float64(1024) } nvalue = int64(fvalue * math.Pow(scale, float64(strings.Index("_KMGTP", matches[2])))) } else { return fallback } if nvalue < min { nvalue = min } if nvalue > max { nvalue = max } return nvalue } func (c *UConfig) GetDuration(path string, fallback float64) time.Duration { return c.GetDurationBounds(path, fallback, 0, math.MaxFloat64) } func (c *UConfig) GetDurationBounds(path string, fallback, min, max float64) time.Duration { value, err := c.value(path) if err != nil { return time.Duration(fallback * float64(time.Second)) } nvalue := float64(0.0) if matches := duration1.FindAllStringSubmatch(strings.TrimSpace(strings.ToUpper(value)), -1); matches != nil { for index := 0; index < len(matches); index++ { if uvalue, err := strconv.ParseFloat(matches[index][1], 64); err == nil { switch matches[index][2] { case "Y": nvalue += uvalue * 86400 * 365.256 case "MO": nvalue += uvalue * 86400 * 365.256 / 12 case "D": nvalue += uvalue * 86400 case "H": nvalue += uvalue * 3600 case "MN": nvalue += uvalue * 60 case "S": nvalue += uvalue case "": nvalue += uvalue case "MS": nvalue += uvalue / 1000 case "US": nvalue += uvalue / 1000000 } } } } if matches := duration2.FindStringSubmatch(strings.TrimSpace(value)); matches != nil { hours, _ := strconv.ParseFloat(matches[1], 64) minutes, _ := strconv.ParseFloat(matches[2], 64) seconds, _ := strconv.ParseFloat(matches[3], 64) milliseconds, _ := strconv.ParseFloat(matches[4], 64) nvalue = (hours * 3600) + (math.Min(minutes, 59) * 60) + math.Min(seconds, 59) + (milliseconds / 1000) } return time.Duration(math.Max(math.Min(nvalue, max), min) * float64(time.Second)) } func Seconds(input time.Duration) float64 { return float64(input) / float64(time.Second) } func Args() (args []string) { for index := 1; index < len(os.Args); index++ { option := os.Args[index] if args == nil { if option[0] == '-' { if option != "-" && option != "--" && !strings.Contains(option, "=") && index < len(os.Args)-1 && os.Args[index+1][0] != '-' { index++ } } else { args = []string{} } } if args != nil { args = append(args, option) } else if option == "--" { args = []string{} } } return }