feat: add initial project setup
All checks were successful
default / dotnet-default-workflow (push) Successful in 1m2s
release / dotnet-release-workflow (push) Successful in 1m6s

This commit is contained in:
Louis Seubert 2026-04-05 08:23:42 +02:00
commit ed1e31314d
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
32 changed files with 3397 additions and 0 deletions

397
.editorconfig Normal file
View file

@ -0,0 +1,397 @@
root = true
[*]
indent_style = tab
indent_size = 4
tab_width = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 120
[*.{md,json,yaml,yml}]
indent_size = 2
indent_style = space
trim_trailing_whitespace = false
[*.{csproj,props,targets,slnx,config}]
indent_size = 2
indent_style = space
[*.{cs,vb}]
#### code style rule default severity ####
dotnet_analyzer_diagnostic.category-style.severity = warning
#### .NET Coding Conventions ####
[*.{cs,vb}]
# Organize usings
file_header_template = Copyright (c) The Geekeey Authors\nSPDX-License-Identifier: EUPL-1.2
dotnet_separate_import_directive_groups = true
dotnet_sort_system_directives_first = true
# this. and Me. preferences
dotnet_style_qualification_for_event = false
dotnet_style_qualification_for_field = false
dotnet_style_qualification_for_method = false
dotnet_style_qualification_for_property = false
# Language keywords vs BCL types preferences
dotnet_style_predefined_type_for_locals_parameters_members = true
dotnet_style_predefined_type_for_member_access = true
# Parentheses preferences
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity
dotnet_style_parentheses_in_other_operators = never_if_unnecessary
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity
# Modifier preferences
dotnet_style_require_accessibility_modifiers = for_non_interface_members
# Expression-level preferences
dotnet_diagnostic.IDE0270.severity = none
dotnet_style_coalesce_expression = true
dotnet_style_collection_initializer = true
dotnet_style_explicit_tuple_names = true
dotnet_style_null_propagation = true
dotnet_style_object_initializer = true
dotnet_style_operator_placement_when_wrapping = beginning_of_line
dotnet_style_prefer_auto_properties = true
dotnet_style_prefer_compound_assignment = true
dotnet_diagnostic.IDE0045.severity = suggestion
dotnet_style_prefer_conditional_expression_over_assignment = true
dotnet_diagnostic.IDE0046.severity = suggestion
dotnet_style_prefer_conditional_expression_over_return = true
dotnet_style_prefer_inferred_anonymous_type_member_names = true
dotnet_style_prefer_inferred_tuple_names = true
dotnet_style_prefer_is_null_check_over_reference_equality_method = true
dotnet_style_prefer_simplified_boolean_expressions = true
dotnet_style_prefer_simplified_interpolation = true
dotnet_style_namespace_match_folder = false
# Field preferences
dotnet_style_readonly_field = true
# Suppression preferences
dotnet_remove_unnecessary_suppression_exclusions = none
#### C# Coding Conventions ####
[*.cs]
# var preferences
csharp_style_var_for_built_in_types = true
csharp_style_var_when_type_is_apparent = true
csharp_style_var_elsewhere = true
# Expression-bodied members
csharp_style_expression_bodied_accessors = true
csharp_style_expression_bodied_constructors = false
csharp_style_expression_bodied_indexers = true
csharp_style_expression_bodied_lambdas = true
csharp_style_expression_bodied_local_functions = false
csharp_style_expression_bodied_methods = false
csharp_style_expression_bodied_operators = false
csharp_style_expression_bodied_properties = true
# Pattern matching preferences
csharp_style_pattern_matching_over_as_with_null_check = true
csharp_style_pattern_matching_over_is_with_cast_check = true
csharp_style_prefer_not_pattern = true
csharp_style_prefer_pattern_matching = true
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true
# Modifier preferences
csharp_prefer_static_local_function = true
csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async
# Code-block preferences
csharp_prefer_braces = true
csharp_prefer_simple_using_statement = true
csharp_style_prefer_top_level_statements = false
# Expression-level preferences
csharp_prefer_simple_default_expression = true
csharp_style_deconstructed_variable_declaration = true
csharp_style_inlined_variable_declaration = true
csharp_style_pattern_local_over_anonymous_function = true
csharp_style_prefer_index_operator = true
csharp_style_prefer_range_operator = true
csharp_style_throw_expression = true
dotnet_diagnostic.IDE0058.severity = suggestion
csharp_style_unused_value_assignment_preference = discard_variable
csharp_style_unused_value_expression_statement_preference = discard_variable
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace
# 'namespace' preferences
csharp_style_namespace_declarations = file_scoped
# 'constructor' preferences
csharp_style_prefer_primary_constructors = false
#### C# Formatting Rules ####
[*.cs]
# New line preferences
csharp_new_line_before_catch = true
csharp_new_line_before_else = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_open_brace = all
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_labels = one_less_than_current
csharp_indent_switch_labels = true
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = false
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Wrapping preferences
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#### .NET Naming styles ####
[*.{cs,vb}]
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces
dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum
dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.types_and_namespaces.required_modifiers =
dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion
dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces
dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.interfaces.required_modifiers =
dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums
dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.enums.applicable_kinds = enum
dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.enums.required_modifiers =
dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion
dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters
dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase
dotnet_naming_symbols.type_parameters.applicable_kinds = namespace
dotnet_naming_symbols.type_parameters.applicable_accessibilities = *
dotnet_naming_symbols.type_parameters.required_modifiers =
dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods
dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.methods.applicable_kinds = method
dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.methods.required_modifiers =
dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion
dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters
dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase
dotnet_naming_symbols.parameters.applicable_kinds = parameter
dotnet_naming_symbols.parameters.applicable_accessibilities = *
dotnet_naming_symbols.parameters.required_modifiers =
dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties
dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.properties.applicable_kinds = property
dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.properties.required_modifiers =
dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.events_should_be_pascalcase.symbols = events
dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.events.applicable_kinds = event
dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.events.required_modifiers =
# local
dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables
dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase
dotnet_naming_symbols.local_variables.applicable_kinds = local
dotnet_naming_symbols.local_variables.applicable_accessibilities = local
dotnet_naming_symbols.local_variables.required_modifiers =
dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_symbols.local_functions.applicable_accessibilities = *
dotnet_naming_symbols.local_functions.required_modifiers =
dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion
dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants
dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase
dotnet_naming_symbols.local_constants.applicable_kinds = local
dotnet_naming_symbols.local_constants.applicable_accessibilities = local
dotnet_naming_symbols.local_constants.required_modifiers = const
# private
dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion
dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields
dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields
dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase
dotnet_naming_symbols.private_static_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_fields.required_modifiers = static
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields
dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields
dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.private_constant_fields.applicable_kinds = field
dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected
dotnet_naming_symbols.private_constant_fields.required_modifiers = const
# public
dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields
dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.public_fields.applicable_kinds = field
dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_fields.required_modifiers =
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields
dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields
dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.public_constant_fields.applicable_kinds = field
dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal
dotnet_naming_symbols.public_constant_fields.required_modifiers = const
# others
dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion
dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members
dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
dotnet_naming_symbols.non_field_members.required_modifiers =
# Naming styles
dotnet_naming_style.pascalcase.required_prefix =
dotnet_naming_style.pascalcase.required_suffix =
dotnet_naming_style.pascalcase.word_separator =
dotnet_naming_style.pascalcase.capitalization = pascal_case
dotnet_naming_style.ipascalcase.required_prefix = I
dotnet_naming_style.ipascalcase.required_suffix =
dotnet_naming_style.ipascalcase.word_separator =
dotnet_naming_style.ipascalcase.capitalization = pascal_case
dotnet_naming_style.tpascalcase.required_prefix = T
dotnet_naming_style.tpascalcase.required_suffix =
dotnet_naming_style.tpascalcase.word_separator =
dotnet_naming_style.tpascalcase.capitalization = pascal_case
dotnet_naming_style._camelcase.required_prefix = _
dotnet_naming_style._camelcase.required_suffix =
dotnet_naming_style._camelcase.word_separator =
dotnet_naming_style._camelcase.capitalization = camel_case
dotnet_naming_style.camelcase.required_prefix =
dotnet_naming_style.camelcase.required_suffix =
dotnet_naming_style.camelcase.word_separator =
dotnet_naming_style.camelcase.capitalization = camel_case
dotnet_naming_style.s_camelcase.required_prefix = s_
dotnet_naming_style.s_camelcase.required_suffix =
dotnet_naming_style.s_camelcase.word_separator =
dotnet_naming_style.s_camelcase.capitalization = camel_case

View file

@ -0,0 +1,43 @@
name: default
on:
push:
branches: [ "main", "develop" ]
paths-ignore:
- "doc/**"
- "*.md"
pull_request:
branches: [ "main", "develop" ]
paths-ignore:
- "doc/**"
- "*.md"
jobs:
default:
name: dotnet-default-workflow
runs-on: debian-latest
strategy:
matrix:
dotnet-version: [ "10.0" ]
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
steps:
- name: checkout
uses: https://code.geekeey.de/actions/checkout@1
- name: nuget login
run: |
# This token is readonly and can only be used for restore
dotnet nuget update source geekeey --store-password-in-clear-text \
--username "${{ github.actor }}" --password "${{ github.token }}"
- name: dotnet pack
run: |
dotnet pack -p:ContinuousIntegrationBuild=true
- name: dotnet format --verify-no-changes
run: |
dotnet format --no-restore --verify-no-changes --verbosity normal
- name: dotnet test
run: |
dotnet test -p:ContinuousIntegrationBuild=true

View file

@ -0,0 +1,40 @@
name: release
on:
push:
tags: [ "[0-9]+.[0-9]+.[0-9]+" ]
jobs:
release:
name: dotnet-release-workflow
runs-on: debian-latest
strategy:
matrix:
dotnet-version: [ "10.0" ]
container: mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet-version }}
steps:
- uses: https://code.geekeey.de/actions/checkout@1
- name: nuget login
run: |
# This token is readonly and can only be used for restore
dotnet nuget update source geekeey --store-password-in-clear-text \
--username "${{ github.actor }}" --password "${{ github.token }}"
- name: dotnet pack
run: |
dotnet pack -p:ContinuousIntegrationBuild=true
- name: dotnet format --verify-no-changes
run: |
dotnet format --no-restore --verify-no-changes --verbosity normal
- name: dotnet test
run: |
dotnet test -p:ContinuousIntegrationBuild=true
- name: dotnet nuget push
run: |
# The token used here is only intended to publish packages
dotnet nuget push -k "${{ secrets.geekeey_package_registry }}" \
artifacts/package/release/Geekeey.*.nupkg

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
artifacts/
*.DotSettings.user

22
CHANGELOG.md Normal file
View file

@ -0,0 +1,22 @@
# Changelog
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-05-21
### Added
- This is the initial release of the library.
## [Unreleased]
### Added
### Changed
### Removed
[1.0.0]: https://code.geekeey.de/geekeey/semver/releases/tag/1.0.0
[Unreleased]: https://code.geekeey.de/geekeey/semver/compare/1.0.0...HEAD

37
Directory.Build.props Normal file
View file

@ -0,0 +1,37 @@
<Project>
<PropertyGroup Condition="'$(ArtifactsPath)' == ''">
<ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
</PropertyGroup>
<PropertyGroup>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<VersionPrefix>1.0.0</VersionPrefix>
</PropertyGroup>
<PropertyGroup>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<WarningsAsErrors>nullable</WarningsAsErrors>
<WarningsAsErrors Condition="'$(ContinuousIntegrationBuild)' == 'true'">true</WarningsAsErrors>
</PropertyGroup>
<PropertyGroup Label="NuGet Package Info">
<Authors>The Geekeey Team</Authors>
<Copyright>Copyright (c) The Geekeey Team 2026</Copyright>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.SourceLink.Gitea" PrivateAssets="all" />
</ItemGroup>
<PropertyGroup>
<NuGetAuditLevel>moderate</NuGetAuditLevel>
<NuGetAuditMode>all</NuGetAuditMode>
</PropertyGroup>
</Project>

