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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
// github copilot commit message instructions (preview)
"github.copilot.chat.commitMessageGeneration.instructions": [
{ "text": "Use conventional commit format: type(scope): description" },
{ "text": "Use imperative mood: 'Add feature' not 'Added feature'" },
{ "text": "Keep subject line under 50 characters" },
{ "text": "Use types: feat, fix, docs, style, refactor, perf, test, chore, ci" },
{ "text": "Include scope when relevant (e.g., api, ui, auth)" },
{ "text": "Reference issue numbers with # prefix" }
]
}
13 changes: 13 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "test",
"type": "shell",
"command": "dotnet test --nologo",
"args": [],
"problemMatcher": [
"$msCompile"
],
"group": "build"
}
6 changes: 5 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>

<ItemGroup Label="Default Dependencies">
<PackageVersion Include="NaughtyStrings" Version="2.4.1" />
</ItemGroup>

<ItemGroup Label="Dependencies">
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageVersion Include="NaughtyStrings" Version="2.4.1" />

</ItemGroup>

<ItemGroup Label="Libraries for comparison">
Expand Down
88 changes: 88 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Justfile .NET - Benjamin Abt 2025 - https://benjamin-abt.com
# https://github.com/BenjaminAbt/templates/blob/main/justfile/dotnet

set shell := ["pwsh", "-c"]

# ===== Configurable defaults =====
CONFIG := "Debug"
TFM := "net10.0"
BENCH_PRJ := "perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj"

# ===== Default / Help =====
default: help

help:
# Overview:
just --list
# Usage:
# just build
# just test
# just bench

# ===== Basic .NET Workflows =====
restore:
dotnet restore

build *ARGS:
dotnet build --configuration "{{CONFIG}}" --nologo --verbosity minimal {{ARGS}}

rebuild *ARGS:
dotnet build --configuration "{{CONFIG}}" --nologo --verbosity minimal --no-incremental {{ARGS}}

clean:
dotnet clean --configuration "{{CONFIG}}" --nologo

run *ARGS:
dotnet run --project --framework "{{TFM}}" --configuration "{{CONFIG}}" --no-launch-profile {{ARGS}}

# ===== Quality / Tests =====
format:
dotnet format --verbosity minimal

format-check:
dotnet format --verify-no-changes --verbosity minimal

test *ARGS:
dotnet test --configuration "{{CONFIG}}" --framework "{{TFM}}" --nologo --verbosity minimal {{ARGS}}

test-cov:
dotnet test --configuration "{{CONFIG}}" --framework "{{TFM}}" --nologo --verbosity minimal /p:CollectCoverage=true /p:CoverletOutputFormat="cobertura,lcov,opencover" /p:CoverletOutput="./TestResults/coverage/coverage"


test-filter QUERY:
dotnet test --configuration "{{CONFIG}}" --framework "{{TFM}}" --nologo --verbosity minimal --filter "{{QUERY}}"

# ===== Packaging / Release =====
pack *ARGS:
dotnet pack --configuration "{{CONFIG}}" --nologo --verbosity minimal -o "./artifacts/packages" {{ARGS}}

publish *ARGS:
dotnet publish --configuration "{{CONFIG}}" --framework "{{TFM}}" --nologo --verbosity minimal -o "./artifacts/publish/{{TFM}}" {{ARGS}}

publish-sc RID *ARGS:
dotnet publish --configuration "{{CONFIG}}" --framework "{{TFM}}" --runtime "{{RID}}" --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=false --nologo --verbosity minimal -o "./artifacts/publish/{{TFM}}-{{RID}}" {{ARGS}}

# ===== Benchmarks =====
bench *ARGS:
dotnet run --configuration Release --project "{{BENCH_PRJ}}" --framework "{{TFM}}" {{ARGS}}

# ===== Housekeeping =====
clean-artifacts:
if (Test-Path "./artifacts") { Remove-Item "./artifacts" -Recurse -Force }

clean-all:
just clean
just clean-artifacts
# Optionally: git clean -xdf

# ===== Combined Flows =====
fmt-build:
just format
just build

