38 Commits
1.1.1 ... 1.3.4

Author SHA1 Message Date
Matt Nadareski
18b8357cd6 Bump version 2024-04-16 11:55:38 -04:00
Matt Nadareski
c19b59dc1c Fix issues with parentable path on Linux 2024-04-16 11:50:31 -04:00
Matt Nadareski
1bdb483205 Be explicit about the characters being replaced 2024-04-16 11:18:58 -04:00
Matt Nadareski
b99f8531c1 Use string instead of array for null terminator 2024-04-16 11:12:57 -04:00
Matt Nadareski
602b951be7 Guarantee a return value for ReadBytes 2024-04-16 11:08:13 -04:00
Matt Nadareski
046bb5875a Simplify ReadBytes implementation 2024-04-16 11:05:54 -04:00
Matt Nadareski
6074fa3c3f Add U/Int128 implementations for .NET 7 and above 2024-04-16 11:03:28 -04:00
Matt Nadareski
bdad58c0dc Extract common stream read functionality 2024-04-16 10:57:17 -04:00
Matt Nadareski
4097a8cc8c Add ReadOnlyCompositeStream 2024-04-16 10:46:41 -04:00
Matt Nadareski
8e3293dd7d Bump version 2024-04-02 15:57:38 -04:00
Matt Nadareski
7ac4df8201 Add quoted string reading 2024-04-02 15:52:15 -04:00
Matt Nadareski
f888cd4e57 Bump version 2024-03-12 16:27:56 -04:00
Matt Nadareski
04fc2dc96e Fix incorrect nullability check 2024-03-11 13:38:10 -04:00
Matt Nadareski
2371813175 Allow Ensure to take a null string 2024-03-11 13:35:34 -04:00
Matt Nadareski
99b3bb25f2 Make ParentablePath normalization clearer 2024-03-05 11:34:38 -05:00
Matt Nadareski
2ee40e341f Ensure paths are normalized on ParentablePath creation 2024-03-05 11:02:46 -05:00
Matt Nadareski
90ed6a9a65 Normalize paths to Windows for tests 2024-03-05 10:54:58 -05:00
Matt Nadareski
89e29c9cab Port tests from SabreTools 2024-03-05 10:45:31 -05:00
Matt Nadareski
1a6ff6d64f Move library to subfolder to prep for test additions 2024-03-05 10:40:45 -05:00
Matt Nadareski
878e9e97db Bump version 2024-03-05 10:17:31 -05:00
Matt Nadareski
09acfd3ad2 Parentable path output directory ensure 2024-03-05 10:15:16 -05:00
Matt Nadareski
8c2eff6e3e Trim ParentablePath pieces 2024-03-05 09:51:00 -05:00
Matt Nadareski
dbf8548d8c Use Matching to replace NaturalSort 2024-02-29 21:06:56 -05:00
Matt Nadareski
bcbf5bff42 Add notes about NaturalSort namespace 2024-02-29 00:25:02 -05:00
Matt Nadareski
c3c58f004a Update copyright date 2024-02-27 19:12:28 -05:00
Matt Nadareski
cf11fe50d0 Add nuget package and PR workflows 2024-02-27 19:12:17 -05:00
Matt Nadareski
5ddd1e4213 Use more lenient file reading 2023-12-13 15:44:16 -05:00
Matt Nadareski
75cc8376a8 Bump version 2023-11-21 11:24:19 -05:00
Matt Nadareski
0dea1fb437 Use Linq bridge library 2023-11-21 00:22:56 -05:00
Matt Nadareski
92df6b21e3 Support .NET Framework 2.0 2023-11-21 00:12:40 -05:00
Matt Nadareski
7da7967762 Support .NET Framework 3.5 2023-11-20 22:00:32 -05:00
Matt Nadareski
6c482ab98b Bump version 2023-11-14 13:46:34 -05:00
Matt Nadareski
8aaac551eb Cut off at .NET Framework 4.0 2023-11-08 10:47:42 -05:00
Matt Nadareski
026e2ee052 Move Linq-like methods 2023-11-08 01:37:27 -05:00
Matt Nadareski
9b3553e43f Support ancient .NET 2023-11-08 01:01:15 -05:00
Matt Nadareski
5221546af9 Expand supported RIDs 2023-11-07 23:31:59 -05:00
Matt Nadareski
658df0e91c Remove unnecessary csproj 2023-11-07 22:40:43 -05:00
Matt Nadareski
fd75f122d1 Enable latest language version 2023-11-07 22:04:05 -05:00
30 changed files with 1149 additions and 1121 deletions

49
.github/workflows/build_nupkg.yml vendored Normal file
View File

@@ -0,0 +1,49 @@
name: Nuget Pack
on:
push:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build library
run: dotnet build
- name: Run tests
run: dotnet test
- name: Pack
run: dotnet pack
- name: Upload build
uses: actions/upload-artifact@v4
with:
name: 'Nuget Package'
path: 'SabreTools.IO/bin/Release/*.nupkg'
- name: Upload to rolling
uses: ncipollo/release-action@v1.14.0
with:
allowUpdates: True
artifacts: 'SabreTools.IO/bin/Release/*.nupkg'
body: 'Last built commit: ${{ github.sha }}'
name: 'Rolling Release'
prerelease: True
replacesArtifacts: True
tag: "rolling"
updateOnlyUnreleased: True

20
.github/workflows/check_pr.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: Build PR
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Build
run: dotnet build
- name: Run tests
run: dotnet test

View File

@@ -1,376 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using SabreTools.IO.Readers;
using SabreTools.IO.Writers;
namespace SabreTools.IO
{
/// <summary>
/// Key-value pair INI file
/// </summary>
#if NET48
public class IniFile : IDictionary<string, string>
#else
public class IniFile : IDictionary<string, string?>
#endif
{
#if NET48
private Dictionary<string, string> _keyValuePairs = new Dictionary<string, string>();
#else
private Dictionary<string, string?>? _keyValuePairs = new Dictionary<string, string?>();
#endif
#if NET48
public string this[string key]
#else
public string? this[string? key]
#endif
{
get
{
if (_keyValuePairs == null)
#if NET48
_keyValuePairs = new Dictionary<string, string>();
#else
_keyValuePairs = new Dictionary<string, string?>();
#endif
key = key?.ToLowerInvariant() ?? string.Empty;
if (_keyValuePairs.ContainsKey(key))
return _keyValuePairs[key];
return null;
}
set
{
if (_keyValuePairs == null)
#if NET48
_keyValuePairs = new Dictionary<string, string>();
#else
_keyValuePairs = new Dictionary<string, string?>();
#endif
key = key?.ToLowerInvariant() ?? string.Empty;
_keyValuePairs[key] = value;
}
}
/// <summary>
/// Create an empty INI file
/// </summary>
public IniFile()
{
}
/// <summary>
/// Populate an INI file from path
/// </summary>
public IniFile(string path)
{
this.Parse(path);
}
/// <summary>
/// Populate an INI file from stream
/// </summary>
public IniFile(Stream stream)
{
this.Parse(stream);
}
/// <summary>
/// Add or update a key and value to the INI file
/// </summary>
public void AddOrUpdate(string key, string value)
{
this[key] = value;
}
/// <summary>
/// Remove a key from the INI file
/// </summary>
public bool Remove(string key)
{
if (_keyValuePairs != null && _keyValuePairs.ContainsKey(key))
{
_keyValuePairs.Remove(key.ToLowerInvariant());
return true;
}
return false;
}
/// <summary>
/// Read an INI file based on the path
/// </summary>
public bool Parse(string path)
{
// If we don't have a file, we can't read it
if (!File.Exists(path))
return false;
using (var fileStream = File.OpenRead(path))
{
return Parse(fileStream);
}
}
/// <summary>
/// Read an INI file from a stream
/// </summary>
#if NET48
public bool Parse(Stream stream)
#else
public bool Parse(Stream? stream)
#endif
{
// If the stream is invalid or unreadable, we can't process it
if (stream == null || !stream.CanRead || stream.Position >= stream.Length - 1)
return false;
// Keys are case-insensitive by default
try
{
using (var reader = new IniReader(stream, Encoding.UTF8))
{
// TODO: Can we use the section header in the reader?
#if NET48
string section = string.Empty;
#else
string? section = string.Empty;
#endif
while (!reader.EndOfStream)
{
// If we dont have a next line
if (!reader.ReadNextLine())
break;
// Process the row according to type
switch (reader.RowType)
{
case IniRowType.SectionHeader:
section = reader.Section;
break;
case IniRowType.KeyValue:
#if NET48
string key = reader.KeyValuePair?.Key;
#else
string? key = reader.KeyValuePair?.Key;
#endif
// Section names are prepended to the key with a '.' separating
if (!string.IsNullOrEmpty(section))
key = $"{section}.{key}";
// Set or overwrite keys in the returned dictionary
this[key] = reader.KeyValuePair?.Value;
break;
default:
// No-op
break;
}
}
}
}
catch
{
// We don't care what the error was, just catch and return
return false;
}
return true;
}
/// <summary>
/// Write an INI file to a path
/// </summary>
public bool Write(string path)
{
// If we don't have a valid dictionary with values, we can't write out
if (_keyValuePairs == null || _keyValuePairs.Count == 0)
return false;
using (var fileStream = File.OpenWrite(path))
{
return Write(fileStream);
}
}
/// <summary>
/// Write an INI file to a stream
/// </summary>
public bool Write(Stream stream)
{
// If we don't have a valid dictionary with values, we can't write out
if (_keyValuePairs == null || _keyValuePairs.Count == 0)
return false;
// If the stream is invalid or unwritable, we can't output to it
if (stream == null || !stream.CanWrite || stream.Position >= stream.Length - 1)
return false;
try
{
using (IniWriter writer = new IniWriter(stream, Encoding.UTF8))
{
// Order the dictionary by keys to link sections together
var orderedKeyValuePairs = _keyValuePairs.OrderBy(kvp => kvp.Key);
string section = string.Empty;
foreach (var keyValuePair in orderedKeyValuePairs)
{
// Extract the key and value
string key = keyValuePair.Key;
#if NET48
string value = keyValuePair.Value;
#else
string? value = keyValuePair.Value;
#endif
// We assume '.' is a section name separator
if (key.Contains('.'))
{
// Split the key by '.'
string[] data = keyValuePair.Key.Split('.');
// If the key contains an '.', we need to put them back in
string newSection = data[0].Trim();
key = string.Join(".", data.Skip(1)).Trim();
// If we have a new section, write it out
if (!string.Equals(newSection, section, StringComparison.OrdinalIgnoreCase))
{
writer.WriteSection(newSection);
section = newSection;
}
}
// Now write out the key and value in a standardized way
writer.WriteKeyValuePair(key, value);
}
}
}
catch
{
// We don't care what the error was, just catch and return
return false;
}
return true;
}
#region IDictionary Impelementations
#if NET48
public ICollection<string> Keys => _keyValuePairs?.Keys;
public ICollection<string> Values => _keyValuePairs?.Values;
public int Count => _keyValuePairs?.Count ?? 0;
public bool IsReadOnly => false;
public void Add(string key, string value) => this[key] = value;
bool IDictionary<string, string>.Remove(string key) => Remove(key);
public bool TryGetValue(string key, out string value)
{
value = null;
return _keyValuePairs?.TryGetValue(key.ToLowerInvariant(), out value) ?? false;
}
public void Add(KeyValuePair<string, string> item) => this[item.Key] = item.Value;
public void Clear() => _keyValuePairs?.Clear();
public bool Contains(KeyValuePair<string, string> item)
{
var newItem = new KeyValuePair<string, string>(item.Key.ToLowerInvariant(), item.Value);
return (_keyValuePairs as ICollection<KeyValuePair<string, string>>)?.Contains(newItem) ?? false;
}
public bool ContainsKey(string key) => _keyValuePairs?.ContainsKey(key?.ToLowerInvariant() ?? string.Empty) ?? false;
public void CopyTo(KeyValuePair<string, string>[] array, int arrayIndex)
{
(_keyValuePairs as ICollection<KeyValuePair<string, string>>)?.CopyTo(array, arrayIndex);
}
public bool Remove(KeyValuePair<string, string> item)
{
var newItem = new KeyValuePair<string, string>(item.Key.ToLowerInvariant(), item.Value);
return (_keyValuePairs as ICollection<KeyValuePair<string, string>>)?.Remove(newItem) ?? false;
}
public IEnumerator<KeyValuePair<string, string>> GetEnumerator()
{
return (_keyValuePairs as IEnumerable<KeyValuePair<string, string>>)?.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return (_keyValuePairs as IEnumerable)?.GetEnumerator();
}
#else
public ICollection<string> Keys => _keyValuePairs?.Keys?.ToArray() ?? Array.Empty<string>();
public ICollection<string?> Values => _keyValuePairs?.Values?.ToArray() ?? Array.Empty<string?>();
public int Count => (_keyValuePairs as ICollection<KeyValuePair<string, string>>)?.Count ?? 0;
public bool IsReadOnly => false;
public void Add(string key, string? value) => this[key] = value;
bool IDictionary<string, string?>.Remove(string key) => Remove(key);
public bool TryGetValue(string key, out string? value)
{
value = null;
return _keyValuePairs?.TryGetValue(key.ToLowerInvariant(), out value) ?? false;
}
public void Add(KeyValuePair<string, string?> item) => this[item.Key] = item.Value;
public void Clear() => _keyValuePairs?.Clear();
public bool Contains(KeyValuePair<string, string?> item)
{
var newItem = new KeyValuePair<string, string?>(item.Key.ToLowerInvariant(), item.Value);
return (_keyValuePairs as ICollection<KeyValuePair<string, string?>>)?.Contains(newItem) ?? false;
}
public bool ContainsKey(string? key) => _keyValuePairs?.ContainsKey(key?.ToLowerInvariant() ?? string.Empty) ?? false;
public void CopyTo(KeyValuePair<string, string?>[] array, int arrayIndex)
{
(_keyValuePairs as ICollection<KeyValuePair<string, string?>>)?.CopyTo(array, arrayIndex);
}
public bool Remove(KeyValuePair<string, string?> item)
{
var newItem = new KeyValuePair<string, string?>(item.Key.ToLowerInvariant(), item.Value);
return (_keyValuePairs as ICollection<KeyValuePair<string, string?>>)?.Remove(newItem) ?? false;
}
public IEnumerator<KeyValuePair<string, string?>> GetEnumerator()
{
return (_keyValuePairs as IEnumerable<KeyValuePair<string, string?>>)!.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return (_keyValuePairs as IEnumerable)!.GetEnumerator();
}
#endif
#endregion
}
}

