Merge pull request #96 from zimmski/quoting-quoting-everywhere
do quoting in INI and Help
diff --git a/convert.go b/convert.go
index be8de39..d5675c5 100644
--- a/convert.go
+++ b/convert.go
@@ -294,6 +294,32 @@
return nil
}
+func isPrint(s string) bool {
+ for _, c := range s {
+ if !strconv.IsPrint(c) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func quoteIfNeeded(s string) string {
+ if !isPrint(s) {
+ return strconv.Quote(s)
+ }
+
+ return s
+}
+
+func unquoteIfPossible(s string) (string, error) {
+ if s[0] != '"' {
+ return s, nil
+ }
+
+ return strconv.Unquote(s)
+}
+
func wrapText(s string, l int, prefix string) string {
// Basic text wrapping of s at spaces to fit in l
var ret string
diff --git a/help.go b/help.go
index d16f3b1..e26fcd0 100644
--- a/help.go
+++ b/help.go
@@ -171,7 +171,13 @@
def, _ = convertToString(option.value, option.tag)
}
} else if len(defs) != 0 {
- def = strings.Join(defs, ", ")
+ l := len(defs) - 1
+
+ for i := 0; i < l; i++ {
+ def += quoteIfNeeded(defs[i]) + ", "
+ }
+
+ def += quoteIfNeeded(defs[l])
}
var envDef string
@@ -188,8 +194,7 @@
var desc string
if def != "" {
- desc = fmt.Sprintf("%s (%v)%s", option.Description, def,
- envDef)
+ desc = fmt.Sprintf("%s (%v)%s", option.Description, def, envDef)
} else {
desc = option.Description + envDef
}
diff --git a/help_test.go b/help_test.go
index def3043..32220fb 100644
--- a/help_test.go
+++ b/help_test.go
@@ -15,8 +15,8 @@
PtrSlice []*string `long:"ptrslice" description:"A slice of pointers to string"`
EmptyDescription bool `long:"empty-description"`
- Default string `long:"default" default:"Some value" description:"Test default value"`
- DefaultArray []string `long:"default-array" default:"Some value" default:"Another value" description:"Test default array value"`
+ Default string `long:"default" default:"Some\nvalue" description:"Test default value"`
+ DefaultArray []string `long:"default-array" default:"Some value" default:"Other\tvalue" description:"Test default array value"`
DefaultMap map[string]string `long:"default-map" default:"some:value" default:"another:value" description:"Testdefault map value"`
EnvDefault1 string `long:"env-default1" default:"Some value" env:"ENV_DEFAULT" description:"Test env-default1 value"`
EnvDefault2 string `long:"env-default2" env:"ENV_DEFAULT" description:"Test env-default2 value"`
@@ -79,8 +79,8 @@
/c: Call phone number
/ptrslice: A slice of pointers to string
/empty-description
- /default: Test default value (Some value)
- /default-array: Test default array value (Some value, Another value)
+ /default: Test default value ("Some\nvalue")
+ /default-array: Test default array value (Some value, "Other\tvalue")
/default-map: Testdefault map value (some:value, another:value)
/env-default1: Test env-default1 value (Some value) [%ENV_DEFAULT%]
/env-default2: Test env-default2 value [%ENV_DEFAULT%]
@@ -115,8 +115,8 @@
-c= Call phone number
--ptrslice= A slice of pointers to string
--empty-description
- --default= Test default value (Some value)
- --default-array= Test default array value (Some value, Another value)
+ --default= Test default value ("Some\nvalue")
+ --default-array= Test default array value (Some value, "Other\tvalue")
--default-map= Testdefault map value (some:value, another:value)
--env-default1= Test env-default1 value (Some value) [$ENV_DEFAULT]
--env-default2= Test env-default2 value [$ENV_DEFAULT]
diff --git a/ini_private.go b/ini_private.go
index 2e72b87..0f2bc06 100644
--- a/ini_private.go
+++ b/ini_private.go
@@ -7,16 +7,22 @@
"os"
"reflect"
"sort"
+ "strconv"
"strings"
)
type iniValue struct {
- Name string
- Value string
+ Name string
+ Value string
+ Quoted bool
+ LineNumber uint
}
type iniSection []iniValue
-type ini map[string]iniSection
+type ini struct {
+ File string
+ Sections map[string]iniSection
+}
func readFullLine(reader *bufio.Reader) (string, error) {
var line []byte
@@ -102,50 +108,49 @@
oname := optionIniName(option)
- commentOption := ""
- if (options&(IniIncludeDefaults|IniCommentDefaults)) == IniIncludeDefaults|IniCommentDefaults && option.valueIsDefault() {
- commentOption = "; "
- }
+ commentOption := (options&(IniIncludeDefaults|IniCommentDefaults)) == IniIncludeDefaults|IniCommentDefaults && option.valueIsDefault()
- switch val.Type().Kind() {
+ kind := val.Type().Kind()
+ switch kind {
case reflect.Slice:
- for idx := 0; idx < val.Len(); idx++ {
- v, _ := convertToString(val.Index(idx), option.tag)
- fmt.Fprintf(writer, "%s%s = %s\n", commentOption, oname, v)
- }
+ kind = val.Type().Elem().Kind()
if val.Len() == 0 {
- fmt.Fprintf(writer, "; %s =\n", oname)
+ writeOption(writer, oname, kind, "", "", true, option.iniQuote)
+ } else {
+ for idx := 0; idx < val.Len(); idx++ {
+ v, _ := convertToString(val.Index(idx), option.tag)
+
+ writeOption(writer, oname, kind, "", v, commentOption, option.iniQuote)
+ }
}
case reflect.Map:
- mkeys := val.MapKeys()
- keys := make([]string, len(val.MapKeys()))
- kkmap := make(map[string]reflect.Value)
-
- for i, k := range mkeys {
- keys[i], _ = convertToString(k, option.tag)
- kkmap[keys[i]] = k
- }
-
- sort.Strings(keys)
-
- for _, k := range keys {
- v, _ := convertToString(val.MapIndex(kkmap[k]), option.tag)
-
- fmt.Fprintf(writer, "%s%s = %s:%s\n", commentOption, oname, k, v)
- }
+ kind = val.Type().Elem().Kind()
if val.Len() == 0 {
- fmt.Fprintf(writer, "; %s =\n", oname)
+ writeOption(writer, oname, kind, "", "", true, option.iniQuote)
+ } else {
+ mkeys := val.MapKeys()
+ keys := make([]string, len(val.MapKeys()))
+ kkmap := make(map[string]reflect.Value)
+
+ for i, k := range mkeys {
+ keys[i], _ = convertToString(k, option.tag)
+ kkmap[keys[i]] = k
+ }
+
+ sort.Strings(keys)
+
+ for _, k := range keys {
+ v, _ := convertToString(val.MapIndex(kkmap[k]), option.tag)
+
+ writeOption(writer, oname, kind, k, v, commentOption, option.iniQuote)
+ }
}
default:
v, _ := convertToString(val, option.tag)
- if len(v) != 0 {
- fmt.Fprintf(writer, "%s%s = %s\n", commentOption, oname, v)
- } else {
- fmt.Fprintf(writer, "%s%s =\n", commentOption, oname)
- }
+ writeOption(writer, oname, kind, "", v, commentOption, option.iniQuote)
}
if comments {
@@ -158,6 +163,27 @@
}
}
+func writeOption(writer io.Writer, optionName string, optionType reflect.Kind, optionKey string, optionValue string, commentOption bool, forceQuote bool) {
+ if forceQuote || (optionType == reflect.String && !isPrint(optionValue)) {
+ optionValue = strconv.Quote(optionValue)
+ }
+
+ comment := ""
+ if commentOption {
+ comment = "; "
+ }
+
+ fmt.Fprintf(writer, "%s%s =", comment, optionName)
+
+ if optionKey != "" {
+ fmt.Fprintf(writer, " %s:%s", optionKey, optionValue)
+ } else if optionValue != "" {
+ fmt.Fprintf(writer, " %s", optionValue)
+ }
+
+ fmt.Fprintln(writer)
+}
+
func writeCommandIni(command *Command, namespace string, writer io.Writer, options IniOptions) {
command.eachGroup(func(group *Group) {
writeGroupIni(command, group, namespace, writer, options)
@@ -194,7 +220,7 @@
return nil
}
-func readIniFromFile(filename string) (ini, error) {
+func readIniFromFile(filename string) (*ini, error) {
file, err := os.Open(filename)
if err != nil {
@@ -206,8 +232,11 @@
return readIni(file, filename)
}
-func readIni(contents io.Reader, filename string) (ini, error) {
- ret := make(ini)
+func readIni(contents io.Reader, filename string) (*ini, error) {
+ ret := &ini{
+ File: filename,
+ Sections: make(map[string]iniSection),
+ }
reader := bufio.NewReader(contents)
@@ -215,7 +244,7 @@
section := make(iniSection, 0, 10)
sectionname := ""
- ret[sectionname] = section
+ ret.Sections[sectionname] = section
var lineno uint
@@ -256,11 +285,11 @@
}
sectionname = name
- section = ret[name]
+ section = ret.Sections[name]
if section == nil {
section = make(iniSection, 0, 10)
- ret[name] = section
+ ret.Sections[name] = section
}
continue
@@ -279,13 +308,30 @@
name := strings.TrimSpace(keyval[0])
value := strings.TrimSpace(keyval[1])
+ quoted := false
+
+ if len(value) != 0 && value[0] == '"' {
+ if v, err := strconv.Unquote(value); err == nil {
+ value = v
+
+ quoted = true
+ } else {
+ return nil, &IniError{
+ Message: err.Error(),
+ File: filename,
+ LineNumber: lineno,
+ }
+ }
+ }
section = append(section, iniValue{
- Name: name,
- Value: value,
+ Name: name,
+ Value: value,
+ Quoted: quoted,
+ LineNumber: lineno,
})
- ret[sectionname] = section
+ ret.Sections[sectionname] = section
}
return ret, nil
@@ -311,10 +357,12 @@
return nil
}
-func (i *IniParser) parse(ini ini) error {
+func (i *IniParser) parse(ini *ini) error {
p := i.parser
- for name, section := range ini {
+ var quotesLookup = make(map[*Option]bool)
+
+ for name, section := range ini.Sections {
groups := i.matchingGroups(name)
if len(groups) == 0 {
@@ -343,10 +391,11 @@
if opt == nil {
if (p.Options & IgnoreUnknown) == None {
- return newError(
- ErrUnknownFlag,
- fmt.Sprintf("unknown option: %s", inival.Name),
- )
+ return &IniError{
+ Message: fmt.Sprintf("unknown option: %s", inival.Name),
+ File: ini.File,
+ LineNumber: inival.LineNumber,
+ }
}
continue
@@ -356,15 +405,51 @@
if !opt.canArgument() && len(inival.Value) == 0 {
pval = nil
+ } else {
+ if opt.value.Type().Kind() == reflect.Map {
+ parts := strings.SplitN(inival.Value, ":", 2)
+
+ // only handle unquoting
+ if len(parts) == 2 && parts[1][0] == '"' {
+ if v, err := strconv.Unquote(parts[1]); err == nil {
+ parts[1] = v
+
+ inival.Quoted = true
+ } else {
+ return &IniError{
+ Message: err.Error(),
+ File: ini.File,
+ LineNumber: inival.LineNumber,
+ }
+ }
+
+ s := parts[0] + ":" + parts[1]
+
+ pval = &s
+ }
+ }
}
if err := opt.set(pval); err != nil {
- return wrapError(err)
+ return &IniError{
+ Message: err.Error(),
+ File: ini.File,
+ LineNumber: inival.LineNumber,
+ }
+ }
+
+ // either all INI values are quoted or only values who need quoting
+ if _, ok := quotesLookup[opt]; !inival.Quoted || !ok {
+ quotesLookup[opt] = inival.Quoted
}
opt.tag.Set("_read-ini-name", inival.Name)
}
}
+ for opt, quoted := range quotesLookup {
+ opt.iniQuote = quoted
+ }
+
return nil
}
diff --git a/ini_test.go b/ini_test.go
index e2b5cdc..8c3176c 100644
--- a/ini_test.go
+++ b/ini_test.go
@@ -5,6 +5,7 @@
"fmt"
"io/ioutil"
"os"
+ "reflect"
"strings"
"testing"
)
@@ -54,11 +55,11 @@
EmptyDescription = false
; Test default value
-Default = Some value
+Default = "Some\nvalue"
; Test default array value
DefaultArray = Some value
-DefaultArray = Another value
+DefaultArray = "Other\tvalue"
; Testdefault map value
DefaultMap = another:value
@@ -109,11 +110,11 @@
; EmptyDescription = false
; Test default value
-; Default = Some value
+; Default = "Some\nvalue"
; Test default array value
; DefaultArray = Some value
-; DefaultArray = Another value
+; DefaultArray = "Other\tvalue"
; Testdefault map value
; DefaultMap = another:value
@@ -242,16 +243,23 @@
verbose = true
verbose = true
+DefaultMap = another:"value\n1"
+DefaultMap = some:value 2
+
[Application Options]
; A slice of pointers to string
; PtrSlice =
; Test default value
-Default = Some value
+Default = "New\nvalue"
+
+; Test env-default1 value
+EnvDefault1 = New value
[Other Options]
# A slice of strings
-# StringSlice =
+StringSlice = "some\nvalue"
+StringSlice = another value
; A map from string to int
int-map = a:2
@@ -268,6 +276,16 @@
assertBoolArray(t, opts.Verbose, []bool{true, true})
+ if v := map[string]string{"another": "value\n1", "some": "value 2"}; !reflect.DeepEqual(opts.DefaultMap, v) {
+ t.Fatalf("Expected %#v for DefaultMap but got %#v", v, opts.DefaultMap)
+ }
+
+ assertString(t, opts.Default, "New\nvalue")
+
+ assertString(t, opts.EnvDefault1, "New value")
+
+ assertStringArray(t, opts.Other.StringSlice, []string{"some\nvalue", "another value"})
+
if v, ok := opts.Other.IntMap["a"]; !ok {
t.Errorf("Expected \"a\" in Other.IntMap")
} else if v != 2 {
@@ -281,6 +299,222 @@
}
}
+func TestReadAndWriteIni(t *testing.T) {
+ var tests = []struct {
+ options IniOptions
+ read string
+ write string
+ }{
+ {
+ IniIncludeComments,
+ `[Application Options]
+; Show verbose debug information
+verbose = true
+verbose = true
+
+; Test default value
+Default = "quote me"
+
+; Test default array value
+DefaultArray = 1
+DefaultArray = "2"
+DefaultArray = 3
+
+; Testdefault map value
+; DefaultMap =
+
+; Test env-default1 value
+EnvDefault1 = env-def
+
+; Test env-default2 value
+EnvDefault2 = env-def
+
+[Other Options]
+; A slice of strings
+; StringSlice =
+
+; A map from string to int
+int-map = a:2
+int-map = b:"3"
+
+`,
+ `[Application Options]
+; Show verbose debug information
+verbose = true
+verbose = true
+
+; Test default value
+Default = "quote me"
+
+; Test default array value
+DefaultArray = 1
+DefaultArray = 2
+DefaultArray = 3
+
+; Testdefault map value
+; DefaultMap =
+
+; Test env-default1 value
+EnvDefault1 = env-def
+
+; Test env-default2 value
+EnvDefault2 = env-def
+
+[Other Options]
+; A slice of strings
+; StringSlice =
+
+; A map from string to int
+int-map = a:2
+int-map = b:3
+
+`,
+ },
+ {
+ IniIncludeComments,
+ `[Application Options]
+; Show verbose debug information
+verbose = true
+verbose = true
+
+; Test default value
+Default = "quote me"
+
+; Test default array value
+DefaultArray = "1"
+DefaultArray = "2"
+DefaultArray = "3"
+
+; Testdefault map value
+; DefaultMap =
+
+; Test env-default1 value
+EnvDefault1 = env-def
+
+; Test env-default2 value
+EnvDefault2 = env-def
+
+[Other Options]
+; A slice of strings
+; StringSlice =
+
+; A map from string to int
+int-map = a:"2"
+int-map = b:"3"
+
+`,
+ `[Application Options]
+; Show verbose debug information
+verbose = true
+verbose = true
+
+; Test default value
+Default = "quote me"
+
+; Test default array value
+DefaultArray = "1"
+DefaultArray = "2"
+DefaultArray = "3"
+
+; Testdefault map value
+; DefaultMap =
+
+; Test env-default1 value
+EnvDefault1 = env-def
+
+; Test env-default2 value
+EnvDefault2 = env-def
+
+[Other Options]
+; A slice of strings
+; StringSlice =
+
+; A map from string to int
+int-map = a:"2"
+int-map = b:"3"
+
+`,
+ },
+ }
+
+ for _, test := range tests {
+ var opts helpOptions
+
+ p := NewNamedParser("TestIni", Default)
+ p.AddGroup("Application Options", "The application options", &opts)
+
+ inip := NewIniParser(p)
+
+ read := strings.NewReader(test.read)
+ err := inip.Parse(read)
+ if err != nil {
+ t.Fatalf("Unexpected error: %s", err)
+ }
+
+ var write bytes.Buffer
+ inip.Write(&write, test.options)
+
+ got := write.String()
+
+ msg := fmt.Sprintf("with ini options %b", test.options)
+ assertDiff(t, got, test.write, msg)
+ }
+}
+
+func TestReadIniWrongQuoting(t *testing.T) {
+ var tests = []struct {
+ iniFile string
+ lineNumber uint
+ }{
+ {
+ iniFile: `Default = "New\nvalue`,
+ lineNumber: 1,
+ },
+ {
+ iniFile: `StringSlice = "New\nvalue`,
+ lineNumber: 1,
+ },
+ {
+ iniFile: `StringSlice = "New\nvalue"
+ StringSlice = "Second\nvalue`,
+ lineNumber: 2,
+ },
+ {
+ iniFile: `DefaultMap = some:"value`,
+ lineNumber: 1,
+ },
+ {
+ iniFile: `DefaultMap = some:value
+ DefaultMap = another:"value`,
+ lineNumber: 2,
+ },
+ }
+
+ for _, test := range tests {
+ var opts helpOptions
+
+ p := NewNamedParser("TestIni", Default)
+ p.AddGroup("Application Options", "The application options", &opts)
+
+ inip := NewIniParser(p)
+
+ inic := test.iniFile
+
+ b := strings.NewReader(inic)
+ err := inip.Parse(b)
+
+ if err == nil {
+ t.Fatalf("Expect error")
+ }
+
+ iniError := err.(*IniError)
+
+ if iniError.LineNumber != test.lineNumber {
+ t.Fatalf("Expect error on line %d", test.lineNumber)
+ }
+ }
+}
+
func TestIniCommands(t *testing.T) {
var opts struct {
Value string `short:"v" long:"value"`
@@ -354,7 +588,15 @@
t.Fatalf("Expected error")
}
- assertError(t, err, ErrUnknownFlag, "unknown option: value")
+ iniError := err.(*IniError)
+
+ if v := uint(2); iniError.LineNumber != v {
+ t.Errorf("Expected opts.Add.Name to be %d, but got %d", v, iniError.LineNumber)
+ }
+
+ if v := "unknown option: value"; iniError.Message != v {
+ t.Errorf("Expected opts.Add.Name to be %s, but got %s", v, iniError.Message)
+ }
}
func TestIniParse(t *testing.T) {
diff --git a/option.go b/option.go
index 26f2441..29e702c 100644
--- a/option.go
+++ b/option.go
@@ -68,9 +68,11 @@
// The struct field value which the option represents.
value reflect.Value
- iniUsedName string
- tag multiTag
- isSet bool
+ // Determines if the option will be always quoted in the INI output
+ iniQuote bool
+
+ tag multiTag
+ isSet bool
}
// LongNameWithNamespace returns the option's long name with the group namespaces
diff --git a/parser_private.go b/parser_private.go
index ee637e9..67a5b02 100644
--- a/parser_private.go
+++ b/parser_private.go
@@ -164,11 +164,22 @@
}
err = option.set(nil)
- } else if argument != nil {
- err = option.set(argument)
- } else if canarg && !s.eof() {
- arg := s.pop()
- err = option.set(&arg)
+ } else if argument != nil || (canarg && !s.eof()) {
+ var arg string
+
+ if argument != nil {
+ arg = *argument
+ } else {
+ arg = s.pop()
+ }
+
+ if option.tag.Get("unquote") != "false" {
+ arg, err = unquoteIfPossible(arg)
+ }
+
+ if err == nil {
+ err = option.set(&arg)
+ }
} else if option.OptionalArgument {
option.empty()
diff --git a/parser_test.go b/parser_test.go
index add87d8..444705d 100644
--- a/parser_test.go
+++ b/parser_test.go
@@ -3,6 +3,7 @@
import (
"os"
"reflect"
+ "strconv"
"strings"
"testing"
"time"
@@ -12,6 +13,10 @@
Int int `long:"i"`
IntDefault int `long:"id" default:"1"`
+ String string `long:"str"`
+ StringDefault string `long:"strd" default:"abc"`
+ StringNotUnquoted string `long:"strnot" unquote:"false"`
+
Time time.Duration `long:"t"`
TimeDefault time.Duration `long:"td" default:"1m"`
@@ -35,6 +40,9 @@
Int: 0,
IntDefault: 1,
+ String: "",
+ StringDefault: "abc",
+
Time: 0,
TimeDefault: time.Minute,
@@ -47,11 +55,14 @@
},
{
msg: "non-zero value arguments, expecting overwritten arguments",
- args: []string{"--i=3", "--id=3", "--t=3ms", "--td=3ms", "--m=c:3", "--md=c:3", "--s=3", "--sd=3"},
+ args: []string{"--i=3", "--id=3", "--str=def", "--strd=def", "--t=3ms", "--td=3ms", "--m=c:3", "--md=c:3", "--s=3", "--sd=3"},
expected: defaultOptions{
Int: 3,
IntDefault: 3,
+ String: "def",
+ StringDefault: "def",
+
Time: 3 * time.Millisecond,
TimeDefault: 3 * time.Millisecond,
@@ -64,11 +75,14 @@
},
{
msg: "zero value arguments, expecting overwritten arguments",
- args: []string{"--i=0", "--id=0", "--t=0ms", "--td=0s", "--m=:0", "--md=:0", "--s=0", "--sd=0"},
+ args: []string{"--i=0", "--id=0", "--str=\"\"", "--strd=\"\"", "--t=0ms", "--td=0s", "--m=:0", "--md=:0", "--s=0", "--sd=0"},
expected: defaultOptions{
Int: 0,
IntDefault: 0,
+ String: "",
+ StringDefault: "",
+
Time: 0,
TimeDefault: 0,
@@ -99,6 +113,76 @@
}
}
+func TestUnquoting(t *testing.T) {
+ var tests = []struct {
+ arg string
+ err error
+ value string
+ }{
+ {
+ arg: "\"abc",
+ err: strconv.ErrSyntax,
+ value: "",
+ },
+ {
+ arg: "\"\"abc\"",
+ err: strconv.ErrSyntax,
+ value: "",
+ },
+ {
+ arg: "\"abc\"",
+ err: nil,
+ value: "abc",
+ },
+ {
+ arg: "\"\\\"abc\\\"\"",
+ err: nil,
+ value: "\"abc\"",
+ },
+ {
+ arg: "\"\\\"abc\"",
+ err: nil,
+ value: "\"abc",
+ },
+ }
+
+ for _, test := range tests {
+ var opts defaultOptions
+
+ for _, delimiter := range []bool{false, true} {
+ p := NewParser(&opts, None)
+
+ var err error
+ if delimiter {
+ _, err = p.ParseArgs([]string{"--str=" + test.arg, "--strnot=" + test.arg})
+ } else {
+ _, err = p.ParseArgs([]string{"--str", test.arg, "--strnot", test.arg})
+ }
+
+ if test.err == nil {
+ if err != nil {
+ t.Fatalf("Expected no error but got: %v", err)
+ }
+
+ if test.value != opts.String {
+ t.Fatalf("Expected String to be %q but got %q", test.value, opts.String)
+ }
+ if q := strconv.Quote(test.value); q != opts.StringNotUnquoted {
+ t.Fatalf("Expected StringDefault to be %q but got %q", q, opts.StringNotUnquoted)
+ }
+ } else {
+ if err == nil {
+ t.Fatalf("Expected error")
+ } else if e, ok := err.(*Error); ok {
+ if strings.HasPrefix(e.Message, test.err.Error()) {
+ t.Fatalf("Expected error message to end with %q but got %v", test.err.Error(), e.Message)
+ }
+ }
+ }
+ }
+ }
+}
+
// envRestorer keeps a copy of a set of env variables and can restore the env from them
type envRestorer struct {
env map[string]string