ci:
just clean
just restore
just format-check
just build
just test-cov
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2021-2023 MyCSharp
Copyright (c) 2021-2025 MyCSharp

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
11 changes: 11 additions & 0 deletions MyCSharp.HttpUserAgentParser.sln
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_", "_", "{5738CE0D-5E6E-47
Directory.Build.props = Directory.Build.props
Directory.Packages.props = Directory.Packages.props
global.json = global.json
Justfile = Justfile
LICENSE = LICENSE
NuGet.config = NuGet.config
README.md = README.md
Expand Down Expand Up @@ -81,6 +82,16 @@ Global
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{45927CF7-1BF4-479B-BBAA-8AD9CA901AE4} = {008A2BAB-78B4-42EB-A5D4-DE434438CEF0}
{3357BEC0-8216-409E-A539-F9A71DBACB81} = {008A2BAB-78B4-42EB-A5D4-DE434438CEF0}
{F16697F7-74B4-441D-A0C0-1A0572AC3AB0} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9}
{75960783-8BF9-479C-9ECF-E9653B74C9A2} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9}
{3C8CCD44-F47C-4624-8997-54C42F02E376} = {008A2BAB-78B4-42EB-A5D4-DE434438CEF0}
{39FC1EC2-2AD3-411F-A545-AB6CCB94FB7E} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9}
{A0D213E9-6408-46D1-AFAF-5096C2F6E027} = {FAAD18A0-E1B8-448D-B611-AFBDA8A89808}
{165EE915-1A4F-4875-90CE-1A2AE1540AE7} = {F54C9296-4EF7-40F0-9F20-F23A2270ABC9}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E8B0C994-0BF2-4692-9E22-E48B265B2804}
EndGlobalSection
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ by [@BenjaminAbt](https://github.com/BenjaminAbt) and [@gfoidl](https://github.c

MIT License

Copyright (c) 2021-2023 MyCSharp
Copyright (c) 2021-2025 MyCSharp

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
<Nullable>disable</Nullable>
</PropertyGroup>

<!-- Use project build name as assembly name to satisfy benchmark.NET -->
<PropertyGroup>
<RootNamespace>$(MSBuildProjectName)</RootNamespace>
<AssemblyName>$(MSBuildProjectName)</AssemblyName>
</PropertyGroup>

<PropertyGroup Condition="'$(OS)' == 'Windows_NT'">
<DefineConstants>$(DefineConstants);OS_WIN</DefineConstants>
</PropertyGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using MyCSharp.HttpUserAgentParser;

#if OS_WIN
using BenchmarkDotNet.Diagnostics.Windows.Configs;
#endif

namespace MyCSharp.HttpUserAgentParser.Benchmarks;
namespace HttpUserAgentParser.Benchmarks;

[MemoryDiagnoser]
[SimpleJob(RuntimeMoniker.Net80)]
Expand Down Expand Up @@ -43,7 +44,7 @@ public void Parse()

for (int i = 0; i < testUserAgentMix.Length; ++i)
{
results[i] = HttpUserAgentParser.Parse(testUserAgentMix[i]);
results[i] = MyCSharp.HttpUserAgentParser.HttpUserAgentParser.Parse(testUserAgentMix[i]);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using DeviceDetectorNET;
using MyCSharp.HttpUserAgentParser;
using MyCSharp.HttpUserAgentParser.Providers;

namespace MyCSharp.HttpUserAgentParser.Benchmarks.LibraryComparison;
namespace HttpUserAgentParser.Benchmarks.LibraryComparison;

[ShortRunJob]
[MemoryDiagnoser]
Expand All @@ -33,7 +34,7 @@ public IEnumerable<TestData> GetTestUserAgents()
[BenchmarkCategory("Basic")]
public HttpUserAgentInformation MyCSharpBasic()
{
HttpUserAgentInformation info = HttpUserAgentParser.Parse(Data.UserAgent);
HttpUserAgentInformation info = MyCSharp.HttpUserAgentParser.HttpUserAgentParser.Parse(Data.UserAgent);
return info;
}

Expand Down
2 changes: 1 addition & 1 deletion src/HttpUserAgentParser.AspNetCore/LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2021-2023 MyCSharp
Copyright (c) 2021-2025 MyCSharp

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 1 addition & 1 deletion src/HttpUserAgentParser.MemoryCache/LICENSE.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2021-2023 MyCSharp
Copyright (c) 2021-2025 MyCSharp

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
112 changes: 104 additions & 8 deletions src/HttpUserAgentParser/HttpUserAgentParser.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright © https://myCSharp.de - all rights reserved

using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using System.Runtime.CompilerServices;

namespace MyCSharp.HttpUserAgentParser;

Expand Down Expand Up @@ -48,11 +48,15 @@ public static HttpUserAgentInformation Parse(string userAgent)
/// </summary>
public static HttpUserAgentPlatformInformation? GetPlatform(string userAgent)
{
foreach (HttpUserAgentPlatformInformation item in HttpUserAgentStatics.Platforms)
// Fast, allocation-free token scan (keeps public statics untouched)
ReadOnlySpan<char> ua = userAgent.AsSpan();
foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) platform in HttpUserAgentStatics.s_platformRules)
{
if (item.Regex.IsMatch(userAgent))
if (ContainsIgnoreCase(ua, platform.Token))
{
return item;
return new HttpUserAgentPlatformInformation(
HttpUserAgentStatics.GetPlatformRegexForToken(platform.Token),
platform.Name, platform.PlatformType);
}
}

Expand All @@ -73,13 +77,40 @@ public static bool TryGetPlatform(string userAgent, [NotNullWhen(true)] out Http
/// </summary>
public static (string Name, string? Version)? GetBrowser(string userAgent)
{
foreach ((Regex key, string? value) in HttpUserAgentStatics.Browsers)
ReadOnlySpan<char> ua = userAgent.AsSpan();
foreach ((string Name, string DetectToken, string? VersionToken) browserRule in HttpUserAgentStatics.s_browserRules)
{
Match match = key.Match(userAgent);
if (match.Success)
if (!TryIndexOf(ua, browserRule.DetectToken, out int detectIndex))
{
return (value, match.Groups[1].Value);
continue;
}

// Version token may differ (e.g., Safari uses "Version/")
int versionSearchStart = detectIndex;
if (!string.IsNullOrEmpty(browserRule.VersionToken))
{
if (TryIndexOf(ua, browserRule.VersionToken!, out int vtIndex))
{
versionSearchStart = vtIndex + browserRule.VersionToken!.Length;
}
else
{
// If specific version token wasn't found, fall back to detect token area
versionSearchStart = detectIndex + browserRule.DetectToken.Length;
}
}
else
{
versionSearchStart = detectIndex + browserRule.DetectToken.Length;
}

string? version = null;
if (TryExtractVersion(ua, versionSearchStart, out Range range))
{
version = userAgent.AsSpan(range.Start.Value, range.End.Value - range.Start.Value).ToString();
}

return (browserRule.Name, version);
}

return null;
Expand Down Expand Up @@ -143,4 +174,69 @@ public static bool TryGetMobileDevice(string userAgent, [NotNullWhen(true)] out
device = GetMobileDevice(userAgent);
return device is not null;
}

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool ContainsIgnoreCase(ReadOnlySpan<char> haystack, string needle)
=> TryIndexOf(haystack, needle, out _);

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool TryIndexOf(ReadOnlySpan<char> haystack, string needle, out int index)
{
index = haystack.IndexOf(needle.AsSpan(), StringComparison.OrdinalIgnoreCase);
return index >= 0;
}

/// <summary>
/// Extracts a dotted numeric version starting at or after <paramref name="startIndex"/>.
/// Accepts digits and dots; skips common separators ('/', ' ', ':', '=') until first digit.
/// Returns false if no version-like token is found.
/// </summary>
private static bool TryExtractVersion(ReadOnlySpan<char> haystack, int startIndex, out Range range)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The startIndex isn't needed, as the ROS haystack can be sliced therefore to proper start.

{
range = default;
if ((uint)startIndex >= (uint)haystack.Length)
{
return false;
}

// Limit search window to avoid scanning entire UA string unnecessarily
const int window = 128;
int end = Math.Min(haystack.Length, startIndex + window);
int i = startIndex;

// Skip separators until we hit a digit
while (i < end)
{
char c = haystack[i];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could slice the haystack to avoid some bound checks here and below.

if ((uint)(c - '0') <= 9)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's worth to use a vectorized search here too?
IndexOf will be a method call, so potentially slower, but a simple self coded search for one vector length could be faster. Maybe in combination with a SWAR search.

I can try this later (September). But we cache that info later on, so is it worth it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's worth to use a vectorized search here too?

Yes, but tbh vectorized search is way over my casual knowledge ☹

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we merge this and create a new PR end sept?

Copy link
Contributor

@gfoidl gfoidl Aug 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, I'll file an issue and assign to me so I can't forget it.

Edit: #73

{
break;
}
i++;
}

if (i >= end)
{
return false;
}

int s = i;
while (i < end)
{
char c = haystack[i];
if (!((uint)(c - '0') <= 9 || c == '.'))
{
break;
}
i++;
}

if (i == s)
{
return false;
}

range = new Range(s, i);
return true;
}
}
Loading