FileSystemics.SpanIO 0.5.0

dotnet add package FileSystemics.SpanIO --version 0.5.0
                    
NuGet\Install-Package FileSystemics.SpanIO -Version 0.5.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="FileSystemics.SpanIO" Version="0.5.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="FileSystemics.SpanIO" Version="0.5.0" />
                    
Directory.Packages.props
<PackageReference Include="FileSystemics.SpanIO" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add FileSystemics.SpanIO --version 0.5.0
                    
#r "nuget: FileSystemics.SpanIO, 0.5.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package FileSystemics.SpanIO@0.5.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=FileSystemics.SpanIO&version=0.5.0
                    
Install as a Cake Addin
#tool nuget:?package=FileSystemics.SpanIO&version=0.5.0
                    
Install as a Cake Tool

FileSystemics: Span based IO and Abstractions

Span-first System.IO alternatives for .NET. Path parameters are ReadOnlySpan<char> so hot paths can avoid string allocations and unnecessary garbage collection while still calling native filesystem APIs.

About

System.IO is built around strings. Strings for names, strings for extensions, combinations of strings for paths and formatted file names. Fine for most, but this can become expensive in very big data applications with high memory pressure. When hot paths cause GC churn in multithreaded systems everything slows down.

FileSystemics was created for those workloads — log sinks, render and asset pipelines, scanners, build tools, sync agents, and test harnesses that touch many files per second. The core idea is simple: keep paths as ReadOnlySpan<char> through join, normalize, enumerate, and open, and let the caller supply Span<char> scratch space (or rent a char[] when paths exceed the stack threshold) instead of forcing intermediate strings on every call.

This bypasses a lot of the BCL internal logic and there are other micro-performance tradeoffs. We're trading some nanoseconds for memory stability across a complex system, which should improve overall speed.

Caveat artifex.

Packages

Core span-based path, file, directory, and drive APIs (zero dependencies)

dotnet add package FileSystemics.SpanIO

Abstractions, mockables, custom platforms.

dotnet add package FileSystemics.SpanIO.Platform

Abstraction layers

Three entry points serve different concerns; pick the narrowest one for your use case:

Layer When to use
SpanPath / SpanFile / SpanDirectory Hot paths, libraries, zero DI — static APIs on ReadOnlySpan<char>
ISpanFileSystem (SpanFileSystem.Default) App and test code with dependency injection and mocks
IPlatformHost / Platform.Use Custom path rules or native I/O backends (orthogonal to DI)

SpanFileSystem.Default still respects Platform.Use because adapters delegate to static Span* APIs.

Usage Examples

Static APIs (SpanPath, SpanFile, SpanDirectory)

File-based logging

High-volume loggers should not Path.Combine on every write. Join at sink construction, open the handle once, and append each ReadOnlySpan<char> line without transcoding — the in-memory char span is written as UTF-16LE bytes via MemoryMarshal.AsBytes.

using System.Runtime.InteropServices;
using FileSystemics.IO;
using Microsoft.Win32.SafeHandles;

public sealed class FileLogSink : IDisposable {
    private readonly SafeFileHandle _handle;
    private long _offset;

    public FileLogSink(ReadOnlySpan<char> logDir) {
        Span<char> logPath = stackalloc char[logDir.Length + 8];
        SpanPath.TryJoin(logDir, "app.log".AsSpan(), logPath, out int length);
        _handle = SpanFile.OpenHandle(
            logPath[..length], FileMode.Append, FileAccess.Write, FileShare.Read);
        _offset = RandomAccess.GetLength(_handle);
    }

    public void Write(ReadOnlySpan<char> line) {
        ReadOnlySpan<byte> bytes = MemoryMarshal.AsBytes(line);
        RandomAccess.Write(_handle, bytes, _offset);
        _offset += bytes.Length;
    }

    public void Write(string line) => Write(line.AsSpan());

    public void Dispose() => _handle.Dispose();
}

Alternatively:

public sealed class TextLogSink : IDisposable {
    private readonly StreamWriter _writer;

    public TextLogSink(ReadOnlySpan<char> logDir) {
        Span<char> logPath = stackalloc char[logDir.Length + 8];
        SpanPath.TryJoin(logDir, "app.log".AsSpan(), logPath, out int length);
        _writer = SpanFile.AppendText(logPath[..length]);
    }

    public void Write(ReadOnlySpan<char> line) => _writer.Write(line);

    public void Write(string line) => _writer.WriteLine(line);

    public void Dispose() => _writer.Dispose();
}
Log retention

Nightly jobs delete or compress rotated logs. Walk the directory as spans; filter on the file-name prefix before touching disk metadata.

ReadOnlySpan<char> logDir = settings.LogDirectory.AsSpan();
ReadOnlySpan<char> pattern = "*.log".AsSpan();
ReadOnlySpan<char> rotatedPrefix = "app-".AsSpan();
Span<char> buffer = stackalloc char[SpanDirectory.GetEntryPathBufferCapacity(logDir)];

foreach (ReadOnlySpan<char> path in SpanDirectory.EnumerateFiles(logDir, buffer, pattern)) {
    ReadOnlySpan<char> name = SpanPath.GetFileName(path);
    if (name.StartsWith(rotatedPrefix) && SpanFile.Exists(path)) {
        CompressOrDelete(path);
    }
}
Render-farm frame paths

Compositor output paths share a fixed shape; only the frame index changes.