View File

@@ -1,116 +0,0 @@
/*
*
* Links for info and original source code:
*
* https://blog.codinghorror.com/sorting-for-humans-natural-sort-order/
* http://www.codeproject.com/Articles/22517/Natural-Sort-Comparer
*
* Exact code implementation used with permission, originally by motoschifo
*
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
/// TODO: Make this namespace a separate library
namespace NaturalSort
{
internal class NaturalComparer : Comparer<string>, IDisposable
{
private readonly Dictionary<string, string[]> table;
public NaturalComparer()
{
table = new Dictionary<string, string[]>();
}
public void Dispose()
{
table.Clear();
}
#if NET48
public override int Compare(string x, string y)
#else
public override int Compare(string? x, string? y)
#endif
{
if (x == null || y == null)
{
if (x == null && y != null)
return -1;
else if (x != null && y == null)
return 1;
else
return 0;
}
if (x.ToLowerInvariant() == y.ToLowerInvariant())
{
return x.CompareTo(y);
}
#if NET48
if (!table.TryGetValue(x, out string[] x1))
#else
if (!table.TryGetValue(x, out string[]? x1))
#endif
{
//x1 = Regex.Split(x.Replace(" ", string.Empty), "([0-9]+)");
x1 = Regex.Split(x.ToLowerInvariant(), "([0-9]+)").Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
table.Add(x, x1);
}
#if NET48
if (!table.TryGetValue(y, out string[] y1))
#else
if (!table.TryGetValue(y, out string[]? y1))
#endif
{
//y1 = Regex.Split(y.Replace(" ", string.Empty), "([0-9]+)");
y1 = Regex.Split(y.ToLowerInvariant(), "([0-9]+)").Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
table.Add(y, y1);
}
for (int i = 0; i < x1.Length && i < y1.Length; i++)
{
if (x1[i] != y1[i])
{
return PartCompare(x1[i], y1[i]);
}
}
if (y1.Length > x1.Length)
{
return 1;
}
else if (x1.Length > y1.Length)
{
return -1;
}
else
{
return x.CompareTo(y);
}
}
private static int PartCompare(string left, string right)
{
if (!long.TryParse(left, out long x))
{
return NaturalComparerUtil.CompareNumeric(left, right);
}
if (!long.TryParse(right, out long y))
{
return NaturalComparerUtil.CompareNumeric(left, right);
}
// If we have an equal part, then make sure that "longer" ones are taken into account
if (x.CompareTo(y) == 0)
{
return left.Length - right.Length;
}
return x.CompareTo(y);
}
}
}

View File

@@ -1,79 +0,0 @@
using System.IO;
/// TODO: Make this namespace a separate library
namespace NaturalSort
{
internal static class NaturalComparerUtil
{
public static int CompareNumeric(string s1, string s2)
{
// Save the orginal strings, for later comparison
string s1orig = s1;
string s2orig = s2;
// We want to normalize the strings, so we set both to lower case
s1 = s1.ToLowerInvariant();
s2 = s2.ToLowerInvariant();
// If the strings are the same exactly, return
if (s1 == s2)
return s1orig.CompareTo(s2orig);
// If one is null, then say that's less than
if (s1 == null)
return -1;
if (s2 == null)
return 1;
// Now split into path parts after converting AltDirSeparator to DirSeparator
s1 = s1.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
s2 = s2.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
string[] s1parts = s1.Split(Path.DirectorySeparatorChar);
string[] s2parts = s2.Split(Path.DirectorySeparatorChar);
// Then compare each part in turn
for (int j = 0; j < s1parts.Length && j < s2parts.Length; j++)
{
int compared = CompareNumericPart(s1parts[j], s2parts[j]);
if (compared != 0)
return compared;
}
// If we got out here, then it looped through at least one of the strings
if (s1parts.Length > s2parts.Length)
return 1;
if (s1parts.Length < s2parts.Length)
return -1;
return s1orig.CompareTo(s2orig);
}
private static int CompareNumericPart(string s1, string s2)
{
// Otherwise, loop through until we have an answer
for (int i = 0; i < s1.Length && i < s2.Length; i++)
{
int s1c = s1[i];
int s2c = s2[i];
// If the characters are the same, continue
if (s1c == s2c)
continue;
// If they're different, check which one was larger
if (s1c > s2c)
return 1;
if (s1c < s2c)
return -1;
}
// If we got out here, then it looped through at least one of the strings
if (s1.Length > s2.Length)
return 1;
if (s1.Length < s2.Length)
return -1;
return 0;
}
}
}

View File

@@ -1,116 +0,0 @@
/*
*
* Links for info and original source code:
*
* https://blog.codinghorror.com/sorting-for-humans-natural-sort-order/
* http://www.codeproject.com/Articles/22517/Natural-Sort-Comparer
*
* Exact code implementation used with permission, originally by motoschifo
*
*/
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
/// TODO: Make this namespace a separate library
namespace NaturalSort
{
internal class NaturalReversedComparer : Comparer<string>, IDisposable
{
private readonly Dictionary<string, string[]> table;
public NaturalReversedComparer()
{
table = new Dictionary<string, string[]>();
}
public void Dispose()
{
table.Clear();
}
#if NET48
public override int Compare(string x, string y)
#else
public override int Compare(string? x, string? y)
#endif
{
if (x == null || y == null)
{
if (x == null && y != null)
return -1;
else if (x != null && y == null)
return 1;
else
return 0;
}
if (y.ToLowerInvariant() == x.ToLowerInvariant())
{
return y.CompareTo(x);
}
#if NET48
if (!table.TryGetValue(x, out string[] x1))
#else
if (!table.TryGetValue(x, out string[]? x1))
#endif
{
//x1 = Regex.Split(x.Replace(" ", string.Empty), "([0-9]+)");
x1 = Regex.Split(x.ToLowerInvariant(), "([0-9]+)").Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
table.Add(x, x1);
}
#if NET48
if (!table.TryGetValue(y, out string[] y1))
#else
if (!table.TryGetValue(y, out string[]? y1))
#endif
{
//y1 = Regex.Split(y.Replace(" ", string.Empty), "([0-9]+)");
y1 = Regex.Split(y.ToLowerInvariant(), "([0-9]+)").Where(s => !string.IsNullOrWhiteSpace(s)).ToArray();
table.Add(y, y1);
}
for (int i = 0; i < x1.Length && i < y1.Length; i++)
{
if (x1[i] != y1[i])
{
return PartCompare(x1[i], y1[i]);
}
}
if (y1.Length > x1.Length)
{
return 1;
}
else if (x1.Length > y1.Length)
{
return -1;
}
else
{
return y.CompareTo(x);
}
}
private static int PartCompare(string left, string right)
{
if (!long.TryParse(left, out long x))
{
return NaturalComparerUtil.CompareNumeric(right, left);
}
if (!long.TryParse(right, out long y))
{
return NaturalComparerUtil.CompareNumeric(right, left);
}
// If we have an equal part, then make sure that "longer" ones are taken into account
if (y.CompareTo(x) == 0)
{
return right.Length - left.Length;
}
return y.CompareTo(x);
}
}
}

View File

@@ -1,8 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net7.0</TargetFrameworks>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,23 @@
using Xunit;
namespace SabreTools.IO.Test
{
public class IOExtensionsTests
{
[Theory]
[InlineData(null, null)]
[InlineData("", null)]
[InlineData(" ", null)]
[InlineData("no-extension", null)]
[InlineData("NO-EXTENSION", null)]
[InlineData("no-extension.", null)]
[InlineData("NO-EXTENSION.", null)]
[InlineData("filename.ext", "ext")]
[InlineData("FILENAME.EXT", "ext")]
public void NormalizedExtensionTest(string? path, string? expected)
{
string? actual = path.GetNormalizedExtension();
Assert.Equal(expected, actual);
}
}
}

View File

