From 9f87e8b8f2562fc84efa9b97b3551a73f2bdf241 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Fri, 22 Aug 2025 20:15:40 +0200 Subject: [PATCH 01/11] feat(dependencies): add NaughtyStrings to Default Dependencies feat(solution): add NestedProjects section to solution file chore: add commit message instructions to settings chore: create Justfile for build and benchmark commands chore: add Directory.Build.props for benchmark project --- .vscode/settings.json | 11 +++++++++++ Directory.Packages.props | 6 +++++- Justfile | 13 +++++++++++++ MyCSharp.HttpUserAgentParser.sln | 10 ++++++++++ perf/Directory,Build.props | 9 +++++++++ 5 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 .vscode/settings.json create mode 100644 Justfile create mode 100644 perf/Directory,Build.props diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2ef0a8a --- /dev/null +++ b/.vscode/settings.json @@ -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" } + ] +} diff --git a/Directory.Packages.props b/Directory.Packages.props index f7ce539..eaa97d7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,12 +3,16 @@ true + + + + - + diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..02b7b50 --- /dev/null +++ b/Justfile @@ -0,0 +1,13 @@ +set shell := ["pwsh", "-c"] + + +# Build the solution +build: + dotnet build + +# Run benchmarks (Release) +bench: + dotnet run --configuration Release --project "perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj" --framework net10.0 + + + diff --git a/MyCSharp.HttpUserAgentParser.sln b/MyCSharp.HttpUserAgentParser.sln index 958aeb0..a444be3 100644 --- a/MyCSharp.HttpUserAgentParser.sln +++ b/MyCSharp.HttpUserAgentParser.sln @@ -81,6 +81,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 diff --git a/perf/Directory,Build.props b/perf/Directory,Build.props new file mode 100644 index 0000000..cf76fb2 --- /dev/null +++ b/perf/Directory,Build.props @@ -0,0 +1,9 @@ + + + + $(MSBuildProjectName) + $(MSBuildProjectName) + + + + From ef396d18eb2f55c2287630d96bcdc806f9abf10e Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Fri, 22 Aug 2025 20:27:17 +0200 Subject: [PATCH 02/11] feat(parser): enhance user agent parsing with zero-allocation checks --- perf/Directory,Build.props | 9 -- .../HttpUserAgentParser.Benchmarks.csproj | 6 + .../HttpUserAgentParser.cs | 113 ++++++++++++++++-- .../HttpUserAgentStatics.cs | 98 +++++++++++++++ 4 files changed, 209 insertions(+), 17 deletions(-) delete mode 100644 perf/Directory,Build.props diff --git a/perf/Directory,Build.props b/perf/Directory,Build.props deleted file mode 100644 index cf76fb2..0000000 --- a/perf/Directory,Build.props +++ /dev/null @@ -1,9 +0,0 @@ - - - - $(MSBuildProjectName) - $(MSBuildProjectName) - - - - diff --git a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj index e190b11..eb6a92c 100644 --- a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj +++ b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj @@ -5,6 +5,12 @@ disable + + + $(MSBuildProjectName) + $(MSBuildProjectName) + + $(DefineConstants);OS_WIN diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index e32d5d8..f5b989d 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -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; @@ -48,11 +48,16 @@ public static HttpUserAgentInformation Parse(string userAgent) /// public static HttpUserAgentPlatformInformation? GetPlatform(string userAgent) { - foreach (HttpUserAgentPlatformInformation item in HttpUserAgentStatics.Platforms) + // Fast, allocation-free token scan (keeps public statics untouched) + ReadOnlySpan ua = userAgent.AsSpan(); + foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) p in HttpUserAgentStatics.s_platformRules) { - if (item.Regex.IsMatch(userAgent)) + if (ContainsIgnoreCase(ua, p.Token)) { - return item; + return new HttpUserAgentPlatformInformation( + regex: HttpUserAgentStatics.GetPlatformRegexForToken(p.Token), + name: p.Name, + platformType: p.PlatformType); } } @@ -73,13 +78,40 @@ public static bool TryGetPlatform(string userAgent, [NotNullWhen(true)] out Http /// public static (string Name, string? Version)? GetBrowser(string userAgent) { - foreach ((Regex key, string? value) in HttpUserAgentStatics.Browsers) + ReadOnlySpan ua = userAgent.AsSpan(); + foreach ((string Name, string DetectToken, string? VersionToken) rule in HttpUserAgentStatics.s_browserRules) { - Match match = key.Match(userAgent); - if (match.Success) + if (!TryIndexOf(ua, rule.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(rule.VersionToken)) + { + if (TryIndexOf(ua, rule.VersionToken!, out int vtIndex)) + { + versionSearchStart = vtIndex + rule.VersionToken!.Length; + } + else + { + // If specific version token wasn't found, fall back to detect token area + versionSearchStart = detectIndex + rule.DetectToken.Length; + } + } + else + { + versionSearchStart = detectIndex + rule.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 (rule.Name, version); } return null; @@ -143,4 +175,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 haystack, string needle) + => TryIndexOf(haystack, needle, out _); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryIndexOf(ReadOnlySpan haystack, string needle, out int index) + { + index = haystack.IndexOf(needle.AsSpan(), StringComparison.OrdinalIgnoreCase); + return index >= 0; + } + + /// + /// Extracts a dotted numeric version starting at or after . + /// Accepts digits and dots; skips common separators ('/', ' ', ':', '=') until first digit. + /// Returns false if no version-like token is found. + /// + private static bool TryExtractVersion(ReadOnlySpan haystack, int startIndex, out Range range) + { + 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]; + if ((uint)(c - '0') <= 9) + { + 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; + } } diff --git a/src/HttpUserAgentParser/HttpUserAgentStatics.cs b/src/HttpUserAgentParser/HttpUserAgentStatics.cs index 32d4580..28d2510 100644 --- a/src/HttpUserAgentParser/HttpUserAgentStatics.cs +++ b/src/HttpUserAgentParser/HttpUserAgentStatics.cs @@ -70,6 +70,62 @@ public static class HttpUserAgentStatics new(CreateDefaultPlatformRegex("symbian"), "Symbian OS", HttpUserAgentPlatformType.Symbian), ]; + /// + /// Fast-path platform token rules for zero-allocation Contains checks + /// + internal static readonly (string Token, string Name, HttpUserAgentPlatformType PlatformType)[] s_platformRules = + [ + ("windows nt 10.0", "Windows 10", HttpUserAgentPlatformType.Windows), + ("windows nt 6.3", "Windows 8.1", HttpUserAgentPlatformType.Windows), + ("windows nt 6.2", "Windows 8", HttpUserAgentPlatformType.Windows), + ("windows nt 6.1", "Windows 7", HttpUserAgentPlatformType.Windows), + ("windows nt 6.0", "Windows Vista", HttpUserAgentPlatformType.Windows), + ("windows nt 5.2", "Windows 2003", HttpUserAgentPlatformType.Windows), + ("windows nt 5.1", "Windows XP", HttpUserAgentPlatformType.Windows), + ("windows nt 5.0", "Windows 2000", HttpUserAgentPlatformType.Windows), + ("windows nt 4.0", "Windows NT 4.0", HttpUserAgentPlatformType.Windows), + ("winnt4.0", "Windows NT 4.0", HttpUserAgentPlatformType.Windows), + ("winnt 4.0", "Windows NT", HttpUserAgentPlatformType.Windows), + ("winnt", "Windows NT", HttpUserAgentPlatformType.Windows), + ("windows 98", "Windows 98", HttpUserAgentPlatformType.Windows), + ("win98", "Windows 98", HttpUserAgentPlatformType.Windows), + ("windows 95", "Windows 95", HttpUserAgentPlatformType.Windows), + ("win95", "Windows 95", HttpUserAgentPlatformType.Windows), + ("windows phone", "Windows Phone", HttpUserAgentPlatformType.Windows), + ("windows", "Unknown Windows OS", HttpUserAgentPlatformType.Windows), + ("android", "Android", HttpUserAgentPlatformType.Android), + ("blackberry", "BlackBerry", HttpUserAgentPlatformType.BlackBerry), + ("iphone", "iOS", HttpUserAgentPlatformType.IOS), + ("ipad", "iOS", HttpUserAgentPlatformType.IOS), + ("ipod", "iOS", HttpUserAgentPlatformType.IOS), + ("cros", "ChromeOS", HttpUserAgentPlatformType.ChromeOS), + ("os x", "Mac OS X", HttpUserAgentPlatformType.MacOS), + ("ppc mac", "Power PC Mac", HttpUserAgentPlatformType.MacOS), + ("freebsd", "FreeBSD", HttpUserAgentPlatformType.Linux), + ("ppc", "Macintosh", HttpUserAgentPlatformType.Linux), + ("linux", "Linux", HttpUserAgentPlatformType.Linux), + ("debian", "Debian", HttpUserAgentPlatformType.Linux), + ("sunos", "Sun Solaris", HttpUserAgentPlatformType.Generic), + ("beos", "BeOS", HttpUserAgentPlatformType.Generic), + ("apachebench", "ApacheBench", HttpUserAgentPlatformType.Generic), + ("aix", "AIX", HttpUserAgentPlatformType.Generic), + ("irix", "Irix", HttpUserAgentPlatformType.Generic), + ("osf", "DEC OSF", HttpUserAgentPlatformType.Generic), + ("hp-ux", "HP-UX", HttpUserAgentPlatformType.Windows), + ("netbsd", "NetBSD", HttpUserAgentPlatformType.Generic), + ("bsdi", "BSDi", HttpUserAgentPlatformType.Generic), + ("openbsd", "OpenBSD", HttpUserAgentPlatformType.Unix), + ("gnu", "GNU/Linux", HttpUserAgentPlatformType.Linux), + ("unix", "Unknown Unix OS", HttpUserAgentPlatformType.Unix), + ("symbian", "Symbian OS", HttpUserAgentPlatformType.Symbian), + ]; + + // Precompiled platform regex map to attach to PlatformInformation without per-call allocations + private static readonly Dictionary s_platformRegexMap = s_platformRules + .ToDictionary(p => p.Token, p => CreateDefaultPlatformRegex(p.Token), StringComparer.OrdinalIgnoreCase); + + internal static Regex GetPlatformRegexForToken(string token) => s_platformRegexMap[token]; + /// /// Regex defauls for browser mappings /// @@ -122,6 +178,48 @@ private static Regex CreateDefaultBrowserRegex(string key) { CreateDefaultBrowserRegex("Ubuntu"), "Ubuntu Web Browser" }, }; + /// + /// Fast-path browser token rules. If these fail to extract a version, code will fall back to regex rules. + /// + internal static readonly (string Name, string DetectToken, string? VersionToken)[] s_browserRules = + [ + ("Opera", "OPR", null), + ("Flock", "Flock", null), + ("Edge", "Edge", null), + ("Edge", "EdgA", null), + ("Edge", "Edg", null), + ("Vivaldi", "Vivaldi", null), + ("Brave", "Brave Chrome", null), + ("Chrome", "Chrome", null), + ("Chrome", "CriOS", null), + ("Opera", "Opera", "Version/"), + ("Opera", "Opera", null), + ("Internet Explorer", "MSIE", "MSIE "), + ("Internet Explorer", "Internet Explorer", null), + ("Internet Explorer", "Trident", "rv:"), + ("Shiira", "Shiira", null), + ("Firefox", "Firefox", null), + ("Firefox", "FxiOS", null), + ("Chimera", "Chimera", null), + ("Phoenix", "Phoenix", null), + ("Firebird", "Firebird", null), + ("Camino", "Camino", null), + ("Netscape", "Netscape", null), + ("OmniWeb", "OmniWeb", null), + ("Safari", "Version/", "Version/"), + ("Mozilla", "Mozilla", null), + ("Konqueror", "Konqueror", null), + ("iCab", "icab", null), + ("Lynx", "Lynx", null), + ("Links", "Links", null), + ("HotJava", "hotjava", null), + ("Amaya", "amaya", null), + ("IBrowse", "IBrowse", null), + ("Maxthon", "Maxthon", null), + ("Apple iPod", "ipod touch", null), + ("Ubuntu Web Browser", "Ubuntu", null), + ]; + /// /// Mobiles /// From d767fb4941035cb74fcfdff237a511b3c152982f Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Fri, 22 Aug 2025 20:41:23 +0200 Subject: [PATCH 03/11] chore(justfile): add clean step to Justfile --- Justfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Justfile b/Justfile index 02b7b50..52a2d06 100644 --- a/Justfile +++ b/Justfile @@ -9,5 +9,6 @@ build: bench: dotnet run --configuration Release --project "perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj" --framework net10.0 - - +# Clean the solution +clean: + dotnet clean From 240783abbcf685f6fcd8e50cc09bcc8d930e9418 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Fri, 22 Aug 2025 20:49:29 +0200 Subject: [PATCH 04/11] refactor(parser): change user agent parsing to use ReadOnlySpan --- .../HttpUserAgentParserBenchmarks.cs | 5 +- .../LibraryComparisonBenchmarks.cs | 5 +- .../HttpUserAgentParser.cs | 49 +++++++++---------- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs index acde85f..8b0b706 100644 --- a/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs +++ b/perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParserBenchmarks.cs @@ -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)] @@ -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]); } } } diff --git a/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs b/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs index 94a69b6..4d7bcb0 100644 --- a/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs +++ b/perf/HttpUserAgentParser.Benchmarks/LibraryComparison/LibraryComparisonBenchmarks.cs @@ -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] @@ -33,7 +34,7 @@ public IEnumerable GetTestUserAgents() [BenchmarkCategory("Basic")] public HttpUserAgentInformation MyCSharpBasic() { - HttpUserAgentInformation info = HttpUserAgentParser.Parse(Data.UserAgent); + HttpUserAgentInformation info = MyCSharp.HttpUserAgentParser.HttpUserAgentParser.Parse(Data.UserAgent); return info; } diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index f5b989d..b4955be 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -16,7 +16,7 @@ public static class HttpUserAgentParser /// /// Parses given user agent /// - public static HttpUserAgentInformation Parse(string userAgent) + public static HttpUserAgentInformation Parse(ReadOnlySpan userAgent) { // prepare userAgent = Cleanup(userAgent); @@ -24,7 +24,7 @@ public static HttpUserAgentInformation Parse(string userAgent) // analyze if (TryGetRobot(userAgent, out string? robotName)) { - return HttpUserAgentInformation.CreateForRobot(userAgent, robotName); + return HttpUserAgentInformation.CreateForRobot(userAgent.ToString(), robotName); } HttpUserAgentPlatformInformation? platform = GetPlatform(userAgent); @@ -32,32 +32,30 @@ public static HttpUserAgentInformation Parse(string userAgent) if (TryGetBrowser(userAgent, out (string Name, string? Version)? browser)) { - return HttpUserAgentInformation.CreateForBrowser(userAgent, platform, browser?.Name, browser?.Version, mobileDeviceType); + return HttpUserAgentInformation.CreateForBrowser(userAgent.ToString(), platform, browser?.Name, browser?.Version, mobileDeviceType); } - return HttpUserAgentInformation.CreateForUnknown(userAgent, platform, mobileDeviceType); + return HttpUserAgentInformation.CreateForUnknown(userAgent.ToString(), platform, mobileDeviceType); } /// /// pre-cleanup of user agent /// - public static string Cleanup(string userAgent) => userAgent.Trim(); + public static ReadOnlySpan Cleanup(ReadOnlySpan userAgent) => userAgent.Trim(); /// /// returns the platform or null /// - public static HttpUserAgentPlatformInformation? GetPlatform(string userAgent) + public static HttpUserAgentPlatformInformation? GetPlatform(ReadOnlySpan userAgent) { - // Fast, allocation-free token scan (keeps public statics untouched) - ReadOnlySpan ua = userAgent.AsSpan(); - foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) p in HttpUserAgentStatics.s_platformRules) + foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) p in HttpUserAgentStatics.s_platformRules) { - if (ContainsIgnoreCase(ua, p.Token)) + if (ContainsIgnoreCase(userAgent, p.Token)) { return new HttpUserAgentPlatformInformation( - regex: HttpUserAgentStatics.GetPlatformRegexForToken(p.Token), - name: p.Name, - platformType: p.PlatformType); + regex: HttpUserAgentStatics.GetPlatformRegexForToken(p.Token), + name: p.Name, + platformType: p.PlatformType); } } @@ -67,7 +65,7 @@ public static HttpUserAgentInformation Parse(string userAgent) /// /// returns true if platform was found /// - public static bool TryGetPlatform(string userAgent, [NotNullWhen(true)] out HttpUserAgentPlatformInformation? platform) + public static bool TryGetPlatform(ReadOnlySpan userAgent, [NotNullWhen(true)] out HttpUserAgentPlatformInformation? platform) { platform = GetPlatform(userAgent); return platform is not null; @@ -76,12 +74,11 @@ public static bool TryGetPlatform(string userAgent, [NotNullWhen(true)] out Http /// /// returns the browser or null /// - public static (string Name, string? Version)? GetBrowser(string userAgent) + public static (string Name, string? Version)? GetBrowser(ReadOnlySpan userAgent) { - ReadOnlySpan ua = userAgent.AsSpan(); - foreach ((string Name, string DetectToken, string? VersionToken) rule in HttpUserAgentStatics.s_browserRules) + foreach ((string Name, string DetectToken, string? VersionToken) rule in HttpUserAgentStatics.s_browserRules) { - if (!TryIndexOf(ua, rule.DetectToken, out int detectIndex)) + if (!TryIndexOf(userAgent, rule.DetectToken, out int detectIndex)) { continue; } @@ -90,7 +87,7 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) int versionSearchStart = detectIndex; if (!string.IsNullOrEmpty(rule.VersionToken)) { - if (TryIndexOf(ua, rule.VersionToken!, out int vtIndex)) + if (TryIndexOf(userAgent, rule.VersionToken!, out int vtIndex)) { versionSearchStart = vtIndex + rule.VersionToken!.Length; } @@ -106,9 +103,9 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) } string? version = null; - if (TryExtractVersion(ua, versionSearchStart, out Range range)) + if (TryExtractVersion(userAgent, versionSearchStart, out Range range)) { - version = userAgent.AsSpan(range.Start.Value, range.End.Value - range.Start.Value).ToString(); + version = userAgent.ToString(); } return (rule.Name, version); @@ -120,7 +117,7 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) /// /// returns true if browser was found /// - public static bool TryGetBrowser(string userAgent, [NotNullWhen(true)] out (string Name, string? Version)? browser) + public static bool TryGetBrowser(ReadOnlySpan userAgent, [NotNullWhen(true)] out (string Name, string? Version)? browser) { browser = GetBrowser(userAgent); return browser is not null; @@ -129,7 +126,7 @@ public static bool TryGetBrowser(string userAgent, [NotNullWhen(true)] out (stri /// /// returns the robot or null /// - public static string? GetRobot(string userAgent) + public static string? GetRobot(ReadOnlySpan userAgent) { foreach ((string key, string value) in HttpUserAgentStatics.Robots) { @@ -145,7 +142,7 @@ public static bool TryGetBrowser(string userAgent, [NotNullWhen(true)] out (stri /// /// returns true if robot was found /// - public static bool TryGetRobot(string userAgent, [NotNullWhen(true)] out string? robotName) + public static bool TryGetRobot(ReadOnlySpan userAgent, [NotNullWhen(true)] out string? robotName) { robotName = GetRobot(userAgent); return robotName is not null; @@ -154,7 +151,7 @@ public static bool TryGetRobot(string userAgent, [NotNullWhen(true)] out string? /// /// returns the device or null /// - public static string? GetMobileDevice(string userAgent) + public static string? GetMobileDevice(ReadOnlySpan userAgent) { foreach ((string key, string value) in HttpUserAgentStatics.Mobiles) { @@ -170,7 +167,7 @@ public static bool TryGetRobot(string userAgent, [NotNullWhen(true)] out string? /// /// returns true if device was found /// - public static bool TryGetMobileDevice(string userAgent, [NotNullWhen(true)] out string? device) + public static bool TryGetMobileDevice(ReadOnlySpan userAgent, [NotNullWhen(true)] out string? device) { device = GetMobileDevice(userAgent); return device is not null; From a23f6a29049d9c6c7145d55071cc313f9ec1538e Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Fri, 22 Aug 2025 20:58:00 +0200 Subject: [PATCH 05/11] refactor(parser): update user agent parsing for zero allocations --- .vscode/tasks.json | 25 +++++++++++++++++++ .../HttpUserAgentParser.cs | 25 +++++++++---------- .../HttpUserAgentParserDefaultProvider.cs | 2 +- 3 files changed, 38 insertions(+), 14 deletions(-) create mode 100644 .vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..f790c89 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,25 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "problemMatcher": [ + "$msCompile" + ], + "group": "build" + }, + { + "label": "test", + "type": "shell", + "command": "dotnet test --nologo", + "args": [], + "problemMatcher": [ + "$msCompile" + ], + "group": "build" + } + ] +} \ No newline at end of file diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index b4955be..80bdd10 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -14,28 +14,26 @@ public static class HttpUserAgentParser { /// - /// Parses given user agent + /// Parses given user agent string without allocating a copy. Prefer this overload to avoid ToString() allocations. /// - public static HttpUserAgentInformation Parse(ReadOnlySpan userAgent) + public static HttpUserAgentInformation Parse(string userAgent) { - // prepare - userAgent = Cleanup(userAgent); + ReadOnlySpan span = Cleanup(userAgent.AsSpan()); - // analyze - if (TryGetRobot(userAgent, out string? robotName)) + if (TryGetRobot(span, out string? robotName)) { - return HttpUserAgentInformation.CreateForRobot(userAgent.ToString(), robotName); + return HttpUserAgentInformation.CreateForRobot(userAgent, robotName); } - HttpUserAgentPlatformInformation? platform = GetPlatform(userAgent); - string? mobileDeviceType = GetMobileDevice(userAgent); + HttpUserAgentPlatformInformation? platform = GetPlatform(span); + string? mobileDeviceType = GetMobileDevice(span); - if (TryGetBrowser(userAgent, out (string Name, string? Version)? browser)) + if (TryGetBrowser(span, out (string Name, string? Version)? browser)) { - return HttpUserAgentInformation.CreateForBrowser(userAgent.ToString(), platform, browser?.Name, browser?.Version, mobileDeviceType); + return HttpUserAgentInformation.CreateForBrowser(userAgent, platform, browser?.Name, browser?.Version, mobileDeviceType); } - return HttpUserAgentInformation.CreateForUnknown(userAgent.ToString(), platform, mobileDeviceType); + return HttpUserAgentInformation.CreateForUnknown(userAgent, platform, mobileDeviceType); } /// @@ -105,7 +103,8 @@ public static (string Name, string? Version)? GetBrowser(ReadOnlySpan user string? version = null; if (TryExtractVersion(userAgent, versionSearchStart, out Range range)) { - version = userAgent.ToString(); + // Only allocate the version substring, not the whole user agent + version = userAgent[range].ToString(); } return (rule.Name, version); diff --git a/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs b/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs index f43e3c4..7b3bd51 100644 --- a/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs +++ b/src/HttpUserAgentParser/Providers/HttpUserAgentParserDefaultProvider.cs @@ -8,7 +8,7 @@ namespace MyCSharp.HttpUserAgentParser.Providers; public class HttpUserAgentParserDefaultProvider : IHttpUserAgentParserProvider { /// - /// returns the result of + /// returns the result of /// public HttpUserAgentInformation Parse(string userAgent) => HttpUserAgentParser.Parse(userAgent); From d5ebc2275d994d1ce923fa8e68fd9e2612ca52b6 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Fri, 22 Aug 2025 21:40:32 +0200 Subject: [PATCH 06/11] refactor(parser): update user agent methods for string usage --- .../HttpUserAgentParser.cs | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 80bdd10..f5b989d 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -14,21 +14,23 @@ public static class HttpUserAgentParser { /// - /// Parses given user agent string without allocating a copy. Prefer this overload to avoid ToString() allocations. + /// Parses given user agent /// public static HttpUserAgentInformation Parse(string userAgent) { - ReadOnlySpan span = Cleanup(userAgent.AsSpan()); + // prepare + userAgent = Cleanup(userAgent); - if (TryGetRobot(span, out string? robotName)) + // analyze + if (TryGetRobot(userAgent, out string? robotName)) { return HttpUserAgentInformation.CreateForRobot(userAgent, robotName); } - HttpUserAgentPlatformInformation? platform = GetPlatform(span); - string? mobileDeviceType = GetMobileDevice(span); + HttpUserAgentPlatformInformation? platform = GetPlatform(userAgent); + string? mobileDeviceType = GetMobileDevice(userAgent); - if (TryGetBrowser(span, out (string Name, string? Version)? browser)) + if (TryGetBrowser(userAgent, out (string Name, string? Version)? browser)) { return HttpUserAgentInformation.CreateForBrowser(userAgent, platform, browser?.Name, browser?.Version, mobileDeviceType); } @@ -39,21 +41,23 @@ public static HttpUserAgentInformation Parse(string userAgent) /// /// pre-cleanup of user agent /// - public static ReadOnlySpan Cleanup(ReadOnlySpan userAgent) => userAgent.Trim(); + public static string Cleanup(string userAgent) => userAgent.Trim(); /// /// returns the platform or null /// - public static HttpUserAgentPlatformInformation? GetPlatform(ReadOnlySpan userAgent) + public static HttpUserAgentPlatformInformation? GetPlatform(string userAgent) { - foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) p in HttpUserAgentStatics.s_platformRules) + // Fast, allocation-free token scan (keeps public statics untouched) + ReadOnlySpan ua = userAgent.AsSpan(); + foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) p in HttpUserAgentStatics.s_platformRules) { - if (ContainsIgnoreCase(userAgent, p.Token)) + if (ContainsIgnoreCase(ua, p.Token)) { return new HttpUserAgentPlatformInformation( - regex: HttpUserAgentStatics.GetPlatformRegexForToken(p.Token), - name: p.Name, - platformType: p.PlatformType); + regex: HttpUserAgentStatics.GetPlatformRegexForToken(p.Token), + name: p.Name, + platformType: p.PlatformType); } } @@ -63,7 +67,7 @@ public static HttpUserAgentInformation Parse(string userAgent) /// /// returns true if platform was found /// - public static bool TryGetPlatform(ReadOnlySpan userAgent, [NotNullWhen(true)] out HttpUserAgentPlatformInformation? platform) + public static bool TryGetPlatform(string userAgent, [NotNullWhen(true)] out HttpUserAgentPlatformInformation? platform) { platform = GetPlatform(userAgent); return platform is not null; @@ -72,11 +76,12 @@ public static bool TryGetPlatform(ReadOnlySpan userAgent, [NotNullWhen(tru /// /// returns the browser or null /// - public static (string Name, string? Version)? GetBrowser(ReadOnlySpan userAgent) + public static (string Name, string? Version)? GetBrowser(string userAgent) { - foreach ((string Name, string DetectToken, string? VersionToken) rule in HttpUserAgentStatics.s_browserRules) + ReadOnlySpan ua = userAgent.AsSpan(); + foreach ((string Name, string DetectToken, string? VersionToken) rule in HttpUserAgentStatics.s_browserRules) { - if (!TryIndexOf(userAgent, rule.DetectToken, out int detectIndex)) + if (!TryIndexOf(ua, rule.DetectToken, out int detectIndex)) { continue; } @@ -85,7 +90,7 @@ public static (string Name, string? Version)? GetBrowser(ReadOnlySpan user int versionSearchStart = detectIndex; if (!string.IsNullOrEmpty(rule.VersionToken)) { - if (TryIndexOf(userAgent, rule.VersionToken!, out int vtIndex)) + if (TryIndexOf(ua, rule.VersionToken!, out int vtIndex)) { versionSearchStart = vtIndex + rule.VersionToken!.Length; } @@ -101,10 +106,9 @@ public static (string Name, string? Version)? GetBrowser(ReadOnlySpan user } string? version = null; - if (TryExtractVersion(userAgent, versionSearchStart, out Range range)) + if (TryExtractVersion(ua, versionSearchStart, out Range range)) { - // Only allocate the version substring, not the whole user agent - version = userAgent[range].ToString(); + version = userAgent.AsSpan(range.Start.Value, range.End.Value - range.Start.Value).ToString(); } return (rule.Name, version); @@ -116,7 +120,7 @@ public static (string Name, string? Version)? GetBrowser(ReadOnlySpan user /// /// returns true if browser was found /// - public static bool TryGetBrowser(ReadOnlySpan userAgent, [NotNullWhen(true)] out (string Name, string? Version)? browser) + public static bool TryGetBrowser(string userAgent, [NotNullWhen(true)] out (string Name, string? Version)? browser) { browser = GetBrowser(userAgent); return browser is not null; @@ -125,7 +129,7 @@ public static bool TryGetBrowser(ReadOnlySpan userAgent, [NotNullWhen(true /// /// returns the robot or null /// - public static string? GetRobot(ReadOnlySpan userAgent) + public static string? GetRobot(string userAgent) { foreach ((string key, string value) in HttpUserAgentStatics.Robots) { @@ -141,7 +145,7 @@ public static bool TryGetBrowser(ReadOnlySpan userAgent, [NotNullWhen(true /// /// returns true if robot was found /// - public static bool TryGetRobot(ReadOnlySpan userAgent, [NotNullWhen(true)] out string? robotName) + public static bool TryGetRobot(string userAgent, [NotNullWhen(true)] out string? robotName) { robotName = GetRobot(userAgent); return robotName is not null; @@ -150,7 +154,7 @@ public static bool TryGetRobot(ReadOnlySpan userAgent, [NotNullWhen(true)] /// /// returns the device or null /// - public static string? GetMobileDevice(ReadOnlySpan userAgent) + public static string? GetMobileDevice(string userAgent) { foreach ((string key, string value) in HttpUserAgentStatics.Mobiles) { @@ -166,7 +170,7 @@ public static bool TryGetRobot(ReadOnlySpan userAgent, [NotNullWhen(true)] /// /// returns true if device was found /// - public static bool TryGetMobileDevice(ReadOnlySpan userAgent, [NotNullWhen(true)] out string? device) + public static bool TryGetMobileDevice(string userAgent, [NotNullWhen(true)] out string? device) { device = GetMobileDevice(userAgent); return device is not null; From 5161624ef444ccd25ca6ec92a21f5e6afbb095e5 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 23 Aug 2025 11:15:38 +0200 Subject: [PATCH 07/11] chore(license): update copyright year to 2025 --- Justfile | 90 +++++++++++++++++-- LICENSE | 2 +- README.md | 2 +- .../LICENSE.txt | 2 +- .../LICENSE.txt | 2 +- src/HttpUserAgentParser/LICENSE.txt | 2 +- 6 files changed, 87 insertions(+), 13 deletions(-) diff --git a/Justfile b/Justfile index 52a2d06..4b447ed 100644 --- a/Justfile +++ b/Justfile @@ -1,14 +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 -# Build the solution -build: - dotnet build +# ===== Basic .NET Workflows ===== +restore: + dotnet restore -# Run benchmarks (Release) -bench: - dotnet run --configuration Release --project "perf/HttpUserAgentParser.Benchmarks/HttpUserAgentParser.Benchmarks.csproj" --framework net10.0 +build *ARGS: + dotnet build --configuration "{{CONFIG}}" --nologo --verbosity minimal {{ARGS}} + +rebuild *ARGS: + dotnet build --configuration "{{CONFIG}}" --nologo --verbosity minimal --no-incremental {{ARGS}} -# Clean the solution clean: - dotnet 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 diff --git a/LICENSE b/LICENSE index 11152f9..023aed7 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md index 6ce902b..5d07d44 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/HttpUserAgentParser.AspNetCore/LICENSE.txt b/src/HttpUserAgentParser.AspNetCore/LICENSE.txt index 11152f9..023aed7 100644 --- a/src/HttpUserAgentParser.AspNetCore/LICENSE.txt +++ b/src/HttpUserAgentParser.AspNetCore/LICENSE.txt @@ -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 diff --git a/src/HttpUserAgentParser.MemoryCache/LICENSE.txt b/src/HttpUserAgentParser.MemoryCache/LICENSE.txt index 11152f9..023aed7 100644 --- a/src/HttpUserAgentParser.MemoryCache/LICENSE.txt +++ b/src/HttpUserAgentParser.MemoryCache/LICENSE.txt @@ -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 diff --git a/src/HttpUserAgentParser/LICENSE.txt b/src/HttpUserAgentParser/LICENSE.txt index 11152f9..023aed7 100644 --- a/src/HttpUserAgentParser/LICENSE.txt +++ b/src/HttpUserAgentParser/LICENSE.txt @@ -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 From 40620c632b95595e1ecb3a610001535a4e186517 Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 23 Aug 2025 11:20:23 +0200 Subject: [PATCH 08/11] refactor(parser): improve browser rule variable naming --- .vscode/tasks.json | 12 ------------ MyCSharp.HttpUserAgentParser.sln | 1 + src/HttpUserAgentParser/HttpUserAgentParser.cs | 16 ++++++++-------- 3 files changed, 9 insertions(+), 20 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f790c89..5ddca9a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,16 +1,6 @@ { "version": "2.0.0", "tasks": [ - { - "label": "test", - "type": "shell", - "command": "dotnet test --nologo", - "args": [], - "problemMatcher": [ - "$msCompile" - ], - "group": "build" - }, { "label": "test", "type": "shell", @@ -21,5 +11,3 @@ ], "group": "build" } - ] -} \ No newline at end of file diff --git a/MyCSharp.HttpUserAgentParser.sln b/MyCSharp.HttpUserAgentParser.sln index a444be3..7596310 100644 --- a/MyCSharp.HttpUserAgentParser.sln +++ b/MyCSharp.HttpUserAgentParser.sln @@ -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 diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index f5b989d..84c6253 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -79,30 +79,30 @@ public static bool TryGetPlatform(string userAgent, [NotNullWhen(true)] out Http public static (string Name, string? Version)? GetBrowser(string userAgent) { ReadOnlySpan ua = userAgent.AsSpan(); - foreach ((string Name, string DetectToken, string? VersionToken) rule in HttpUserAgentStatics.s_browserRules) + foreach ((string Name, string DetectToken, string? VersionToken) browserRule in HttpUserAgentStatics.s_browserRules) { - if (!TryIndexOf(ua, rule.DetectToken, out int detectIndex)) + if (!TryIndexOf(ua, browserRule.DetectToken, out int detectIndex)) { continue; } // Version token may differ (e.g., Safari uses "Version/") int versionSearchStart = detectIndex; - if (!string.IsNullOrEmpty(rule.VersionToken)) + if (!string.IsNullOrEmpty(browserRule.VersionToken)) { - if (TryIndexOf(ua, rule.VersionToken!, out int vtIndex)) + if (TryIndexOf(ua, browserRule.VersionToken!, out int vtIndex)) { - versionSearchStart = vtIndex + rule.VersionToken!.Length; + versionSearchStart = vtIndex + browserRule.VersionToken!.Length; } else { // If specific version token wasn't found, fall back to detect token area - versionSearchStart = detectIndex + rule.DetectToken.Length; + versionSearchStart = detectIndex + browserRule.DetectToken.Length; } } else { - versionSearchStart = detectIndex + rule.DetectToken.Length; + versionSearchStart = detectIndex + browserRule.DetectToken.Length; } string? version = null; @@ -111,7 +111,7 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) version = userAgent.AsSpan(range.Start.Value, range.End.Value - range.Start.Value).ToString(); } - return (rule.Name, version); + return (browserRule.Name, version); } return null; From 9d9f88cdb57779254f9c0bb61798e7d2e905b51e Mon Sep 17 00:00:00 2001 From: Benjamin Abt Date: Sat, 23 Aug 2025 11:29:12 +0200 Subject: [PATCH 09/11] refactor(parser): improve variable naming in platform parsing --- src/HttpUserAgentParser/HttpUserAgentParser.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 84c6253..9c3bb87 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -50,14 +50,13 @@ public static HttpUserAgentInformation Parse(string userAgent) { // Fast, allocation-free token scan (keeps public statics untouched) ReadOnlySpan ua = userAgent.AsSpan(); - foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) p in HttpUserAgentStatics.s_platformRules) + foreach ((string Token, string Name, HttpUserAgentPlatformType PlatformType) platform in HttpUserAgentStatics.s_platformRules) { - if (ContainsIgnoreCase(ua, p.Token)) + if (ContainsIgnoreCase(ua, platform.Token)) { return new HttpUserAgentPlatformInformation( - regex: HttpUserAgentStatics.GetPlatformRegexForToken(p.Token), - name: p.Name, - platformType: p.PlatformType); + HttpUserAgentStatics.GetPlatformRegexForToken(platform.Token), + platform.Name, platform.PlatformType); } } From 1ae2901e848fa90c6e2fce277b78dcc15f1ed4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnther=20Foidl?= Date: Sat, 23 Aug 2025 14:23:44 +0200 Subject: [PATCH 10/11] Use FrozenDictionary as we only have read access after construction --- .../HttpUserAgentStatics.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/HttpUserAgentParser/HttpUserAgentStatics.cs b/src/HttpUserAgentParser/HttpUserAgentStatics.cs index 28d2510..3eca3f0 100644 --- a/src/HttpUserAgentParser/HttpUserAgentStatics.cs +++ b/src/HttpUserAgentParser/HttpUserAgentStatics.cs @@ -1,5 +1,6 @@ // Copyright © https://myCSharp.de - all rights reserved +using System.Collections.Frozen; using System.Text.RegularExpressions; namespace MyCSharp.HttpUserAgentParser; @@ -121,8 +122,8 @@ internal static readonly (string Token, string Name, HttpUserAgentPlatformType P ]; // Precompiled platform regex map to attach to PlatformInformation without per-call allocations - private static readonly Dictionary s_platformRegexMap = s_platformRules - .ToDictionary(p => p.Token, p => CreateDefaultPlatformRegex(p.Token), StringComparer.OrdinalIgnoreCase); + private static readonly FrozenDictionary s_platformRegexMap = s_platformRules + .ToFrozenDictionary(p => p.Token, p => CreateDefaultPlatformRegex(p.Token), StringComparer.OrdinalIgnoreCase); internal static Regex GetPlatformRegexForToken(string token) => s_platformRegexMap[token]; @@ -139,7 +140,7 @@ private static Regex CreateDefaultBrowserRegex(string key) /// /// Browsers /// - public static readonly Dictionary Browsers = new() + public static readonly FrozenDictionary Browsers = new Dictionary() { { CreateDefaultBrowserRegex("OPR"), "Opera" }, { CreateDefaultBrowserRegex("Flock"), "Flock" }, @@ -176,7 +177,7 @@ private static Regex CreateDefaultBrowserRegex(string key) { CreateDefaultBrowserRegex("Maxthon"), "Maxthon" }, { CreateDefaultBrowserRegex("ipod touch"), "Apple iPod" }, { CreateDefaultBrowserRegex("Ubuntu"), "Ubuntu Web Browser" }, - }; + }.ToFrozenDictionary(); /// /// Fast-path browser token rules. If these fail to extract a version, code will fall back to regex rules. @@ -223,7 +224,7 @@ internal static readonly (string Name, string DetectToken, string? VersionToken) /// /// Mobiles /// - public static readonly Dictionary Mobiles = new(StringComparer.InvariantCultureIgnoreCase) + public static readonly FrozenDictionary Mobiles = new Dictionary(StringComparer.InvariantCultureIgnoreCase) { // Legacy { "mobileexplorer", "Mobile Explorer" }, @@ -306,7 +307,7 @@ internal static readonly (string Name, string DetectToken, string? VersionToken) { "up.browser", "Generic Mobile" }, { "smartphone", "Generic Mobile" }, { "cellphone", "Generic Mobile" }, - }; + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); /// /// Robots @@ -385,8 +386,9 @@ public static readonly (string Key, string Value)[] Robots = /// /// Tools /// - public static readonly Dictionary Tools = new(StringComparer.OrdinalIgnoreCase) + public static readonly FrozenDictionary Tools = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "curl", "curl" } - }; + } + .ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); } From 324f1a8da407b4ca03f1f23efd5e084bab20c409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=BCnther=20Foidl?= Date: Sat, 23 Aug 2025 15:02:41 +0200 Subject: [PATCH 11/11] Removed bound checks in TryExtractVersion --- .../HttpUserAgentParser.cs | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/src/HttpUserAgentParser/HttpUserAgentParser.cs b/src/HttpUserAgentParser/HttpUserAgentParser.cs index 9c3bb87..5e788fd 100644 --- a/src/HttpUserAgentParser/HttpUserAgentParser.cs +++ b/src/HttpUserAgentParser/HttpUserAgentParser.cs @@ -105,9 +105,10 @@ public static (string Name, string? Version)? GetBrowser(string userAgent) } string? version = null; - if (TryExtractVersion(ua, versionSearchStart, out Range range)) + ua = ua.Slice(versionSearchStart); + if (TryExtractVersion(ua, out Range range)) { - version = userAgent.AsSpan(range.Start.Value, range.End.Value - range.Start.Value).ToString(); + version = ua[range].ToString(); } return (browserRule.Name, version); @@ -176,60 +177,53 @@ public static bool TryGetMobileDevice(string userAgent, [NotNullWhen(true)] out } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool ContainsIgnoreCase(ReadOnlySpan haystack, string needle) + private static bool ContainsIgnoreCase(ReadOnlySpan haystack, ReadOnlySpan needle) => TryIndexOf(haystack, needle, out _); [MethodImpl(MethodImplOptions.AggressiveInlining)] - private static bool TryIndexOf(ReadOnlySpan haystack, string needle, out int index) + private static bool TryIndexOf(ReadOnlySpan haystack, ReadOnlySpan needle, out int index) { - index = haystack.IndexOf(needle.AsSpan(), StringComparison.OrdinalIgnoreCase); + index = haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase); return index >= 0; } /// - /// Extracts a dotted numeric version starting at or after . + /// Extracts a dotted numeric version. /// Accepts digits and dots; skips common separators ('/', ' ', ':', '=') until first digit. /// Returns false if no version-like token is found. /// - private static bool TryExtractVersion(ReadOnlySpan haystack, int startIndex, out Range range) + private static bool TryExtractVersion(ReadOnlySpan haystack, out Range range) { 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; + const int Window = 128; + if (haystack.Length >= Window) + { + haystack = haystack.Slice(0, Window); + } - // Skip separators until we hit a digit - while (i < end) + int i = 0; + for (; i < haystack.Length; ++i) { char c = haystack[i]; - if ((uint)(c - '0') <= 9) + if (char.IsBetween(c, '0', '9')) { break; } - i++; - } - - if (i >= end) - { - return false; } int s = i; - while (i < end) + haystack = haystack.Slice(i + 1); + for (i = 0; i < haystack.Length; ++i) { char c = haystack[i]; - if (!((uint)(c - '0') <= 9 || c == '.')) + if (!(char.IsBetween(c, '0', '9') || c == '.')) { break; } - i++; } + i += s + 1; // shift back the previous domain if (i == s) {