Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions file.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"iter"
"os"
"slices"
"strings"
Expand Down Expand Up @@ -241,6 +242,91 @@ func (f *File) ChildSections(name string) []*Section {
return f.Section(name).ChildSections()
}

// 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
// [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, 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() {
if n, sub, ok := splitSubsection(sec.name); ok && strings.EqualFold(n, name) {
if !yield(sub, sec) {
return
}
}
}
}
}

// 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. 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
}
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 into the lowercased name and the
// sub, as Subsections describes.
func splitSubsection(s string) (name, sub string, ok bool) {
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)
}
}
return strings.Cut(strings.ToLower(s[:i])+"."+b.String(), ".")
}

// SectionNames returns list of section names.
func (f *File) SectionNames() []string {
f.maybeRLock()
Expand Down
120 changes: 120 additions & 0 deletions file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,126 @@ func TestFile_ChildSections(t *testing.T) {
}
}

func TestFile_Subsections(t *testing.T) {
f, err := Load([]byte(`
[forge]
[forge "loose.com"]
TYPE = exact
[Forge "loose.com"]
TYPE = loose
[Forge "upper.com"]
[forge.Dotted.COM]
[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"]
[forgery "other.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", "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)
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.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) {
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\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)
s := f.Subsection("Forge", "GitHub.com")
assert.Equal(t, "github", s.Key("TYPE").String())
assert.Equal(t, "git", s.Key("USER").String())
})
}

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: `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`},
{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)
Expand Down
Loading