@@ -0,0 +1,87 @@
using System;
using Xunit;
namespace SabreTools.IO.Test
{
public class ParentablePathTests
{
[Theory]
[InlineData("", null, false, null)]
[InlineData("", null, true, null)]
[InlineData(" ", null, false, null)]
[InlineData(" ", null, true, null)]
[InlineData("C:\\Directory\\Filename.ext", null, false, "Filename.ext")]
[InlineData("C:\\Directory\\Filename.ext", null, true, "Filename.ext")]
[InlineData("C:\\Directory\\Filename.ext", "C:\\Directory\\Filename.ext", false, "Filename.ext")]
[InlineData("C:\\Directory\\Filename.ext", "C:\\Directory\\Filename.ext", true, "Filename.ext")]
[InlineData("C:\\Directory\\SubDir\\Filename.ext", "C:\\Directory", false, "SubDir\\Filename.ext")]
[InlineData("C:\\Directory\\SubDir\\Filename.ext", "C:\\Directory", true, "SubDir-Filename.ext")]
public void NormalizedFileNameTest(string current, string? parent, bool sanitize, string? expected)
{
// Hack to support Windows paths on Linux for testing only
if (System.IO.Path.DirectorySeparatorChar == '/')
{
current = current.Replace('\\', '/');
parent = parent?.Replace('\\', '/');
expected = expected?.Replace('\\', '/');
}
var path = new ParentablePath(current, parent);
string? actual = path.GetNormalizedFileName(sanitize);
Assert.Equal(expected, actual);
}
[Theory]
[InlineData("", null, null, false, null)]
[InlineData("", null, null, true, null)]
[InlineData(" ", null, null, false, null)]
[InlineData(" ", null, null, true, null)]
[InlineData("C:\\Directory\\Filename.ext", null, null, false, null)]
[InlineData("C:\\Directory\\Filename.ext", null, null, true, "C:\\Directory")]
[InlineData("C:\\Directory\\Filename.ext", "C:\\Directory\\Filename.ext", null, false, null)]
[InlineData("C:\\Directory\\Filename.ext", "C:\\Directory\\Filename.ext", null, true, "C:\\Directory")]
[InlineData("C:\\Directory\\SubDir\\Filename.ext", "C:\\Directory", null, false, null)]
[InlineData("C:\\Directory\\SubDir\\Filename.ext", "C:\\Directory", null, true, "C:\\Directory\\SubDir")]
[InlineData("", null, "D:\\OutputDirectory", false, null)]
[InlineData("", null, "D:\\OutputDirectory", true, null)]
[InlineData(" ", null, "D:\\OutputDirectory", false, null)]
[InlineData(" ", null, "D:\\OutputDirectory", true, null)]
[InlineData("C:\\Directory\\Filename.ext", null, "D:\\OutputDirectory", false, "D:\\OutputDirectory")]
[InlineData("C:\\Directory\\Filename.ext", null, "D:\\OutputDirectory", true, "C:\\Directory")]
[InlineData("C:\\Directory\\Filename.ext", "C:\\Directory\\Filename.ext", "D:\\OutputDirectory", false, "D:\\OutputDirectory")]
[InlineData("C:\\Directory\\Filename.ext", "C:\\Directory\\Filename.ext", "D:\\OutputDirectory", true, "C:\\Directory")]
[InlineData("C:\\Directory\\SubDir\\Filename.ext", "C:\\Directory", "D:\\OutputDirectory", false, "D:\\OutputDirectory\\SubDir")]
[InlineData("C:\\Directory\\SubDir\\Filename.ext", "C:\\Directory", "D:\\OutputDirectory", true, "C:\\Directory\\SubDir")]
[InlineData("", null, "%cd%", false, null)]
[InlineData("", null, "%cd%", true, null)]
[InlineData(" ", null, "%cd%", false, null)]
[InlineData(" ", null, "%cd%", true, null)]
[InlineData("C:\\Directory\\Filename.ext", null, "%cd%", false, "%cd%")]
[InlineData("C:\\Directory\\Filename.ext", null, "%cd%", true, "C:\\Directory")]
[InlineData("C:\\Directory\\Filename.ext", "C:\\Directory\\Filename.ext", "%cd%", false, "%cd%")]
[InlineData("C:\\Directory\\Filename.ext", "C:\\Directory\\Filename.ext", "%cd%", true, "C:\\Directory")]
[InlineData("C:\\Directory\\SubDir\\Filename.ext", "C:\\Directory", "%cd%", false, "%cd%\\Directory\\SubDir")]
[InlineData("C:\\Directory\\SubDir\\Filename.ext", "C:\\Directory", "%cd%", true, "C:\\Directory\\SubDir")]
public void GetOutputPathTest(string current, string? parent, string? outDir, bool inplace, string? expected)
{
// Hacks because I can't use environment vars as parameters
if (outDir == "%cd%")
outDir = Environment.CurrentDirectory.TrimEnd('\\', '/');
if (expected?.Contains("%cd%") == true)
expected = expected.Replace("%cd%", Environment.CurrentDirectory.TrimEnd('\\', '/'));
// Hack to support Windows paths on Linux for testing only
if (System.IO.Path.DirectorySeparatorChar == '/')
{
current = current.Replace('\\', '/');
parent = parent?.Replace('\\', '/');
outDir = outDir?.Replace('\\', '/');
expected = expected?.Replace('\\', '/');
}
var path = new ParentablePath(current, parent);
string? actual = path.GetOutputPath(outDir, inplace);
Assert.Equal(expected, actual);
}
}
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.IO;
using Xunit;
namespace SabreTools.IO.Test
{
public class ReadOnlyCompositeStreamTests
{
[Fact]
public void DefaultConstructorTest()
{
var stream = new ReadOnlyCompositeStream();
Assert.Equal(0, stream.Length);
Assert.Equal(0, stream.Position);
}
[Fact]
public void EmptyArrayConstructorTest()
{
Stream[] arr = [new MemoryStream()];
var stream = new ReadOnlyCompositeStream(arr);
Assert.Equal(0, stream.Length);
Assert.Equal(0, stream.Position);
}
[Fact]
public void EmptyEnumerableConstructorTest()
{
// Empty enumerable constructor
List<Stream> list = [new MemoryStream()];
var stream = new ReadOnlyCompositeStream(list);
Assert.Equal(0, stream.Length);
Assert.Equal(0, stream.Position);
}
[Fact]
public void FilledArrayConstructorTest()
{
Stream[] arr = [new MemoryStream(new byte[1024]), new MemoryStream(new byte[1024])];
var stream = new ReadOnlyCompositeStream(arr);
Assert.Equal(2048, stream.Length);
Assert.Equal(0, stream.Position);
}
[Fact]
public void FilledEnumerableConstructorTest()
{
List<Stream> list = [new MemoryStream(new byte[1024]), new MemoryStream(new byte[1024])];
var stream = new ReadOnlyCompositeStream(list);
Assert.Equal(2048, stream.Length);
Assert.Equal(0, stream.Position);
}
[Fact]
public void AddStreamTest()
{
var stream = new ReadOnlyCompositeStream();
Assert.Equal(0, stream.Length);
Assert.Equal(0, stream.Position);
stream.AddStream(new MemoryStream(new byte[1024]));
Assert.Equal(1024, stream.Length);
Assert.Equal(0, stream.Position);
}
[Fact]
public void EmptyStreamReadTest()
{
var stream = new ReadOnlyCompositeStream();
byte[] buf = new byte[512];
Assert.Throws<ArgumentOutOfRangeException>(() => stream.Read(buf, 0, 512));
}
[Fact]
public void SingleStreamReadTest()
{
Stream[] arr = [new MemoryStream(new byte[1024])];
var stream = new ReadOnlyCompositeStream(arr);
byte[] buf = new byte[512];
int read = stream.Read(buf, 0, 512);
Assert.Equal(512, read);
}
[Fact]
public void MultipleStreamSingleContainedReadTest()
{
Stream[] arr = [new MemoryStream(new byte[1024]), new MemoryStream(new byte[1024])];
var stream = new ReadOnlyCompositeStream(arr);
byte[] buf = new byte[512];
int read = stream.Read(buf, 0, 512);
Assert.Equal(512, read);
}
[Fact]
public void MultipleStreamMultipleContainedReadTest()
{
Stream[] arr = [new MemoryStream(new byte[256]), new MemoryStream(new byte[256])];
var stream = new ReadOnlyCompositeStream(arr);
byte[] buf = new byte[512];
int read = stream.Read(buf, 0, 512);
Assert.Equal(512, read);
}
[Fact]
public void SingleStreamExtraReadTest()
{
Stream[] arr = [new MemoryStream(new byte[256])];
var stream = new ReadOnlyCompositeStream(arr);
byte[] buf = new byte[512];
int read = stream.Read(buf, 0, 512);
Assert.Equal(256, read);
}
[Fact]
public void MultipleStreamExtraReadTest()
{
Stream[] arr = [new MemoryStream(new byte[128]), new MemoryStream(new byte[128])];
var stream = new ReadOnlyCompositeStream(arr);
byte[] buf = new byte[512];
int read = stream.Read(buf, 0, 512);
Assert.Equal(256, read);
}
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net6.0;net8.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.6.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SabreTools.IO\SabreTools.IO.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Assembly Properties -->
<TargetFrameworks>net48;net6.0;net7.0;net8.0</TargetFrameworks>
<RuntimeIdentifiers>win-x86;win-x64;linux-x64;osx-x64</RuntimeIdentifiers>
<Version>1.1.1</Version>
<!-- Package Properties -->
<Authors>Matt Nadareski</Authors>
<Description>Common IO utilities by other SabreTools projects</Description>
<Copyright>Copyright (c) Matt Nadareski 2019-2023</Copyright>
<PackageProjectUrl>https://github.com/SabreTools/</PackageProjectUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/SabreTools/SabreTools.IO</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>io reading writing ini csv ssv tsv</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<PropertyGroup Condition="'$(TargetFramework)'!='net48'">
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath=""/>
</ItemGroup>
</Project>

View File

@@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SabreTools.IO", "SabreTools.IO.csproj", "{87CE4411-80D9-49FF-894C-761F1C20D9A5}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SabreTools.IO", "SabreTools.IO\SabreTools.IO.csproj", "{87CE4411-80D9-49FF-894C-761F1C20D9A5}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SabreTools.IO.Test", "SabreTools.IO.Test\SabreTools.IO.Test.csproj", "{A9767735-5042-48A1-849C-96035DB1DD53}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -18,5 +20,9 @@ Global
{87CE4411-80D9-49FF-894C-761F1C20D9A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{87CE4411-80D9-49FF-894C-761F1C20D9A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{87CE4411-80D9-49FF-894C-761F1C20D9A5}.Release|Any CPU.Build.0 = Release|Any CPU
{A9767735-5042-48A1-849C-96035DB1DD53}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A9767735-5042-48A1-849C-96035DB1DD53}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A9767735-5042-48A1-849C-96035DB1DD53}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A9767735-5042-48A1-849C-96035DB1DD53}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -74,7 +74,7 @@ namespace SabreTools.IO
int i3 = BitConverter.ToInt32(retval, 8);
int i4 = BitConverter.ToInt32(retval, 12);
return new decimal(new int[] { i1, i2, i3, i4 });
return new decimal([i1, i2, i3, i4]);
}
/// <summary>

View File

@@ -15,11 +15,7 @@ namespace SabreTools.IO
/// </summary>
public static byte ReadByte(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 1);
#else
byte[]? buffer = content.ReadBytes(ref offset, 1);
#endif
if (buffer == null)
return default;
@@ -29,11 +25,7 @@ namespace SabreTools.IO
/// <summary>
/// Read a UInt8[] and increment the pointer to an array
/// </summary>
#if NET48
public static byte[] ReadBytes(this byte[] content, ref int offset, int count)
#else
public static byte[]? ReadBytes(this byte[]? content, ref int offset, int count)
#endif
{
// If the byte array is invalid, don't do anything
if (content == null)
@@ -64,11 +56,7 @@ namespace SabreTools.IO
/// </summary>
public static sbyte ReadSByte(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 1);
#else
byte[]? buffer = content.ReadBytes(ref offset, 1);
#endif
if (buffer == null)
return default;
@@ -80,11 +68,7 @@ namespace SabreTools.IO
/// </summary>
public static char ReadChar(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 1);
#else
byte[]? buffer = content.ReadBytes(ref offset, 1);
#endif
if (buffer == null)
return default;
@@ -96,11 +80,7 @@ namespace SabreTools.IO
/// </summary>
public static short ReadInt16(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 2);
#else
byte[]? buffer = content.ReadBytes(ref offset, 2);
#endif
if (buffer == null)
return default;
@@ -112,11 +92,7 @@ namespace SabreTools.IO
/// </summary>
public static short ReadInt16BigEndian(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 2);
#else
byte[]? buffer = content.ReadBytes(ref offset, 2);
#endif
if (buffer == null)
return default;
@@ -129,11 +105,7 @@ namespace SabreTools.IO
/// </summary>
public static ushort ReadUInt16(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 2);
#else
byte[]? buffer = content.ReadBytes(ref offset, 2);
#endif
if (buffer == null)
return default;
@@ -145,11 +117,7 @@ namespace SabreTools.IO
/// </summary>
public static ushort ReadUInt16BigEndian(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 2);
#else
byte[]? buffer = content.ReadBytes(ref offset, 2);
#endif
if (buffer == null)
return default;
@@ -162,11 +130,7 @@ namespace SabreTools.IO
/// </summary>
public static int ReadInt32(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 4);
#else
byte[]? buffer = content.ReadBytes(ref offset, 4);
#endif
if (buffer == null)
return default;
@@ -178,11 +142,7 @@ namespace SabreTools.IO
/// </summary>
public static int ReadInt32BigEndian(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 4);
#else
byte[]? buffer = content.ReadBytes(ref offset, 4);
#endif
if (buffer == null)
return default;
@@ -195,11 +155,7 @@ namespace SabreTools.IO
/// </summary>
public static uint ReadUInt32(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 4);
#else
byte[]? buffer = content.ReadBytes(ref offset, 4);
#endif
if (buffer == null)
return default;
@@ -211,11 +167,7 @@ namespace SabreTools.IO
/// </summary>
public static uint ReadUInt32BigEndian(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 4);
#else
byte[]? buffer = content.ReadBytes(ref offset, 4);
#endif
if (buffer == null)
return default;
@@ -228,11 +180,7 @@ namespace SabreTools.IO
/// </summary>
public static long ReadInt64(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 8);
#else
byte[]? buffer = content.ReadBytes(ref offset, 8);
#endif
if (buffer == null)
return default;
@@ -244,11 +192,7 @@ namespace SabreTools.IO
/// </summary>
public static long ReadInt64BigEndian(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 8);
#else
byte[]? buffer = content.ReadBytes(ref offset, 8);
#endif
if (buffer == null)
return default;
@@ -261,11 +205,7 @@ namespace SabreTools.IO
/// </summary>
public static ulong ReadUInt64(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 8);
#else
byte[]? buffer = content.ReadBytes(ref offset, 8);
#endif
if (buffer == null)
return default;
@@ -277,11 +217,7 @@ namespace SabreTools.IO
/// </summary>
public static ulong ReadUInt64BigEndian(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 8);
#else
byte[]? buffer = content.ReadBytes(ref offset, 8);
#endif
if (buffer == null)
return default;
@@ -294,11 +230,7 @@ namespace SabreTools.IO
/// </summary>
public static Guid ReadGuid(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 16);
#else
byte[]? buffer = content.ReadBytes(ref offset, 16);
#endif
if (buffer == null)
return default;
@@ -310,11 +242,7 @@ namespace SabreTools.IO
/// </summary>
public static Guid ReadGuidBigEndian(this byte[] content, ref int offset)
{
#if NET48
byte[] buffer = content.ReadBytes(ref offset, 16);
#else
byte[]? buffer = content.ReadBytes(ref offset, 16);
#endif
if (buffer == null)
return default;
@@ -325,20 +253,12 @@ namespace SabreTools.IO
/// <summary>
/// Read a null-terminated string from the stream
/// </summary>
#if NET48
public static string ReadString(this byte[] content, ref int offset) => content.ReadString(ref offset, Encoding.Default);
#else
public static string? ReadString(this byte[] content, ref int offset) => content.ReadString(ref offset, Encoding.Default);
#endif
/// <summary>
/// Read a null-terminated string from the stream
/// </summary>
#if NET48
public static string ReadString(this byte[] content, ref int offset, Encoding encoding)
#else
public static string? ReadString(this byte[] content, ref int offset, Encoding encoding)
#endif
{
if (offset >= content.Length)
return null;
@@ -346,7 +266,7 @@ namespace SabreTools.IO
byte[] nullTerminator = encoding.GetBytes(new char[] { '\0' });
int charWidth = nullTerminator.Length;
List<char> keyChars = new List<char>();
var keyChars = new List<char>();
while (offset < content.Length)
{
char c = encoding.GetChars(content, offset, charWidth)[0];
@@ -357,7 +277,7 @@ namespace SabreTools.IO
break;
}
return new string(keyChars.ToArray()).TrimEnd('\0');
return new string([.. keyChars]).TrimEnd('\0');
}
}
}

View File

@@ -16,14 +16,14 @@ namespace SabreTools.IO
/// <param name="dir">Directory to check</param>
/// <param name="create">True if the directory should be created, false otherwise (default)</param>
/// <returns>Full path to the directory</returns>
public static string Ensure(this string dir, bool create = false)
public static string Ensure(this string? dir, bool create = false)
{
// If the output directory is invalid
if (string.IsNullOrWhiteSpace(dir))
if (string.IsNullOrEmpty(dir))
dir = PathTool.GetRuntimeDirectory();
// Get the full path for the output directory
dir = Path.GetFullPath(dir.Trim('"'));
dir = Path.GetFullPath(dir!.Trim('"'));
// If we're creating the output folder, do so
if (create && !Directory.Exists(dir))
@@ -50,7 +50,7 @@ namespace SabreTools.IO
// Try to open the file
try
{
FileStream file = File.OpenRead(filename);
FileStream file = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
if (file == null)
return Encoding.Default;
@@ -60,7 +60,7 @@ namespace SabreTools.IO
file.Dispose();
// Disable warning about UTF7 usage
#pragma warning disable SYSLIB0001
#pragma warning disable SYSLIB0001
// Analyze the BOM
if (bom[0] == 0x2b && bom[1] == 0x2f && bom[2] == 0x76) return Encoding.UTF7;
@@ -70,7 +70,7 @@ namespace SabreTools.IO
if (bom[0] == 0 && bom[1] == 0 && bom[2] == 0xfe && bom[3] == 0xff) return Encoding.UTF32;
return Encoding.Default;
#pragma warning restore SYSLIB0001
#pragma warning restore SYSLIB0001
}
catch
{
@@ -83,29 +83,21 @@ namespace SabreTools.IO
/// </summary>
/// <param name="path">Path to get extension from</param>
/// <returns>Extension, if possible</returns>
#if NET48
public static string GetNormalizedExtension(this string path)
#else
public static string? GetNormalizedExtension(this string? path)
#endif
{
// Check null or empty first
if (string.IsNullOrWhiteSpace(path))
if (string.IsNullOrEmpty(path))
return null;
// Get the extension from the path, if possible
#if NET48
string ext = Path.GetExtension(path)?.ToLowerInvariant();
#else
string? ext = Path.GetExtension(path)?.ToLowerInvariant();
#endif
// Check if the extension is null or empty
if (string.IsNullOrWhiteSpace(ext))
if (string.IsNullOrEmpty(ext))
return null;
// Make sure that extensions are valid
ext = ext.TrimStart('.');
ext = ext!.TrimStart('.');
return ext;
}
@@ -115,11 +107,7 @@ namespace SabreTools.IO
/// </summary>
/// <param name="root">Root directory to parse</param>
/// <returns>IEumerable containing all directories that are empty, an empty enumerable if the root is empty, null otherwise</returns>
#if NET48
public static List<string> ListEmpty(this string root)
#else
public static List<string>? ListEmpty(this string? root)
#endif
{
// Check null or empty first
if (string.IsNullOrEmpty(root))
@@ -130,13 +118,23 @@ namespace SabreTools.IO
return null;
// If it does and it is empty, return a blank enumerable
#if NET20 || NET35
if (!Directory.GetFiles(root, "*", SearchOption.AllDirectories).Any())
#else
if (!Directory.EnumerateFileSystemEntries(root, "*", SearchOption.AllDirectories).Any())
return new List<string>();
#endif
return [];
// Otherwise, get the complete list
#if NET20 || NET35
return Directory.GetDirectories(root, "*", SearchOption.AllDirectories)
.Where(dir => !Directory.GetFiles(dir, "*", SearchOption.AllDirectories).Any())
.ToList();
#else
return Directory.EnumerateDirectories(root, "*", SearchOption.AllDirectories)
.Where(dir => !Directory.EnumerateFileSystemEntries(dir, "*", SearchOption.AllDirectories).Any())
.ToList();
#endif
}
}
}

277
SabreTools.IO/IniFile.cs Normal file
View File

@@ -0,0 +1,277 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using SabreTools.IO.Readers;
using SabreTools.IO.Writers;
namespace SabreTools.IO
{
/// <summary>
/// Key-value pair INI file
/// </summary>
public class IniFile : IDictionary<string, string?>
{
private Dictionary<string, string?>? _keyValuePairs = [];
public string? this[string? key]
{
get
{
_keyValuePairs ??= [];
key = key?.ToLowerInvariant() ?? string.Empty;
if (_keyValuePairs.ContainsKey(key))
return _keyValuePairs[key];
return null;
}
set
{
_keyValuePairs ??= [];
key = key?.ToLowerInvariant() ?? string.Empty;
_keyValuePairs[key] = value;
}
}
/// <summary>
/// Create an empty INI file
/// </summary>
public IniFile()
{
}
/// <summary>
/// Populate an INI file from path
/// </summary>
public IniFile(string path)
{
this.Parse(path);
}
/// <summary>
/// Populate an INI file from stream
/// </summary>
public IniFile(Stream stream)
{
this.Parse(stream);
}
/// <summary>
/// Add or update a key and value to the INI file
/// </summary>
public void AddOrUpdate(string key, string value)
{
this[key] = value;
}
/// <summary>
/// Remove a key from the INI file
/// </summary>
public bool Remove(string key)
{
if (_keyValuePairs != null && _keyValuePairs.ContainsKey(key))
{
_keyValuePairs.Remove(key.ToLowerInvariant());
return true;
}
return false;
}
/// <summary>
/// Read an INI file based on the path
/// </summary>
public bool Parse(string path)
{
// If we don't have a file, we can't read it
if (!File.Exists(path))
return false;
using var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
return Parse(fileStream);
}
/// <summary>
/// Read an INI file from a stream
/// </summary>
public bool Parse(Stream? stream)
{
// If the stream is invalid or unreadable, we can't process it
if (stream == null || !stream.CanRead || stream.Position >= stream.Length - 1)
return false;
// Keys are case-insensitive by default
try
{
// TODO: Can we use the section header in the reader?
using var reader = new IniReader(stream, Encoding.UTF8);
string? section = string.Empty;
while (!reader.EndOfStream)
{
// If we dont have a next line
if (!reader.ReadNextLine())
break;
// Process the row according to type
switch (reader.RowType)
{
case IniRowType.SectionHeader:
section = reader.Section;
break;
case IniRowType.KeyValue:
string? key = reader.KeyValuePair?.Key;
// Section names are prepended to the key with a '.' separating
if (!string.IsNullOrEmpty(section))
key = $"{section}.{key}";
// Set or overwrite keys in the returned dictionary
this[key] = reader.KeyValuePair?.Value;
break;
default:
// No-op
break;
}
}
}
catch
{
// We don't care what the error was, just catch and return
return false;
}
return true;
}
/// <summary>
/// Write an INI file to a path
/// </summary>
public bool Write(string path)
{
// If we don't have a valid dictionary with values, we can't write out
if (_keyValuePairs == null || _keyValuePairs.Count == 0)
return false;
using var fileStream = File.OpenWrite(path);
return Write(fileStream);
}
/// <summary>
/// Write an INI file to a stream
/// </summary>
public bool Write(Stream stream)
{
// If we don't have a valid dictionary with values, we can't write out
if (_keyValuePairs == null || _keyValuePairs.Count == 0)
return false;
// If the stream is invalid or unwritable, we can't output to it
if (stream == null || !stream.CanWrite || stream.Position >= stream.Length - 1)
return false;
try
{
using IniWriter writer = new(stream, Encoding.UTF8);
// Order the dictionary by keys to link sections together
var orderedKeyValuePairs = _keyValuePairs.OrderBy(kvp => kvp.Key);
string section = string.Empty;
foreach (var keyValuePair in orderedKeyValuePairs)
{
// Extract the key and value
string key = keyValuePair.Key;
string? value = keyValuePair.Value;
// We assume '.' is a section name separator
if (key.Contains("."))
{
// Split the key by '.'
string[] data = keyValuePair.Key.Split('.');
// If the key contains an '.', we need to put them back in
string newSection = data[0].Trim();
key = string.Join(".", data.Skip(1).ToArray()).Trim();
// If we have a new section, write it out
if (!string.Equals(newSection, section, StringComparison.OrdinalIgnoreCase))
{
writer.WriteSection(newSection);
section = newSection;
}
}
// Now write out the key and value in a standardized way
writer.WriteKeyValuePair(key, value);
}
}
catch
{
// We don't care what the error was, just catch and return
return false;
}
return true;
}
#region IDictionary Impelementations
public ICollection<string> Keys => _keyValuePairs?.Keys?.ToArray() ?? [];
public ICollection<string?> Values => _keyValuePairs?.Values?.ToArray() ?? [];
public int Count => (_keyValuePairs as ICollection<KeyValuePair<string, string>>)?.Count ?? 0;
public bool IsReadOnly => false;
public void Add(string key, string? value) => this[key] = value;
bool IDictionary<string, string?>.Remove(string key) => Remove(key);
public bool TryGetValue(string key, out string? value)
{
value = null;
return _keyValuePairs?.TryGetValue(key.ToLowerInvariant(), out value) ?? false;
}
public void Add(KeyValuePair<string, string?> item) => this[item.Key] = item.Value;
public void Clear() => _keyValuePairs?.Clear();
public bool Contains(KeyValuePair<string, string?> item)
{
var newItem = new KeyValuePair<string, string?>(item.Key.ToLowerInvariant(), item.Value);
return (_keyValuePairs as ICollection<KeyValuePair<string, string?>>)?.Contains(newItem) ?? false;
}
public bool ContainsKey(string? key) => _keyValuePairs?.ContainsKey(key?.ToLowerInvariant() ?? string.Empty) ?? false;
public void CopyTo(KeyValuePair<string, string?>[] array, int arrayIndex)
{
(_keyValuePairs as ICollection<KeyValuePair<string, string?>>)?.CopyTo(array, arrayIndex);
}
public bool Remove(KeyValuePair<string, string?> item)
{
var newItem = new KeyValuePair<string, string?>(item.Key.ToLowerInvariant(), item.Value);
return (_keyValuePairs as ICollection<KeyValuePair<string, string?>>)?.Remove(newItem) ?? false;
}
public IEnumerator<KeyValuePair<string, string?>> GetEnumerator()
{
return (_keyValuePairs as IEnumerable<KeyValuePair<string, string?>>)!.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return (_keyValuePairs as IEnumerable)!.GetEnumerator();
}
#endregion
}
}

View File

@@ -11,29 +11,17 @@ namespace SabreTools.IO
/// <summary>
/// Current full path represented
/// </summary>
#if NET48
public string CurrentPath { get; private set; }
#else
public string CurrentPath { get; init; }
#endif
/// <summary>
/// Possible parent path represented (may be null or empty)
/// </summary>
#if NET48
public string ParentPath { get; private set; }
#else
public string? ParentPath { get; init; }
#endif
public string? ParentPath { get; private set; }
#if NET48
public ParentablePath(string currentPath, string parentPath = null)
#else
public ParentablePath(string currentPath, string? parentPath = null)
#endif
{
CurrentPath = currentPath;
ParentPath = parentPath;
CurrentPath = currentPath.Trim();
ParentPath = parentPath?.Trim();
}
/// <summary>
@@ -41,26 +29,22 @@ namespace SabreTools.IO
/// </summary>
/// <param name="sanitize">True if path separators should be converted to '-', false otherwise</param>
/// <returns>Subpath for the file</returns>
#if NET48
public string GetNormalizedFileName(bool sanitize)
#else
public string? GetNormalizedFileName(bool sanitize)
#endif
{
// If the current path is empty, we can't do anything
if (string.IsNullOrWhiteSpace(CurrentPath))
if (string.IsNullOrEmpty(CurrentPath))
return null;
// Assume the current path is the filename
string filename = Path.GetFileName(CurrentPath);
// If we have a true ParentPath, remove it from CurrentPath and return the remainder
if (!string.IsNullOrWhiteSpace(ParentPath) && !string.Equals(CurrentPath, ParentPath, StringComparison.Ordinal))
filename = CurrentPath.Remove(0, ParentPath.Length + 1);
if (!string.IsNullOrEmpty(ParentPath) && !PathsEqual(CurrentPath, ParentPath))
filename = CurrentPath.Remove(0, ParentPath!.Length + 1);
// If we're sanitizing the path after, do so
if (sanitize)
filename = filename.Replace(Path.DirectorySeparatorChar, '-').Replace(Path.AltDirectorySeparatorChar, '-');
filename = filename.Replace('\\', '-').Replace('/', '-');
return filename;
}
@@ -71,22 +55,19 @@ namespace SabreTools.IO
/// <param name="outDir">Output directory to use</param>
/// <param name="inplace">True if the output file should go to the same input folder, false otherwise</param>
/// <returns>Complete output path</returns>
#if NET48
public string GetOutputPath(string outDir, bool inplace)
#else
public string? GetOutputPath(string outDir, bool inplace)
#endif
public string? GetOutputPath(string? outDir, bool inplace)
{
// If the current path is empty, we can't do anything
if (string.IsNullOrWhiteSpace(CurrentPath))
if (string.IsNullOrEmpty(CurrentPath))
return null;
// If the output dir is empty (and we're not inplace), we can't do anything
if (string.IsNullOrWhiteSpace(outDir) && !inplace)
outDir = outDir?.Trim();
if (string.IsNullOrEmpty(outDir) && !inplace)
return null;
// Check if we have a split path or not
bool splitpath = !string.IsNullOrWhiteSpace(ParentPath);
bool splitpath = !string.IsNullOrEmpty(ParentPath);
// If we have an inplace output, use the directory name from the input path
if (inplace)
@@ -103,19 +84,53 @@ namespace SabreTools.IO
// If we are processing a path that is coming from a directory and we are outputting to the current directory, we want to get the subfolder to write to
if (outDir == Environment.CurrentDirectory)
workingParent = Path.GetDirectoryName(ParentPath ?? string.Empty) ?? string.Empty;
// Handle bizarre Windows-like paths on Linux
if (workingParent.EndsWith(":") && Path.DirectorySeparatorChar == '/')
workingParent += '/';
// Determine the correct subfolder based on the working parent directory
#if NET48
int extraLength = workingParent.EndsWith(":")
|| workingParent.EndsWith(Path.DirectorySeparatorChar.ToString())
|| workingParent.EndsWith(Path.AltDirectorySeparatorChar.ToString()) ? 0 : 1;
#else
int extraLength = workingParent.EndsWith(':')
|| workingParent.EndsWith(Path.DirectorySeparatorChar)
|| workingParent.EndsWith(Path.AltDirectorySeparatorChar) ? 0 : 1;
#endif
|| workingParent.EndsWith("\\")
|| workingParent.EndsWith("/") ? 0 : 1;
return Path.GetDirectoryName(Path.Combine(outDir, CurrentPath.Remove(0, workingParent.Length + extraLength)));
string strippedPath = CurrentPath.Remove(0, workingParent.Length + extraLength);
string combinedPath = Path.Combine(outDir!, strippedPath);
return Path.GetDirectoryName(combinedPath);
}
/// <summary>
/// Determine if two paths are equal or not
/// </summary>
private static bool PathsEqual(string? path1, string? path2, bool caseSenstive = false)
{
// Handle null path cases
if (path1 == null && path2 == null)
return true;
else if (path1 == null ^ path2 == null)
return false;
// Normalize the paths before comparing
path1 = NormalizeDirectorySeparators(path1);
path2 = NormalizeDirectorySeparators(path2);
// Compare and return
return string.Equals(path1, path2, caseSenstive ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Normalize directory separators for the current system
/// </summary>
/// <param name="input">Input path that may contain separators</param>
/// <returns>Normalized path with separators fixed, if possible</returns>
private static string? NormalizeDirectorySeparators(string? input)
{
// Null inputs are skipped
if (input == null)
return null;
// Replace alternate directory separators with the correct one
return input.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
}
}
}

View File

@@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NaturalSort;
using SabreTools.Matching;
namespace SabreTools.IO
{
@@ -29,14 +28,10 @@ namespace SabreTools.IO
// If we have a wildcard
string pattern = "*";
if (input.Contains('*') || input.Contains('?'))
if (input.Contains("*") || input.Contains("?"))
{
pattern = Path.GetFileName(input);
#if NET48
input = input.Substring(0, input.Length - pattern.Length);
#else
input = input[..^pattern.Length];
#endif
}
// Get the parent path in case of appending
@@ -62,7 +57,7 @@ namespace SabreTools.IO
/// <returns>List with all new files</returns>
private static List<string> GetDirectoriesOrdered(string dir, string pattern = "*")
{
return GetDirectoriesOrderedHelper(dir, new List<string>(), pattern);
return GetDirectoriesOrderedHelper(dir, [], pattern);
}
/// <summary>
@@ -75,7 +70,7 @@ namespace SabreTools.IO
private static List<string> GetDirectoriesOrderedHelper(string dir, List<string> infiles, string pattern)
{
// Take care of the files in the top directory
List<string> toadd = Directory.EnumerateDirectories(dir, pattern, SearchOption.TopDirectoryOnly).ToList();
List<string> toadd = [.. Directory.GetDirectories(dir, pattern, SearchOption.TopDirectoryOnly)];
toadd.Sort(new NaturalComparer());
infiles.AddRange(toadd);
@@ -108,14 +103,10 @@ namespace SabreTools.IO
// If we have a wildcard
string pattern = "*";
if (input.Contains('*') || input.Contains('?'))
if (input.Contains("*") || input.Contains("?"))
{
pattern = Path.GetFileName(input);
#if NET48
input = input.Substring(0, input.Length - pattern.Length);
#else
input = input[..^pattern.Length];
#endif
}
// Get the parent path in case of appending
@@ -145,7 +136,7 @@ namespace SabreTools.IO
/// <returns>List with all new files</returns>
public static List<string> GetFilesOrdered(string dir, string pattern = "*")
{
return GetFilesOrderedHelper(dir, new List<string>(), pattern);
return GetFilesOrderedHelper(dir, [], pattern);
}
/// <summary>
@@ -158,12 +149,12 @@ namespace SabreTools.IO
private static List<string> GetFilesOrderedHelper(string dir, List<string> infiles, string pattern)
{
// Take care of the files in the top directory
List<string> toadd = Directory.EnumerateFiles(dir, pattern, SearchOption.TopDirectoryOnly).ToList();
List<string> toadd = [.. Directory.GetFiles(dir, pattern, SearchOption.TopDirectoryOnly)];
toadd.Sort(new NaturalComparer());
infiles.AddRange(toadd);
// Then recurse through and add from the directories
List<string> subDirs = Directory.EnumerateDirectories(dir, pattern, SearchOption.TopDirectoryOnly).ToList();
List<string> subDirs = [.. Directory.GetDirectories(dir, pattern, SearchOption.TopDirectoryOnly)];
subDirs.Sort(new NaturalComparer());
foreach (string subdir in subDirs)
{
@@ -173,7 +164,7 @@ namespace SabreTools.IO
// Return the new list
return infiles;
}
/// <summary>
/// Get the current runtime directory
/// </summary>

View File

@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace SabreTools.IO
{
/// <summary>
/// Read-only stream wrapper around multiple, consecutive streams
/// </summary>
public class ReadOnlyCompositeStream : Stream
{
#region Properties
/// <inheritdoc/>
public override bool CanRead => true;
/// <inheritdoc/>
public override bool CanSeek => true;
/// <inheritdoc/>
public override bool CanWrite => false;
/// <inheritdoc/>
public override long Length => _length;
/// <inheritdoc/>
public override long Position
{
get => _position;
set
{
_position = value;
if (_position < 0)
_position = 0;
else if (_position >= _length)
_position = _length - 1;
}
}
#endregion
#region Internal State
/// <summary>
/// Internal collection of streams to read from
/// </summary>
private readonly List<Stream> _streams;
/// <summary>
/// Total length of all internal streams
/// </summary>
private long _length;
/// <summary>
/// Overall position in the stream wrapper
/// </summary>
private long _position;
#endregion
/// <summary>
/// Create a new, empty ReadOnlyCompositeStream
/// </summary>
public ReadOnlyCompositeStream()
{
_streams = [];
_length = 0;
_position = 0;
}
/// <summary>
/// Create a new ReadOnlyCompositeStream from an existing collection of Streams
/// </summary>
public ReadOnlyCompositeStream(Stream[] streams)
{
_streams = [.. streams];
_length = 0;
_position = 0;
// Verify the streams and add to the length
foreach (var stream in streams)
{
if (!stream.CanRead || !stream.CanSeek)
throw new ArgumentException($"All members of {nameof(streams)} need to be readable and seekable");
_length += stream.Length;
}
}
/// <summary>
/// Create a new ReadOnlyCompositeStream from an existing collection of Streams
/// </summary>
public ReadOnlyCompositeStream(IEnumerable<Stream> streams)
{
_streams = streams.ToList();
_length = 0;
_position = 0;
// Verify the streams and add to the length
foreach (var stream in streams)
{
if (!stream.CanRead || !stream.CanSeek)
throw new ArgumentException($"All members of {nameof(streams)} need to be readable and seekable");
_length += stream.Length;
}
}
/// <summary>
/// Add a new stream to the collection
/// </summary>
public bool AddStream(Stream stream)
{
// Verify the stream
if (!stream.CanRead || !stream.CanSeek)
return false;
// Add the stream to the end
_streams.Add(stream);
_length += stream.Length;
return true;
}
#region Stream Implementations
/// <inheritdoc/>
public override void Flush() => throw new NotImplementedException();
/// <inheritdoc/>
public override int Read(byte[] buffer, int offset, int count)
{
// Determine which stream we start reading from
(int streamIndex, long streamOffset) = DetermineStreamIndex(offset);
if (streamIndex == -1)
throw new ArgumentOutOfRangeException(nameof(offset));
// Determine if the stream fully contains the requested segment
bool singleStream = StreamContains(streamIndex, streamOffset, count);
// If we can read from a single stream
if (singleStream)
{
_position += count;
_streams[streamIndex].Seek(streamOffset, SeekOrigin.Begin);
return _streams[streamIndex].Read(buffer, offset, count);
}
// For all other cases, we read until there's no more
int readBytes = 0, originalCount = count;
while (readBytes < originalCount)
{
// Determine how much can be read from the current stream
long currentBytes = _streams[streamIndex].Length - streamOffset;
int shouldRead = Math.Min((int)currentBytes, count);
// Read from the current stream
_position += shouldRead;
_streams[streamIndex].Seek(streamOffset, SeekOrigin.Begin);
readBytes += _streams[streamIndex].Read(buffer, offset, shouldRead);
// Update the read variables
offset += shouldRead;
count -= shouldRead;
// Move to the next stream
streamIndex++;
streamOffset = 0;
// Validate the next stream exists
if (streamIndex >= _streams.Count)
break;
}
// Return the number of bytes that could be read
return readBytes;
}
/// <inheritdoc/>
public override long Seek(long offset, SeekOrigin origin)
{
// Handle the "seek"
switch (origin)
{
case SeekOrigin.Begin: _position = offset; break;
case SeekOrigin.Current: _position += offset; break;
case SeekOrigin.End: _position = _length - offset - 1; break;
default: throw new ArgumentException($"Invalid value for {nameof(origin)}");
};
// Handle out-of-bounds seeks
if (_position < 0)
_position = 0;
else if (_position >= _length)
_position = _length - 1;
return _position;
}
/// <inheritdoc/>
public override void SetLength(long value) => throw new NotImplementedException();
/// <inheritdoc/>
public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException();
#endregion
#region Helpers
/// <summary>
/// Determine the index of the stream that contains a particular offset
/// </summary>
/// <returns>Index of the stream containing the offset and the real offset in the stream, (-1, -1) on error</returns>
private (int index, long realOffset) DetermineStreamIndex(int offset)
{
// If the offset is out of bounds
if (offset < 0 || offset >= _length)
return (-1, -1);
// Seek through until we hit the correct offset
long currentLength = 0;
for (int i = 0; i < _streams.Count; i++)
{
currentLength += _streams[i].Length;
if (currentLength > offset)
{
long realOffset = offset - (currentLength - _streams[i].Length);
return (i, realOffset);
}
}
// Should never happen
return (-1, -1);
}
/// <summary>
/// Determines if a stream contains a particular segment
/// </summary>
private bool StreamContains(int streamIndex, long offset, int length)
{
// Ensure the arguments are valid
if (streamIndex < 0 || streamIndex >= _streams.Count)
throw new ArgumentOutOfRangeException(nameof(streamIndex));
if (offset < 0 || offset >= _streams[streamIndex].Length)
throw new ArgumentOutOfRangeException(nameof(offset));
// Handle the general case
return _streams[streamIndex].Length - offset >= length;
}
#endregion
}
}

View File

@@ -23,20 +23,12 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Internal stream reader for inputting
/// </summary>
#if NET48
private readonly StreamReader sr;
#else
private readonly StreamReader? sr;
#endif
/// <summary>
/// Contents of the current line, unprocessed
/// </summary>
#if NET48
public string CurrentLine { get; private set; } = string.Empty;
#else
public string? CurrentLine { get; private set; } = string.Empty;
#endif
/// <summary>
/// Get the current line number
@@ -57,20 +49,12 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Contents of the currently read line as an internal item
/// </summary>
#if NET48
public Dictionary<string, string> Internal { get; private set; } = new Dictionary<string, string>();
#else
public Dictionary<string, string>? Internal { get; private set; } = new Dictionary<string, string>();
#endif
public Dictionary<string, string>? Internal { get; private set; } = [];
/// <summary>
/// Current internal item name
/// </summary>
#if NET48
public string InternalName { get; private set; }
#else
public string? InternalName { get; private set; }
#endif
/// <summary>
/// Get if we should be making DosCenter exceptions
@@ -101,11 +85,7 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Current top-level being read
/// </summary>
#if NET48
public string TopLevel { get; private set; } = string.Empty;
#else
public string? TopLevel { get; private set; } = string.Empty;
#endif
/// <summary>
/// Constructor for opening a write from a file
@@ -151,11 +131,7 @@ namespace SabreTools.IO.Readers
// Standalone (special case for DC dats)
if (CurrentLine.StartsWith("Name:"))
{
#if NET48
string temp = CurrentLine.Substring("Name:".Length).Trim();
#else
string temp = CurrentLine["Name:".Length..].Trim();
#endif
CurrentLine = $"Name: {temp}";
}
@@ -188,11 +164,11 @@ namespace SabreTools.IO.Readers
string normalizedValue = gc[1].Value.ToLowerInvariant();
string[] linegc = SplitLineAsCMP(gc[2].Value);
Internal = new Dictionary<string, string>();
Internal = [];
for (int i = 0; i < linegc.Length; i++)
{
string key = linegc[i].Replace("\"", string.Empty);
if (string.IsNullOrWhiteSpace(key))
if (string.IsNullOrEmpty(key))
continue;
string value = string.Empty;

View File

@@ -11,11 +11,7 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Internal stream reader for inputting
/// </summary>
#if NET48
private readonly StreamReader sr;
#else
private readonly StreamReader? sr;
#endif
/// <summary>
/// Get if at end of stream
@@ -36,11 +32,7 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Contents of the current line, unprocessed
/// </summary>
#if NET48
public string CurrentLine { get; private set; } = string.Empty;
#else
public string? CurrentLine { get; private set; } = string.Empty;
#endif
/// <summary>
/// Get the current line number
@@ -55,11 +47,7 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Current section being read
/// </summary>
#if NET48
public string Section { get; private set; } = string.Empty;
#else
public string? Section { get; private set; } = string.Empty;
#endif
/// <summary>
/// Validate that rows are in key=value format
@@ -123,14 +111,14 @@ namespace SabreTools.IO.Readers
}
// KeyValuePair
else if (CurrentLine.Contains('='))
else if (CurrentLine.Contains("="))
{
// Split the line by '=' for key-value pairs
string[] data = CurrentLine.Split('=');
// If the value field contains an '=', we need to put them back in
string key = data[0].Trim();
string value = string.Join("=", data.Skip(1)).Trim();
string value = string.Join("=", data.Skip(1).ToArray()).Trim();
KeyValuePair = new KeyValuePair<string, string>(key, value);
RowType = IniRowType.KeyValue;

View File

@@ -12,11 +12,7 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Internal stream reader for inputting
/// </summary>
#if NET48
private readonly StreamReader sr;
#else
private readonly StreamReader? sr;
#endif
/// <summary>
/// Internal value to say how many fields should be written
@@ -37,11 +33,7 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Contents of the current line, unprocessed
/// </summary>
#if NET48
public string CurrentLine { get; private set; } = string.Empty;
#else
public string? CurrentLine { get; private set; } = string.Empty;
#endif
/// <summary>
/// Get the current line number
@@ -56,20 +48,12 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Header row values
/// </summary>
#if NET48
public List<string> HeaderValues { get; set; } = null;
#else
public List<string>? HeaderValues { get; set; } = null;
#endif
/// <summary>
/// Get the current line values
/// </summary>
#if NET48
public List<string> Line { get; private set; } = null;
#else
public List<string>? Line { get; private set; } = null;
#endif
/// <summary>
/// Assume that values are wrapped in quotes
@@ -127,11 +111,7 @@ namespace SabreTools.IO.Readers
if (!sr.BaseStream.CanRead || sr.EndOfStream)
return false;
#if NET48
string fullLine = sr.ReadLine();
#else
string? fullLine = sr.ReadLine();
#endif
CurrentLine = fullLine;
LineNumber++;
@@ -144,9 +124,11 @@ namespace SabreTools.IO.Readers
// https://stackoverflow.com/questions/3776458/split-a-comma-separated-string-with-both-quoted-and-unquoted-strings
var lineSplitRegex = new Regex($"(?:^|{Separator})(\"(?:[^\"]+|\"\")*\"|[^{Separator}]*)");
var temp = new List<string>();
foreach (Match match in lineSplitRegex.Matches(fullLine))
foreach (Match? match in lineSplitRegex.Matches(fullLine).Cast<Match?>())
{
string curr = match.Value;
string? curr = match?.Value;
if (curr == null)
continue;
if (curr.Length == 0)
temp.Add("");
@@ -157,7 +139,7 @@ namespace SabreTools.IO.Readers
Line = temp;
}
// Otherwise, just split on the delimiter
else
{
@@ -181,11 +163,7 @@ namespace SabreTools.IO.Readers
/// <summary>
/// Get the value for the current line for the current key
/// </summary>
#if NET48
public string GetValue(string key)
#else
public string? GetValue(string key)
#endif
{
// No header means no key-based indexing
if (!Header)

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Assembly Properties -->
<TargetFrameworks>net20;net35;net40;net452;net462;net472;net48;netcoreapp3.1;net5.0;net6.0;net7.0;net8.0</TargetFrameworks>
<RuntimeIdentifiers>win-x86;win-x64;win-arm64;linux-x64;linux-arm64;osx-x64;osx-arm64</RuntimeIdentifiers>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Version>1.3.4</Version>
<!-- Package Properties -->
<Authors>Matt Nadareski</Authors>
<Description>Common IO utilities by other SabreTools projects</Description>
<Copyright>Copyright (c) Matt Nadareski 2019-2024</Copyright>
<PackageProjectUrl>https://github.com/SabreTools/</PackageProjectUrl>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/SabreTools/SabreTools.IO</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageTags>io reading writing ini csv ssv tsv</PackageTags>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include="../README.md" Pack="true" PackagePath=""/>
</ItemGroup>
<!-- Support for old .NET versions -->
<ItemGroup Condition="$(TargetFramework.StartsWith(`net2`)) OR $(TargetFramework.StartsWith(`net3`))">
<PackageReference Include="Net30.LinqBridge" Version="1.3.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SabreTools.Matching" Version="1.3.1" />
</ItemGroup>
</Project>

View File

@@ -17,36 +17,22 @@ namespace SabreTools.IO
/// </summary>
public static byte ReadByteValue(this Stream stream)
{
byte[] buffer = new byte[1];
stream.Read(buffer, 0, 1);
byte[] buffer = ReadToBuffer(stream, 1);
return buffer[0];
}
/// <summary>
/// Read a UInt8[] from the stream
/// </summary>
#if NET48
public static byte[] ReadBytes(this Stream stream, int count)
#else
public static byte[]? ReadBytes(this Stream stream, int count)
#endif
{
// If there's an invalid byte count, don't do anything
if (count <= 0)
return null;
byte[] buffer = new byte[count];
stream.Read(buffer, 0, count);
return buffer;
}
=> ReadToBuffer(stream, count);
/// <summary>
/// Read a Int8 from the stream
/// </summary>
public static sbyte ReadSByte(this Stream stream)
{
byte[] buffer = new byte[1];
stream.Read(buffer, 0, 1);
byte[] buffer = ReadToBuffer(stream, 1);
return (sbyte)buffer[0];
}
@@ -55,8 +41,7 @@ namespace SabreTools.IO
/// </summary>
public static char ReadChar(this Stream stream)
{
byte[] buffer = new byte[1];
stream.Read(buffer, 0, 1);
byte[] buffer = ReadToBuffer(stream, 1);
return (char)buffer[0];
}
@@ -65,8 +50,7 @@ namespace SabreTools.IO
/// </summary>
public static short ReadInt16(this Stream stream)
{
byte[] buffer = new byte[2];
stream.Read(buffer, 0, 2);
byte[] buffer = ReadToBuffer(stream, 2);
return BitConverter.ToInt16(buffer, 0);
}
@@ -75,8 +59,7 @@ namespace SabreTools.IO
/// </summary>
public static short ReadInt16BigEndian(this Stream stream)
{
byte[] buffer = new byte[2];
stream.Read(buffer, 0, 2);
byte[] buffer = ReadToBuffer(stream, 2);
Array.Reverse(buffer);
return BitConverter.ToInt16(buffer, 0);
}
@@ -86,8 +69,7 @@ namespace SabreTools.IO
/// </summary>
public static ushort ReadUInt16(this Stream stream)
{
byte[] buffer = new byte[2];
stream.Read(buffer, 0, 2);
byte[] buffer = ReadToBuffer(stream, 2);
return BitConverter.ToUInt16(buffer, 0);
}
@@ -96,8 +78,7 @@ namespace SabreTools.IO
/// </summary>
public static ushort ReadUInt16BigEndian(this Stream stream)
{
byte[] buffer = new byte[2];
stream.Read(buffer, 0, 2);
byte[] buffer = ReadToBuffer(stream, 2);
Array.Reverse(buffer);
return BitConverter.ToUInt16(buffer, 0);
}
@@ -107,8 +88,7 @@ namespace SabreTools.IO
/// </summary>
public static int ReadInt32(this Stream stream)
{
byte[] buffer = new byte[4];
stream.Read(buffer, 0, 4);
byte[] buffer = ReadToBuffer(stream, 4);
return BitConverter.ToInt32(buffer, 0);
}
@@ -117,8 +97,7 @@ namespace SabreTools.IO
/// </summary>
public static int ReadInt32BigEndian(this Stream stream)
{
byte[] buffer = new byte[4];
stream.Read(buffer, 0, 4);
byte[] buffer = ReadToBuffer(stream, 4);
Array.Reverse(buffer);
return BitConverter.ToInt32(buffer, 0);
}
@@ -128,8 +107,7 @@ namespace SabreTools.IO
/// </summary>
public static uint ReadUInt32(this Stream stream)
{
byte[] buffer = new byte[4];
stream.Read(buffer, 0, 4);
byte[] buffer = ReadToBuffer(stream, 4);
return BitConverter.ToUInt32(buffer, 0);
}
@@ -138,8 +116,7 @@ namespace SabreTools.IO
/// </summary>
public static uint ReadUInt32BigEndian(this Stream stream)
{
byte[] buffer = new byte[4];
stream.Read(buffer, 0, 4);
byte[] buffer = ReadToBuffer(stream, 4);
Array.Reverse(buffer);
return BitConverter.ToUInt32(buffer, 0);
}
@@ -149,8 +126,7 @@ namespace SabreTools.IO
/// </summary>
public static long ReadInt64(this Stream stream)
{
byte[] buffer = new byte[8];
stream.Read(buffer, 0, 8);
byte[] buffer = ReadToBuffer(stream, 8);
return BitConverter.ToInt64(buffer, 0);
}
@@ -159,8 +135,7 @@ namespace SabreTools.IO
/// </summary>
public static long ReadInt64BigEndian(this Stream stream)
{
byte[] buffer = new byte[8];
stream.Read(buffer, 0, 8);
byte[] buffer = ReadToBuffer(stream, 8);
Array.Reverse(buffer);
return BitConverter.ToInt64(buffer, 0);
}
@@ -170,8 +145,7 @@ namespace SabreTools.IO
/// </summary>
public static ulong ReadUInt64(this Stream stream)
{
byte[] buffer = new byte[8];
stream.Read(buffer, 0, 8);
byte[] buffer = ReadToBuffer(stream, 8);
return BitConverter.ToUInt64(buffer, 0);
}
@@ -180,8 +154,7 @@ namespace SabreTools.IO
/// </summary>
public static ulong ReadUInt64BigEndian(this Stream stream)
{
byte[] buffer = new byte[8];
stream.Read(buffer, 0, 8);
byte[] buffer = ReadToBuffer(stream, 8);
Array.Reverse(buffer);
return BitConverter.ToUInt64(buffer, 0);
}
@@ -191,8 +164,7 @@ namespace SabreTools.IO
/// </summary>
public static Guid ReadGuid(this Stream stream)
{
byte[] buffer = new byte[16];
stream.Read(buffer, 0, 16);
byte[] buffer = ReadToBuffer(stream, 16);
return new Guid(buffer);
}
@@ -201,37 +173,69 @@ namespace SabreTools.IO
/// </summary>
public static Guid ReadGuidBigEndian(this Stream stream)
{
byte[] buffer = new byte[16];
stream.Read(buffer, 0, 16);
byte[] buffer = ReadToBuffer(stream, 16);
Array.Reverse(buffer);
return new Guid(buffer);
}
#if NET7_0_OR_GREATER
/// <summary>
/// Read a null-terminated string from the stream
/// Read a Int128 from the stream
/// </summary>
#if NET48
public static string ReadString(this Stream stream) => stream.ReadString(Encoding.Default);
#else
public static string? ReadString(this Stream stream) => stream.ReadString(Encoding.Default);
public static Int128 ReadInt128(this Stream stream)
{
byte[] buffer = ReadToBuffer(stream, 16);
return new Int128(BitConverter.ToUInt64(buffer, 0), BitConverter.ToUInt64(buffer, 8));
}
/// <summary>
/// Read a Int128 from the stream in big-endian format
/// </summary>
public static Int128 ReadInt128BigEndian(this Stream stream)
{
byte[] buffer = ReadToBuffer(stream, 16);
Array.Reverse(buffer);
return new Int128(BitConverter.ToUInt64(buffer, 0), BitConverter.ToUInt64(buffer, 8));
}
/// <summary>
/// Read a UInt128 from the stream
/// </summary>
public static UInt128 ReadUInt128(this Stream stream)
{
byte[] buffer = ReadToBuffer(stream, 16);
return new UInt128(BitConverter.ToUInt64(buffer, 0), BitConverter.ToUInt64(buffer, 8));
}
/// <summary>
/// Read a UInt128 from the stream in big-endian format
/// </summary>
public static UInt128 ReadUInt128BigEndian(this Stream stream)
{
byte[] buffer = ReadToBuffer(stream, 16);
Array.Reverse(buffer);
return new UInt128(BitConverter.ToUInt64(buffer, 0), BitConverter.ToUInt64(buffer, 8));
}
#endif
/// <summary>
/// Read a null-terminated string from the stream
/// </summary>
#if NET48
public static string ReadString(this Stream stream, Encoding encoding)
#else
public static string? ReadString(this Stream stream)
=> stream.ReadString(Encoding.Default);
/// <summary>
/// Read a null-terminated string from the stream
/// </summary>
public static string? ReadString(this Stream stream, Encoding encoding)
#endif
{
if (stream.Position >= stream.Length)
return null;
byte[] nullTerminator = encoding.GetBytes(new char[] { '\0' });
byte[] nullTerminator = encoding.GetBytes("\0");
int charWidth = nullTerminator.Length;
List<byte> tempBuffer = new List<byte>();
var tempBuffer = new List<byte>();
byte[] buffer = new byte[charWidth];
while (stream.Position < stream.Length && stream.Read(buffer, 0, charWidth) != 0 && !buffer.SequenceEqual(nullTerminator))
@@ -239,7 +243,46 @@ namespace SabreTools.IO
tempBuffer.AddRange(buffer);
}
return encoding.GetString(tempBuffer.ToArray());
return encoding.GetString([.. tempBuffer]);
}
/// <summary>
/// Read a string that is terminated by a newline but contains a quoted portion that
/// may also contain a newline from the stream
/// </summary>
public static string? ReadQuotedString(this Stream stream)
=> stream.ReadQuotedString(Encoding.Default);
/// <summary>
/// Read a string that is terminated by a newline but contains a quoted portion that
/// may also contain a newline from the stream
/// </summary>
public static string? ReadQuotedString(this Stream stream, Encoding encoding)
{
if (stream.Position >= stream.Length)
return null;
var bytes = new List<byte>();
bool openQuote = false;
while (stream.Position < stream.Length)
{
// Read the byte value
byte b = stream.ReadByteValue();
// If we have a quote, flip the flag
if (b == (byte)'"')
openQuote = !openQuote;
// If we have a newline not in a quoted string, exit the loop
else if (b == (byte)'\n' && !openQuote)
break;
// Add the byte to the set
bytes.Add(b);
}
var line = encoding.GetString([.. bytes]);
return line.TrimEnd();
}
/// <summary>
@@ -280,5 +323,27 @@ namespace SabreTools.IO
return -1;
}
}
/// <summary>
/// Read a number of bytes from the current Stream to a buffer
/// </summary>
private static byte[] ReadToBuffer(Stream stream, int length)
{
// If we have an invalid length
if (length < 0)
throw new ArgumentOutOfRangeException($"{nameof(length)} must be 0 or a positive value");
// Handle the 0-byte case
if (length == 0)
return [];
// Handle the general case, forcing a read of the correct length
byte[] buffer = new byte[length];
int read = stream.Read(buffer, 0, length);
if (read < length)
throw new EndOfStreamException(nameof(stream));
return buffer;
}
}
}

View File

@@ -44,20 +44,6 @@ namespace SabreTools.IO.Writers
/// <summary>
/// Tag information for the stack
/// </summary>
#if NET48
private class TagInfo
{
public string Name { get; set; }
public bool Mixed { get; set; }
public void Init()
{
Name = null;
Mixed = false;
}
}
#else
private record struct TagInfo(string? Name, bool Mixed)
{
public void Init()
@@ -66,7 +52,6 @@ namespace SabreTools.IO.Writers
Mixed = false;
}
}
#endif
/// <summary>
/// Internal stream writer
@@ -86,7 +71,7 @@ namespace SabreTools.IO.Writers
/// <summary>
/// State table for determining the state machine
/// </summary>
private readonly State[] stateTable = {
private readonly State[] stateTable = [
// State.Start State.Prolog State.Element State.Attribute State.Content State.AttrOnly State.Epilog
//
/* Token.None */ State.Prolog, State.Prolog, State.Content, State.Content, State.Content, State.Error, State.Epilog,
@@ -97,7 +82,7 @@ namespace SabreTools.IO.Writers
/* Token.StartAttribute */ State.AttrOnly, State.Error, State.Attribute, State.Attribute, State.Error, State.Error, State.Error,
/* Token.EndAttribute */ State.Error, State.Error, State.Error, State.Element, State.Error, State.Epilog, State.Error,
/* Token.Content */ State.Content, State.Content, State.Content, State.Attribute, State.Content, State.Attribute, State.Epilog,
};
];
/// <summary>
/// Current state in the machine
@@ -185,11 +170,7 @@ namespace SabreTools.IO.Writers
/// <summary>
/// Write a complete element with content
/// </summary>
#if NET48
public void WriteElementString(string name, string value)
#else
public void WriteElementString(string name, string? value)
#endif
{
WriteStartElement(name);
WriteString(value);
@@ -202,11 +183,7 @@ namespace SabreTools.IO.Writers
/// <param name="name">Name of the element</param>
/// <param name="value">Value to write in the element</param>
/// <param name="throwOnError">Indicates if an error should be thrown on a missing required value</param>
#if NET48
public void WriteRequiredElementString(string name, string value, bool throwOnError = false)
#else
public void WriteRequiredElementString(string name, string? value, bool throwOnError = false)
#endif
{
// Throw an exception if we are configured to
if (value == null && throwOnError)
@@ -220,11 +197,7 @@ namespace SabreTools.IO.Writers
/// </summary>
/// <param name="name">Name of the element</param>
/// <param name="value">Value to write in the element</param>
#if NET48
public void WriteOptionalElementString(string name, string value)
#else
public void WriteOptionalElementString(string name, string? value)
#endif
{
if (!string.IsNullOrEmpty(value))
WriteElementString(name, value);
@@ -277,11 +250,7 @@ namespace SabreTools.IO.Writers
/// <param name="name">Name of the attribute</param>
/// <param name="value">Value to write in the attribute</param>
/// <param name="quoteOverride">Non-null to overwrite the writer setting, null otherwise</param>
#if NET48
public void WriteAttributeString(string name, string value, bool? quoteOverride = null)
#else
public void WriteAttributeString(string name, string? value, bool? quoteOverride = null)
#endif
{
WriteStartAttribute(name, quoteOverride);
WriteString(value);
@@ -295,11 +264,7 @@ namespace SabreTools.IO.Writers
/// <param name="value">Value to write in the attribute</param>
/// <param name="quoteOverride">Non-null to overwrite the writer setting, null otherwise</param>
/// <param name="throwOnError">Indicates if an error should be thrown on a missing required value</param>
#if NET48
public void WriteRequiredAttributeString(string name, string value, bool? quoteOverride = null, bool throwOnError = false)
#else
public void WriteRequiredAttributeString(string name, string? value, bool? quoteOverride = null, bool throwOnError = false)
#endif
{
// Throw an exception if we are configured to
if (value == null && throwOnError)
@@ -314,11 +279,7 @@ namespace SabreTools.IO.Writers
/// <param name="name">Name of the attribute</param>
/// <param name="value">Value to write in the attribute</param>
/// <param name="quoteOverride">Non-null to overwrite the writer setting, null otherwise</param>
#if NET48
public void WriteOptionalAttributeString(string name, string value, bool? quoteOverride = null)
#else
public void WriteOptionalAttributeString(string name, string? value, bool? quoteOverride = null)
#endif
{
if (!string.IsNullOrEmpty(value))
WriteAttributeString(name, value, quoteOverride);
@@ -330,11 +291,7 @@ namespace SabreTools.IO.Writers
/// <param name="name">Name of the attribute</param>
/// <param name="value">Value to write in the attribute</param>
/// <param name="quoteOverride">Non-null to overwrite the writer setting, null otherwise</param>
#if NET48
public void WriteStandalone(string name, string value, bool? quoteOverride = null)
#else
public void WriteStandalone(string name, string? value, bool? quoteOverride = null)
#endif
{
try
{
@@ -378,11 +335,7 @@ namespace SabreTools.IO.Writers
/// <param name="value">Value to write in the attribute</param>
/// <param name="quoteOverride">Non-null to overwrite the writer setting, null otherwise</param>
/// <param name="throwOnError">Indicates if an error should be thrown on a missing required value</param>
#if NET48
public void WriteRequiredStandalone(string name, string value, bool? quoteOverride = null, bool throwOnError = false)
#else
public void WriteRequiredStandalone(string name, string? value, bool? quoteOverride = null, bool throwOnError = false)
#endif
{
// Throw an exception if we are configured to
if (value == null && throwOnError)
@@ -397,11 +350,7 @@ namespace SabreTools.IO.Writers
/// <param name="name">Name of the attribute</param>
/// <param name="value">Value to write in the attribute</param>
/// <param name="quoteOverride">Non-null to overwrite the writer setting, null otherwise</param>
#if NET48
public void WriteOptionalStandalone(string name, string value, bool? quoteOverride = null)
#else
public void WriteOptionalStandalone(string name, string? value, bool? quoteOverride = null)
#endif
{
if (!string.IsNullOrEmpty(value))
WriteStandalone(name, value, quoteOverride);
@@ -410,11 +359,7 @@ namespace SabreTools.IO.Writers
/// <summary>
/// Write a string content value
/// </summary>
#if NET48
public void WriteString(string value)
#else
public void WriteString(string? value)
#endif
{
try
{
@@ -424,7 +369,7 @@ namespace SabreTools.IO.Writers
// If we're writing quotes, don't write out quote characters internally
if (Quotes)
value = value.Replace("\"", "''");
value = value!.Replace("\"", "''");
sw.Write(value);
}

View File

@@ -9,11 +9,7 @@ namespace SabreTools.IO.Writers
/// <summary>
/// Internal stream writer for outputting
/// </summary>
#if NET48
private readonly StreamWriter sw;
#else
private readonly StreamWriter? sw;
#endif
/// <summary>
/// Constructor for writing to a file
@@ -34,81 +30,53 @@ namespace SabreTools.IO.Writers
/// <summary>
/// Write a section tag
/// </summary>
#if NET48
public void WriteSection(string value)
#else
public void WriteSection(string? value)
#endif
{
if (sw?.BaseStream == null)
return;
if (string.IsNullOrWhiteSpace(value))
if (string.IsNullOrEmpty(value))
throw new ArgumentException("Section tag cannot be null or empty", nameof(value));
sw.WriteLine($"[{value.TrimStart('[').TrimEnd(']')}]");
sw.WriteLine($"[{value!.TrimStart('[').TrimEnd(']')}]");
}
/// <summary>
/// Write a key value pair
/// </summary>
#if NET48
public void WriteKeyValuePair(string key, string value)
#else
public void WriteKeyValuePair(string key, string? value)
#endif
{
if (sw?.BaseStream == null)
return;
if (string.IsNullOrWhiteSpace(key))
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
#if NET48
value = value != null ? value : string.Empty;
#else
value ??= string.Empty;
#endif
sw.WriteLine($"{key}={value}");
}
/// <summary>
/// Write a comment
/// </summary>
#if NET48
public void WriteComment(string value)
#else
public void WriteComment(string? value)
#endif
{
if (sw?.BaseStream == null)
return;
#if NET48
value = value != null ? value : string.Empty;
#else
value ??= string.Empty;
#endif
sw.WriteLine($";{value}");
}
/// <summary>
/// Write a generic string
/// </summary>
#if NET48
public void WriteString(string value)
#else
public void WriteString(string? value)
#endif
{
if (sw?.BaseStream == null)
return;
#if NET48
value = value != null ? value : string.Empty;
#else
value ??= string.Empty;
#endif
sw.Write(value);
}
@@ -119,7 +87,7 @@ namespace SabreTools.IO.Writers
{
if (sw?.BaseStream == null)
return;
sw.WriteLine();
}

View File

@@ -60,11 +60,7 @@ namespace SabreTools.IO.Writers
/// <summary>
/// Write a header row
/// </summary>
#if NET48
public void WriteHeader(string[] headers)
#else
public void WriteHeader(string?[] headers)
#endif
{
// If we haven't written anything out, we can write headers
if (!header && !firstRow)
@@ -76,11 +72,7 @@ namespace SabreTools.IO.Writers
/// <summary>
/// Write a value row
/// </summary>
#if NET48
public void WriteValues(object[] values, bool newline = true)
#else
public void WriteValues(object?[] values, bool newline = true)
#endif
{
// If the writer can't be used, we error
if (sw == null || !sw.BaseStream.CanWrite)