2
Directory.Build.targets Normal file
View file

@ -0,0 +1,2 @@
<Project>
</Project>

9
Directory.Packages.props Normal file
View file

@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.SourceLink.Gitea" Version="10.0.102" />
<PackageVersion Include="TUnit" Version="1.11.51" />
</ItemGroup>
</Project>

287
LICENSE.md Normal file
View file

@ -0,0 +1,287 @@
EUROPEAN UNION PUBLIC LICENCE v. 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the EUPL) applies to the Work (as defined
below) which is provided under the terms of this Licence. Any use of the Work,
other than as authorised under this Licence is prohibited (to the extent such
use is covered by a right of the copyright holder of the Work).
The Work is provided under the terms of this Licence when the Licensor (as
defined below) has placed the following notice immediately following the
copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the EUPL.
1. Definitions
In this Licence, the following terms have the following meaning:
- The Licence: this Licence.
- The Original Work: the work or software distributed or communicated by the
Licensor under this Licence, available as Source Code and also as Executable
Code as the case may be.
- Derivative Works: the works or software that could be created by the
Licensee, based upon the Original Work or modifications thereof. This Licence
does not define the extent of modification or dependence on the Original Work
required in order to classify a work as a Derivative Work; this extent is
determined by copyright law applicable in the country mentioned in Article 15.
- The Work: the Original Work or its Derivative Works.
- The Source Code: the human-readable form of the Work which is the most
convenient for people to study and modify.
- The Executable Code: any code which has generally been compiled and which is
meant to be interpreted by a computer as a program.
- The Licensor: the natural or legal person that distributes or communicates
the Work under the Licence.
- Contributor(s): any natural or legal person who modifies the Work under the
Licence, or otherwise contributes to the creation of a Derivative Work.
- The Licensee or You: any natural or legal person who makes any usage of
the Work under the terms of the Licence.
- Distribution or Communication: any act of selling, giving, lending,
renting, distributing, communicating, transmitting, or otherwise making
available, online or offline, copies of the Work or providing access to its
essential functionalities at the disposal of any other natural or legal
person.
2. Scope of the rights granted by the Licence
The Licensor hereby grants You a worldwide, royalty-free, non-exclusive,
sublicensable licence to do the following, for the duration of copyright vested
in the Original Work:
- use the Work in any circumstance and for all usage,
- reproduce the Work,
- modify the Work, and make Derivative Works based upon the Work,
- communicate to the public, including the right to make available or display
the Work or copies thereof to the public and perform publicly, as the case may
be, the Work,
- distribute the Work or copies thereof,
- lend and rent the Work or copies thereof,
- sublicense rights in the Work or copies thereof.
Those rights can be exercised on any media, supports and formats, whether now
known or later invented, as far as the applicable law permits so.
In the countries where moral rights apply, the Licensor waives his right to
exercise his moral right to the extent allowed by law in order to make effective
the licence of the economic rights here above listed.
The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to
any patents held by the Licensor, to the extent necessary to make use of the
rights granted on the Work under this Licence.
3. Communication of the Source Code
The Licensor may provide the Work either in its Source Code form, or as
Executable Code. If the Work is provided as Executable Code, the Licensor
provides in addition a machine-readable copy of the Source Code of the Work
along with each copy of the Work that the Licensor distributes or indicates, in
a notice following the copyright notice attached to the Work, a repository where
the Source Code is easily and freely accessible for as long as the Licensor
continues to distribute or communicate the Work.
4. Limitations on copyright
Nothing in this Licence is intended to deprive the Licensee of the benefits from
any exception or limitation to the exclusive rights of the rights owners in the
Work, of the exhaustion of those rights or of other applicable limitations
thereto.
5. Obligations of the Licensee
The grant of the rights mentioned above is subject to some restrictions and
obligations imposed on the Licensee. Those obligations are the following:
Attribution right: The Licensee shall keep intact all copyright, patent or
trademarks notices and all notices that refer to the Licence and to the
disclaimer of warranties. The Licensee must include a copy of such notices and a
copy of the Licence with every copy of the Work he/she distributes or
communicates. The Licensee must cause any Derivative Work to carry prominent
notices stating that the Work has been modified and the date of modification.
Copyleft clause: If the Licensee distributes or communicates copies of the
Original Works or Derivative Works, this Distribution or Communication will be
done under the terms of this Licence or of a later version of this Licence
unless the Original Work is expressly distributed only under this version of the
Licence — for example by communicating EUPL v. 1.2 only. The Licensee
(becoming Licensor) cannot offer or impose any additional terms or conditions on
the Work or Derivative Work that alter or restrict the terms of the Licence.
Compatibility clause: If the Licensee Distributes or Communicates Derivative
Works or copies thereof based upon both the Work and another work licensed under
a Compatible Licence, this Distribution or Communication can be done under the
terms of this Compatible Licence. For the sake of this clause, Compatible
Licence refers to the licences listed in the appendix attached to this Licence.
Should the Licensee's obligations under the Compatible Licence conflict with
his/her obligations under this Licence, the obligations of the Compatible
Licence shall prevail.
Provision of Source Code: When distributing or communicating copies of the Work,
the Licensee will provide a machine-readable copy of the Source Code or indicate
a repository where this Source will be easily and freely available for as long
as the Licensee continues to distribute or communicate the Work.
Legal Protection: This Licence does not grant permission to use the trade names,
trademarks, service marks, or names of the Licensor, except as required for
reasonable and customary use in describing the origin of the Work and
reproducing the content of the copyright notice.
6. Chain of Authorship
The original Licensor warrants that the copyright in the Original Work granted
hereunder is owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each Contributor warrants that the copyright in the modifications he/she brings
to the Work are owned by him/her or licensed to him/her and that he/she has the
power and authority to grant the Licence.
Each time You accept the Licence, the original Licensor and subsequent
Contributors grant You a licence to their contributions to the Work, under the
terms of this Licence.
7. Disclaimer of Warranty
The Work is a work in progress, which is continuously improved by numerous
Contributors. It is not a finished work and may therefore contain defects or
bugs inherent to this type of development.
For the above reason, the Work is provided under the Licence on an as is basis
and without warranties of any kind concerning the Work, including without
limitation merchantability, fitness for a particular purpose, absence of defects
or errors, accuracy, non-infringement of intellectual property rights other than
copyright as stated in Article 6 of this Licence.
This disclaimer of warranty is an essential part of the Licence and a condition
for the grant of any rights to the Work.
8. Disclaimer of Liability
Except in the cases of wilful misconduct or damages directly caused to natural
persons, the Licensor will in no event be liable for any direct or indirect,
material or moral, damages of any kind, arising out of the Licence or of the use
of the Work, including without limitation, damages for loss of goodwill, work
stoppage, computer failure or malfunction, loss of data or any commercial
damage, even if the Licensor has been advised of the possibility of such damage.
However, the Licensor will be liable under statutory product liability laws as
far such laws apply to the Work.
9. Additional agreements
While distributing the Work, You may choose to conclude an additional agreement,
defining obligations or services consistent with this Licence. However, if
accepting obligations, You may act only on your own behalf and on your sole
responsibility, not on behalf of the original Licensor or any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor harmless
for any liability incurred by, or claims asserted against such Contributor by
the fact You have accepted any warranty or additional liability.
10. Acceptance of the Licence
The provisions of this Licence can be accepted by clicking on an icon I agree
placed under the bottom of a window displaying the text of this Licence or by
affirming consent in any other similar way, in accordance with the rules of
applicable law. Clicking on that icon indicates your clear and irrevocable
acceptance of this Licence and all of its terms and conditions.
Similarly, you irrevocably accept this Licence and all of its terms and
conditions by exercising any rights granted to You by Article 2 of this Licence,
such as the use of the Work, the creation by You of a Derivative Work or the
Distribution or Communication by You of the Work or copies thereof.
11. Information to the public
In case of any Distribution or Communication of the Work by means of electronic
communication by You (for example, by offering to download the Work from a
remote location) the distribution channel or media (for example, a website) must
at least provide to the public the information requested by the applicable law
regarding the Licensor, the Licence and the way it may be accessible, concluded,
stored and reproduced by the Licensee.
12. Termination of the Licence
The Licence and the rights granted hereunder will terminate automatically upon
any breach by the Licensee of the terms of the Licence.
Such a termination will not terminate the licences of any person who has
received the Work from the Licensee under the Licence, provided such persons
remain in full compliance with the Licence.
13. Miscellaneous
Without prejudice of Article 9 above, the Licence represents the complete
agreement between the Parties as to the Work.
If any provision of the Licence is invalid or unenforceable under applicable
law, this will not affect the validity or enforceability of the Licence as a
whole. Such provision will be construed or reformed so as necessary to make it
valid and enforceable.
The European Commission may publish other linguistic versions or new versions of
this Licence or updated versions of the Appendix, so far this is required and
reasonable, without reducing the scope of the rights granted by the Licence. New
versions of the Licence will be published with a unique version number.
All linguistic versions of this Licence, approved by the European Commission,
have identical value. Parties can take advantage of the linguistic version of
their choice.
14. Jurisdiction
Without prejudice to specific agreement between parties,
- any litigation resulting from the interpretation of this License, arising
between the European Union institutions, bodies, offices or agencies, as a
Licensor, and any Licensee, will be subject to the jurisdiction of the Court
of Justice of the European Union, as laid down in article 272 of the Treaty on
the Functioning of the European Union,
- any litigation arising between other parties and resulting from the
interpretation of this License, will be subject to the exclusive jurisdiction
of the competent court where the Licensor resides or conducts its primary
business.
15. Applicable Law
Without prejudice to specific agreement between parties,
- this Licence shall be governed by the law of the European Union Member State
where the Licensor has his seat, resides or has his registered office,
- this licence shall be governed by Belgian law if the Licensor has no seat,
residence or registered office inside a European Union Member State.
Appendix
Compatible Licences according to Article 5 EUPL are:
- GNU General Public License (GPL) v. 2, v. 3
- GNU Affero General Public License (AGPL) v. 3
- Open Software License (OSL) v. 2.1, v. 3.0
- Eclipse Public License (EPL) v. 1.0
- CeCILL v. 2.0, v. 2.1
- Mozilla Public Licence (MPL) v. 2
- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3
- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for
works other than software
- European Union Public Licence (EUPL) v. 1.1, v. 1.2
- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong
Reciprocity (LiLiQ-R+).
The European Commission may update this Appendix to later versions of the above
licences without producing a new version of the EUPL, as long as they provide
the rights granted in Article 2 of this Licence and protect the covered Source
Code from exclusive appropriation.
All other changes or additions to this Appendix require the production of a new
EUPL version.

82
README.md Normal file
View file

