diff options
Diffstat (limited to 'config')
| -rw-r--r-- | config/config.go | 638 | ||||
| -rw-r--r-- | config/parser.go | 496 | ||||
| -rw-r--r-- | config/value_parse.go | 158 |
3 files changed, 654 insertions, 638 deletions
diff --git a/config/config.go b/config/config.go index b761dce5..4ff8b21d 100644 --- a/config/config.go +++ b/config/config.go @@ -3,15 +3,10 @@ package config import ( "bufio" - "bytes" "errors" "fmt" "io" - "math" - "strconv" "strings" - - "codeberg.org/lindenii/furgit/internal/intconv" ) // Config holds all parsed configuration entries from a Git config file. @@ -175,636 +170,3 @@ func (c *Config) Entries() []ConfigEntry { return result } - -type configParser struct { - reader *bufio.Reader - lineNum int - currentSection string - currentSubsec string - peeked byte - hasPeeked bool -} - -func (p *configParser) parse() (*Config, error) { - cfg := &Config{} - - err := p.skipBOM() - if err != nil { - return nil, err - } - - for { - ch, err := p.nextChar() - if errors.Is(err, io.EOF) { - break - } - - if err != nil { - return nil, err - } - - // Skip leading whitespace between entries. - if isWhitespace(ch) { - continue - } - - // Comments - if ch == '#' || ch == ';' { - err := p.skipToEOL() - if err != nil && !errors.Is(err, io.EOF) { - return nil, err - } - - continue - } - - // Section header - if ch == '[' { - err := p.parseSection() - if err != nil { - return nil, fmt.Errorf("furgit: config: line %d: %w", p.lineNum, err) - } - - continue - } - - // Key-value pair - if isLetter(ch) { - p.unreadChar(ch) - - err := p.parseKeyValue(cfg) - if err != nil { - return nil, fmt.Errorf("furgit: config: line %d: %w", p.lineNum, err) - } - - continue - } - - return nil, fmt.Errorf("furgit: config: line %d: unexpected character %q", p.lineNum, ch) - } - - return cfg, nil -} - -func (p *configParser) nextChar() (byte, error) { - if p.hasPeeked { - p.hasPeeked = false - - return p.peeked, nil - } - - ch, err := p.reader.ReadByte() - if err != nil { - return 0, err - } - - if ch == '\r' { - next, err := p.reader.ReadByte() - if err == nil && next == '\n' { - ch = '\n' - } else if err == nil { - // Weird but ok - _ = p.reader.UnreadByte() - } - } - - if ch == '\n' { - p.lineNum++ - } - - return ch, nil -} - -func (p *configParser) unreadChar(ch byte) { - p.peeked = ch - - p.hasPeeked = true - if ch == '\n' && p.lineNum > 1 { - p.lineNum-- - } -} - -func (p *configParser) skipBOM() error { - first, err := p.reader.ReadByte() - if errors.Is(err, io.EOF) { - return nil - } - - if err != nil { - return err - } - - if first != 0xef { - _ = p.reader.UnreadByte() - - return nil - } - - second, err := p.reader.ReadByte() - if err != nil { - if errors.Is(err, io.EOF) { - _ = p.reader.UnreadByte() - - return nil - } - - return err - } - - third, err := p.reader.ReadByte() - if err != nil { - if errors.Is(err, io.EOF) { - _ = p.reader.UnreadByte() - _ = p.reader.UnreadByte() - - return nil - } - - return err - } - - if second == 0xbb && third == 0xbf { - return nil - } - - _ = p.reader.UnreadByte() - _ = p.reader.UnreadByte() - _ = p.reader.UnreadByte() - - return nil -} - -func (p *configParser) skipToEOL() error { - for { - ch, err := p.nextChar() - if err != nil { - return err - } - - if ch == '\n' { - return nil - } - } -} - -func (p *configParser) parseSection() error { - var name bytes.Buffer - - for { - ch, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF in section header") - } - - if ch == ']' { - section := name.String() - if !isValidSection(section) { - return fmt.Errorf("invalid section name: %q", section) - } - - p.currentSection = strings.ToLower(section) - p.currentSubsec = "" - - return nil - } - - if isWhitespace(ch) { - return p.parseExtendedSection(&name) - } - - if !isKeyChar(ch) && ch != '.' { - return fmt.Errorf("invalid character in section name: %q", ch) - } - - name.WriteByte(toLower(ch)) - } -} - -func (p *configParser) parseExtendedSection(sectionName *bytes.Buffer) error { - for { - ch, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF in section header") - } - - if !isWhitespace(ch) { - if ch != '"' { - return errors.New("expected quote after section name") - } - - break - } - } - - var subsec bytes.Buffer - - for { - ch, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF in subsection") - } - - if ch == '\n' { - return errors.New("newline in subsection") - } - - if ch == '"' { - break - } - - if ch == '\\' { - next, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF after backslash in subsection") - } - - if next == '\n' { - return errors.New("newline after backslash in subsection") - } - - subsec.WriteByte(next) - } else { - subsec.WriteByte(ch) - } - } - - ch, err := p.nextChar() - if err != nil { - return errors.New("unexpected EOF after subsection") - } - - if ch != ']' { - return fmt.Errorf("expected ']' after subsection, got %q", ch) - } - - section := sectionName.String() - if !isValidSection(section) { - return fmt.Errorf("invalid section name: %q", section) - } - - p.currentSection = strings.ToLower(section) - p.currentSubsec = subsec.String() - - return nil -} - -func (p *configParser) parseKeyValue(cfg *Config) error { - if p.currentSection == "" { - return errors.New("key-value pair before any section header") - } - - var key bytes.Buffer - - for { - ch, err := p.nextChar() - if errors.Is(err, io.EOF) { - break - } - - if err != nil { - return err - } - - if ch == '=' || ch == '\n' || isSpace(ch) { - p.unreadChar(ch) - - break - } - - if !isKeyChar(ch) { - return fmt.Errorf("invalid character in key: %q", ch) - } - - key.WriteByte(toLower(ch)) - } - - keyStr := key.String() - if len(keyStr) == 0 { - return errors.New("empty key name") - } - - if !isLetter(keyStr[0]) { - return errors.New("key must start with a letter") - } - - for { - ch, err := p.nextChar() - if errors.Is(err, io.EOF) { - cfg.entries = append(cfg.entries, ConfigEntry{ - Section: p.currentSection, - Subsection: p.currentSubsec, - Key: keyStr, - Kind: ValueValueless, - Value: "", - }) - - return nil - } - - if err != nil { - return err - } - - if ch == '\n' { - cfg.entries = append(cfg.entries, ConfigEntry{ - Section: p.currentSection, - Subsection: p.currentSubsec, - Key: keyStr, - Kind: ValueValueless, - Value: "", - }) - - return nil - } - - if ch == '#' || ch == ';' { - err := p.skipToEOL() - if err != nil && !errors.Is(err, io.EOF) { - return err - } - - cfg.entries = append(cfg.entries, ConfigEntry{ - Section: p.currentSection, - Subsection: p.currentSubsec, - Key: keyStr, - Kind: ValueValueless, - Value: "", - }) - - return nil - } - - if ch == '=' { - break - } - - if !isSpace(ch) { - return fmt.Errorf("unexpected character after key: %q", ch) - } - } - - value, err := p.parseValue() - if err != nil { - return err - } - - cfg.entries = append(cfg.entries, ConfigEntry{ - Section: p.currentSection, - Subsection: p.currentSubsec, - Key: keyStr, - Kind: ValueString, - Value: value, - }) - - return nil -} - -func (p *configParser) parseValue() (string, error) { - var ( - value bytes.Buffer - inQuote bool - inComment bool - ) - - trimLen := 0 - - for { - ch, err := p.nextChar() - if errors.Is(err, io.EOF) { - if inQuote { - return "", errors.New("unexpected EOF in quoted value") - } - - if trimLen > 0 { - return truncateAtNUL(value.String()[:trimLen]), nil - } - - return truncateAtNUL(value.String()), nil - } - - if err != nil { - return "", err - } - - if ch == '\n' { - if inQuote { - return "", errors.New("newline in quoted value") - } - - if trimLen > 0 { - return truncateAtNUL(value.String()[:trimLen]), nil - } - - return truncateAtNUL(value.String()), nil - } - - if inComment { - continue - } - - if isWhitespace(ch) && !inQuote { - if trimLen == 0 && value.Len() > 0 { - trimLen = value.Len() - } - - if value.Len() > 0 { - value.WriteByte(ch) - } - - continue - } - - if !inQuote && (ch == '#' || ch == ';') { - inComment = true - - continue - } - - if trimLen > 0 { - trimLen = 0 - } - - if ch == '\\' { - next, err := p.nextChar() - if errors.Is(err, io.EOF) { - return "", errors.New("unexpected EOF after backslash") - } - - if err != nil { - return "", err - } - - switch next { - case '\n': - continue - case 'n': - value.WriteByte('\n') - case 't': - value.WriteByte('\t') - case 'b': - value.WriteByte('\b') - case '\\', '"': - value.WriteByte(next) - default: - return "", fmt.Errorf("invalid escape sequence: \\%c", next) - } - - continue - } - - if ch == '"' { - inQuote = !inQuote - - continue - } - - value.WriteByte(ch) - } -} - -func isValidSection(s string) bool { - if len(s) == 0 { - return false - } - - for i := range len(s) { - ch := s[i] - if !isLetter(ch) && !isDigit(ch) && ch != '-' && ch != '.' { - return false - } - } - - return true -} - -func isKeyChar(ch byte) bool { - return isLetter(ch) || isDigit(ch) || ch == '-' -} - -func parseBool(value string) (bool, error) { - switch { - case strings.EqualFold(value, "true"), - strings.EqualFold(value, "yes"), - strings.EqualFold(value, "on"): - return true, nil - case strings.EqualFold(value, "false"), - strings.EqualFold(value, "no"), - strings.EqualFold(value, "off"), - value == "": - return false, nil - } - - n, err := parseInt32(value) - if err != nil { - return false, fmt.Errorf("invalid boolean value %q", value) - } - - return n != 0, nil -} - -func parseInt32(value string) (int32, error) { - n64, err := parseInt64WithMax(value, math.MaxInt32) - if err != nil { - return 0, err - } - - return intconv.Int64ToInt32(n64) -} - -func parseInt(value string) (int, error) { - n64, err := parseInt64WithMax(value, int64(int(^uint(0)>>1))) - if err != nil { - return 0, err - } - - return int(n64), nil -} - -func parseInt64(value string) (int64, error) { - return parseInt64WithMax(value, int64(^uint64(0)>>1)) -} - -func parseInt64WithMax(value string, maxValue int64) (int64, error) { - if value == "" { - return 0, errors.New("empty value") - } - - trimmed := strings.TrimLeft(value, " \t\n\r\f\v") - if trimmed == "" { - return 0, errors.New("empty value") - } - - numPart := trimmed - factor := int64(1) - - if last := trimmed[len(trimmed)-1]; last == 'k' || last == 'K' || last == 'm' || last == 'M' || last == 'g' || last == 'G' { - switch toLower(last) { - case 'k': - factor = 1024 - case 'm': - factor = 1024 * 1024 - case 'g': - factor = 1024 * 1024 * 1024 - } - - numPart = trimmed[:len(trimmed)-1] - } - - if numPart == "" { - return 0, errors.New("missing integer value") - } - - n, err := strconv.ParseInt(numPart, 0, 64) - if err != nil { - return 0, err - } - - intMax := maxValue - intMin := -maxValue - 1 - - if n > 0 && n > intMax/factor { - return 0, errors.New("integer overflow") - } - - if n < 0 && n < intMin/factor { - return 0, errors.New("integer overflow") - } - - n *= factor - - return n, nil -} - -func truncateAtNUL(value string) string { - for i := range len(value) { - if value[i] == 0 { - return value[:i] - } - } - - return value -} - -func isSpace(ch byte) bool { - return ch == ' ' || ch == '\t' -} - -func isWhitespace(ch byte) bool { - return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == '\v' || ch == '\f' -} - -func isLetter(ch byte) bool { - return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') -} - -func isDigit(ch byte) bool { - return ch >= '0' && ch <= '9' -} - -func toLower(ch byte) byte { - if ch >= 'A' && ch <= 'Z' { - return ch + ('a' - 'A') - } - - return ch -} diff --git a/config/parser.go b/config/parser.go new file mode 100644 index 00000000..edaf1944 --- /dev/null +++ b/config/parser.go @@ -0,0 +1,496 @@ +package config + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "strings" +) + +type configParser struct { + reader *bufio.Reader + lineNum int + currentSection string + currentSubsec string + peeked byte + hasPeeked bool +} + +func (p *configParser) parse() (*Config, error) { + cfg := &Config{} + + err := p.skipBOM() + if err != nil { + return nil, err + } + + for { + ch, err := p.nextChar() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return nil, err + } + + // Skip leading whitespace between entries. + if isWhitespace(ch) { + continue + } + + // Comments + if ch == '#' || ch == ';' { + err := p.skipToEOL() + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + + continue + } + + // Section header + if ch == '[' { + err := p.parseSection() + if err != nil { + return nil, fmt.Errorf("furgit: config: line %d: %w", p.lineNum, err) + } + + continue + } + + // Key-value pair + if isLetter(ch) { + p.unreadChar(ch) + + err := p.parseKeyValue(cfg) + if err != nil { + return nil, fmt.Errorf("furgit: config: line %d: %w", p.lineNum, err) + } + + continue + } + + return nil, fmt.Errorf("furgit: config: line %d: unexpected character %q", p.lineNum, ch) + } + + return cfg, nil +} + +func (p *configParser) nextChar() (byte, error) { + if p.hasPeeked { + p.hasPeeked = false + + return p.peeked, nil + } + + ch, err := p.reader.ReadByte() + if err != nil { + return 0, err + } + + if ch == '\r' { + next, err := p.reader.ReadByte() + if err == nil && next == '\n' { + ch = '\n' + } else if err == nil { + // Weird but ok + _ = p.reader.UnreadByte() + } + } + + if ch == '\n' { + p.lineNum++ + } + + return ch, nil +} + +func (p *configParser) unreadChar(ch byte) { + p.peeked = ch + + p.hasPeeked = true + if ch == '\n' && p.lineNum > 1 { + p.lineNum-- + } +} + +func (p *configParser) skipBOM() error { + first, err := p.reader.ReadByte() + if errors.Is(err, io.EOF) { + return nil + } + + if err != nil { + return err + } + + if first != 0xef { + _ = p.reader.UnreadByte() + + return nil + } + + second, err := p.reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + _ = p.reader.UnreadByte() + + return nil + } + + return err + } + + third, err := p.reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) { + _ = p.reader.UnreadByte() + _ = p.reader.UnreadByte() + + return nil + } + + return err + } + + if second == 0xbb && third == 0xbf { + return nil + } + + _ = p.reader.UnreadByte() + _ = p.reader.UnreadByte() + _ = p.reader.UnreadByte() + + return nil +} + +func (p *configParser) skipToEOL() error { + for { + ch, err := p.nextChar() + if err != nil { + return err + } + + if ch == '\n' { + return nil + } + } +} + +func (p *configParser) parseSection() error { + var name bytes.Buffer + + for { + ch, err := p.nextChar() + if err != nil { + return errors.New("unexpected EOF in section header") + } + + if ch == ']' { + section := name.String() + if !isValidSection(section) { + return fmt.Errorf("invalid section name: %q", section) + } + + p.currentSection = strings.ToLower(section) + p.currentSubsec = "" + + return nil + } + + if isWhitespace(ch) { + return p.parseExtendedSection(&name) + } + + if !isKeyChar(ch) && ch != '.' { + return fmt.Errorf("invalid character in section name: %q", ch) + } + + name.WriteByte(toLower(ch)) + } +} + +func (p *configParser) parseExtendedSection(sectionName *bytes.Buffer) error { + for { + ch, err := p.nextChar() + if err != nil { + return errors.New("unexpected EOF in section header") + } + + if !isWhitespace(ch) { + if ch != '"' { + return errors.New("expected quote after section name") + } + + break + } + } + + var subsec bytes.Buffer + + for { + ch, err := p.nextChar() + if err != nil { + return errors.New("unexpected EOF in subsection") + } + + if ch == '\n' { + return errors.New("newline in subsection") + } + + if ch == '"' { + break + } + + if ch == '\\' { + next, err := p.nextChar() + if err != nil { + return errors.New("unexpected EOF after backslash in subsection") + } + + if next == '\n' { + return errors.New("newline after backslash in subsection") + } + + subsec.WriteByte(next) + } else { + subsec.WriteByte(ch) + } + } + + ch, err := p.nextChar() + if err != nil { + return errors.New("unexpected EOF after subsection") + } + + if ch != ']' { + return fmt.Errorf("expected ']' after subsection, got %q", ch) + } + + section := sectionName.String() + if !isValidSection(section) { + return fmt.Errorf("invalid section name: %q", section) + } + + p.currentSection = strings.ToLower(section) + p.currentSubsec = subsec.String() + + return nil +} + +func (p *configParser) parseKeyValue(cfg *Config) error { + if p.currentSection == "" { + return errors.New("key-value pair before any section header") + } + + var key bytes.Buffer + + for { + ch, err := p.nextChar() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return err + } + + if ch == '=' || ch == '\n' || isSpace(ch) { + p.unreadChar(ch) + + break + } + + if !isKeyChar(ch) { + return fmt.Errorf("invalid character in key: %q", ch) + } + + key.WriteByte(toLower(ch)) + } + + keyStr := key.String() + if len(keyStr) == 0 { + return errors.New("empty key name") + } + + if !isLetter(keyStr[0]) { + return errors.New("key must start with a letter") + } + + for { + ch, err := p.nextChar() + if errors.Is(err, io.EOF) { + cfg.entries = append(cfg.entries, ConfigEntry{ + Section: p.currentSection, + Subsection: p.currentSubsec, + Key: keyStr, + Kind: ValueValueless, + Value: "", + }) + + return nil + } + + if err != nil { + return err + } + + if ch == '\n' { + cfg.entries = append(cfg.entries, ConfigEntry{ + Section: p.currentSection, + Subsection: p.currentSubsec, + Key: keyStr, + Kind: ValueValueless, + Value: "", + }) + + return nil + } + + if ch == '#' || ch == ';' { + err := p.skipToEOL() + if err != nil && !errors.Is(err, io.EOF) { + return err + } + + cfg.entries = append(cfg.entries, ConfigEntry{ + Section: p.currentSection, + Subsection: p.currentSubsec, + Key: keyStr, + Kind: ValueValueless, + Value: "", + }) + + return nil + } + + if ch == '=' { + break + } + + if !isSpace(ch) { + return fmt.Errorf("unexpected character after key: %q", ch) + } + } + + value, err := p.parseValue() + if err != nil { + return err + } + + cfg.entries = append(cfg.entries, ConfigEntry{ + Section: p.currentSection, + Subsection: p.currentSubsec, + Key: keyStr, + Kind: ValueString, + Value: value, + }) + + return nil +} + +func (p *configParser) parseValue() (string, error) { + var ( + value bytes.Buffer + inQuote bool + inComment bool + ) + + trimLen := 0 + + for { + ch, err := p.nextChar() + if errors.Is(err, io.EOF) { + if inQuote { + return "", errors.New("unexpected EOF in quoted value") + } + + if trimLen > 0 { + return truncateAtNUL(value.String()[:trimLen]), nil + } + + return truncateAtNUL(value.String()), nil + } + + if err != nil { + return "", err + } + + if ch == '\n' { + if inQuote { + return "", errors.New("newline in quoted value") + } + + if trimLen > 0 { + return truncateAtNUL(value.String()[:trimLen]), nil + } + + return truncateAtNUL(value.String()), nil + } + + if inComment { + continue + } + + if isWhitespace(ch) && !inQuote { + if trimLen == 0 && value.Len() > 0 { + trimLen = value.Len() + } + + if value.Len() > 0 { + value.WriteByte(ch) + } + + continue + } + + if !inQuote && (ch == '#' || ch == ';') { + inComment = true + + continue + } + + if trimLen > 0 { + trimLen = 0 + } + + if ch == '\\' { + next, err := p.nextChar() + if errors.Is(err, io.EOF) { + return "", errors.New("unexpected EOF after backslash") + } + + if err != nil { + return "", err + } + + switch next { + case '\n': + continue + case 'n': + value.WriteByte('\n') + case 't': + value.WriteByte('\t') + case 'b': + value.WriteByte('\b') + case '\\', '"': + value.WriteByte(next) + default: + return "", fmt.Errorf("invalid escape sequence: \\%c", next) + } + + continue + } + + if ch == '"' { + inQuote = !inQuote + + continue + } + + value.WriteByte(ch) + } +} diff --git a/config/value_parse.go b/config/value_parse.go new file mode 100644 index 00000000..9659e476 --- /dev/null +++ b/config/value_parse.go @@ -0,0 +1,158 @@ +package config + +import ( + "errors" + "fmt" + "math" + "strconv" + "strings" + + "codeberg.org/lindenii/furgit/internal/intconv" +) + +func isValidSection(s string) bool { + if len(s) == 0 { + return false + } + + for i := range len(s) { + ch := s[i] + if !isLetter(ch) && !isDigit(ch) && ch != '-' && ch != '.' { + return false + } + } + + return true +} + +func isKeyChar(ch byte) bool { + return isLetter(ch) || isDigit(ch) || ch == '-' +} + +func parseBool(value string) (bool, error) { + switch { + case strings.EqualFold(value, "true"), + strings.EqualFold(value, "yes"), + strings.EqualFold(value, "on"): + return true, nil + case strings.EqualFold(value, "false"), + strings.EqualFold(value, "no"), + strings.EqualFold(value, "off"), + value == "": + return false, nil + } + + n, err := parseInt32(value) + if err != nil { + return false, fmt.Errorf("invalid boolean value %q", value) + } + + return n != 0, nil +} + +func parseInt32(value string) (int32, error) { + n64, err := parseInt64WithMax(value, math.MaxInt32) + if err != nil { + return 0, err + } + + return intconv.Int64ToInt32(n64) +} + +func parseInt(value string) (int, error) { + n64, err := parseInt64WithMax(value, int64(int(^uint(0)>>1))) + if err != nil { + return 0, err + } + + return int(n64), nil +} + +func parseInt64(value string) (int64, error) { + return parseInt64WithMax(value, int64(^uint64(0)>>1)) +} + +func parseInt64WithMax(value string, maxValue int64) (int64, error) { + if value == "" { + return 0, errors.New("empty value") + } + + trimmed := strings.TrimLeft(value, " \t\n\r\f\v") + if trimmed == "" { + return 0, errors.New("empty value") + } + + numPart := trimmed + factor := int64(1) + + if last := trimmed[len(trimmed)-1]; last == 'k' || last == 'K' || last == 'm' || last == 'M' || last == 'g' || last == 'G' { + switch toLower(last) { + case 'k': + factor = 1024 + case 'm': + factor = 1024 * 1024 + case 'g': + factor = 1024 * 1024 * 1024 + } + + numPart = trimmed[:len(trimmed)-1] + } + + if numPart == "" { + return 0, errors.New("missing integer value") + } + + n, err := strconv.ParseInt(numPart, 0, 64) + if err != nil { + return 0, err + } + + intMax := maxValue + intMin := -maxValue - 1 + + if n > 0 && n > intMax/factor { + return 0, errors.New("integer overflow") + } + + if n < 0 && n < intMin/factor { + return 0, errors.New("integer overflow") + } + + n *= factor + + return n, nil +} + +func truncateAtNUL(value string) string { + for i := range len(value) { + if value[i] == 0 { + return value[:i] + } + } + + return value +} + +func isSpace(ch byte) bool { + return ch == ' ' || ch == '\t' +} + +func isWhitespace(ch byte) bool { + return ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' || ch == '\v' || ch == '\f' +} + +func isLetter(ch byte) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') +} + +func isDigit(ch byte) bool { + return ch >= '0' && ch <= '9' +} + +func toLower(ch byte) byte { + if ch >= 'A' && ch <= 'Z' { + return ch + ('a' - 'A') + } + + return ch +} |
