Skip to content

Commit 6b887e1

Browse files
committed
ini: add basic INI file deserialiser
Add a basic INI file deserialiser
1 parent afe938c commit 6b887e1

File tree

2 files changed

+342
-0
lines changed

2 files changed

+342
-0
lines changed

src/shared/Core.Tests/IniFileTests.cs

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
using System.Collections.Generic;
2+
using System.Text;
3+
using GitCredentialManager.Tests.Objects;
4+
using Xunit;
5+
6+
namespace GitCredentialManager.Tests
7+
{
8+
public class IniFileTests
9+
{
10+
[Fact]
11+
public void IniSectionName_Equality()
12+
{
13+
var a1 = new IniSectionName("foo");
14+
var b1 = new IniSectionName("foo");
15+
Assert.Equal(a1,b1);
16+
Assert.Equal(a1.GetHashCode(),b1.GetHashCode());
17+
18+
var a2 = new IniSectionName("foo");
19+
var b2 = new IniSectionName("FOO");
20+
Assert.Equal(a2,b2);
21+
Assert.Equal(a2.GetHashCode(),b2.GetHashCode());
22+
23+
var a3 = new IniSectionName("foo", "bar");
24+
var b3 = new IniSectionName("foo", "BAR");
25+
Assert.NotEqual(a3,b3);
26+
Assert.NotEqual(a3.GetHashCode(),b3.GetHashCode());
27+
28+
var a4 = new IniSectionName("foo", "bar");
29+
var b4 = new IniSectionName("FOO", "bar");
30+
Assert.Equal(a4,b4);
31+
Assert.Equal(a4.GetHashCode(),b4.GetHashCode());
32+
}
33+
34+
[Fact]
35+
public void IniSerializer_Deserialize()
36+
{
37+
const string path = "/tmp/test.ini";
38+
string iniText = @"
39+
[one]
40+
foo = 123
41+
[two]
42+
foo = abc
43+
# comment
44+
[two ""subsection name""] # comment [section]
45+
foo = this is different # comment prop = val
46+
47+
#[notasection]
48+
49+
[
50+
[bad #section]
51+
recovery tests]
52+
[]
53+
]
54+
55+
[three]
56+
bar = a
57+
bar = b
58+
# comment
59+
bar = c
60+
empty =
61+
[TWO]
62+
foo = hello
63+
widget = ""Hello, World!""
64+
[four]
65+
[five]
66+
prop1 = ""this hash # is inside quotes""
67+
prop2 = ""this hash # is inside quotes"" # this line has two hashes
68+
prop3 = "" this dquoted string has three spaces around ""
69+
#prop4 = this property has been commented-out
70+
";
71+
72+
var fs = new TestFileSystem
73+
{
74+
Files = { [path] = Encoding.UTF8.GetBytes(iniText) }
75+
};
76+
77+
IniFile ini = IniSerializer.Deserialize(fs, path);
78+
79+
Assert.Equal(6, ini.Sections.Count);
80+
81+
AssertSection(ini, "one", out IniSection one);
82+
Assert.Equal(1, one.Properties.Count);
83+
AssertProperty(one, "foo", "123");
84+
85+
AssertSection(ini, "two", out IniSection twoA);
86+
Assert.Equal(3, twoA.Properties.Count);
87+
AssertProperty(twoA, "foo", "hello");
88+
AssertProperty(twoA, "widget", "Hello, World!");
89+
90+
AssertSection(ini, "two", "subsection name", out IniSection twoB);
91+
Assert.Equal(1, twoB.Properties.Count);
92+
AssertProperty(twoB, "foo", "this is different");
93+
94+
AssertSection(ini, "three", out IniSection three);
95+
Assert.Equal(4, three.Properties.Count);
96+
AssertMultiProperty(three, "bar", "a", "b", "c");
97+
AssertProperty(three, "empty", "");
98+
99+
AssertSection(ini, "four", out IniSection four);
100+
Assert.Equal(0, four.Properties.Count);
101+
102+
AssertSection(ini, "five", out IniSection five);
103+
Assert.Equal(3, five.Properties.Count);
104+
AssertProperty(five, "prop1", "this hash # is inside quotes");
105+
AssertProperty(five, "prop2", "this hash # is inside quotes");
106+
AssertProperty(five, "prop3", " this dquoted string has three spaces around ");
107+
}
108+
109+
private static void AssertSection(IniFile file, string name, out IniSection section)
110+
{
111+
Assert.True(file.TryGetSection(name, out section));
112+
Assert.Equal(name, section.Name.Name);
113+
Assert.Null(section.Name.SubName);
114+
}
115+
116+
private static void AssertSection(IniFile file, string name, string subName, out IniSection section)
117+
{
118+
Assert.True(file.TryGetSection(name, subName, out section));
119+
Assert.Equal(name, section.Name.Name);
120+
Assert.Equal(subName, section.Name.SubName);
121+
}
122+
123+
private static void AssertProperty(IniSection section, string name, string value)
124+
{
125+
Assert.True(section.TryGetProperty(name, out var actualValue));
126+
Assert.Equal(value, actualValue);
127+
}
128+
129+
private static void AssertMultiProperty(IniSection section, string name, params string[] values)
130+
{
131+
Assert.True(section.TryGetMultiProperty(name, out IEnumerable<string> actualValues));
132+
Assert.Equal(values, actualValues);
133+
}
134+
}
135+
}