@ -0,0 +1,82 @@
# `Geekeey.SemVer`
SemVer is a .NET library for parsing and comparing semantic version numbers. It provides a simple API for working with
semantic versioning, making it easy to manage version numbers in your .NET projects.
## Features
- **Parsing**: Parse semantic version strings into structured objects.
- **Comparison**: Compare semantic version objects to determine their order.
- **Validation**: Validate semantic version strings to ensure they conform to the SemVer specification.
- **Pre-release and Build Metadata**: Support for pre-release versions and build metadata as defined in the SemVer specification.
## Getting Started
### Install the NuGet package:
```shell
dotnet add package Geekeey.SemVer
```
You may need to add our NuGet feed to your `nuget.config` this can be done by running the following command:
```shell
dotnet nuget add source -n geekeey https://code.geekeey.de/api/packages/geekeey/nuget/index.json
```
### Usage
```csharp
using Geekeey.SemVer;
var version = SemanticVersion.Parse("1.2.3-beta+build.7");
var next = new SemanticVersion(1, 3, 0);
Console.WriteLine(version);
Console.WriteLine(version.Major);
Console.WriteLine(version.Prerelease);
Console.WriteLine(version.Metadata);
Console.WriteLine(version < next);
```
You can also use `TryParse` when you need to handle invalid input without exceptions.
```csharp
using Geekeey.SemVer;
if (SemanticVersion.TryParse("1.2.3-alpha", out var parsed))
{
Console.WriteLine(parsed);
}
```
Version ranges support npm-style and Maven-style syntax.
```csharp
using Geekeey.SemVer;
var npmRange = SemanticVersionRange.Parse("^1.2.3");
var mavenRange = SemanticVersionRange.Parse("[1.2.3,2.0.0)");
var candidate = SemanticVersion.Parse("1.4.2");
Console.WriteLine(npmRange.Contains(candidate));
Console.WriteLine(mavenRange.Contains(candidate));
Console.WriteLine(npmRange.ToString("m", null));
Console.WriteLine(mavenRange.ToString("ns", null));
```
Both `SemanticVersion` and `SemanticVersionRange` integrate with `System.Text.Json`.
```csharp
using System.Text.Json;
using Geekeey.SemVer;
var version = SemanticVersion.Parse("1.2.3-beta+build.7");
var range = SemanticVersionRange.Parse("^1.2.3");
var versionJson = JsonSerializer.Serialize(version);
var rangeJson = JsonSerializer.Serialize(range);
var roundTrippedVersion = JsonSerializer.Deserialize<SemanticVersion>(versionJson);
var roundTrippedRange = JsonSerializer.Deserialize<SemanticVersionRange>(rangeJson);
```

11
global.json Normal file
View file

