From 5c5bf6da257ddab2d6d1f8f208d548710a78357a Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 20:18:20 +0000 Subject: [PATCH 1/5] Add Subsections and Subsection for Git-style section names Read [name "sub"] headers, such as [forge "github.com"], without parsing section names by hand. Names are parsed when read, so the lexer and stored section names are unchanged. --- file.go | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ file_test.go | 63 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/file.go b/file.go index 5219466..e5d0481 100644 --- a/file.go +++ b/file.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "iter" "os" "slices" "strings" @@ -241,6 +242,84 @@ func (f *File) ChildSections(name string) []*Section { return f.Section(name).ChildSections() } +// Subsections returns the sections written in Git's config style as +// [name "sub"], with each one's sub. The name matches in any case, and +// spaces and tabs around and between the two parts are allowed. In sub, \" +// and \\ read as " and \, and a backslash before another character is +// dropped, as in Git. +// +// Section names are read as written, so a few things differ from Git: +// - A section whose name doesn't split as above, such as a sub without +// its closing quote, is skipped without an error. +// - Headers that differ only in spacing or in the case of the name, such +// as [forge "x"] and [Forge "x"], are separate sections, and both are +// returned with sub "x". +// - With CaseInsensitive or CaseInsensitiveSections, sub is lowercased, +// as the whole section name is when it's read. +func (f *File) Subsections(name string) iter.Seq2[string, *Section] { + secs := f.Sections() + return func(yield func(string, *Section) bool) { + for _, sec := range secs { + if n, sub, ok := splitSubsection(sec.name); ok && strings.EqualFold(n, name) { + if !yield(sub, sec) { + return + } + } + } + } +} + +// Subsection returns the section written in Git's config style as +// [name "sub"], matched as Subsections does. When there are several, it +// returns the one written exactly as [name "sub"], or else the first. When +// there is none, it returns a section that reads as empty, as Section does, +// and a change adds it as [name "sub"], with " and \ in sub escaped. +func (f *File) Subsection(name, sub string) *Section { + quoted := name + ` "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(sub) + `"` + if sec, err := f.GetSection(quoted); err == nil { + return sec + } + if f.options.Insensitive || f.options.InsensitiveSections { + sub = strings.ToLower(sub) + } + for s, sec := range f.Subsections(name) { + if s == sub { + return sec + } + } + return f.Section(quoted) +} + +// splitSubsection splits a section name of the form `name "sub"` into name +// and the unescaped sub. +func splitSubsection(s string) (name, sub string, ok bool) { + s = strings.Trim(s, " \t") + i := strings.IndexAny(s, " \t") + if i < 1 { + return "", "", false + } + name, rest := s[:i], strings.TrimLeft(s[i:], " \t") + if len(rest) < 2 || rest[0] != '"' || rest[len(rest)-1] != '"' { + return "", "", false + } + var b strings.Builder + for i := 1; i < len(rest)-1; i++ { + switch c := rest[i]; c { + case '\\': + i++ + if i == len(rest)-1 { + return "", "", false // the closing quote is escaped + } + b.WriteByte(rest[i]) + case '"': + return "", "", false + default: + b.WriteByte(c) + } + } + return name, b.String(), true +} + // SectionNames returns list of section names. func (f *File) SectionNames() []string { f.maybeRLock() diff --git a/file_test.go b/file_test.go index 9c1a7b6..0210853 100644 --- a/file_test.go +++ b/file_test.go @@ -421,6 +421,69 @@ func TestFile_ChildSections(t *testing.T) { } } +func TestFile_Subsections(t *testing.T) { + f, err := Load([]byte(` +[forge "loose.com"] +TYPE = loose +[Forge "upper.com"] +[forge "tab.com" ] +[forge "a\"b\\c\d"] +[forge "exact.com"] +[forge "loose.com"] +TYPE = exact +[forge "unclosed.com] +[forge "escaped.com\"] +[forge "in"side"] +[forge"nospace.com"] +[forgery "other.com"] +[forge] +[forge.dotted.com] +[storage "local"] +`)) + require.NoError(t, err) + + var subs, names []string + for sub, sec := range f.Subsections("forge") { + subs = append(subs, sub) + names = append(names, sec.Name()) + } + assert.Equal(t, []string{"loose.com", "upper.com", "tab.com", `a"b\cd`, "exact.com", "loose.com"}, subs) + assert.Equal(t, `forge "exact.com"`, names[4]) + + for sub := range f.Subsections("FORGE") { + assert.Equal(t, "loose.com", sub) + break + } + + t.Run("Subsection", func(t *testing.T) { + assert.Equal(t, "exact", f.Subsection("forge", "loose.com").Key("TYPE").String()) + assert.Equal(t, `Forge "upper.com"`, f.Subsection("forge", "upper.com").Name()) + assert.Equal(t, `forge "a\"b\\c\d"`, f.Subsection("forge", `a"b\cd`).Name()) + assert.False(t, f.HasSection(f.Subsection("forge", "missing.com").Name())) + + f.Subsection("storage", `r2 "x"\`).Key("TYPE").SetValue("s3") + var buf bytes.Buffer + _, err := f.WriteTo(&buf) + require.NoError(t, err) + assert.Contains(t, buf.String(), `[storage "r2 \"x\"\\"]`) + + g, err := Load(buf.Bytes()) + require.NoError(t, err) + assert.Equal(t, "s3", g.Subsection("storage", `r2 "x"\`).Key("TYPE").String()) + }) + + t.Run("CaseInsensitive", func(t *testing.T) { + f, err := Load([]byte(`[Forge "GitHub.com"]`+"\nTYPE = github"), CaseInsensitive(true)) + require.NoError(t, err) + var subs []string + for sub := range f.Subsections("forge") { + subs = append(subs, sub) + } + assert.Equal(t, []string{"github.com"}, subs) + assert.Equal(t, "github", f.Subsection("Forge", "GitHub.com").Key("TYPE").String()) + }) +} + func TestFile_SectionStrings(t *testing.T) { f, err := Load(fullConf) require.NoError(t, err) From 454c28c501c8bee4d81b4503dcc7172b97a0bfae Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 15:32:15 -0500 Subject: [PATCH 2/5] Read sections on each range in Subsections and correct docstrings Subsections captured the section list when called, so sections added afterwards were missed. The docstrings now also say that only spacing between the parts matches Git, that name case merges sections under the case-insensitive options, and that Subsection writes a lowercased header with those options. --- file.go | 24 ++++++++++++++---------- file_test.go | 21 ++++++++++++++++++++- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/file.go b/file.go index e5d0481..9db7af3 100644 --- a/file.go +++ b/file.go @@ -243,23 +243,25 @@ func (f *File) ChildSections(name string) []*Section { } // Subsections returns the sections written in Git's config style as -// [name "sub"], with each one's sub. The name matches in any case, and -// spaces and tabs around and between the two parts are allowed. In sub, \" -// and \\ read as " and \, and a backslash before another character is -// dropped, as in Git. +// [name "sub"], with each one's sub. The sections are read each time the +// result is ranged over. The name matches in any case, and spaces and tabs +// between the two parts are allowed. In sub, \" and \\ read as " and \, and +// a backslash before another character is dropped, as in Git. // // Section names are read as written, so a few things differ from Git: +// - Spaces and tabs before the name or after the closing quote are +// allowed, where Git rejects the header. // - A section whose name doesn't split as above, such as a sub without // its closing quote, is skipped without an error. -// - Headers that differ only in spacing or in the case of the name, such -// as [forge "x"] and [Forge "x"], are separate sections, and both are -// returned with sub "x". +// - Headers that differ only in spacing, such as [forge "x"] and +// [forge "x"], are separate sections, and both are returned with sub +// "x". So are headers that differ only in the case of the name, such as +// [Forge "x"], unless CaseInsensitive or CaseInsensitiveSections is set. // - With CaseInsensitive or CaseInsensitiveSections, sub is lowercased, // as the whole section name is when it's read. func (f *File) Subsections(name string) iter.Seq2[string, *Section] { - secs := f.Sections() return func(yield func(string, *Section) bool) { - for _, sec := range secs { + for _, sec := range f.Sections() { if n, sub, ok := splitSubsection(sec.name); ok && strings.EqualFold(n, name) { if !yield(sub, sec) { return @@ -273,7 +275,9 @@ func (f *File) Subsections(name string) iter.Seq2[string, *Section] { // [name "sub"], matched as Subsections does. When there are several, it // returns the one written exactly as [name "sub"], or else the first. When // there is none, it returns a section that reads as empty, as Section does, -// and a change adds it as [name "sub"], with " and \ in sub escaped. +// and a change adds it as [name "sub"], with " and \ in sub escaped. With +// CaseInsensitive or CaseInsensitiveSections, the added header is +// lowercased, as any section name is. func (f *File) Subsection(name, sub string) *Section { quoted := name + ` "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(sub) + `"` if sec, err := f.GetSection(quoted); err == nil { diff --git a/file_test.go b/file_test.go index 0210853..de60a40 100644 --- a/file_test.go +++ b/file_test.go @@ -472,8 +472,20 @@ TYPE = exact assert.Equal(t, "s3", g.Subsection("storage", `r2 "x"\`).Key("TYPE").String()) }) + t.Run("RangedAfterChange", func(t *testing.T) { + f := Empty() + seq := f.Subsections("forge") + _, err := f.NewSection(`forge "x"`) + require.NoError(t, err) + var subs []string + for sub := range seq { + subs = append(subs, sub) + } + assert.Equal(t, []string{"x"}, subs) + }) + t.Run("CaseInsensitive", func(t *testing.T) { - f, err := Load([]byte(`[Forge "GitHub.com"]`+"\nTYPE = github"), CaseInsensitive(true)) + f, err := Load([]byte(`[Forge "GitHub.com"]`+"\nTYPE = github\n"+`[forge "github.com"]`+"\nUSER = git"), CaseInsensitive(true)) require.NoError(t, err) var subs []string for sub := range f.Subsections("forge") { @@ -481,6 +493,13 @@ TYPE = exact } assert.Equal(t, []string{"github.com"}, subs) assert.Equal(t, "github", f.Subsection("Forge", "GitHub.com").Key("TYPE").String()) + assert.Equal(t, "git", f.Subsection("Forge", "GitHub.com").Key("USER").String()) + + f.Subsection("Storage", "R2").Key("TYPE").SetValue("s3") + var buf bytes.Buffer + _, err = f.WriteTo(&buf) + require.NoError(t, err) + assert.Contains(t, buf.String(), `[storage "r2"]`) }) } From cd9940379d6da0d019b2f6c9481dd63171642aaf Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 15:56:06 -0500 Subject: [PATCH 3/5] Read subsections as Git does Subsections and Subsection now follow Git's header rules at read time: both [name "sub"] and [name.sub] forms, Git's name characters, no spaces around the parts, and headers Git takes as the same subsection merged into one read-only Subsection, with keys read from the last section that has them. CaseInsensitive lowercasing the sub is documented as a difference from Git. --- file.go | 200 ++++++++++++++++++++++++++++++++++++--------------- file_test.go | 98 +++++++++++++++---------- 2 files changed, 201 insertions(+), 97 deletions(-) diff --git a/file.go b/file.go index 9db7af3..ef62180 100644 --- a/file.go +++ b/file.go @@ -242,47 +242,54 @@ func (f *File) ChildSections(name string) []*Section { return f.Section(name).ChildSections() } -// Subsections returns the sections written in Git's config style as -// [name "sub"], with each one's sub. The sections are read each time the -// result is ranged over. The name matches in any case, and spaces and tabs -// between the two parts are allowed. In sub, \" and \\ read as " and \, and -// a backslash before another character is dropped, as in Git. +// Subsections returns the subsections of the section name as Git reads +// them, with each one's sub. The sections are read each time the result is +// ranged over. // -// Section names are read as written, so a few things differ from Git: -// - Spaces and tabs before the name or after the closing quote are -// allowed, where Git rejects the header. -// - A section whose name doesn't split as above, such as a sub without -// its closing quote, is skipped without an error. -// - Headers that differ only in spacing, such as [forge "x"] and -// [forge "x"], are separate sections, and both are returned with sub -// "x". So are headers that differ only in the case of the name, such as -// [Forge "x"], unless CaseInsensitive or CaseInsensitiveSections is set. -// - With CaseInsensitive or CaseInsensitiveSections, sub is lowercased, -// as the whole section name is when it's read. -func (f *File) Subsections(name string) iter.Seq2[string, *Section] { - return func(yield func(string, *Section) bool) { +// As in Git, a section is written as [name "sub"] or, in the older form, as +// [name.sub]. The name is made of letters, digits, - and ., and matches in +// any case. Spaces and tabs between the two parts are allowed. In a quoted +// sub, \" and \\ read as " and \, and a backslash before another character +// is dropped. A dotted sub is lowercased. A dot in the name moves the rest +// into sub, so [forge.a "b"] and [forge.a.b] have sub "a.b". Headers with +// the same name and sub, such as [forge "x"], [Forge "x"] and [forge.x], +// make one subsection. +// +// A section Git would reject, such as [ forge "x"] or [for_ge "x"], is +// skipped without an error. +// +// With CaseInsensitive or CaseInsensitiveSections, a quoted sub is +// lowercased too, because section names are lowercased when they're read, +// so [forge "X"] and [forge "x"] make one subsection, where Git keeps two. +func (f *File) Subsections(name string) iter.Seq2[string, *Subsection] { + return func(yield func(string, *Subsection) bool) { + var subs []*Subsection + bySub := make(map[string]*Subsection) for _, sec := range f.Sections() { - if n, sub, ok := splitSubsection(sec.name); ok && strings.EqualFold(n, name) { - if !yield(sub, sec) { - return - } + n, sub, ok := splitSubsection(sec.name) + if !ok || !strings.EqualFold(n, name) { + continue + } + if s := bySub[sub]; s != nil { + s.secs = append(s.secs, sec) + continue + } + s := &Subsection{f: f, sub: sub, secs: []*Section{sec}} + bySub[sub] = s + subs = append(subs, s) + } + for _, s := range subs { + if !yield(s.sub, s) { + return } } } } -// Subsection returns the section written in Git's config style as -// [name "sub"], matched as Subsections does. When there are several, it -// returns the one written exactly as [name "sub"], or else the first. When -// there is none, it returns a section that reads as empty, as Section does, -// and a change adds it as [name "sub"], with " and \ in sub escaped. With -// CaseInsensitive or CaseInsensitiveSections, the added header is -// lowercased, as any section name is. -func (f *File) Subsection(name, sub string) *Section { - quoted := name + ` "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(sub) + `"` - if sec, err := f.GetSection(quoted); err == nil { - return sec - } +// Subsection returns the subsection sub of the section name, matched as +// Subsections does. When there is none, it returns a subsection that reads +// as empty. +func (f *File) Subsection(name, sub string) *Subsection { if f.options.Insensitive || f.options.InsensitiveSections { sub = strings.ToLower(sub) } @@ -291,37 +298,112 @@ func (f *File) Subsection(name, sub string) *Section { return sec } } - return f.Section(quoted) + return &Subsection{f: f, sub: sub} } -// splitSubsection splits a section name of the form `name "sub"` into name -// and the unescaped sub. +// splitSubsection splits a section name as Git reads it into the +// lowercased name and the sub, as Subsections describes. func splitSubsection(s string) (name, sub string, ok bool) { - s = strings.Trim(s, " \t") - i := strings.IndexAny(s, " \t") - if i < 1 { - return "", "", false - } - name, rest := s[:i], strings.TrimLeft(s[i:], " \t") - if len(rest) < 2 || rest[0] != '"' || rest[len(rest)-1] != '"' { - return "", "", false - } - var b strings.Builder - for i := 1; i < len(rest)-1; i++ { - switch c := rest[i]; c { - case '\\': - i++ - if i == len(rest)-1 { - return "", "", false // the closing quote is escaped - } - b.WriteByte(rest[i]) - case '"': + i := 0 + for i < len(s) && isGitNameChar(s[i]) { + i++ + } + full := strings.ToLower(s[:i]) + if i < len(s) { + rest := strings.TrimLeft(s[i:], " \t") + if i == 0 || len(rest) == len(s[i:]) || len(rest) < 2 || rest[0] != '"' || rest[len(rest)-1] != '"' { return "", "", false - default: - b.WriteByte(c) + } + var b strings.Builder + for j := 1; j < len(rest)-1; j++ { + switch c := rest[j]; c { + case '\\': + j++ + if j == len(rest)-1 { + return "", "", false // the closing quote is escaped + } + b.WriteByte(rest[j]) + case '"': + return "", "", false + default: + b.WriteByte(c) + } + } + full += "." + b.String() + } + return strings.Cut(full, ".") +} + +// isGitNameChar reports whether c can be in a section name in Git. +func isGitNameChar(c byte) bool { + return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-' || c == '.' +} + +// Subsection is a subsection as Git reads it, from every section written +// with its name and sub. It is for reading: a key is read from the last of +// those sections that has it, as Git takes the last value. +type Subsection struct { + f *File + sub string + secs []*Section +} + +// Sections returns the sections the subsection is read from, in file order. +func (s *Subsection) Sections() []*Section { + return slices.Clone(s.secs) +} + +// Key returns the key with the given name from the last section that has +// it. Unlike Section.Key, it doesn't look in parent sections. When there is +// none, it returns a key that reads as empty, and a change to it isn't +// saved. +func (s *Subsection) Key(name string) *Key { + if s.f.options.Insensitive || s.f.options.InsensitiveKeys { + name = strings.ToLower(name) + } + if key := s.key(name); key != nil { + return key + } + return newKey(newSection(s.f, ""), name, "") +} + +// HasKey reports whether any of the sections has a key with the given name. +func (s *Subsection) HasKey(name string) bool { + if s.f.options.Insensitive || s.f.options.InsensitiveKeys { + name = strings.ToLower(name) + } + return s.key(name) != nil +} + +func (s *Subsection) key(name string) *Key { + s.f.maybeRLock() + defer s.f.maybeRUnlock() + for i := len(s.secs) - 1; i >= 0; i-- { + if key := s.secs[i].current().byName[name]; key != nil { + return key + } + } + return nil +} + +// Keys returns one key for each name in the sections, in the order the +// names first appear, each from the last section that has it. +func (s *Subsection) Keys() []*Key { + s.f.maybeRLock() + defer s.f.maybeRUnlock() + var keys []*Key + at := make(map[string]int) + for _, sec := range s.secs { + for _, key := range sec.current().keys { + if i, ok := at[key.name]; ok { + keys[i] = key + continue + } + at[key.name] = len(keys) + keys = append(keys, key) } } - return name, b.String(), true + return keys } // SectionNames returns list of section names. diff --git a/file_test.go b/file_test.go index de60a40..cc3bca9 100644 --- a/file_test.go +++ b/file_test.go @@ -423,32 +423,38 @@ func TestFile_ChildSections(t *testing.T) { func TestFile_Subsections(t *testing.T) { f, err := Load([]byte(` -[forge "loose.com"] -TYPE = loose -[Forge "upper.com"] -[forge "tab.com" ] -[forge "a\"b\\c\d"] -[forge "exact.com"] +[forge] +PARENT = p [forge "loose.com"] -TYPE = exact +TYPE = first +USER = git +[Forge "loose.com"] +TYPE = second +[forge.Dotted.COM] +TYPE = dotted +[forge "dotted.com"] +USER = quoted +[forge "tab.com"] +[forge "a\"b\\c\d"] +[forge.a "b"] +[forge ""] +[ forge "lead.com"] +[forge "trail.com" ] +[forge"nospace.com"] +[for_ge "underscore.com"] [forge "unclosed.com] [forge "escaped.com\"] [forge "in"side"] -[forge"nospace.com"] [forgery "other.com"] -[forge] -[forge.dotted.com] [storage "local"] `)) require.NoError(t, err) - var subs, names []string - for sub, sec := range f.Subsections("forge") { + var subs []string + for sub := range f.Subsections("forge") { subs = append(subs, sub) - names = append(names, sec.Name()) } - assert.Equal(t, []string{"loose.com", "upper.com", "tab.com", `a"b\cd`, "exact.com", "loose.com"}, subs) - assert.Equal(t, `forge "exact.com"`, names[4]) + assert.Equal(t, []string{"loose.com", "dotted.com", "tab.com", `a"b\cd`, "a.b", ""}, subs) for sub := range f.Subsections("FORGE") { assert.Equal(t, "loose.com", sub) @@ -456,20 +462,33 @@ TYPE = exact } t.Run("Subsection", func(t *testing.T) { - assert.Equal(t, "exact", f.Subsection("forge", "loose.com").Key("TYPE").String()) - assert.Equal(t, `Forge "upper.com"`, f.Subsection("forge", "upper.com").Name()) - assert.Equal(t, `forge "a\"b\\c\d"`, f.Subsection("forge", `a"b\cd`).Name()) - assert.False(t, f.HasSection(f.Subsection("forge", "missing.com").Name())) - - f.Subsection("storage", `r2 "x"\`).Key("TYPE").SetValue("s3") - var buf bytes.Buffer - _, err := f.WriteTo(&buf) - require.NoError(t, err) - assert.Contains(t, buf.String(), `[storage "r2 \"x\"\\"]`) - - g, err := Load(buf.Bytes()) - require.NoError(t, err) - assert.Equal(t, "s3", g.Subsection("storage", `r2 "x"\`).Key("TYPE").String()) + loose := f.Subsection("forge", "loose.com") + assert.Len(t, loose.Sections(), 2) + assert.Equal(t, "second", loose.Key("TYPE").String()) + assert.Equal(t, "git", loose.Key("USER").String()) + var keys []string + for _, k := range loose.Keys() { + keys = append(keys, k.Name()+"="+k.Value()) + } + assert.Equal(t, []string{"TYPE=second", "USER=git"}, keys) + + dotted := f.Subsection("forge", "dotted.com") + assert.Equal(t, "dotted", dotted.Key("TYPE").String()) + assert.Equal(t, "quoted", dotted.Key("USER").String()) + assert.False(t, dotted.HasKey("PARENT")) + assert.False(t, f.Subsection("forge", "Dotted.COM").HasKey("TYPE")) + + assert.Len(t, f.Subsection("forge", `a"b\cd`).Sections(), 1) + assert.Len(t, f.Subsection("forge", "a.b").Sections(), 1) + + names := f.SectionNames() + missing := f.Subsection("forge", "missing.com") + assert.Empty(t, missing.Sections()) + assert.False(t, missing.HasKey("TYPE")) + assert.Empty(t, missing.Key("TYPE").String()) + missing.Key("TYPE").SetValue("x") + assert.Equal(t, names, f.SectionNames()) + assert.Empty(t, missing.Key("TYPE").String()) }) t.Run("RangedAfterChange", func(t *testing.T) { @@ -484,22 +503,25 @@ TYPE = exact assert.Equal(t, []string{"x"}, subs) }) + t.Run("AllowNonUniqueSections", func(t *testing.T) { + f, err := Load([]byte("[forge \"x\"]\nA = 1\n[forge \"x\"]\nA = 2"), AllowNonUniqueSections(true)) + require.NoError(t, err) + s := f.Subsection("forge", "x") + assert.Len(t, s.Sections(), 2) + assert.Equal(t, "2", s.Key("A").String()) + }) + t.Run("CaseInsensitive", func(t *testing.T) { - f, err := Load([]byte(`[Forge "GitHub.com"]`+"\nTYPE = github\n"+`[forge "github.com"]`+"\nUSER = git"), CaseInsensitive(true)) + f, err := Load([]byte(`[Forge "GitHub.com"]`+"\nTYPE = github\n"+`[forge "GITHUB.COM"]`+"\nUSER = git"), CaseInsensitive(true)) require.NoError(t, err) var subs []string for sub := range f.Subsections("forge") { subs = append(subs, sub) } assert.Equal(t, []string{"github.com"}, subs) - assert.Equal(t, "github", f.Subsection("Forge", "GitHub.com").Key("TYPE").String()) - assert.Equal(t, "git", f.Subsection("Forge", "GitHub.com").Key("USER").String()) - - f.Subsection("Storage", "R2").Key("TYPE").SetValue("s3") - var buf bytes.Buffer - _, err = f.WriteTo(&buf) - require.NoError(t, err) - assert.Contains(t, buf.String(), `[storage "r2"]`) + s := f.Subsection("Forge", "GitHub.com") + assert.Equal(t, "github", s.Key("TYPE").String()) + assert.Equal(t, "git", s.Key("USER").String()) }) } From 6ded30bacc3fb492a4703f1e1f901c2ac311bbda Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 16:04:28 -0500 Subject: [PATCH 4/5] Return one Section per header from Subsections A merged read-only Subsection could not keep Git's last value when the loader had already merged same-spelled headers, and lost MapTo, %(name)s references and shadows. Subsections and Subsection now return the *Section of each header, keeping Git's header rules, and document that headers spelled differently for the same sub are not merged. An empty name, as in [ "x"], is read as Git reads it. --- file.go | 133 ++++++++++++--------------------------------------- file_test.go | 102 ++++++++++++++++++++++----------------- 2 files changed, 87 insertions(+), 148 deletions(-) diff --git a/file.go b/file.go index ef62180..d78d37e 100644 --- a/file.go +++ b/file.go @@ -242,8 +242,8 @@ func (f *File) ChildSections(name string) []*Section { return f.Section(name).ChildSections() } -// Subsections returns the subsections of the section name as Git reads -// them, with each one's sub. The sections are read each time the result is +// Subsections returns the sections of the section name as Git reads them, +// with each one's sub. The sections are read each time the result is // ranged over. // // As in Git, a section is written as [name "sub"] or, in the older form, as @@ -251,45 +251,39 @@ func (f *File) ChildSections(name string) []*Section { // any case. Spaces and tabs between the two parts are allowed. In a quoted // sub, \" and \\ read as " and \, and a backslash before another character // is dropped. A dotted sub is lowercased. A dot in the name moves the rest -// into sub, so [forge.a "b"] and [forge.a.b] have sub "a.b". Headers with -// the same name and sub, such as [forge "x"], [Forge "x"] and [forge.x], -// make one subsection. +// into sub, so [forge.a "b"] and [forge.a.b] have sub "a.b". A section Git +// would reject, such as [ forge "x"] or [for_ge "x"], is skipped without an +// error. // -// A section Git would reject, such as [ forge "x"] or [for_ge "x"], is -// skipped without an error. -// -// With CaseInsensitive or CaseInsensitiveSections, a quoted sub is -// lowercased too, because section names are lowercased when they're read, -// so [forge "X"] and [forge "x"] make one subsection, where Git keeps two. -func (f *File) Subsections(name string) iter.Seq2[string, *Subsection] { - return func(yield func(string, *Subsection) bool) { - var subs []*Subsection - bySub := make(map[string]*Subsection) +// Unlike Git, headers written differently for the same sub, such as +// [forge "x"], [Forge "x"] and [forge.x], are separate sections, and each +// is returned with sub "x". Headers written the same are one section, as +// they are for Section. With CaseInsensitive or CaseInsensitiveSections, a +// quoted sub is lowercased too, because section names are lowercased when +// they're read, so [forge "X"] and [forge "x"] are one section with sub "x", +// where Git keeps two subsections. +func (f *File) Subsections(name string) iter.Seq2[string, *Section] { + return func(yield func(string, *Section) bool) { for _, sec := range f.Sections() { - n, sub, ok := splitSubsection(sec.name) - if !ok || !strings.EqualFold(n, name) { - continue - } - if s := bySub[sub]; s != nil { - s.secs = append(s.secs, sec) - continue - } - s := &Subsection{f: f, sub: sub, secs: []*Section{sec}} - bySub[sub] = s - subs = append(subs, s) - } - for _, s := range subs { - if !yield(s.sub, s) { - return + if n, sub, ok := splitSubsection(sec.name); ok && strings.EqualFold(n, name) { + if !yield(sub, sec) { + return + } } } } } -// Subsection returns the subsection sub of the section name, matched as -// Subsections does. When there is none, it returns a subsection that reads -// as empty. -func (f *File) Subsection(name, sub string) *Subsection { +// Subsection returns the section of the section name with the given sub, +// matched as Subsections does. When there are several, it returns the one +// written as [name "sub"], or else the first. When there is none, it +// returns a section that reads as empty, as Section does, and a change adds +// it as [name "sub"], with " and \ in sub escaped. +func (f *File) Subsection(name, sub string) *Section { + quoted := name + ` "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(sub) + `"` + if sec, err := f.GetSection(quoted); err == nil { + return sec + } if f.options.Insensitive || f.options.InsensitiveSections { sub = strings.ToLower(sub) } @@ -298,7 +292,7 @@ func (f *File) Subsection(name, sub string) *Subsection { return sec } } - return &Subsection{f: f, sub: sub} + return f.Section(quoted) } // splitSubsection splits a section name as Git reads it into the @@ -311,7 +305,7 @@ func splitSubsection(s string) (name, sub string, ok bool) { full := strings.ToLower(s[:i]) if i < len(s) { rest := strings.TrimLeft(s[i:], " \t") - if i == 0 || len(rest) == len(s[i:]) || len(rest) < 2 || rest[0] != '"' || rest[len(rest)-1] != '"' { + if len(rest) == len(s[i:]) || len(rest) < 2 || rest[0] != '"' || rest[len(rest)-1] != '"' { return "", "", false } var b strings.Builder @@ -339,73 +333,6 @@ func isGitNameChar(c byte) bool { return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-' || c == '.' } -// Subsection is a subsection as Git reads it, from every section written -// with its name and sub. It is for reading: a key is read from the last of -// those sections that has it, as Git takes the last value. -type Subsection struct { - f *File - sub string - secs []*Section -} - -// Sections returns the sections the subsection is read from, in file order. -func (s *Subsection) Sections() []*Section { - return slices.Clone(s.secs) -} - -// Key returns the key with the given name from the last section that has -// it. Unlike Section.Key, it doesn't look in parent sections. When there is -// none, it returns a key that reads as empty, and a change to it isn't -// saved. -func (s *Subsection) Key(name string) *Key { - if s.f.options.Insensitive || s.f.options.InsensitiveKeys { - name = strings.ToLower(name) - } - if key := s.key(name); key != nil { - return key - } - return newKey(newSection(s.f, ""), name, "") -} - -// HasKey reports whether any of the sections has a key with the given name. -func (s *Subsection) HasKey(name string) bool { - if s.f.options.Insensitive || s.f.options.InsensitiveKeys { - name = strings.ToLower(name) - } - return s.key(name) != nil -} - -func (s *Subsection) key(name string) *Key { - s.f.maybeRLock() - defer s.f.maybeRUnlock() - for i := len(s.secs) - 1; i >= 0; i-- { - if key := s.secs[i].current().byName[name]; key != nil { - return key - } - } - return nil -} - -// Keys returns one key for each name in the sections, in the order the -// names first appear, each from the last section that has it. -func (s *Subsection) Keys() []*Key { - s.f.maybeRLock() - defer s.f.maybeRUnlock() - var keys []*Key - at := make(map[string]int) - for _, sec := range s.secs { - for _, key := range sec.current().keys { - if i, ok := at[key.name]; ok { - keys[i] = key - continue - } - at[key.name] = len(keys) - keys = append(keys, key) - } - } - return keys -} - // SectionNames returns list of section names. func (f *File) SectionNames() []string { f.maybeRLock() diff --git a/file_test.go b/file_test.go index cc3bca9..7dd7add 100644 --- a/file_test.go +++ b/file_test.go @@ -424,16 +424,12 @@ func TestFile_ChildSections(t *testing.T) { func TestFile_Subsections(t *testing.T) { f, err := Load([]byte(` [forge] -PARENT = p [forge "loose.com"] -TYPE = first -USER = git +TYPE = exact [Forge "loose.com"] -TYPE = second +TYPE = loose +[Forge "upper.com"] [forge.Dotted.COM] -TYPE = dotted -[forge "dotted.com"] -USER = quoted [forge "tab.com"] [forge "a\"b\\c\d"] [forge.a "b"] @@ -450,11 +446,13 @@ USER = quoted `)) require.NoError(t, err) - var subs []string - for sub := range f.Subsections("forge") { + var subs, names []string + for sub, sec := range f.Subsections("forge") { subs = append(subs, sub) + names = append(names, sec.Name()) } - assert.Equal(t, []string{"loose.com", "dotted.com", "tab.com", `a"b\cd`, "a.b", ""}, subs) + assert.Equal(t, []string{"loose.com", "loose.com", "upper.com", "dotted.com", "tab.com", `a"b\cd`, "a.b", ""}, subs) + assert.Equal(t, `Forge "loose.com"`, names[1]) for sub := range f.Subsections("FORGE") { assert.Equal(t, "loose.com", sub) @@ -462,33 +460,21 @@ USER = quoted } t.Run("Subsection", func(t *testing.T) { - loose := f.Subsection("forge", "loose.com") - assert.Len(t, loose.Sections(), 2) - assert.Equal(t, "second", loose.Key("TYPE").String()) - assert.Equal(t, "git", loose.Key("USER").String()) - var keys []string - for _, k := range loose.Keys() { - keys = append(keys, k.Name()+"="+k.Value()) - } - assert.Equal(t, []string{"TYPE=second", "USER=git"}, keys) - - dotted := f.Subsection("forge", "dotted.com") - assert.Equal(t, "dotted", dotted.Key("TYPE").String()) - assert.Equal(t, "quoted", dotted.Key("USER").String()) - assert.False(t, dotted.HasKey("PARENT")) - assert.False(t, f.Subsection("forge", "Dotted.COM").HasKey("TYPE")) - - assert.Len(t, f.Subsection("forge", `a"b\cd`).Sections(), 1) - assert.Len(t, f.Subsection("forge", "a.b").Sections(), 1) - - names := f.SectionNames() - missing := f.Subsection("forge", "missing.com") - assert.Empty(t, missing.Sections()) - assert.False(t, missing.HasKey("TYPE")) - assert.Empty(t, missing.Key("TYPE").String()) - missing.Key("TYPE").SetValue("x") - assert.Equal(t, names, f.SectionNames()) - assert.Empty(t, missing.Key("TYPE").String()) + assert.Equal(t, "exact", f.Subsection("forge", "loose.com").Key("TYPE").String()) + assert.Equal(t, `Forge "upper.com"`, f.Subsection("forge", "upper.com").Name()) + assert.Equal(t, "forge.Dotted.COM", f.Subsection("forge", "dotted.com").Name()) + assert.Equal(t, `forge "a\"b\\c\d"`, f.Subsection("forge", `a"b\cd`).Name()) + assert.False(t, f.HasSection(f.Subsection("forge", "missing.com").Name())) + + f.Subsection("storage", `r2 "x"\`).Key("TYPE").SetValue("s3") + var buf bytes.Buffer + _, err := f.WriteTo(&buf) + require.NoError(t, err) + assert.Contains(t, buf.String(), `[storage "r2 \"x\"\\"]`) + + g, err := Load(buf.Bytes()) + require.NoError(t, err) + assert.Equal(t, "s3", g.Subsection("storage", `r2 "x"\`).Key("TYPE").String()) }) t.Run("RangedAfterChange", func(t *testing.T) { @@ -503,14 +489,6 @@ USER = quoted assert.Equal(t, []string{"x"}, subs) }) - t.Run("AllowNonUniqueSections", func(t *testing.T) { - f, err := Load([]byte("[forge \"x\"]\nA = 1\n[forge \"x\"]\nA = 2"), AllowNonUniqueSections(true)) - require.NoError(t, err) - s := f.Subsection("forge", "x") - assert.Len(t, s.Sections(), 2) - assert.Equal(t, "2", s.Key("A").String()) - }) - t.Run("CaseInsensitive", func(t *testing.T) { f, err := Load([]byte(`[Forge "GitHub.com"]`+"\nTYPE = github\n"+`[forge "GITHUB.COM"]`+"\nUSER = git"), CaseInsensitive(true)) require.NoError(t, err) @@ -525,6 +503,40 @@ USER = quoted }) } +func TestSplitSubsection(t *testing.T) { + tests := []struct { + in string + name, sub string + ok bool + }{ + {in: `forge "x"`, name: "forge", sub: "x", ok: true}, + {in: `Forge.X.Y`, name: "forge", sub: "x.y", ok: true}, + {in: `forge.`, name: "forge", sub: "", ok: true}, + {in: `forge..x`, name: "forge", sub: ".x", ok: true}, + {in: `.forge`, name: "", sub: "forge", ok: true}, + {in: ` "x"`, name: "", sub: "x", ok: true}, + {in: `-a "x"`, name: "-a", sub: "x", ok: true}, + {in: `forge "x]"`, name: "forge", sub: "x]", ok: true}, + {in: `forge`}, + {in: ``}, + {in: `forge x`}, + {in: `forge.a b`}, + {in: `forge "x"y`}, + {in: `forge "x" "y"`}, + {in: `forge.x ]`}, + } + for _, test := range tests { + t.Run(test.in, func(t *testing.T) { + name, sub, ok := splitSubsection(test.in) + assert.Equal(t, test.ok, ok) + if ok { + assert.Equal(t, test.name, name) + assert.Equal(t, test.sub, sub) + } + }) + } +} + func TestFile_SectionStrings(t *testing.T) { f, err := Load(fullConf) require.NoError(t, err) From 2686758acd69eb27d86b61078b8692b82d31c0e2 Mon Sep 17 00:00:00 2001 From: Joe Chen Date: Wed, 11 Nov 2026 17:35:56 -0500 Subject: [PATCH 5/5] Accept any section name without spaces, tabs or quotes in Subsections Drop Git's name character check so names such as for_ge are read, and document it as a difference from Git. The Subsection docstring now says an added header is lowercased with the case-insensitive options. --- file.go | 88 ++++++++++++++++++++++++---------------------------- file_test.go | 4 +++ 2 files changed, 45 insertions(+), 47 deletions(-) diff --git a/file.go b/file.go index d78d37e..b58f26f 100644 --- a/file.go +++ b/file.go @@ -247,21 +247,22 @@ func (f *File) ChildSections(name string) []*Section { // ranged over. // // As in Git, a section is written as [name "sub"] or, in the older form, as -// [name.sub]. The name is made of letters, digits, - and ., and matches in -// any case. Spaces and tabs between the two parts are allowed. In a quoted -// sub, \" and \\ read as " and \, and a backslash before another character -// is dropped. A dotted sub is lowercased. A dot in the name moves the rest -// into sub, so [forge.a "b"] and [forge.a.b] have sub "a.b". A section Git -// would reject, such as [ forge "x"] or [for_ge "x"], is skipped without an -// error. +// [name.sub]. The name matches in any case. Spaces and tabs between the two +// parts are allowed. In a quoted sub, \" and \\ read as " and \, and a +// backslash before another character is dropped. A dotted sub is +// lowercased. A dot in the name moves the rest into sub, so [forge.a "b"] +// and [forge.a.b] have sub "a.b". A section that doesn't split this way, +// such as [ forge "x"] or [forge "x" ], is skipped without an error. // -// Unlike Git, headers written differently for the same sub, such as -// [forge "x"], [Forge "x"] and [forge.x], are separate sections, and each -// is returned with sub "x". Headers written the same are one section, as -// they are for Section. With CaseInsensitive or CaseInsensitiveSections, a -// quoted sub is lowercased too, because section names are lowercased when -// they're read, so [forge "X"] and [forge "x"] are one section with sub "x", -// where Git keeps two subsections. +// Unlike Git, the name may have any character but a space, a tab or ", so +// [for_ge "x"] is read with name "for_ge" where Git rejects it. Headers +// written differently for the same sub, such as [forge "x"], [Forge "x"] +// and [forge.x], are separate sections, and each is returned with sub "x". +// Headers written the same are one section, as they are for Section. With +// CaseInsensitive or CaseInsensitiveSections, a quoted sub is lowercased +// too, because section names are lowercased when they're read, so +// [forge "X"] and [forge "x"] are one section with sub "x", where Git keeps +// two subsections. func (f *File) Subsections(name string) iter.Seq2[string, *Section] { return func(yield func(string, *Section) bool) { for _, sec := range f.Sections() { @@ -278,7 +279,9 @@ func (f *File) Subsections(name string) iter.Seq2[string, *Section] { // matched as Subsections does. When there are several, it returns the one // written as [name "sub"], or else the first. When there is none, it // returns a section that reads as empty, as Section does, and a change adds -// it as [name "sub"], with " and \ in sub escaped. +// it as [name "sub"], with " and \ in sub escaped. With CaseInsensitive or +// CaseInsensitiveSections, the added header is lowercased, as any section +// name is. func (f *File) Subsection(name, sub string) *Section { quoted := name + ` "` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(sub) + `"` if sec, err := f.GetSection(quoted); err == nil { @@ -295,42 +298,33 @@ func (f *File) Subsection(name, sub string) *Section { return f.Section(quoted) } -// splitSubsection splits a section name as Git reads it into the -// lowercased name and the sub, as Subsections describes. +// splitSubsection splits a section name into the lowercased name and the +// sub, as Subsections describes. func splitSubsection(s string) (name, sub string, ok bool) { - i := 0 - for i < len(s) && isGitNameChar(s[i]) { - i++ - } - full := strings.ToLower(s[:i]) - if i < len(s) { - rest := strings.TrimLeft(s[i:], " \t") - if len(rest) == len(s[i:]) || len(rest) < 2 || rest[0] != '"' || rest[len(rest)-1] != '"' { + i := strings.IndexAny(s, " \t\"") + if i == -1 { + return strings.Cut(strings.ToLower(s), ".") + } + rest := strings.TrimLeft(s[i:], " \t") + if len(rest) == len(s[i:]) || len(rest) < 2 || rest[0] != '"' || rest[len(rest)-1] != '"' { + return "", "", false + } + var b strings.Builder + for j := 1; j < len(rest)-1; j++ { + switch c := rest[j]; c { + case '\\': + j++ + if j == len(rest)-1 { + return "", "", false // the closing quote is escaped + } + b.WriteByte(rest[j]) + case '"': return "", "", false + default: + b.WriteByte(c) } - var b strings.Builder - for j := 1; j < len(rest)-1; j++ { - switch c := rest[j]; c { - case '\\': - j++ - if j == len(rest)-1 { - return "", "", false // the closing quote is escaped - } - b.WriteByte(rest[j]) - case '"': - return "", "", false - default: - b.WriteByte(c) - } - } - full += "." + b.String() } - return strings.Cut(full, ".") -} - -// isGitNameChar reports whether c can be in a section name in Git. -func isGitNameChar(c byte) bool { - return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-' || c == '.' + return strings.Cut(strings.ToLower(s[:i])+"."+b.String(), ".") } // SectionNames returns list of section names. diff --git a/file_test.go b/file_test.go index 7dd7add..45e3aa7 100644 --- a/file_test.go +++ b/file_test.go @@ -517,6 +517,10 @@ func TestSplitSubsection(t *testing.T) { {in: ` "x"`, name: "", sub: "x", ok: true}, {in: `-a "x"`, name: "-a", sub: "x", ok: true}, {in: `forge "x]"`, name: "forge", sub: "x]", ok: true}, + {in: `for_ge "x"`, name: "for_ge", sub: "x", ok: true}, + {in: `for_ge.x`, name: "for_ge", sub: "x", ok: true}, + {in: `forge"x"`}, + {in: `forge."x"`}, {in: `forge`}, {in: ``}, {in: `forge x`},