ReadOnlySpan<char> outputDir = job.OutputDirectory.AsSpan();
ReadOnlySpan<char> fileName = "frame_000000.exr".AsSpan();
Span<char> framePath = stackalloc char[outputDir.Length + fileName.Length + 1];

SpanPath.TryJoin(outputDir, fileName, framePath, out int pathLength);
int digitOffset = pathLength - 10; // six digits immediately before ".exr"

for (int frame = 0; frame < job.FrameCount; frame++) {
    frame.TryFormat(framePath[digitOffset..(digitOffset + 6)], out _, format: "D6");
    using FileStream output = SpanFile.OpenWrite(framePath[..pathLength]);
    WriteFrame(output, frame);
}
Texture catalog scan

A watch folder ingests new textures. Enumeration yields spans; parsing stays allocation-free until you hit a cache or GPU API that needs string.

ReadOnlySpan<char> watchDir = catalog.InboxDirectory.AsSpan();
ReadOnlySpan<char> pattern = "*.png".AsSpan();
ReadOnlySpan<char> pngExt = ".png".AsSpan();
Span<char> buffer = stackalloc char[SpanDirectory.GetEntryPathBufferCapacity(watchDir)];

foreach (ReadOnlySpan<char> path in SpanDirectory.EnumerateFiles(watchDir, buffer, pattern)) {
    if (!SpanPath.GetExtension(path).Equals(pngExt, StringComparison.OrdinalIgnoreCase)) {
        continue;
    }

    ReadOnlySpan<char> stem = SpanPath.GetFileNameWithoutExtension(path);
    if (catalog.TryGetThumbnail(stem, out Thumbnail thumb)) {
        QueueImport(path, thumb);
    }
}

Abstraction layer (ISpanFileSystem)

App and test code inject ISpanFileSystem (SpanFileSystem.Default in production, fakes in tests). Path, file, and directory operations go through fs.Path, fs.File, and fs.Directory.

Log archiver

Archive rotated logs without Path.Combine per entry. fs.Directory.EnumerateFiles walks matches into a caller buffer; copy through fs.File so tests can substitute the filesystem.

using FileSystemics.Abstractions;

public sealed class LogArchiver(ISpanFileSystem fs) {
    public void Archive(ReadOnlySpan<char> logDir, ReadOnlySpan<char> archiveDir) {
        ReadOnlySpan<char> pattern = "*.log".AsSpan();
        Span<char> entry = stackalloc char[fs.Directory.GetEntryPathBufferCapacity(logDir)];
        Span<char> dest = stackalloc char[fs.Directory.GetEntryPathBufferCapacity(archiveDir)];

        foreach (ReadOnlySpan<char> path in fs.Directory.EnumerateFiles(logDir, entry, pattern)) {
            ReadOnlySpan<char> name = fs.Path.GetFileName(path);
            fs.Path.TryJoin(archiveDir, name, dest, out int destLength);
            fs.File.Copy(path, dest[..destLength], overwrite: false);
        }
    }
}

Same zero-allocation walk as static SpanDirectory.EnumerateFiles, routed through ISpanDirectory for dependency injection.

Custom platform hosts

Reference FileSystemics.SpanIO.Platform when you need to override path rules or filesystem behavior (tests, plugins, cross-platform simulation). An IPlatformHost bundles three surfaces: FileSystem, Path, and Drives.

The simplest host is the OS default — no wrapper required:

IPlatformHost myHost = Platform.Actual;

To change path semantics while keeping real OS file I/O, derive from PlatformHost and delegate FileSystem / Drives to Platform.Actual:

using FileSystemics.IO;

IPlatformHost myHost = new CustomPathHost(Platform.Actual);

using (Platform.Use(myHost)) {
    // SpanPath, SpanFile, SpanDirectory, and native dispatch use myHost for this scope
    ReadOnlySpan<char> dir = SpanPath.GetDirectoryName(@"dir\subdir\file.txt".AsSpan());
}

sealed class CustomPathHost(IPlatformHost inner) : PlatformHost {
    // Implement IFileSystemPlatform (or wrap inner.Path) to change how IO commands are executed
    public override IFileSystemPlatform FileSystem => inner.FileSystem;
    // Implement IDrivePlatform (or wrap inner.Path) to change drive letters, information etc.
    public override IDrivePlatform Drives => inner.Drives;
    // Implement IPathRules (or wrap inner.Path) to change separators, roots, encoding, etc.
    public override IPathRules Path { get; } = new MyPathRules(inner.Path);
}

When rebinding separators on an OS that uses different native rules (e.g. backslash paths on Linux), override GetRootLength and IsPathRooted as well — see test/FileSystemics.SpanIO.Platform.Tests/MockPlatform/ForwardSlashPathHost.cs.

Register a named host for process-wide selection via the FILESYSTEMICS_PLATFORM environment variable:

Platform.Register("backslash", () => new BackslashPathHost(Platform.Actual));
// FILESYSTEMICS_PLATFORM=backslash

Platform.Use scopes overrides and restores the previous host on dispose; nested scopes are supported.

Contributing

Issues and pull requests are welcome, give me your feedback.

Support

If FileSystemics is useful to you, or if you're morally and philosophically opposed to unnecessary heap allocation, donations are highly appreciated and help fund development/research and support:

GitHub Sponsors · Patreon · PayPal

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on FileSystemics.SpanIO:

Package Downloads
FileSystemics.SpanIO.Platform

Swappable platform hosts and default ISpanFileSystem adapters for FileSystemics.SpanIO.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.5.0 347 6/15/2026