@ -0,0 +1,11 @@
{
"$schema": "https://www.schemastore.org/global.json",
"sdk": {
"version": "10.0.0",
"rollForward": "latestMinor"
},
"msbuild-sdks": {},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

19
nuget.config Normal file
View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<config>
<add key="defaultPushSource" value="geekeey" />
</config>
<packageSources>
<clear />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
<add key="geekeey" value="https://code.geekeey.de/api/packages/geekeey/nuget/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget">
<package pattern="*" />
</packageSource>
<packageSource key="geekeey">
<package pattern="Geekeey.*" />
</packageSource>
</packageSourceMapping>
</configuration>

4
semver.slnx Normal file
View file

@ -0,0 +1,4 @@
<Solution>
<Project Path="src/semver/Geekeey.SemVer.csproj" />
<Project Path="src/semver.tests/Geekeey.SemVer.Tests.csproj" />
</Solution>

View file

@ -0,0 +1,15 @@
[*.{cs,vb}]
# disable CA1822: Mark members as static
# -> TUnit requiring instance methods for test cases
dotnet_diagnostic.CA1822.severity = none
# disable CA1707: Identifiers should not contain underscores
dotnet_diagnostic.CA1707.severity = none
# disable IDE0060: Remove unused parameter
dotnet_diagnostic.IDE0060.severity = none
# disable IDE0005: Unnecessary using directive
dotnet_diagnostic.IDE0005.severity = none
# disable IDE0390: Method can be made synchronous
dotnet_diagnostic.IDE0390.severity = none
# disable IDE0391: Method can be made synchronous
dotnet_diagnostic.IDE0391.severity = none

View file

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverageAttribute" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\semver\Geekeey.SemVer.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,115 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.SemVer.Tests;
internal sealed class SemanticVersionComparerTests
{
[Test]
[Arguments("1.0.0", "2.0.0", -1)]
[Arguments("2.0.0", "1.0.0", 1)]
[Arguments("1.0.0", "1.1.0", -1)]
[Arguments("1.1.0", "1.0.0", 1)]
[Arguments("1.0.0", "1.0.1", -1)]
[Arguments("1.0.1", "1.0.0", 1)]
[Arguments("1.0.0-alpha", "1.0.0", -1)]
[Arguments("1.0.0", "1.0.0-alpha", 1)]
[Arguments("1.0.0-alpha", "1.0.0-alpha.1", -1)]
[Arguments("1.0.0-alpha.1", "1.0.0-alpha.beta", -1)]
[Arguments("1.0.0-alpha.beta", "1.0.0-beta", -1)]
[Arguments("1.0.0-beta", "1.0.0-beta.2", -1)]
[Arguments("1.0.0-beta.2", "1.0.0-beta.11", -1)]
[Arguments("1.0.0-beta.11", "1.0.0-rc.1", -1)]
[Arguments("1.0.0-rc.1", "1.0.0", -1)]
[Arguments("1.0.0-alpha.5", "1.0.0-alpha.10", -1)]
[Arguments("1.0.0-alpha.10", "1.0.0-alpha.5", 1)]
[Arguments("1.0.0-alpha.beta", "1.0.0-alpha.5", 1)]
[Arguments("1.0.0-alpha.5", "1.0.0-alpha.beta", -1)]
[Arguments("1.0.0-alpha.1.2", "1.0.0-alpha.1.2.3", -1)]
[Arguments("1.0.0-alpha.1.2.3", "1.0.0-alpha.1.2", 1)]
[Arguments("1.0.0-alpha-b", "1.0.0-alpha-a", 1)]
[Arguments("1.0.0-0.3.7", "1.0.0-x.7.z.92", -1)]
[Arguments("1.0.0-x.7.z.92", "1.0.0-0.3.7", 1)]
[Arguments("1.0.0+build.1", "1.0.0+build.2", 0)]
[Arguments("1.0.0-alpha+build.1", "1.0.0-alpha+build.2", 0)]
public async Task I_can_compare_by_precedence(string v1Str, string v2Str, int expected)
{
var v1 = SemanticVersion.Parse(v1Str);
var v2 = SemanticVersion.Parse(v2Str);
var result = SemanticVersionComparer.Priority.Compare(v1, v2);
await Assert.That(Math.Sign(result)).IsEqualTo(expected);
}
[Test]
[Arguments("1.0.0+build.1", "1.0.0+build.2", 0)]
[Arguments("1.0.0-alpha+build.1", "1.0.0-alpha+build.2", 0)]
public async Task I_can_confirm_precedence_ignores_metadata(string v1Str, string v2Str, int expected)
{
var v1 = SemanticVersion.Parse(v1Str);
var v2 = SemanticVersion.Parse(v2Str);
var result = SemanticVersionComparer.Priority.Compare(v1, v2);
await Assert.That(result).IsEqualTo(expected);
}
[Test]
[Arguments("1.0.0", "1.0.0+build.1", -1)]
[Arguments("1.0.0+build.1", "1.0.0", 1)]
[Arguments("1.0.0+build.1", "1.0.0+build.2", -1)]
[Arguments("1.0.0+build.1.1", "1.0.0+build.1", 1)]
[Arguments("1.0.0+a.b", "1.0.0+a.a", 1)]
[Arguments("1.0.0+a.a", "1.0.0+a.b", -1)]
[Arguments("1.0.0+a.b", "1.0.0+a.b.c", -1)]
[Arguments("1.0.0+a.b.c", "1.0.0+a.b", 1)]
[Arguments("1.0.0+1.2.3", "1.0.0+1.2.4", -1)]
[Arguments("1.0.0+1.2.4", "1.0.0+1.2.3", 1)]
[Arguments("1.0.0+01", "1.0.0+1", -1)]
public async Task I_can_compare_by_sort_order(string v1Str, string v2Str, int expected)
{
var v1 = SemanticVersion.Parse(v1Str);
var v2 = SemanticVersion.Parse(v2Str);
var result = SemanticVersionComparer.SortOrder.Compare(v1, v2);
await Assert.That(Math.Sign(result)).IsEqualTo(expected);
}
[Test]
public async Task I_can_confirm_precedence_equality_ignores_metadata()
{
var v1 = SemanticVersion.Parse("1.0.0-alpha+build.1");
var v2 = SemanticVersion.Parse("1.0.0-alpha+build.2");
await Assert.That(SemanticVersionComparer.Priority.Equals(v1, v2)).IsTrue();
await Assert.That(SemanticVersionComparer.Priority.GetHashCode(v1)).IsEqualTo(SemanticVersionComparer.Priority.GetHashCode(v2));
}
[Test]
public async Task I_can_confirm_sort_order_equality_includes_metadata()
{
var v1 = SemanticVersion.Parse("1.0.0-alpha+build.1");
var v2 = SemanticVersion.Parse("1.0.0-alpha+build.2");
await Assert.That(SemanticVersionComparer.SortOrder.Equals(v1, v2)).IsFalse();
await Assert.That(SemanticVersionComparer.SortOrder.GetHashCode(v1)).IsNotEqualTo(SemanticVersionComparer.SortOrder.GetHashCode(v2));
}
[Test]
public async Task I_can_confirm_equality_for_identical_versions()
{
var v1 = SemanticVersion.Parse("1.0.0-alpha.1");
var v2 = SemanticVersion.Parse("1.0.0-alpha.1");
await Assert.That(SemanticVersionComparer.Priority.Equals(v1, v2)).IsTrue();
await Assert.That(SemanticVersionComparer.SortOrder.Equals(v1, v2)).IsTrue();
await Assert.That(SemanticVersionComparer.Priority.GetHashCode(v1)).IsEqualTo(SemanticVersionComparer.Priority.GetHashCode(v2));
await Assert.That(SemanticVersionComparer.SortOrder.GetHashCode(v1)).IsEqualTo(SemanticVersionComparer.SortOrder.GetHashCode(v2));
}
[Test]
public async Task I_can_confirm_equality_for_different_versions()
{
var v1 = SemanticVersion.Parse("1.0.0-alpha.1");
var v2 = SemanticVersion.Parse("1.0.0-alpha.2");
await Assert.That(SemanticVersionComparer.Priority.Equals(v1, v2)).IsFalse();
await Assert.That(SemanticVersionComparer.SortOrder.Equals(v1, v2)).IsFalse();
}
}

View file

@ -0,0 +1,261 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.SemVer.Tests;
internal sealed class SemanticVersionRangeTests
{
[Test]
[Arguments("1.2.3", "1.2.3", true)]
[Arguments("1.2.3", "1.2.4", false)]
[Arguments(">1.2.3", "1.2.4", true)]
[Arguments(">1.2.3", "1.2.3", false)]
[Arguments(">=1.2.3", "1.2.3", true)]
[Arguments("<1.2.3", "1.2.2", true)]
[Arguments("<=1.2.3", "1.2.3", true)]
public async Task I_can_satisfy_npm_basic_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
[Arguments("1.2.3 - 2.3.4", "1.2.3", true)]
[Arguments("1.2.3 - 2.3.4", "2.3.4", true)]
[Arguments("1.2.3 - 2.3.4", "1.2.2", false)]
[Arguments("1.2.3 - 2.3.4", "2.3.5", false)]
public async Task I_can_satisfy_npm_hyphen_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
[Arguments("^1.2.3", "1.2.3", true)]
[Arguments("^1.2.3", "1.9.9", true)]
[Arguments("^1.2.3", "2.0.0", false)]
[Arguments("^0.2.3", "0.2.3", true)]
[Arguments("^0.2.3", "0.2.4", true)]
[Arguments("^0.2.3", "0.3.0", false)]
[Arguments("^0.0.3", "0.0.3", true)]
[Arguments("^0.0.3", "0.0.4", false)]
public async Task I_can_satisfy_npm_caret_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
[Arguments("~1.2.3", "1.2.3", true)]
[Arguments("~1.2.3", "1.2.9", true)]
[Arguments("~1.2.3", "1.3.0", false)]
[Arguments("~1.2", "1.2.0", true)]
[Arguments("~1.2", "1.2.9", true)]
[Arguments("~1.2", "1.3.0", false)]
[Arguments("~1", "1.0.0", true)]
[Arguments("~1", "1.9.9", true)]
[Arguments("~1", "2.0.0", false)]
public async Task I_can_satisfy_npm_tilde_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
[Arguments("1.2.x", "1.2.0", true)]
[Arguments("1.2.x", "1.2.9", true)]
[Arguments("1.2.x", "1.3.0", false)]
[Arguments("1.x", "1.0.0", true)]
[Arguments("1.x", "1.9.9", true)]
[Arguments("1.x", "2.0.0", false)]
[Arguments("*", "0.0.0", true)]
[Arguments("*", "9.9.9", true)]
public async Task I_can_satisfy_npm_wildcard_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
public async Task I_can_parse_exact_version_ranges_via_public_api()
{
var range = SemanticVersionRange.Parse("=1.2.3");
await Assert.That(range.Contains(SemanticVersion.Parse("1.2.3"))).IsTrue();
await Assert.That(range.Contains(SemanticVersion.Parse("1.2.4"))).IsFalse();
await Assert.That(range.ToString("n", null)).IsEqualTo("1.2.3");
}
[Test]
[Arguments(">1.2.3 || <1.0.0", "1.2.4", true)]
[Arguments(">1.2.3 || <1.0.0", "0.9.9", true)]
[Arguments(">1.2.3 || <1.0.0", "1.1.0", false)]
[Arguments(">1.0.0 <=3.2.6 || >=1.0.0 <6.0.0", "2.0.0", true)]
[Arguments(">1.0.0 <=3.2.6 || >=1.0.0 <6.0.0", "5.0.0", true)]
[Arguments(">1.0.0 <=3.2.6 || >=1.0.0 <6.0.0", "7.0.0", false)]
public async Task I_can_satisfy_npm_or_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
[Arguments("^6.2.3-alpha.1", "6.2.3-alpha.1", true)]
[Arguments("^6.2.3-alpha.1", "6.2.3-beta", true)]
[Arguments("^6.2.3-alpha.1", "6.2.2", false)]
[Arguments("^6.2.3-alpha.1", "7.0.0", false)]
public async Task I_can_satisfy_complex_npm_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
public async Task I_can_handle_two_constraints_without_combining_them()
{
var range = SemanticVersionRange.Parse(">=1.0.0 !=2.0.0");
await Assert.That(range.Contains(SemanticVersion.Parse("1.5.0"))).IsTrue();
await Assert.That(range.Contains(SemanticVersion.Parse("2.0.0"))).IsFalse();
await Assert.That(range.ToString("n", null)).IsEqualTo(">=1.0.0 !=2.0.0");
}
[Test]
[Arguments("^5.*", "5.1.1", "6.1.0", ">=5.0.0 <6.0.0")]
[Arguments("5.*", "5.1.1", "6.1.0", ">=5.0.0 <6.0.0")]
[Arguments(">=5.*", "5.1.1", "6.1.0", ">=5.0.0 <6.0.0")]
[Arguments(">5.*", "5.0.1", "5.0.0", ">5.0.0 <6.0.0")]
[Arguments("<=5.*", "5.9.9", "6.0.0", "<6.0.0")]
[Arguments("<5.*", "4.9.9", "5.0.0", "<5.0.0")]
public async Task I_can_handle_ranges_with_wildcard(string range, string inside, string outside, string expected)
{
var r = SemanticVersionRange.Parse(range);
await Assert.That(r.Contains(SemanticVersion.Parse(inside))).IsTrue();
await Assert.That(r.Contains(SemanticVersion.Parse(outside))).IsFalse();
await Assert.That(r.ToString("n", null)).IsEqualTo(expected);
}
[Test]
[Arguments("[1.2.3]", "1.2.3", true)]
[Arguments("[1.2.3]", "1.2.4", false)]
[Arguments("[1.2.3, 1.4.0)", "1.2.3", true)]
[Arguments("[1.2.3, 1.4.0)", "1.3.9", true)]
[Arguments("[1.2.3, 1.4.0)", "1.4.0", false)]
[Arguments("(1.2.3, 1.4.0]", "1.2.3", false)]
[Arguments("(1.2.3, 1.4.0]", "1.4.0", true)]
[Arguments("[1.2.3,)", "1.2.3", true)]
[Arguments("[1.2.3,)", "9.9.9", true)]
[Arguments("(,1.4.0]", "1.4.0", true)]
[Arguments("(,1.4.0]", "0.0.0", true)]
[Arguments("(,)", "0.0.0", true)]
[Arguments("(,)", "9.9.9", true)]
public async Task I_can_satisfy_maven_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
[Arguments("[1.2,1.3],[1.5,)", "1.2.0", true)]
[Arguments("[1.2,1.3],[1.5,)", "1.4.0", false)]
[Arguments("[1.2,1.3],[1.5,)", "1.5.0", true)]
public async Task I_can_satisfy_maven_or_ranges(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
[Arguments("^1.2.3", "1.2.3-alpha", false)]
[Arguments("^1.2.3-alpha", "1.2.3-beta", true)]
[Arguments("^1.2.3-alpha", "1.2.4", true)]
[Arguments("^1.2.3-alpha", "1.3.0-alpha", true)]
public async Task I_can_satisfy_prerelease_rules(string range, string version, bool expected)
{
var r = SemanticVersionRange.Parse(range);
var v = SemanticVersion.Parse(version);
await Assert.That(r.Contains(v)).IsEqualTo(expected);
}
[Test]
[Arguments(">=1.0.0 !2.0.0")]
[Arguments("1.2.3 - ")]
[Arguments("!=1.x")]
public async Task I_can_not_parse_invalid_ranges(string range)
{
await Assert.That(() => SemanticVersionRange.Parse(range))
.Throws<FormatException>();
}
[Test]
public async Task I_can_serialize_to_json()
{
var r = SemanticVersionRange.Parse("[1.2.3,2.0.0)");
var json = System.Text.Json.JsonSerializer.Serialize(r);
await Assert.That(json).IsEqualTo("\"^1.2.3\"");
}
[Test]
public async Task I_can_deserialize_from_json()
{
var json = "\"^1.2.3\"";
var r = System.Text.Json.JsonSerializer.Deserialize<SemanticVersionRange>(json);
await Assert.That(r.ToString()).IsEqualTo("^1.2.3");
await Assert.That(r.Contains(new SemanticVersion(1, 2, 4))).IsTrue();
}
[Test]
public async Task I_use_npm_short_format_by_default()
{
var value = SemanticVersionRange.Parse("[1.2.3,2.0.0)");
await Assert.That(value.ToString()).IsEqualTo("^1.2.3");
}
[Test]
[Arguments("^1.2.3", "m", "[1.2.3,2.0.0)")]
[Arguments("~1.2.3", "m", "[1.2.3,1.3.0)")]
[Arguments("1.2.3", "m", "[1.2.3]")]
[Arguments("[1.2.3,2.0.0)", "n", ">=1.2.3 <2.0.0")]
[Arguments("[1.2.3,2.0.0)", "ns", "^1.2.3")]
[Arguments("[1.2.3,1.3.0)", "ns", "~1.2.3")]
[Arguments("[1.2,1.3],[1.5,)", "n", ">=1.2.0 <=1.3.0 || >=1.5.0")]
[Arguments("*", "m", "(,)")]
[Arguments("(,)", "ns", "*")]
public async Task I_can_convert_range_formats(string range, string format, string expected)
{
var value = SemanticVersionRange.Parse(range);
await Assert.That(value.ToString(format, null)).IsEqualTo(expected);
}
[Test]
public async Task I_can_format_ranges_to_chars()
{
var value = SemanticVersionRange.Parse("[1.2.3,2.0.0)");
var destination = new char[32];
var success = value.TryFormat(destination, out var charsWritten, "ns", null);
await Assert.That(success).IsTrue();
await Assert.That(new string(destination[..charsWritten])).IsEqualTo("^1.2.3");
}
[Test]
public async Task I_fail_formatting_when_the_tentative_short_form_overflows()
{
var value = SemanticVersionRange.Parse("[1.2.3,2.0.0)");
var destination = new char[5];
var success = value.TryFormat(destination, out var charsWritten, "ns", null);
await Assert.That(success).IsFalse();
await Assert.That(charsWritten).IsEqualTo(0);
}
}

View file

@ -0,0 +1,225 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text.Encodings.Web;
using System.Text.Json;
namespace Geekeey.SemVer.Tests;
internal sealed class SemanticVersionTests
{
private static readonly JsonSerializerOptions RelaxedOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
[Test]
[Arguments(0, 0, 0, 0)]
[Arguments(1, 1, 1, 0)]
[Arguments(1, 2, 0, 0)]
[Arguments(45, 2, 4, 0)]
public async Task I_can_create_semver_from_version(int major, int minor, int build, int revision)
{
var version = new Version(major, minor, build, revision);
var v = new SemanticVersion(version);
await Assert.That(v.Major).IsEqualTo((ulong)major);
await Assert.That(v.Minor).IsEqualTo((ulong)minor);
await Assert.That(v.Patch).IsEqualTo((ulong)build);
await Assert.That(v.Prerelease).IsNull();
await Assert.That(v.Metadata).IsNull();
}
[Test]
[Arguments(1UL)]
[Arguments(0UL)]
public async Task I_can_create_semver_with_major(ulong major)
{
var v = new SemanticVersion(major);
await Assert.That(v.Major).IsEqualTo(major);
await Assert.That(v.Minor).IsEqualTo(0UL);
await Assert.That(v.Patch).IsEqualTo(0UL);
await Assert.That(v.Prerelease).IsNull();
await Assert.That(v.Metadata).IsNull();
}
[Test]
[Arguments(1UL, 2UL)]
public async Task I_can_create_semver_with_major_and_minor(ulong major, ulong minor)
{
var v = new SemanticVersion(major, minor);
await Assert.That(v.Major).IsEqualTo(major);
await Assert.That(v.Minor).IsEqualTo(minor);
await Assert.That(v.Patch).IsEqualTo(0UL);
await Assert.That(v.Prerelease).IsNull();
await Assert.That(v.Metadata).IsNull();
}
[Test]
[Arguments(1UL, 2UL, 3UL)]
public async Task I_can_create_semver_with_major_minor_and_patch(ulong major, ulong minor, ulong patch)
{
var v = new SemanticVersion(major, minor, patch);
await Assert.That(v.Major).IsEqualTo(major);
await Assert.That(v.Minor).IsEqualTo(minor);
await Assert.That(v.Patch).IsEqualTo(patch);
await Assert.That(v.Prerelease).IsNull();
await Assert.That(v.Metadata).IsNull();
}
[Test]
[Arguments(1UL, 2UL, 3UL, "alpha", "build.1")]
[Arguments(1UL, 2UL, 3UL, null, null)]
public async Task I_can_create_semver_with_all_components(ulong major, ulong minor, ulong patch, string? prerelease, string? metadata)
{
var v = new SemanticVersion(major, minor, patch, prerelease, metadata);
await Assert.That(v.Major).IsEqualTo(major);
await Assert.That(v.Minor).IsEqualTo(minor);
await Assert.That(v.Patch).IsEqualTo(patch);
await Assert.That(v.Prerelease).IsEqualTo(prerelease);
await Assert.That(v.Metadata).IsEqualTo(metadata);
}
[Test]
public async Task I_can_compare_identical_versions()
{
var v1 = new SemanticVersion(1, 2, 3);
var v2 = new SemanticVersion(1, 2, 3);
await Assert.That(v1.CompareTo(v2)).IsEqualTo(0);
}
[Test]
public async Task I_can_compare_with_objects()
{
var v1 = new SemanticVersion(1, 2, 3);
object v2 = new SemanticVersion(1, 2, 3);
await Assert.That(v1.CompareTo(v2)).IsEqualTo(0);
await Assert.That(v1.CompareTo(null)).IsEqualTo(1);
await Assert.That(v1.CompareTo("not a version")).IsEqualTo(1);
}
[Test]
public async Task I_can_confirm_operators_work()
{
var v1 = new SemanticVersion(1, 2, 3);
var v2 = new SemanticVersion(2, 0, 0);
await Assert.That(v1 < v2).IsTrue();
await Assert.That(v1 <= v2).IsTrue();
await Assert.That(v1 > v2).IsFalse();
await Assert.That(v1 >= v2).IsFalse();
}
[Test]
public async Task I_can_parse_from_string()
{
var result = SemanticVersion.Parse("1.2.3");
await Assert.That(result.Major).IsEqualTo(1UL);
await Assert.That(result.Minor).IsEqualTo(2UL);
await Assert.That(result.Patch).IsEqualTo(3UL);
}
[Test]
public async Task I_can_try_parse_from_string()
{
var success = SemanticVersion.TryParse("1.2.3-alpha+build.1", out var result);
await Assert.That(success).IsTrue();
await Assert.That(result.Major).IsEqualTo(1UL);
await Assert.That(result.Minor).IsEqualTo(2UL);
await Assert.That(result.Patch).IsEqualTo(3UL);
await Assert.That(result.Prerelease).IsEqualTo("alpha");
await Assert.That(result.Metadata).IsEqualTo("build.1");
}
[Test]
[Arguments(1, 2, 3, null, null, "1.2.3")]
[Arguments(1, 2, 3, "alpha", null, "1.2.3-alpha")]
[Arguments(1, 2, 3, "alpha", "build.1", "1.2.3-alpha+build.1")]
public async Task I_can_get_string_representation(ulong major, ulong minor, ulong patch, string? pre, string? meta, string expected)
{
var v = new SemanticVersion(major, minor, patch, pre, meta);
// Note: format "f" is needed for full version including metadata based on code
var format = string.IsNullOrEmpty(meta) ? (string.IsNullOrEmpty(pre) ? null : "s") : "f";
await Assert.That(v.ToString(format, null)).IsEqualTo(expected);
}
[Test]
public async Task I_can_format_to_chars()
{
var v = new SemanticVersion(1, 2, 3, "beta", "456");
var dest = new char[32];
var success = v.TryFormat(dest, out var charsWritten, "f", null);
await Assert.That(success).IsTrue();
await Assert.That(new string(dest[..charsWritten])).IsEqualTo("1.2.3-beta+456");
}
[Test]
[Arguments(1, 2, 3, null, null, "\"1.2.3\"")]
[Arguments(1, 2, 3, "alpha", null, "\"1.2.3-alpha\"")]
[Arguments(1, 2, 3, "alpha", "build.1", "\"1.2.3-alpha+build.1\"")]
public async Task I_can_serialize_to_json(ulong major, ulong minor, ulong patch, string? pre, string? meta, string expectedJson)
{
var v = new SemanticVersion(major, minor, patch, pre, meta);
var json = JsonSerializer.Serialize(v, RelaxedOptions);
await Assert.That(json).IsEqualTo(expectedJson);
}
[Test]
public async Task I_can_deserialize_from_json()
{
var json = "\"1.2.3-beta+789\"";
var v = JsonSerializer.Deserialize<SemanticVersion>(json);
await Assert.That(v.Major).IsEqualTo(1UL);
await Assert.That(v.Minor).IsEqualTo(2UL);
await Assert.That(v.Patch).IsEqualTo(3UL);
await Assert.That(v.Prerelease).IsEqualTo("beta");
await Assert.That(v.Metadata).IsEqualTo("789");
}
[Test]
public async Task I_can_handle_invalid_json_token()
{
var json = "123"; // Number instead of string
await Assert.That(() => JsonSerializer.Deserialize<SemanticVersion>(json))
.Throws<JsonException>()
.WithMessage("Expected string");
}
[Test]
public async Task I_can_serialize_as_part_of_object()
{
var obj = new { Version = new SemanticVersion(1, 0, 0, "rc.1", "metadata") };
var json = JsonSerializer.Serialize(obj, RelaxedOptions);
await Assert.That(json).IsEqualTo(/*lang=json,strict*/ "{\"Version\":\"1.0.0-rc.1+metadata\"}");
}
[Test]
public async Task I_can_deserialize_null()
{
var json = "null";
var v = JsonSerializer.Deserialize<SemanticVersion>(json);
await Assert.That(v).IsEqualTo(default(SemanticVersion));
}
[Test]
public async Task I_can_deserialize_empty_string()
{
var json = "\"\"";
await Assert.That(() => JsonSerializer.Deserialize<SemanticVersion>(json))
.Throws<JsonException>();
}
}

View file

@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>true</IsPackable>
</PropertyGroup>
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<InternalsVisibleTo Include="Geekeey.SemVer.Tests" />
</ItemGroup>
<PropertyGroup>
<PackageReadmeFile>package-readme.md</PackageReadmeFile>
<PackageDescription>A .NET library for parsing, comparing, formatting, and serializing semantic versions and version ranges.</PackageDescription>
<PackageIcon>package-icon.png</PackageIcon>
<PackageProjectUrl>https://code.geekeey.de/geekeey/semver/src/branch/main/src/semver</PackageProjectUrl>
<PackageLicenseExpression>EUPL-1.2</PackageLicenseExpression>
</PropertyGroup>
<ItemGroup>
<None Include=".\package-icon.png" Pack="true" PackagePath="\" Visible="false" />
<None Include=".\package-readme.md" Pack="true" PackagePath="\" Visible="false" />
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Visible="false" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,51 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersion : IComparable, IComparable<SemanticVersion>
{
/// <inheritdoc />
public int CompareTo(object? obj)
{
return obj is not SemanticVersion other ? 1 : CompareTo(other);
}
/// <inheritdoc />
public int CompareTo(SemanticVersion other)
{
return SemanticVersionComparer.Priority.Compare(this, other);
}
/// <summary>
/// Determines whether one version is less than another version.
/// </summary>
public static bool operator <(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) < 0;
}
/// <summary>
/// Determines whether one version is less than or equal to another version.
/// </summary>
public static bool operator <=(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) <= 0;
}
/// <summary>
/// Determines whether one version is greater than another version.
/// </summary>
public static bool operator >(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) > 0;
}
/// <summary>
/// Determines whether one version is greater than or equal to another version.
/// </summary>
public static bool operator >=(SemanticVersion left, SemanticVersion right)
{
return left.CompareTo(right) >= 0;
}
}

