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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
106 changes: 98 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,41 @@ 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;
ua = ua.Slice(versionSearchStart);
if (TryExtractVersion(ua, out Range range))
{
version = ua[range].ToString();
}

return (browserRule.Name, version);
}

return null;
Expand Down Expand Up @@ -143,4 +175,62 @@ 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, ReadOnlySpan<char> needle)
=> TryIndexOf(haystack, needle, out _);

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

/// <summary>
/// Extracts a dotted numeric version.
/// 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, out Range range)
{
range = default;

// Limit search window to avoid scanning entire UA string unnecessarily
const int Window = 128;
if (haystack.Length >= Window)
{
haystack = haystack.Slice(0, Window);
}

int i = 0;
for (; i < haystack.Length; ++i)
{
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 (char.IsBetween(c, '0', '9'))
{
break;
}
}

int s = i;
haystack = haystack.Slice(i + 1);

Choose a reason for hiding this comment

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

We have a test for an invalid useragent. With this code it throws an ArgumentOutOfRangeException here

Choose a reason for hiding this comment

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

Mozilla/ (iPhone; U; CPU iPhone OS like Mac OS X) AppleWebKit/ (KHTML, like Gecko) Version/ Mobile/ Safari/

We test for this UA.

Choose a reason for hiding this comment

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

Wanted to add a test and create a pr by myself. But couldn't get the solution to build. (Have not installed .net10 preview on my machine)

Copy link
Member Author

@BenjaminAbt BenjaminAbt Aug 24, 2025

Choose a reason for hiding this comment

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

What does the user agent

Mozilla/ (iPhone; U; CPU iPhone OS like Mac OS X) AppleWebKit/ (KHTML, like Gecko) Version/ Mobile/ Safari/
stand for?

I couldn’t find any exact match online.

Choose a reason for hiding this comment

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

Yeah that is a invalid one.

We have a test which tries to parse this one (can not remember how we came up with this one...)

We use it to test that we do not proceed when the ua is invalid.

I am ok to change my test, but the parser did not throw before and now throws an exception 🤷

Copy link
Member Author

Choose a reason for hiding this comment

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

Okay, a invalid one.

I will take a look. It should not break tests.

for (i = 0; i < haystack.Length; ++i)
{
char c = haystack[i];
if (!(char.IsBetween(c, '0', '9') || c == '.'))
{
break;
}
}
i += s + 1; // shift back the previous domain

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

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