src/shared/Core/IniFile.cs

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Diagnostics;
4+
using System.Linq;
5+
using System.Text.RegularExpressions;
6+
7+
namespace GitCredentialManager
8+
{
9+
public class IniFile
10+
{
11+
public IniFile()
12+
{
13+
Sections = new Dictionary<IniSectionName, IniSection>();
14+
}
15+
16+
public IDictionary<IniSectionName, IniSection> Sections { get; }
17+
18+
public bool TryGetSection(string name, string subName, out IniSection section)
19+
{
20+
return Sections.TryGetValue(new IniSectionName(name, subName), out section);
21+
}
22+
23+
public bool TryGetSection(string name, out IniSection section)
24+
{
25+
return Sections.TryGetValue(new IniSectionName(name), out section);
26+
}
27+
}
28+
29+
[DebuggerDisplay("{DebuggerDisplay}")]
30+
public readonly struct IniSectionName : IEquatable<IniSectionName>
31+
{
32+
public IniSectionName(string name, string subName = null)
33+
{
34+
Name = name;
35+
SubName = string.IsNullOrEmpty(subName) ? null : subName;
36+
}
37+
38+
public string Name { get; }
39+
40+
public string SubName { get; }
41+
42+
public bool Equals(IniSectionName other)
43+
{
44+
// Main section name is case-insensitive, but subsection name IS case-sensitive!
45+
return StringComparer.OrdinalIgnoreCase.Equals(Name, other.Name) &&
46+
StringComparer.Ordinal.Equals(SubName, other.SubName);
47+
}
48+
49+
public override bool Equals(object obj)
50+
{
51+
return obj is IniSectionName other && Equals(other);
52+
}
53+
54+
public override int GetHashCode()
55+
{
56+
unchecked
57+
{
58+
return ((Name != null ? Name.ToLowerInvariant().GetHashCode() : 0) * 397) ^
59+
(SubName != null ? SubName.GetHashCode() : 0);
60+
}
61+
}
62+
63+
private string DebuggerDisplay => SubName is null ? Name : $"{Name} \"{SubName}\"";
64+
}
65+
66+
[DebuggerDisplay("{DebuggerDisplay}")]
67+
public class IniSection
68+
{
69+
public IniSection(IniSectionName name)
70+
{
71+
Name = name;
72+
Properties = new List<IniProperty>();
73+
}
74+
75+
public IniSectionName Name { get; }
76+
77+
public IList<IniProperty> Properties { get; }
78+
79+
public bool TryGetProperty(string name, out string value)
80+
{
81+
if (TryGetMultiProperty(name, out IEnumerable<string> values))
82+
{
83+
value = values.Last();
84+
return true;
85+
}
86+
87+
value = null;
88+
return false;
89+
}
90+
91+
public bool TryGetMultiProperty(string name, out IEnumerable<string> values)
92+
{
93+
IniProperty[] props = Properties
94+
.Where(x => StringComparer.OrdinalIgnoreCase.Equals(x.Name, name))
95+
.ToArray();
96+
97+
if (props.Length == 0)
98+
{
99+
values = Array.Empty<string>();
100+
return false;
101+
}
102+
103+
values = props.Select(x => x.Value);
104+
return true;
105+
}
106+
107+
private string DebuggerDisplay => Name.SubName is null
108+
? $"{Name.Name} [Properties: {Properties.Count}]"
109+
: $"{Name.Name} \"{Name.SubName}\" [Properties: {Properties.Count}]";
110+
}
111+
112+
[DebuggerDisplay("{DebuggerDisplay}")]
113+
public class IniProperty
114+
{
115+
public IniProperty(string name, string value)
116+
{
117+
Name = name;
118+
Value = value;
119+
}
120+
121+
public string Name { get; }
122+
public string Value { get; }
123+
124+
private string DebuggerDisplay => $"{Name}={Value}";
125+
}
126+
127+
public static class IniSerializer
128+
{
129+
private static readonly Regex SectionRegex =
130+
new Regex(@"^\[[^\S#]*(?'name'[^\s#\]]*?)(?:\s+""(?'sub'.+)"")?\s*\]", RegexOptions.Compiled);
131+
132+
private static readonly Regex PropertyRegex =
133+
new Regex(@"^[^\S#]*?(?'name'[^\s#]+)\s*=(?'value'.*)?$", RegexOptions.Compiled);
134+
135+
public static IniFile Deserialize(IFileSystem fs, string path)
136+
{
137+
IEnumerable<string> lines = fs.ReadAllLines(path).Select(x => x.Trim());
138+
139+
var iniFile = new IniFile();
140+
IniSection section = null;
141+
142+
foreach (string line in lines)
143+
{
144+
Match match = SectionRegex.Match(line);
145+
if (match.Success)
146+
{
147+
string mainName = match.Groups["name"].Value;
148+
string subName = match.Groups["sub"].Value;
149+
150+
// Skip empty-named sections
151+
if (string.IsNullOrWhiteSpace(mainName))
152+
{
153+
continue;
154+
}
155+
156+
if (!iniFile.TryGetSection(mainName, subName, out section))
157+
{
158+
var sectionName = new IniSectionName(mainName, subName);
159+
section = new IniSection(sectionName);
160+
iniFile.Sections[sectionName] = section;
161+
}
162+
163+
continue;
164+
}
165+
166+
match = PropertyRegex.Match(line);
167+
if (match.Success)
168+
{
169+
if (section is null)
170+
{
171+
throw new Exception("Missing section header");
172+
}
173+
174+
string propName = match.Groups["name"].Value;
175+
string propValue = match.Groups["value"].Value.Trim();
176+
177+
// Trim trailing comments
178+
int firstDQuote = propValue.IndexOf('"');
179+
int lastDQuote = propValue.LastIndexOf('"');
180+
int commentIdx = propValue.LastIndexOf('#');
181+
if (commentIdx > -1)
182+
{
183+
bool insideDQuotes = firstDQuote > -1 && lastDQuote > -1 &&
184+
(firstDQuote < commentIdx && commentIdx < lastDQuote);
185+
186+
if (!insideDQuotes)
187+
{
188+
propValue = propValue.Substring(0, commentIdx).Trim();
189+
}
190+
}
191+
192+
// Trim book-ending double quotes: "foo" => foo
193+
if (propValue.Length > 1 && propValue[0] == '"' &&
194+
propValue[propValue.Length - 1] == '"')
195+
{
196+
propValue = propValue.Substring(1, propValue.Length - 2);
197+
}
198+
199+
var property = new IniProperty(propName, propValue);
200+
section.Properties.Add(property);
201+
}
202+
}
203+
204+
return iniFile;
205+
}
206+
}
207+
}

0 commit comments

Comments
 (0)