View file

@ -0,0 +1,112 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.CompilerServices;
namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersion : ISpanFormattable
{
/// <inheritdoc />
public override string ToString()
{
return ToString(null, null);
}
#region IFormattable
/// <inheritdoc />
/// <remarks>
/// <list type="bullet">
/// <item><description>s - Default SemVer - [1.2.3-beta.4]</description></item>
/// <item><description>f - Full SemVer - [1.2.3-beta.4+5]</description></item>
/// <item><description>r - Just the SemVer part relevant for compatibility comparison - [1.2.3]</description></item>
/// </list>
/// </remarks>
public string ToString(string? format, IFormatProvider? formatProvider)
{
if (format is not null and not "s" and not "f" and not "r")
{
throw new FormatException($"The format string '{format}' is not supported.");
}
var handler = new DefaultInterpolatedStringHandler(0, 1, formatProvider);
handler.AppendFormatted(this, format);
return handler.ToStringAndClear();
}
#endregion
#region ISpanFormattable
/// <summary>
/// Tries to format the semantic version into the specified span of characters.
/// </summary>
/// <see cref="TryFormat(Span{char},out int,ReadOnlySpan{char},IFormatProvider)">ISpanFormattable.TryFormat</see>
public bool TryFormat(Span<char> destination, out int charsWritten)
{
return TryFormat(destination, out charsWritten, default, null);
}
/// <inheritdoc />
/// <remarks>
/// <list type="bullet">
/// <item><description>s - Default SemVer - [1.2.3-beta.4]</description></item>
/// <item><description>f - Full SemVer - [1.2.3-beta.4+5]</description></item>
/// <item><description>r - Just the SemVer part relevant for compatibility comparison - [1.2.3]</description></item>
/// </list>
/// </remarks>
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
charsWritten = 0;
if (format.IsEmpty)
{
format = "s";
}
if (format is not "s" and not "f" and not "r")
{
throw new FormatException($"The format string '{format}' is not supported.");
}
if (!destination.TryWrite($"{Major}.{Minor}.{Patch}", out var written))
{
charsWritten = 0;
return false;
}
destination = destination[written..];
charsWritten += written;
if (Prerelease is { Length: > 0 } && format is "s" or "f")
{
if (!destination.TryWrite(provider, $"-{Prerelease}", out written))
{
charsWritten = 0;
return false;
}
destination = destination[written..];
charsWritten += written;
}
if (Metadata is { Length: > 0 } && format is "f")
{
if (!destination.TryWrite(provider, $"+{Metadata}", out written))
{
charsWritten = 0;
return false;
}
destination = destination[written..];
charsWritten += written;
}
_ = destination;
return true;
}
#endregion
}

View file

@ -0,0 +1,41 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Geekeey.SemVer;
[JsonConverter(typeof(SemanticVersionJsonConverter))]
public readonly partial record struct SemanticVersion
{
internal sealed class SemanticVersionJsonConverter : JsonConverter<SemanticVersion>
{
public override SemanticVersion Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.Null)
{
return default;
}
if (reader.TokenType is not JsonTokenType.String || reader.GetString() is not { } value)
{
throw new JsonException("Expected string");
}
try
{
return Parse(value);
}
catch (FormatException exception)
{
throw new JsonException(exception.Message, exception);
}
}
public override void Write(Utf8JsonWriter writer, SemanticVersion value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString("f", null));
}
}
}

View file

@ -0,0 +1,155 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersion : ISpanParsable<SemanticVersion>
{
#region IParsable
/// <summary>
/// Parses a string into a <see cref="SemanticVersion"/>.
/// </summary>
/// <see cref="Parse(string, IFormatProvider)"/>
public static SemanticVersion Parse(string s)
{
return Parse(s.AsSpan(), null);
}
/// <inheritdoc />
public static SemanticVersion Parse(string s, IFormatProvider? provider)
{
return Parse(s.AsSpan(), provider);
}
/// <summary>
/// Tries to parse a string into a <see cref="SemanticVersion"/>.
/// </summary>
/// <see cref="TryParse(string, IFormatProvider, out SemanticVersion)"/>
public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersion result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result)
{
return TryParse(s.AsSpan(), provider, out result);
}
#endregion
#region ISpanParsable
/// <summary>
/// Parses a span of characters into a <see cref="SemanticVersion"/>.
/// </summary>
/// <see cref="Parse(ReadOnlySpan{char}, IFormatProvider)"/>
public static SemanticVersion Parse(ReadOnlySpan<char> s)
{
return Parse(s, null);
}
/// <inheritdoc />
public static SemanticVersion Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
{
if (!TryParse(s, provider, out var result))
{
throw new FormatException($"The input string '{s}' was not in a correct format.");
}
return result;
}
/// <summary>
/// Tries to parse a span of characters into a <see cref="SemanticVersion"/>.
/// </summary>
/// <see cref="TryParse(ReadOnlySpan{char}, IFormatProvider, out SemanticVersion)"/>
public static bool TryParse(ReadOnlySpan<char> s, [MaybeNullWhen(false)] out SemanticVersion result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersion result)
{
return TryParsePartially(s, out result, out var components) && components is 3;
}
#endregion
// components = 0: wildcard at major
// components = 1: wildcard at minor
// components = 2: wildcard at patch
// components = 3: no wildcards
internal static bool TryParsePartially(ReadOnlySpan<char> s, out SemanticVersion version, out int components)
{
version = default;
components = 0;
if (s.IsEmpty)
{
return false;
}
var metadata = default(string);
if (s.IndexOf('+') is >= 0 and var metadataIndex)
{
if (s[(metadataIndex + 1)..] is not { IsEmpty: false } value)
{
return false;
}
metadata = new string(value);
s = s[..metadataIndex];
}
var prerelease = default(string);
if (s.IndexOf('-') is >= 0 and var prereleaseIndex)
{
if (s[(prereleaseIndex + 1)..] is not { IsEmpty: false } value)
{
return false;
}
prerelease = new string(value);
s = s[..prereleaseIndex];
}
Span<Range> destination = stackalloc Range[3];
Span<ulong> component = stackalloc ulong[3];
foreach (var range in destination[..s.Split(destination, '.')])
{
if (s[range] is not { IsEmpty: false } segment)
{
return false;
}
if (segment is "*" or "x" or "X")
{
version = components switch
{
0 => default,
1 => new SemanticVersion(component[0], 0, 0),
2 => new SemanticVersion(component[0], component[1], 0),
_ => throw new InvalidOperationException(),
};
return true;
}
if (!ulong.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out var value))
{
return false;
}
component[components++] = value;
}
version = new SemanticVersion(component[0], component[1], component[2], prerelease, metadata);
return true;
}
}

View file

@ -0,0 +1,108 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.SemVer;
/// <summary>
/// Represents a semantic version that adheres to the Semantic Versioning 2.0.0 specification.
/// </summary>
public readonly partial record struct SemanticVersion
{
/// <summary>
/// Converts a <see cref="Version"/> into the equivalent semantic version.
/// </summary>
/// <param name="version">The version to be converted to a semantic version.</param>
/// <remarks>
/// <see cref="Version"/> numbers have the form <em>major</em>.<em>minor</em>[.<em>build</em>[.<em>revision</em>]]
/// where square brackets ('[' and ']') indicate optional components. The first three parts are converted to the
/// major, minor, and patch version numbers of a semantic version.
/// </remarks>
public SemanticVersion(Version version)
: this((ulong)version.Major, (ulong)version.Minor, (ulong)version.Build)
{
}
/// <summary>
/// Constructs a new instance of the <see cref="SemanticVersion" /> class.
/// </summary>
/// <param name="major">The major version number.</param>
public SemanticVersion(ulong major)
: this(major, 0, 0, default, default)
{
}
/// <summary>
/// Constructs a new instance of the <see cref="SemanticVersion" /> class.
/// </summary>
/// <param name="major">The major version number.</param>
/// <param name="minor">The minor version number.</param>
public SemanticVersion(ulong major, ulong minor)
: this(major, minor, 0, default, default)
{
}
/// <summary>
/// Constructs a new instance of the <see cref="SemanticVersion" /> class.
/// </summary>
/// <param name="major">The major version number.</param>
/// <param name="minor">The minor version number.</param>
/// <param name="patch">The patch version number.</param>
public SemanticVersion(ulong major, ulong minor, ulong patch)
: this(major, minor, patch, default, default)
{
}
/// <summary>
/// Constructs a new instance of the <see cref="SemanticVersion" /> class.
/// </summary>
/// <param name="major">The major version number.</param>
/// <param name="minor">The minor version number.</param>
/// <param name="patch">The patch version number.</param>
/// <param name="prerelease">The prerelease identifiers.</param>
/// <param name="metadata">The build metadata identifiers.</param>
public SemanticVersion(ulong major, ulong minor, ulong patch, string? prerelease, string? metadata)
{
Major = major;
Minor = minor;
Patch = patch;
Prerelease = prerelease;
Metadata = metadata;
}
/// <summary>
/// Gets the major version number of the semantic version. This value is a non-negative integer
/// representing the most significant component of the version, typically incremented when
/// making incompatible API changes.
/// </summary>
public ulong Major { get; }
/// <summary>
/// Gets the minor version number of the semantic version. This value is a non-negative integer
/// representing the second most significant component of the version, typically incremented
/// when adding backward-compatible functionalities.
/// </summary>
public ulong Minor { get; }
/// <summary>
/// Gets the patch version number of the semantic version. This value is a non-negative integer
/// representing the least significant component of the version, typically incremented for backwards-compatible
/// bug fixes or other small changes.
/// </summary>
public ulong Patch { get; }
/// <summary>
/// Gets the prerelease label of the semantic version. This value is an optional string
/// indicating a version that is still in development or not yet considered stable.
/// Examples of prerelease labels include "alpha", "beta", or "rc".
/// When present, the prerelease label is used to differentiate between versions with
/// the same major, minor, and patch numbers.
/// </summary>
public string? Prerelease { get; }
/// <summary>
/// Gets the build metadata associated with the semantic version. This optional value provides
/// additional information about the build, such as build number, commit hash, or other relevant
/// details. It does not affect the precedence of the version.
/// </summary>
public string? Metadata { get; }
}

View file

@ -0,0 +1,307 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections;
namespace Geekeey.SemVer;
/// <summary>
/// Provides comparers for <see cref="SemanticVersion"/>.
/// </summary>
public abstract class SemanticVersionComparer : IEqualityComparer<SemanticVersion?>, IEqualityComparer, IComparer<SemanticVersion?>, IComparer
{
/// <summary>
/// Gets or sets the comparer that determines the precedence of semantic versions.
/// </summary>
public static SemanticVersionComparer Priority { get; } = new PrecedenceSemanticVersionComparer();
/// <summary>
/// Gets the comparer that determines the sort order of semantic versions.
/// </summary>
public static SemanticVersionComparer SortOrder { get; } = new SortOrderSemanticVersionComparer();
bool IEqualityComparer.Equals(object? x, object? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
if (x is SemanticVersion v1 && y is SemanticVersion v2)
{
return Equals(v1, v2);
}
throw new ArgumentException($"Type of argument is not {nameof(SemanticVersion)}.");
}
/// <inheritdoc/>
public abstract bool Equals(SemanticVersion? x, SemanticVersion? y);
int IEqualityComparer.GetHashCode(object? obj)
{
if (obj is null)
{
return 0;
}
if (obj is SemanticVersion v)
{
return GetHashCode(v);
}
throw new ArgumentException($"Type of argument is not {nameof(SemanticVersion)}.");
}
/// <inheritdoc/>
public abstract int GetHashCode(SemanticVersion? v);
int IComparer.Compare(object? x, object? y)
{
if (x is null && y is null)
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
if (x is SemanticVersion v1 && y is SemanticVersion v2)
{
return Compare(v1, v2);
}
throw new ArgumentException($"Type of argument is not {nameof(SemanticVersion)}.");
}
/// <inheritdoc/>
public abstract int Compare(SemanticVersion? x, SemanticVersion? y);
/// <para>Precedence order is determined by comparing the major, minor, patch, and prerelease portion in order from left to right. Versions that differ only by build metadata have the same precedence. The major, minor, and patch version numbers are compared numerically. A prerelease version precedes a release version.</para>
/// <para>The prerelease portion is compared by comparing each prerelease identifier from left to right. Numeric prerelease identifiers precede alphanumeric identifiers. Numeric identifiers are compared numerically. Alphanumeric identifiers are compared lexically in ASCII sort order. A longer series of prerelease identifiers follows a shorter series if all the preceding identifiers are equal.</para>
private sealed class PrecedenceSemanticVersionComparer : SemanticVersionComparer
{
public override bool Equals(SemanticVersion? x, SemanticVersion? y)
{
if (x is null && y is null)
{
return true;
}
if (x is null || y is null)
{
return false;
}
var v1 = x.Value;
var v2 = y.Value;
return v1.Major == v2.Major &&
v1.Minor == v2.Minor &&
v1.Patch == v2.Patch &&
string.Equals(v1.Prerelease, v2.Prerelease, StringComparison.Ordinal);
}
public override int GetHashCode(SemanticVersion? v)
{
if (v is null)
{
return 0;
}
var ver = v.Value;
return HashCode.Combine(ver.Major, ver.Minor, ver.Patch, ver.Prerelease);
}
public override int Compare(SemanticVersion? x, SemanticVersion? y)
{
if (x is null && y is null)
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
var v1 = x.Value;
var v2 = y.Value;
var cmp = v1.Major.CompareTo(v2.Major);
if (cmp is not 0)
{
return cmp;
}
cmp = v1.Minor.CompareTo(v2.Minor);
if (cmp is not 0)
{
return cmp;
}
cmp = v1.Patch.CompareTo(v2.Patch);
if (cmp is not 0)
{
return cmp;
}
return ComparePrerelease(v1.Prerelease, v2.Prerelease);
}
}
/// <remarks>
/// <para>Sort order is consistent with precedence order, but provides an order for versions with the same precedence. Sort order is determined by comparing the major, minor, patch, prerelease portion, and build metadata in order from left to right. The major, minor, and patch version numbers are compared numerically. A prerelease version precedes a release version.</para>
/// <para>The prerelease portion is compared by comparing each prerelease identifier from left to right. Numeric prerelease identifiers precede alphanumeric identifiers. Numeric identifiers are compared numerically. Alphanumeric identifiers are compared lexically in ASCII sort order. A longer series of prerelease identifiers follows a shorter series if all the preceding identifiers are equal.</para>
/// <para>Otherwise, equal versions without build metadata precede those with metadata. The build metadata is compared by comparing each metadata identifier. Identifiers are compared lexically in ASCII sort order. A longer series of metadata identifiers follows a shorter series if all the preceding identifiers are equal.</para>
/// </remarks>
private sealed class SortOrderSemanticVersionComparer : SemanticVersionComparer
{
public override bool Equals(SemanticVersion? x, SemanticVersion? y)
{
if (x is null && y is null)
{
return true;
}
if (x is null || y is null)
{
return false;
}
var v1 = x.Value;
var v2 = y.Value;
return v1.Major == v2.Major &&
v1.Minor == v2.Minor &&
v1.Patch == v2.Patch &&
string.Equals(v1.Prerelease, v2.Prerelease, StringComparison.Ordinal) &&
string.Equals(v1.Metadata, v2.Metadata, StringComparison.Ordinal);
}
public override int GetHashCode(SemanticVersion? v)
{
if (v is null)
{
return 0;
}
var ver = v.Value;
return HashCode.Combine(ver.Major, ver.Minor, ver.Patch, ver.Prerelease, ver.Metadata);
}
public override int Compare(SemanticVersion? x, SemanticVersion? y)
{
var cmp = Priority.Compare(x, y);
return cmp is not 0 ? cmp : CompareMetadata(x?.Metadata, y?.Metadata);
}
}
private static int ComparePrerelease(string? x, string? y)
{
if (string.Equals(x, y, StringComparison.Ordinal))
{
return 0;
}
if (x is null)
{
return 1;
}
if (y is null)
{
return -1;
}
var xParts = x.Split('.');
var yParts = y.Split('.');
var length = Math.Min(xParts.Length, yParts.Length);
for (var i = 0; i < length; i++)
{
var xPart = xParts[i];
var yPart = yParts[i];
var xIsNumeric = ulong.TryParse(xPart, out var xNum);
var yIsNumeric = ulong.TryParse(yPart, out var yNum);
if (xIsNumeric && yIsNumeric)
{
var cmp = xNum.CompareTo(yNum);
if (cmp is not 0)
{
return cmp;
}
}
else if (xIsNumeric)
{
return -1;
}
else if (yIsNumeric)
{
return 1;
}
else
{
var cmp = string.CompareOrdinal(xPart, yPart);
if (cmp is not 0)
{
return cmp;
}
}
}
return xParts.Length.CompareTo(yParts.Length);
}
private static int CompareMetadata(string? x, string? y)
{
if (string.Equals(x, y, StringComparison.Ordinal))
{
return 0;
}
if (x is null)
{
return -1;
}
if (y is null)
{
return 1;
}
var xParts = x.Split('.');
var yParts = y.Split('.');
var length = Math.Min(xParts.Length, yParts.Length);
for (var i = 0; i < length; i++)
{
var cmp = string.CompareOrdinal(xParts[i], yParts[i]);
if (cmp is not 0)
{
return cmp;
}
}
return xParts.Length.CompareTo(yParts.Length);
}
}

View file

@ -0,0 +1,226 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Runtime.CompilerServices;
namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersionRange : ISpanFormattable
{
/// <inheritdoc />
public override string ToString()
{
return ToString(null, null);
}
#region IFormattable
/// <inheritdoc />
public string ToString(string? format, IFormatProvider? formatProvider)
{
if (format is not null and not "m" and not "n" and not "ns")
{
throw new FormatException($"The format string '{format}' is not supported.");
}
var handler = new DefaultInterpolatedStringHandler(0, 1, formatProvider);
handler.AppendFormatted(this, format);
return handler.ToStringAndClear();
}
#endregion
#region ISpanFormattable
/// <summary>
/// Tries to format the semantic version range into the specified span of characters.
/// </summary>
/// <see cref="TryFormat(Span{char},out int,ReadOnlySpan{char},IFormatProvider)">ISpanFormattable.TryFormat</see>
public bool TryFormat(Span<char> destination, out int charsWritten)
{
return TryFormat(destination, out charsWritten, default, null);
}
/// <inheritdoc />
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
{
charsWritten = 0;
if (format.IsEmpty)
{
format = "ns";
}
if (format is not "m" and not "n" and not "ns")
{
throw new FormatException($"The format string '{format}' is not supported.");
}
var buf = new SpanBuffer(destination);
var sets = _sets ?? [];
if (format is "m")
{
for (var i = 0; i < sets.Length; i++)
{
if (i > 0 && !buf.TryWrite(','))
{
return false;
}
if (!TryFormatMaven(ref buf, sets[i]))
{
return false;
}
}
}
else
{
for (var i = 0; i < sets.Length; i++)
{
if (i > 0 && !buf.TryWrite(" || "))
{
return false;
}
if (format is "ns")
{
if (!TryFormatSimpleNpm(ref buf, sets[i]))
{
return false;
}
}
else
{
if (!TryFormatNormalNpm(ref buf, sets[i]))
{
return false;
}
}
}
}
charsWritten = buf.Written;
return true;
}
#endregion
private static bool TryFormatSimpleNpm(ref SpanBuffer buf, ConstraintSet set)
{
if (IsAny(set))
{
return buf.TryWrite('*');
}
if (set.Constraints is [{ Operation: Comparison.Eq, Version: var version }])
{
return buf.TryWrite(version);
}
if (set.Constraints.Length is not 2)
{
return false;
}
if (set is not { Lower: { Operation: Comparison.Gte or Comparison.Gt, Version: var lo } })
{
return false;
}
if (set is not { Upper: { Operation: Comparison.Lt or Comparison.Lte, Version: var hi } upper })
{
return false;
}
if (upper.Operation is Comparison.Lt && hi is { Minor: 0, Patch: 0, Prerelease: not { Length: > 0 } })
{
if (lo is { Major: > 0 } && hi.Major == lo.Major + 1)
{
return buf.TryWrite('^') && buf.TryWrite(lo);
}
if (lo is { Major: 0, Minor: > 0 } && hi is { Major: 0 } && hi.Minor == lo.Minor + 1)
{
return buf.TryWrite('^') && buf.TryWrite(lo);
}
if (lo is { Major: 0, Minor: 0 } && hi is { Major: 0, Minor: 0 } && hi.Patch == lo.Patch + 1)
{
return buf.TryWrite('^') && buf.TryWrite(lo);
}
}
if (upper.Operation is Comparison.Lt && hi is { Patch: 0, Prerelease: not { Length: > 0 } })
{
if (hi.Major == lo.Major && hi.Minor == lo.Minor + 1)
{
return buf.TryWrite('~') && buf.TryWrite(lo);
}
}
return false;
}
private static bool TryFormatNormalNpm(ref SpanBuffer buf, ConstraintSet set)
{
if (IsAny(set))
{
return buf.TryWrite('*');
}
for (var i = 0; i < set.Constraints.Length; i++)
{
if (i > 0 && !buf.TryWrite(' '))
{
return false;
}
var @operator = set.Constraints[i].Operation switch
{
Comparison.Neq => "!=",
Comparison.Lt => "<",
Comparison.Lte => "<=",
Comparison.Gt => ">",
Comparison.Gte => ">=",
Comparison.Eq or _ => ReadOnlySpan<char>.Empty,
};
if (!@operator.IsEmpty && !buf.TryWrite(@operator))
{
return false;
}
if (!buf.TryWrite(set.Constraints[i].Version))
{
return false;
}
}
return true;
}
private static bool TryFormatMaven(ref SpanBuffer buf, ConstraintSet set)
{
if (IsAny(set))
{
return buf.TryWrite("(,)");
}
if (set.Constraints is [{ Operation: Comparison.Eq, Version: var version }])
{
return buf.TryWrite('[') && buf.TryWrite(version) && buf.TryWrite(']');
}
return buf.TryWrite(set.Lower?.Operation == Comparison.Gte ? '[' : '(') &&
(!set.Lower.HasValue || buf.TryWrite(set.Lower.Value.Version)) &&
buf.TryWrite(',') &&
(!set.Upper.HasValue || buf.TryWrite(set.Upper.Value.Version)) &&
buf.TryWrite(set.Upper?.Operation == Comparison.Lte ? ']' : ')');
}
private static bool IsAny(ConstraintSet set)
{
return set.Constraints is [] or [{ Operation: Comparison.Gte, Version: { Major: 0, Minor: 0, Patch: 0 } }];
}
}

View file

@ -0,0 +1,41 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Geekeey.SemVer;
[JsonConverter(typeof(SemanticVersionRangeJsonConverter))]
public readonly partial record struct SemanticVersionRange
{
internal sealed class SemanticVersionRangeJsonConverter : JsonConverter<SemanticVersionRange>
{
public override SemanticVersionRange Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.Null)
{
return default;
}
if (reader.TokenType is not JsonTokenType.String || reader.GetString() is not { } value)
{
throw new JsonException("Expected string");
}
try
{
return Parse(value);
}
catch (FormatException exception)
{
throw new JsonException(exception.Message, exception);
}
}
public override void Write(Utf8JsonWriter writer, SemanticVersionRange value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
}

View file

@ -0,0 +1,515 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics.CodeAnalysis;
namespace Geekeey.SemVer;
public readonly partial record struct SemanticVersionRange : ISpanParsable<SemanticVersionRange>
{
#region IParsable
/// <summary>
/// Parses a string into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="Parse(string, IFormatProvider)"/>
public static SemanticVersionRange Parse(string s)
{
return Parse(s.AsSpan(), null);
}
/// <inheritdoc />
public static SemanticVersionRange Parse(string s, IFormatProvider? provider)
{
return Parse(s.AsSpan(), provider);
}
/// <summary>
/// Tries to parse a string into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="TryParse(string, IFormatProvider, out SemanticVersionRange)"/>
public static bool TryParse([NotNullWhen(true)] string? s, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s.AsSpan(), provider, out result);
}
#endregion
#region ISpanParsable
/// <summary>
/// Parses a span of characters into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="Parse(ReadOnlySpan{char}, IFormatProvider)"/>
public static SemanticVersionRange Parse(ReadOnlySpan<char> s)
{
return Parse(s, null);
}
/// <inheritdoc />
public static SemanticVersionRange Parse(ReadOnlySpan<char> s, IFormatProvider? provider)
{
if (!TryParse(s, provider, out var result))
{
throw new FormatException($"The input string '{s}' was not in a correct format.");
}
return result;
}
/// <summary>
/// Tries to parse a span of characters into a <see cref="SemanticVersionRange"/>.
/// </summary>
/// <see cref="TryParse(ReadOnlySpan{char}, IFormatProvider, out SemanticVersionRange)"/>
public static bool TryParse(ReadOnlySpan<char> s, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
return TryParse(s, null, out result);
}
/// <inheritdoc />
public static bool TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, [MaybeNullWhen(false)] out SemanticVersionRange result)
{
result = default;
if (s.IsEmpty)
{
return false;
}
return s[0] is '[' or '(' ? TryParseMaven(s, out result) : TryParseNpm(s, out result);
}
#endregion
private static bool TryParseNpm(ReadOnlySpan<char> s, out SemanticVersionRange result)
{
result = default;
var sets = new List<ConstraintSet>();
foreach (var range in new NpmSetGrouping(s))
{
if (!TryParseNpmSet(s[range].Trim(), out var set))
{
return false;
}
sets.Add(set);
}
result = new SemanticVersionRange([.. sets]);
return true;
}
private ref struct NpmSetGrouping
{
private readonly ReadOnlySpan<char> _span;
private int _currentStart;
private int _currentEnd;
public NpmSetGrouping(ReadOnlySpan<char> span)
{
_span = span;
_currentStart = 0;
_currentEnd = -1;
}
public readonly Range Current => _currentStart.._currentEnd;
public bool MoveNext()
{
_currentStart = _currentEnd is -1 ? 0 : _currentEnd + 2;
if (_currentStart >= _span.Length)
{
return false;
}
var index = _span[_currentStart..].IndexOf("||");
_currentEnd = index >= 0 ? _currentStart + index : _span.Length;
return true;
}
public readonly NpmSetGrouping GetEnumerator()
{
return this;
}
}
private static bool TryParseNpmSet(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
if (s.IsEmpty)
{
return false;
}
// Hyphen range: "1.0.0 - 2.0.0" OR Caret: "^x.y.z" OR Tilde: "~x.y.z"
if (TryParseHyphenRange(s, out set) || TryParseCaretRange(s, out set) || TryParseTildeRange(s, out set))
{
return true;
}
var comparators = new List<Constraint>();
foreach (var range in s.Split(' '))
{
if (s[range] is not { IsEmpty: false } segment)
{
continue;
}
if (!TryParseCompare(segment, comparators))
{
return false;
}
}
if (comparators.Count is 0)
{
return false;
}
set = new ConstraintSet([.. comparators]);
return true;
}
private static bool TryParseHyphenRange(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
for (var i = 0; i < s.Length; i++)
{
if (s[i..] is [' ', '-', ' ', ..])
{
if (!SemanticVersion.TryParse(s[..(i + 0)].Trim(), null, out var lo))
{
return false;
}
if (!SemanticVersion.TryParse(s[(i + 3)..].Trim(), null, out var hi))
{
return false;
}
set = new ConstraintSet([new Constraint(Comparison.Gte, lo), new Constraint(Comparison.Lte, hi)]);
return true;
}
}
return false;
}
private static bool TryParseCaretRange(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
if (s.IsEmpty || s[0] is not '^')
{
return false;
}
s = s[1..];
if (!SemanticVersion.TryParsePartially(s, out var lo, out _))
{
return false;
}
SemanticVersion hi;
if (lo.Major > 0)
{
hi = new SemanticVersion(lo.Major + 1, 0, 0);
}
else if (lo.Minor > 0)
{
hi = new SemanticVersion(0, lo.Minor + 1, 0);
}
else
{
hi = new SemanticVersion(0, 0, lo.Patch + 1);
}
set = new ConstraintSet([new Constraint(Comparison.Gte, lo), new Constraint(Comparison.Lt, hi)]);
return true;
}
private static bool TryParseTildeRange(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
if (s.IsEmpty || s[0] is not '~')
{
return false;
}
s = s[1..];
if (!SemanticVersion.TryParsePartially(s, out var lo, out var wildcard))
{
return false;
}
SemanticVersion hi;
if (wildcard is 1)
{
hi = new SemanticVersion(lo.Major + 1, 0, 0);
}
else
{
hi = new SemanticVersion(lo.Major, lo.Minor + 1, 0);
}
set = new ConstraintSet([new Constraint(Comparison.Gte, lo), new Constraint(Comparison.Lt, hi)]);
return true;
}
private static bool TryParseCompare(ReadOnlySpan<char> s, List<Constraint> constraints)
{
var op = Comparison.Eq;
if (TryParseComparatorPrefix(s, out var comparison, out var rest))
{
op = comparison;
s = rest;
}
if (!SemanticVersion.TryParsePartially(s, out var lo, out var components))
{
return false;
}
if (components is 0)
{
constraints.Add(new Constraint(Comparison.Gte, new SemanticVersion(0, 0, 0)));
return op is Comparison.Eq;
}
if (components is 3)
{
constraints.Add(new Constraint(op, lo));
return true;
}
SemanticVersion hi;
if (components is 1)
{
hi = new SemanticVersion(lo.Major + 1, 0, 0);
}
else
{
hi = new SemanticVersion(lo.Major, lo.Minor + 1, 0);
}
switch (op)
{
case Comparison.Eq:
case Comparison.Gte:
constraints.Add(new Constraint(Comparison.Gte, lo));
constraints.Add(new Constraint(Comparison.Lt, hi));
return true;
case Comparison.Gt:
constraints.Add(new Constraint(Comparison.Gt, lo));
constraints.Add(new Constraint(Comparison.Lt, hi));
return true;
case Comparison.Lte:
constraints.Add(new Constraint(Comparison.Lt, hi));
return true;
case Comparison.Lt:
constraints.Add(new Constraint(Comparison.Lt, lo));
return true;
case Comparison.Neq:
default:
return false;
}
}
private static bool TryParseComparatorPrefix(ReadOnlySpan<char> s, out Comparison op, out ReadOnlySpan<char> remainder)
{
if (s.IsEmpty)
{
op = default;
remainder = default;
return false;
}
switch (s)
{
case ['!', '=', ..]:
op = Comparison.Neq;
remainder = s[2..];
return true;
case ['>', '=', ..]:
op = Comparison.Gte;
remainder = s[2..];
return true;
case ['<', '=', ..]:
op = Comparison.Lte;
remainder = s[2..];
return true;
case ['>', ..]:
op = Comparison.Gt;
remainder = s[1..];
return true;
case ['<', ..]:
op = Comparison.Lt;
remainder = s[1..];
return true;
case ['=', ..]:
op = Comparison.Eq;
remainder = s[1..];
return true;
default:
remainder = s;
op = default;
return false;
}
}
private static bool TryParseMaven(ReadOnlySpan<char> s, out SemanticVersionRange result)
{
result = default;
var sets = new List<ConstraintSet>();
foreach (var range in new MavenSetGrouping(s))
{
if (!TryParseMavenSet(s[range].Trim(), out var set))
{
return false;
}
sets.Add(set);
}
result = new SemanticVersionRange([.. sets]);
return true;
}
private ref struct MavenSetGrouping
{
private readonly ReadOnlySpan<char> _span;
private int _currentStart;
private int _currentEnd;
public MavenSetGrouping(ReadOnlySpan<char> readOnlySpan)
{
_span = readOnlySpan;
_currentStart = 0;
_currentEnd = -1;
}
public readonly Range Current => _currentStart.._currentEnd;
public bool MoveNext()
{
_currentStart = _currentEnd is -1 ? 0 : _currentEnd + 1;
if (_currentStart >= _span.Length)
{
return false;
}
var depth = 0;
var i = _currentStart;
while (i < _span.Length)
{
var ch = _span[i];
if (ch is '[' or '(')
{
depth++;
}
else if (ch is ']' or ')')
{
depth--;
}
else if (ch is ',' && depth is 0)
{
_currentEnd = i;
return true;
}
i++;
}
_currentEnd = _span.Length;
return true;
}
public readonly MavenSetGrouping GetEnumerator()
{
return this;
}
}
private static bool TryParseMavenSet(ReadOnlySpan<char> s, out ConstraintSet set)
{
set = default;
var loInclusive = s[0] is '[';
var hiInclusive = s[^1] is ']';
s = s[1..^1];
if (s.IndexOf(',') is not (>= 0 and var i))
{
if (!loInclusive || !hiInclusive)
{
return false;
}
if (!SemanticVersion.TryParsePartially(s.Trim(), out var version, out _))
{
return false;
}
set = new ConstraintSet([new Constraint(Comparison.Eq, version)]);
return true;
}
var comps = new List<Constraint>();
if (s[..i].Trim() is { IsEmpty: false } loStr)
{
if (!SemanticVersion.TryParsePartially(loStr, out var lo, out _))
{
return false;
}
comps.Add(new Constraint(loInclusive ? Comparison.Gte : Comparison.Gt, lo));
}
if (s[(i + 1)..].Trim() is { IsEmpty: false } hiStr)
{
if (!SemanticVersion.TryParsePartially(hiStr, out var hi, out _))
{
return false;
}
comps.Add(new Constraint(hiInclusive ? Comparison.Lte : Comparison.Lt, hi));
}
// Check for exact match [a,a]
if (comps is [{ Operation: Comparison.Gte, Version: var lhs }, { Operation: Comparison.Lte, Version: var rhs }])
{
if (lhs == rhs)
{
set = new ConstraintSet([new Constraint(Comparison.Eq, lhs)]);
return true;
}
}
set = new ConstraintSet([.. comps]);
return true;
}
}

View file

@ -0,0 +1,105 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.SemVer;
/// <summary>
/// Represents a semantic version range, which is a set of version constraints
/// used to match specific semantic versions based on defined ranges or patterns.
/// </summary>
public readonly partial record struct SemanticVersionRange
{
// OR of AND-groups. null == empty range (matches nothing).
private readonly ConstraintSet[]? _sets;
internal SemanticVersionRange(ConstraintSet[] sets)
{
_sets = sets;
}
/// <summary>
/// Determines whether the specified version is contained in the range.
/// </summary>
/// <param name="version">The version to check.</param>
/// <returns><c>true</c> if the version is contained in the range, otherwise <c>false</c>.</returns>
public bool Contains(SemanticVersion version)
{
if (_sets is null || _sets.Length == 0)
{
return false;
}
return _sets.Any(set => set.Includes(version));
}
}
internal enum Comparison { Eq, Neq, Lt, Lte, Gt, Gte }
internal readonly struct Constraint
{
public readonly Comparison Operation;
public readonly SemanticVersion Version;
public Constraint(Comparison operation, SemanticVersion version)
{
Operation = operation;
Version = version;
}
public bool Includes(SemanticVersion v)
{
return Operation switch
{
Comparison.Eq => v == Version,
Comparison.Neq => v != Version,
Comparison.Lt => v < Version,
Comparison.Lte => v <= Version,
Comparison.Gt => v > Version,
Comparison.Gte => v >= Version,
_ => false
};
}
public override string ToString()
{
return Operation switch
{
Comparison.Neq => $"!={Version}",
Comparison.Lt => $"<{Version}",
Comparison.Lte => $"<={Version}",
Comparison.Gt => $">{Version}",
Comparison.Gte => $">={Version}",
Comparison.Eq or _ => $"{Version}",
};
}
}
// One AND-group of comparators (all must be satisfied).
internal readonly struct ConstraintSet
{
public readonly Constraint[] Constraints;
public ConstraintSet(Constraint[] constraints)
{
Constraints = constraints;
}
public Constraint? Upper => Constraints.Where(c => c.Operation is Comparison.Lt or Comparison.Lte)
.Select(it => (Constraint?)it).FirstOrDefault();
public Constraint? Lower => Constraints.Where(c => c.Operation is Comparison.Gt or Comparison.Gte)
.Select(it => (Constraint?)it).FirstOrDefault();
public bool Includes(SemanticVersion v)
{
foreach (var c in Constraints)
{
if (!c.Includes(v))
{
return false;
}
}
return true;
}
}

43
src/semver/SpanBuffer.cs Normal file
View file

@ -0,0 +1,43 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
namespace Geekeey.SemVer;
internal ref struct SpanBuffer(Span<char> buffer)
{
private readonly Span<char> _buffer = buffer;
public int Written { get; private set; }
public bool TryWrite(ReadOnlySpan<char> value)
{
if (!value.TryCopyTo(_buffer[Written..]))
{
return false;
}
Written += value.Length;
return true;
}
public bool TryWrite<T>(T v) where T : ISpanFormattable
{
if (!v.TryFormat(_buffer[Written..], out var n, [], null))
{
return false;
}
Written += n;
return true;
}
public bool TryWrite<T>(T v, ReadOnlySpan<char> fmt) where T : ISpanFormattable
{
if (!v.TryFormat(_buffer[Written..], out var n, fmt, null))
{
return false;
}
Written += n;
return true;
}
}

BIN
src/semver/package-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,70 @@
SemVer is a .NET library for parsing, comparing, formatting, and serializing semantic versions and version ranges.
## Features
- **Parsing:** parse semantic version strings into structured objects.
- **Comparison:** compare versions with operators or `CompareTo`.
- **Ranges:** evaluate npm-style and Maven-style version ranges.
- **Serialization:** use `System.Text.Json` converters for versions and ranges.
## Getting Started
### Install the NuGet package:
```shell
dotnet add package Geekeey.SemVer
```
You may need to add our NuGet feed to your `nuget.config` this can be done by running the following command:
```shell
dotnet nuget add source -n geekeey https://code.geekeey.de/api/packages/geekeey/nuget/index.json
```
### Usage
```csharp
using Geekeey.SemVer;
var version = SemanticVersion.Parse("1.2.3-beta+build.7");
var next = new SemanticVersion(1, 3, 0);
Console.WriteLine(version);
Console.WriteLine(version.Major);
Console.WriteLine(version.Prerelease);
Console.WriteLine(version.Metadata);
Console.WriteLine(version < next);
if (SemanticVersion.TryParse("1.2.3-alpha", out var parsed))
{
Console.WriteLine(parsed);
}
```
Version ranges support npm-style and Maven-style syntax.
```csharp
using Geekeey.SemVer;
var npmRange = SemanticVersionRange.Parse("^1.2.3");
var mavenRange = SemanticVersionRange.Parse("[1.2.3,2.0.0)");
var candidate = SemanticVersion.Parse("1.4.2");
Console.WriteLine(npmRange.Contains(candidate));
Console.WriteLine(mavenRange.Contains(candidate));
Console.WriteLine(npmRange.ToString("m", null));
Console.WriteLine(mavenRange.ToString("ns", null));
```
Both `SemanticVersion` and `SemanticVersionRange` integrate with `System.Text.Json`.
```csharp
using System.Text.Json;
using Geekeey.SemVer;
var versionJson = JsonSerializer.Serialize(SemanticVersion.Parse("1.2.3-beta+build.7"));
var rangeJson = JsonSerializer.Serialize(SemanticVersionRange.Parse("^1.2.3"));
var roundTrippedVersion = JsonSerializer.Deserialize<SemanticVersion>(versionJson);
var roundTrippedRange = JsonSerializer.Deserialize<SemanticVersionRange>(rangeJson);
```