diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..b8ef32e --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "0.24.2", + "commands": [ + "dotnet-csharpier" + ] + } + } +} \ No newline at end of file diff --git a/.csharpierrc.yaml b/.csharpierrc.yaml new file mode 100644 index 0000000..2e04e88 --- /dev/null +++ b/.csharpierrc.yaml @@ -0,0 +1,15 @@ +# Configures the CSharpier dotnet tool. +# More info: https://csharpier.com/docs/Configuration + +# The line length where the formatter will wrap content. +# Note: This is not a hard limit and some lines will extend slightly past the limit. +# More info: https://csharpier.com/docs/Configuration#print-width +printWidth: 100 + +# Indent with spaces instead of tabs. +# More info: https://csharpier.com/docs/Configuration#use-tabs +useTabs: false + +# The number of spaces per indentation level. +# More info: https://csharpier.com/docs/Configuration#tab-width +tabWidth: 4 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a532d79 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,625 @@ +# Helps maintain consistent styling for various file types and coding languages +# More info: https://editorconfig.org/ + +################### +# Common Settings # +################### + +# This file is the top-most EditorConfig file +root = true + +# All Files +[*] +charset = utf-8 +indent_style = space +indent_size = 4 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +###################################################################### +# File Extension Specific Settings That Deviate From Common Settings # +###################################################################### + +# XML Configuration Files +[*.{xml,config,runsettings,props,targets,ruleset}] +indent_size = 2 + +# Resource Files +[*.rc] +indent_size = 2 + +# JSON Files +[*.json] +indent_size = 2 + +# YAML Files +[*.{yml,yaml}] +indent_size = 2 + +# Markdown Files +[*.md] +indent_size = 2 + +# Visual Studio Solution Files +[*.sln] +indent_style = tab + +# Visual Studio XML Project Files +[*.csproj] +indent_size = 2 + +# C# Files (.NET Code Style Settings) +[*.cs] + +# Default severity for all .NET Code Style rules +# This severity can be overridden for specific rules with the syntax: +# 'dotnet_diagnostic..severity = ' +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/configuration-options#scope +dotnet_analyzer_diagnostic.severity = warning + +#----------------------------------------------------------------------------------------------------------------# +# .NET Style Rules # +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/language-rules#net-style-rules # +#----------------------------------------------------------------------------------------------------------------# + +# 'this.' qualifier +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0003-ide0009 +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_property = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_event = false +# IDE0003: Remove 'this' qualification +dotnet_diagnostic.IDE0003.severity = warning +# IDE0009: Add 'this' qualification +dotnet_diagnostic.IDE0009.severity = warning + +# Language keywords instead of framework type names for type references +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0049 +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true +# IDE0049: Use language keywords instead of framework type names for type references +dotnet_diagnostic.IDE0049.severity = warning + +# Modifier preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0036 +csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async +# IDE0036: Order modifiers +dotnet_diagnostic.IDE0036.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0040 +dotnet_style_require_accessibility_modifiers = always +# IDE0040: Add accessibility modifiers +dotnet_diagnostic.IDE0040.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0044 +dotnet_style_readonly_field = true +# IDE0044: Add readonly modifier +dotnet_diagnostic.IDE0044.severity = warning + +# Parentheses preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0047-ide0048 +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +# IDE0047: Remove unnecessary parentheses +dotnet_diagnostic.IDE0047.severity = warning +# IDE0048: Add parentheses for clarity +dotnet_diagnostic.IDE0048.severity = warning + +# Expression-level preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0017 +dotnet_style_object_initializer = true +# IDE0017: Use object initializers +dotnet_diagnostic.IDE0017.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0028 +dotnet_style_collection_initializer = true +# IDE0028: Use collection initializers +dotnet_diagnostic.IDE0028.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0032 +dotnet_style_prefer_auto_properties = true +# IDE0032: Use auto-implemented property +dotnet_diagnostic.IDE0032.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0033 +dotnet_style_explicit_tuple_names = true +# IDE0033: Use explicitly provided tuple name +dotnet_diagnostic.IDE0033.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0037 +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_inferred_anonymous_type_member_names = true +# IDE0037: Use inferred member name +dotnet_diagnostic.IDE0037.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0045 +dotnet_style_prefer_conditional_expression_over_assignment = true +# IDE0045: Use conditional expression for assignment +dotnet_diagnostic.IDE0045.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0046 +dotnet_style_prefer_conditional_expression_over_return = true +# IDE0046: Use conditional expression for return +dotnet_diagnostic.IDE0046.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0054-ide0074 +dotnet_style_prefer_compound_assignment = true +# IDE0054: Use compound assignment +dotnet_diagnostic.IDE0054.severity = warning +# IDE0074: Use coalesce compound assignment +dotnet_diagnostic.IDE0074.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0071 +dotnet_style_prefer_simplified_interpolation = true +# IDE0071: Simplify interpolation +dotnet_diagnostic.IDE0071.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0075 +dotnet_style_prefer_simplified_boolean_expressions = true +# IDE0075: Simplify conditional expression +dotnet_diagnostic.IDE0075.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0010 +# IDE0010: Add missing cases to switch statement +dotnet_diagnostic.IDE0010.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0070 +# IDE0070: Use system hashing method instead of custom hash code logic +dotnet_diagnostic.IDE0070.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0082 +# IDE0082: Convert 'typeof' to 'nameof' +dotnet_diagnostic.IDE0082.severity = warning + +# Null-checking preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0029-ide0030 +dotnet_style_coalesce_expression = true +# IDE0029: Use coalesce expression (non-nullable types) +dotnet_diagnostic.IDE0029.severity = warning +# IDE0030: Use coalesce expression (nullable types) +dotnet_diagnostic.IDE0030.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0031 +dotnet_style_null_propagation = true +# IDE0031: Use null propagation +dotnet_diagnostic.IDE0031.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0041 +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +# IDE0041: Use 'is null' check +dotnet_diagnostic.IDE0041.severity = warning + +# File header preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0073 +# file_header_template = Copyright (c) 2023 fossbrandon +# IDE0073: Require file header +dotnet_diagnostic.IDE0073.severity = warning + +# Namespace naming preferences +# https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide0130 +dotnet_style_namespace_match_folder = true +# IDE0130: Namespace does not match folder structure +dotnet_diagnostic.IDE0130.severity = suggestion + +#--------------------------------------------------------------------------------------------------------------# +# C# Style Rules # +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/language-rules#c-style-rules # +#--------------------------------------------------------------------------------------------------------------# + +# 'var' preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0007-ide0008 +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = true +# IDE0007: Use 'var' instead of explicit type +dotnet_diagnostic.IDE0007.severity = suggestion +# IDE0008: Use explicit type instead of 'var' +dotnet_diagnostic.IDE0008.severity = suggestion + +# Expression-bodied members +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0021 +csharp_style_expression_bodied_constructors = true +# IDE0021: Use expression body for constructors +dotnet_diagnostic.IDE0021.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0022 +csharp_style_expression_bodied_methods = true +# IDE0022: Use expression body for methods +dotnet_diagnostic.IDE0022.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0023-ide0024 +csharp_style_expression_bodied_operators = true +# IDE0023: Use expression body for conversion operators +dotnet_diagnostic.IDE0023.severity = suggestion +# IDE0024: Use expression body for operators +dotnet_diagnostic.IDE0024.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0025 +csharp_style_expression_bodied_properties = true +# IDE0025: Use expression body for properties +dotnet_diagnostic.IDE0025.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0026 +csharp_style_expression_bodied_indexers = true +# IDE0026: Use expression body for indexers +dotnet_diagnostic.IDE0026.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0027 +csharp_style_expression_bodied_accessors = true +# IDE0027: Use expression body for accessors +dotnet_diagnostic.IDE0027.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0053 +csharp_style_expression_bodied_lambdas = true +# IDE0053: Use expression body for lambdas +dotnet_diagnostic.IDE0053.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0061 +csharp_style_expression_bodied_local_functions = true +# IDE0061: Use expression body for local functions +dotnet_diagnostic.IDE0061.severity = suggestion + +# Pattern matching preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0019 +csharp_style_pattern_matching_over_as_with_null_check = true +# IDE0019: Use pattern matching to avoid 'as' followed by a 'null' check +dotnet_diagnostic.IDE0019.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0020-ide0038 +csharp_style_pattern_matching_over_is_with_cast_check = true +# IDE0020: Use pattern matching to avoid 'is' check followed by a cast (with variable) +dotnet_diagnostic.IDE0020.severity = warning +# IDE0038: Use pattern matching to avoid 'is' check followed by a cast (without variable) +dotnet_diagnostic.IDE0038.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0066 +csharp_style_prefer_switch_expression = true +# IDE0066: Use switch expression +dotnet_diagnostic.IDE0066.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0078 +csharp_style_prefer_pattern_matching = true +# IDE0078: Use pattern matching +dotnet_diagnostic.IDE0078.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0083 +csharp_style_prefer_not_pattern = true +# IDE0083: Use pattern matching (not operator) +dotnet_diagnostic.IDE0083.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0170 +csharp_style_prefer_extended_property_pattern = true +# IDE0170: Simplify property pattern +dotnet_diagnostic.IDE0170.severity = warning + +# Expression-level preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0018 +csharp_style_inlined_variable_declaration = true +# IDE0018: Inline variable declaration +dotnet_diagnostic.IDE0018.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0034 +csharp_prefer_simple_default_expression = true +# IDE0034: Simplify 'default' expression +dotnet_diagnostic.IDE0034.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0039 +csharp_style_prefer_local_over_anonymous_function = true +# IDE0039: Use local function instead of lambda +dotnet_diagnostic.IDE0039.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0042 +csharp_style_deconstructed_variable_declaration = true +# IDE0042: Deconstruct variable declaration +dotnet_diagnostic.IDE0042.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0056 +csharp_style_prefer_index_operator = true +# IDE0056: Use index operator +dotnet_diagnostic.IDE0056.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0057 +csharp_style_prefer_range_operator = true +# IDE0057: Use range operator +dotnet_diagnostic.IDE0057.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0090 +csharp_style_implicit_object_creation_when_type_is_apparent = true +# IDE0090: Simplify 'new' expression +dotnet_diagnostic.IDE0090.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0072 +# IDE0072: Add missing cases to switch expression +dotnet_diagnostic.IDE0072.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0180 +csharp_style_prefer_tuple_swap = true +# IDE0180: Use tuple to swap values +dotnet_diagnostic.IDE0180.severity = warning + +# 'null' checking preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0016 +csharp_style_throw_expression = true +# IDE0016: Use throw expression +dotnet_diagnostic.IDE0016.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide1005 +csharp_style_conditional_delegate_call = true +# IDE1005: Use conditional delegate call +dotnet_diagnostic.IDE1005.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0150 +csharp_style_prefer_null_check_over_type_check = true +# IDE0150: Prefer 'null' check over type check +dotnet_diagnostic.IDE0150.severity = warning + +# Code block preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0011 +csharp_prefer_braces = true +# IDE0011: Add braces +dotnet_diagnostic.IDE0011.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0063 +csharp_prefer_simple_using_statement = true +# IDE0063: Use simple 'using' statement +dotnet_diagnostic.IDE0063.severity = warning + +# 'using' directive preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0065 +csharp_using_directive_placement = outside_namespace +# IDE0065: 'using' directive placement +dotnet_diagnostic.IDE0065.severity = warning + +# Modifier preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0062 +csharp_prefer_static_local_function = true +# IDE0062: Make local function static +dotnet_diagnostic.IDE0062.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0064 +# IDE0064: Make struct fields writable +dotnet_diagnostic.IDE0064.severity = suggestion + +# Namespace declaration preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0160-ide0161 +csharp_style_namespace_declarations = file_scoped +# IDE0160: Use block-scoped namespace +dotnet_diagnostic.IDE0160.severity = warning +# IDE0161: Use file-scoped namespace +dotnet_diagnostic.IDE0161.severity = warning + +#----------------------------------------------------------------------------------------------------------------------# +# Unnecessary Code Rules - Rules that identify parts of the code that are unnecessary and can be refactored or removed # +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/unnecessary-code-rules # +#----------------------------------------------------------------------------------------------------------------------# + +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0001 +# IDE0001: Simplify name +dotnet_diagnostic.IDE0001.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0002 +# IDE0002: Simplify member access +dotnet_diagnostic.IDE0002.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0004 +# IDE0004: Remove unnecessary cast +dotnet_diagnostic.IDE0004.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005 +# IDE0005: Remove unnecessary import +dotnet_diagnostic.IDE0005.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0035 +# IDE0035: Remove unreachable code +dotnet_diagnostic.IDE0035.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0051 +# IDE0051: Remove unused private member +dotnet_diagnostic.IDE0051.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0052 +# IDE0052: Remove unread private member +dotnet_diagnostic.IDE0052.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0058 +csharp_style_unused_value_expression_statement_preference = discard_variable +# IDE0058 Remove unnecessary expression value +dotnet_diagnostic.IDE0058.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0059 +csharp_style_unused_value_assignment_preference = discard_variable +# IDE0059: Remove unnecessary value assignment +dotnet_diagnostic.IDE0059.severity = suggestion +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0060 +dotnet_code_quality_unused_parameters = all +# IDE0060: Remove unused parameter +dotnet_diagnostic.IDE0060.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0079 +dotnet_remove_unnecessary_suppression_exclusions = none +# IDE0079: Remove unnecessary suppression +dotnet_diagnostic.IDE0079.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0080 +# IDE0080: Remove unnecessary suppression operator +dotnet_diagnostic.IDE0080.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0100 +# IDE0100: Remove unnecessary equality operator +dotnet_diagnostic.IDE0100.severity = warning +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0110 +# IDE0110: Remove unnecessary discard +dotnet_diagnostic.IDE0110.severity = warning + +#-------------------------------------------------------------------------------------------------------------------------------------# +# Formatting Rules - Rules that affect how indentation, spaces, and new lines are aligned around .NET programming language constructs # +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0055 # +#-------------------------------------------------------------------------------------------------------------------------------------# + +# Default severity level for all .NET and C# formatting rules below +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0055 +# Note: CSharpier provides opinionated formatting so don't suggest these format options +# IDE0055: Fix formatting +dotnet_diagnostic.IDE0055.severity = none + +# Using directive options +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/dotnet-formatting-options#using-directive-options +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Newline options +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/csharp-formatting-options#new-line-options +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +# Indentation options +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/csharp-formatting-options#indentation-options +csharp_indent_case_contents = true +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents_when_block = false + +# Spacing options +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/csharp-formatting-options#spacing-options +csharp_space_after_cast = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_between_parentheses = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_around_binary_operators = before_and_after +csharp_space_between_method_declaration_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_call_parameter_list_parentheses = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_after_comma = true +csharp_space_before_comma = false +csharp_space_after_dot = false +csharp_space_before_dot = false +csharp_space_after_semicolon_in_for_statement = true +csharp_space_before_semicolon_in_for_statement = false +csharp_space_around_declaration_statements = false +csharp_space_before_open_square_brackets = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_square_brackets = false + +# Wrap options +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/csharp-formatting-options#wrap-options +csharp_preserve_single_line_statements = false +csharp_preserve_single_line_blocks = true + +#----------------------------------------------------------------------------------------------# +# Naming Rules - Rules that specify and enforce how C# elements should be named # +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/naming-rules # +#----------------------------------------------------------------------------------------------# + +# Default severity level for all naming rules below +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/naming-rules#rule-id-ide1006-naming-rule-violation +# IDE1006: Naming rule violation +dotnet_diagnostic.IDE1006.severity = warning + +# Naming Styles +# camel_case_style +dotnet_naming_style.camel_case_style.capitalization = camel_case +# pascal_case_style +dotnet_naming_style.pascal_case_style.capitalization = pascal_case +# pascal_case_with_i_prefix_style +dotnet_naming_style.pascal_case_with_i_prefix_style.capitalization = pascal_case +dotnet_naming_style.pascal_case_with_i_prefix_style.required_prefix = I +# pascal_case_with_t_prefix_style +dotnet_naming_style.pascal_case_with_t_prefix_style.capitalization = pascal_case +dotnet_naming_style.pascal_case_with_t_prefix_style.required_prefix = T +# pascal_case_with_async_suffix_style +dotnet_naming_style.pascal_case_with_async_suffix_style.capitalization = pascal_case +dotnet_naming_style.pascal_case_with_async_suffix_style.required_suffix = Async + +# Naming Groups +# namespace_group +dotnet_naming_symbols.namespace_group.applicable_kinds = namespace +# class_group +dotnet_naming_symbols.class_group.applicable_kinds = class +# struct_group +dotnet_naming_symbols.struct_group.applicable_kinds = struct +# interface_group +dotnet_naming_symbols.interface_group.applicable_kinds = interface +# enum_group +dotnet_naming_symbols.enum_group.applicable_kinds = enum +# non_private_event_group +dotnet_naming_symbols.non_private_event_group.applicable_kinds = event +dotnet_naming_symbols.non_private_event_group.applicable_accessibilities = public, internal, protected, protected_internal, private_protected +# private_event_group +dotnet_naming_symbols.private_event_group.applicable_kinds = event +dotnet_naming_symbols.private_event_group.applicable_accessibilities = private +# delegate_group +dotnet_naming_symbols.delegate_group.applicable_kinds = delegate +# non_private_field_group +dotnet_naming_symbols.non_private_field_group.applicable_kinds = field +dotnet_naming_symbols.non_private_field_group.applicable_accessibilities = public, internal, protected, protected_internal, private_protected +# private_field_group +dotnet_naming_symbols.private_field_group.applicable_kinds = field +dotnet_naming_symbols.private_field_group.applicable_accessibilities = private +# private_const_field_group +dotnet_naming_symbols.private_const_field_group.applicable_kinds = field +dotnet_naming_symbols.private_const_field_group.applicable_accessibilities = private +dotnet_naming_symbols.private_const_field_group.required_modifiers = const +# property_group +dotnet_naming_symbols.property_group.applicable_kinds = property +# method_group +dotnet_naming_symbols.method_group.applicable_kinds = method +# async_method_group +dotnet_naming_symbols.async_method_group.applicable_kinds = method +dotnet_naming_symbols.async_method_group.required_modifiers = async +# parameter_group +dotnet_naming_symbols.parameter_group.applicable_kinds = parameter +# type_parameter_group +dotnet_naming_symbols.type_parameter_group.applicable_kinds = type_parameter +# local_function_group +dotnet_naming_symbols.local_function_group.applicable_kinds = local_function +# locals_group +dotnet_naming_symbols.locals_group.applicable_kinds = local +# catch_all_group +dotnet_naming_symbols.catch_all_group.applicable_kinds = * + +# Naming Rules - More specific rules regarding accessibilities, modifiers, and symbols take precedence over less specific rules +# Namespaces are PascalCase +dotnet_naming_rule.namespaces_should_be_pascal_case.symbols = namespace_group +dotnet_naming_rule.namespaces_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.namespaces_should_be_pascal_case.severity = warning +# Classes/Records are PascalCase +dotnet_naming_rule.classes_and_records_should_be_pascal_case.symbols = class_group +dotnet_naming_rule.classes_and_records_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.classes_and_records_should_be_pascal_case.severity = warning +# Structs are pascalCase +dotnet_naming_rule.structs_should_be_pascal_case.symbols = struct_group +dotnet_naming_rule.structs_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.structs_should_be_pascal_case.severity = warning +# Interfaces are PascalCase and start with 'I' +dotnet_naming_rule.interfaces_should_be_pascal_case_and_start_with_i.symbols = interface_group +dotnet_naming_rule.interfaces_should_be_pascal_case_and_start_with_i.style = pascal_case_with_i_prefix_style +dotnet_naming_rule.interfaces_should_be_pascal_case_and_start_with_i.severity = warning +# Enums are PascalCase +dotnet_naming_rule.enums_should_be_pascal_case.symbols = enum_group +dotnet_naming_rule.enums_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.enums_should_be_pascal_case.severity = warning +# Non-private events are PascalCase +dotnet_naming_rule.non_private_events_should_be_pascal_case.symbols = non_private_event_group +dotnet_naming_rule.non_private_events_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.non_private_events_should_be_pascal_case.severity = warning +# Private events are camelCase +dotnet_naming_rule.private_events_should_be_camel_case.symbols = private_event_group +dotnet_naming_rule.private_events_should_be_camel_case.style = camel_case_style +dotnet_naming_rule.private_events_should_be_camel_case.severity = warning +# Delegates are PascalCase +dotnet_naming_rule.non_private_delegates_should_be_camel_case.symbols = delegate_group +dotnet_naming_rule.non_private_delegates_should_be_camel_case.style = pascal_case_style +dotnet_naming_rule.non_private_delegates_should_be_camel_case.severity = warning +# Non-private fields are PascalCase +dotnet_naming_rule.non_private_fields_should_be_pascal_case.symbols = non_private_field_group +dotnet_naming_rule.non_private_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.non_private_fields_should_be_pascal_case.severity = warning +# Private fields are camelCase +dotnet_naming_rule.private_fields_should_be_camel_case.symbols = private_field_group +dotnet_naming_rule.private_fields_should_be_camel_case.style = camel_case_style +dotnet_naming_rule.private_fields_should_be_camel_case.severity = warning +# Private const fields are PascalCase +dotnet_naming_rule.private_const_fields_should_be_pascal_case.symbols = private_const_field_group +dotnet_naming_rule.private_const_fields_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.private_const_fields_should_be_pascal_case.severity = warning +# Properties are PascalCase +dotnet_naming_rule.non_private_properties_should_be_pascal_case.symbols = property_group +dotnet_naming_rule.non_private_properties_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.non_private_properties_should_be_pascal_case.severity = warning +# Methods are PascalCase +dotnet_naming_rule.methods_should_be_pascal_case.symbols = method_group +dotnet_naming_rule.methods_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.methods_should_be_pascal_case.severity = warning +# Async methods are Pascalcase and end with 'async' +dotnet_naming_rule.async_methods_should_be_pascal_case_and_end_with_async.symbols = async_method_group +dotnet_naming_rule.async_methods_should_be_pascal_case_and_end_with_async.style = pascal_case_with_async_suffix_style +dotnet_naming_rule.async_methods_should_be_pascal_case_and_end_with_async.severity = warning +# Method parameters are camelCase +dotnet_naming_rule.method_parameters_should_be_camel_case.symbols = parameter_group +dotnet_naming_rule.method_parameters_should_be_camel_case.style = camel_case_style +dotnet_naming_rule.method_parameters_should_be_camel_case.severity = warning +# Type parameters are PascalCase and start with 'T' +dotnet_naming_rule.type_parameters_should_be_pascal_case_and_start_with_t.symbols = type_parameter_group +dotnet_naming_rule.type_parameters_should_be_pascal_case_and_start_with_t.style = pascal_case_with_t_prefix_style +dotnet_naming_rule.type_parameters_should_be_pascal_case_and_start_with_t.severity = warning +# Local functions are PascalCase +dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_function_group +dotnet_naming_rule.local_functions_should_be_pascal_case.style = pascal_case_style +dotnet_naming_rule.local_functions_should_be_pascal_case.severity = warning +# Locals are camelCase +dotnet_naming_rule.local_functions_should_be_camel_case.symbols = locals_group +dotnet_naming_rule.local_functions_should_be_camel_case.style = camel_case_style +dotnet_naming_rule.local_functions_should_be_camel_case.severity = warning +# By default, name items with PascalCase +dotnet_naming_rule.default_names_to_pascal_case.symbols = catch_all_group +dotnet_naming_rule.default_names_to_pascal_case.style = pascal_case_style +dotnet_naming_rule.default_names_to_pascal_case.severity = warning + +# Other naming rules +# CA1707: Identifiers should not contain underscores +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1707 +# Test functions and discard parameters use underscores so ignore this rule +dotnet_diagnostic.CA1707.severity = none diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c998326 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Assigns Git attributes to specified pathnames. +# More info: https://git-scm.com/docs/gitattributes + +# ------------------ +# General settings | +# ------------------ +# Set default behaviour to automatically normalize line endings of text files. +* text=auto + +# ---------------------------------------------------------------------- +# Specify the line endings for files that should have a specific style | +# ---------------------------------------------------------------------- +# Checkout with CRLF +*.sln text eol=crlf diff --git a/.gitignore b/.gitignore index 8a30d25..8dd4607 100644 --- a/.gitignore +++ b/.gitignore @@ -395,4 +395,4 @@ FodyWeavers.xsd *.msp # JetBrains Rider -*.sln.iml +*.sln.iml \ No newline at end of file diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..f4aeee2 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,23 @@ +# Configures the Prettier tool with settings not available in the .editorconfig file. +# More info: https://prettier.io/docs/en/configuration.html + +# Specify the line length that the printer will wrap on. +# Overrides max_line_length from the .editorconfig file. +# More info: https://prettier.io/docs/en/options.html#print-width +printWidth: 100 + +# Prefer to use double quotes. +# More info: https://prettier.io/docs/en/options.html#quotes +singleQuote: false + +# Only add quotes around object parameters when required. +# More info: https://prettier.io/docs/en/options.html#quote-props +quoteProps: "as-needed" + +# Wrap markdown prose if it exceeds the print width. +# More info: https://prettier.io/docs/en/options.html#prose-wrap +proseWrap: "always" + +# Allow formatting quoted code embedded in the file. +# More info: https://prettier.io/docs/en/options.html#embedded-language-formatting +embeddedLanguageFormatting: "auto" diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..704a168 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,30 @@ + + + + + + true + + + enable + + + enable + + + + latest-recommended + + true + + + + + true + + + + + + + diff --git a/LICENSE b/LICENSE.txt similarity index 100% rename from LICENSE rename to LICENSE.txt diff --git a/README.md b/README.md index a6522ef..3a5e19e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,174 @@ -# dotnet-style -A dotnet tool that provides a consistent style for C# files using configurable code formatters. +# Style + +`dotnet-style` is a .NET tool that enables users to maintain a consistent and configurable C# code +style throughout projects. + +It wraps existing and reputable tooling with the goal of providing a single interface for managing +C# code style functionality that would otherwise require running these other tools separately. As a +developer, this is less ideal because it requires me to remember the unique syntax and overall +knowledge for each of the separate tools rather than just learning and using this tool instead to +perform the same actions. + +## Install + +The .NET tool is available via [NuGet](https://www.nuget.org/packages/Style). + +### Global Tool + +If you want to run the .NET tool from any directory on your machine without specifying the tool's +location, install it as a global tool so that it is added to a directory visible to your PATH +environment variable. + +Install `dotnet-style` as a global .NET tool using the command `dotnet tool install style --global` + +### Local Tool + +If you prefer to run the .NET tool from select directories, install it as a local tool. This can be +beneficial in instances such as working on a shared code repository where you want any developer who +clones the repo to have easy access to installing the same tools and versions as everyone else +working on the same code. + +Install `dotnet-style` as a local .NET tool using the following steps: + +1. Add a `dotnet-tools.json` manifest file in a `.config` directory under the current directory + using the command `dotnet new tool-manifest` if the files does not currently exist. +2. Install the .NET tool using the command `dotnet tool install style` without any additional + options. + - This updates the `dotnet-tools.json` manifest file with information such as the tool's name and + version so that all developers use the same tool. +3. Anyone who wants to use the local tool can run the command `dotnet tool restore` from within the + repo to restore their local .NET tool(s) to match the manifest file. + + - 💡 `Quick Tip:` You can add this as an `MSBuild` `PreBuildEvent` so that devs don't have to + worry about manually checking for changes and restoring tools when applicable. + + - Ex: + + ```xml + + + + + ``` + +## Setup + +`dotnet-style` wraps existing tools so you will need to setup the tools you plan to use along with +the code style configuration you want to enforce. More information regarding each configurable tool +is listed below: + +- [dotnet-format](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format): + - This tool is bundled with the `.NET 6 SDK` and later. + - If you are using an older version of .NET you'll have to install an older version of the tool + as a .NET tool. + - This tool uses an [EditorConfig](https://editorconfig.org/) as the configurable source of truth + for C# code style. + - Create your own `.editorconfig` file with customized settings and severities if you want to + manage code style using this tool. + - Examples: + - [Roslyn](https://github.com/dotnet/roslyn/blob/main/.editorconfig) has a pretty + comprehensive and highly used EditorConfig. + - Reference the `.editorconfig` files throughout + [dotnet-style repository](https://github.com/fossbrandon/dotnet-style) for extremely + customized (probably overkill 😁) examples where there is a default root configuration + that is overridden by more specific configurations when needed. +- [CSharpier](https://csharpier.com/): + - Install the tool following tool-specific documentation at the resource listed above. + - Because this is an opinionated code formatter, it only allows for a few configuration options to + be specified in a `.csharpierrc` file as explained in the tool-specific documentation. + - Example: Reference the `.csharpierrc.yaml` file located in the the + [dotnet-style repository](https://github.com/fossbrandon/dotnet-style) for an example of how + that repository configures and uses the tool. + +## Usage + +After the `dotnet-style` is installed you can run the tool by typing `dotnet style` in a terminal to +run the tool as a CLI application. + +### General CLI Usage + +```text +Style v1.0.0 + A customizable dotnet tool that helps maintain a consistent C# coding style. + +USAGE + dotnet style [options] + dotnet style [command] [...] + +OPTIONS + -h|--help Shows help text. + --version Shows version information. + +COMMANDS + format Formats C# files according to a defined coding style. + verify Verifies that C# files comply with the defined coding style. + +You can run `dotnet style [command] --help` to show help on a specific command. +``` + +### Format Command Usage + +```text +Style v1.0.0 + A customizable dotnet tool that helps maintain a consistent C# coding style. + +USAGE + dotnet style format [options] + +DESCRIPTION + Formats C# files according to a defined coding style. + +OPTIONS + -p|--path The directory containing files to recursively format. Default: "C:\Users\branfoss\source\repos\Style\src". + -s|--style Whether to format code using the 'dotnet format' formatter to run code style analyzers and apply fixes. Default: "True". + -a|--analyzers Whether to format code using the 'dotnet format' formatter to run third party code style analyzers and apply fixes. Default: "True". + -w|--whitespace Whether to format code using the 'dotnet format' formatter to run whitespace formatting. Default: "False". + -c|--csharpier Whether to format code using the 'CSharpier' opinionated formatter. Note: If formatting with --whitespace, this option must be disabled to avoid conflicts as they both handle whitespace formatting. Default: "True". + -v|--verbosity The output verbosity level. Choices: "Quiet", "Normal", "Verbose". Default: "Normal". + -h|--help Shows help text. +``` + +### Verify Command Usage + +```text +Style v1.0.0 + A customizable dotnet tool that helps maintain a consistent C# coding style. + +USAGE + dotnet style verify [options] + +DESCRIPTION + Verifies that C# files comply with the defined coding style. + +OPTIONS + -p|--path The directory containing files to recursively verify the coding style compliance of. Default: "C:\Users\branfoss\source\repos\Style\src". + -s|--style Whether to verify code complies with the coding style used by the 'dotnet format' formatter for code style analyzer settings. Default: "True". + -a|--analyzers Whether to verify code complies with the coding style used by the 'dotnet format' formatter for third party code style analyzer settings. Default: "True". + -w|--whitespace Whether to verify code complies with the coding style used by the 'dotnet format' formatter for whitespace settings. Default: "False". + -c|--csharpier Whether to verify code complies with the coding style used by the 'CSharpier' opinionated formatter. Note: If verifying with --whitespace, this option must be disabled to avoid conflicts as they both handle whitespace formatting. Default: "True". + -v|--verbosity The output verbosity level. Choices: "Quiet", "Normal", "Verbose". Default: "Normal". + -h|--help Shows help text. +``` + +### Common Usage Examples + +- Simple format usage: `dotnet style format` + - This will recursively format C# files starting in your current directory using the C# formatters + that are enabled by default. +- Customized format usage: + `dotnet style format -p ./src -s true -a true -w true -c false -v verbose` + - This will only format code in the .NET project defined within the relative `./src` directory + using the `dotnet-format` formatter instead of the `CSharpier` formatter to avoid opinionated + formatting. It also outputs additional information for potential debugging in the event there + are errors or you want more details throughout the process. + +**Note:** You can replace `format` with `verify` in any of the above examples to verify whether the +current C# files comply with coding standards rather than actually modifying any code. + +## Disclaimers + +Since this .NET tool relies on other tools to perform the actual code style functions, there are +limitations. For instance, the `dotnet format` formatter tool does not automatically fix all +warnings/errors at the time of writing this. So, depending on how strict you make your +configuration, you may still have to manually fix code to ensure compliance with your coding +standards. diff --git a/Style.sln b/Style.sln new file mode 100644 index 0000000..ff106d1 --- /dev/null +++ b/Style.sln @@ -0,0 +1,38 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33530.505 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Style", "src\Style.csproj", "{73E18D56-925E-4D8F-B2C6-42061D6B2F26}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Style.Tests", "test\Style.Tests.csproj", "{69FF95CA-2051-4E48-85E9-D8A2A7CBED91}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B11F635D-C86C-4056-BF64-8DDC58E65D36}" + ProjectSection(SolutionItems) = preProject + .csharpierrc.yaml = .csharpierrc.yaml + .gitignore = .gitignore + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {73E18D56-925E-4D8F-B2C6-42061D6B2F26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {73E18D56-925E-4D8F-B2C6-42061D6B2F26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {73E18D56-925E-4D8F-B2C6-42061D6B2F26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {73E18D56-925E-4D8F-B2C6-42061D6B2F26}.Release|Any CPU.Build.0 = Release|Any CPU + {69FF95CA-2051-4E48-85E9-D8A2A7CBED91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {69FF95CA-2051-4E48-85E9-D8A2A7CBED91}.Debug|Any CPU.Build.0 = Debug|Any CPU + {69FF95CA-2051-4E48-85E9-D8A2A7CBED91}.Release|Any CPU.ActiveCfg = Release|Any CPU + {69FF95CA-2051-4E48-85E9-D8A2A7CBED91}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {43769B75-8EB2-4A93-855A-47D11EB8B6A1} + EndGlobalSection +EndGlobal diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..987b88d --- /dev/null +++ b/nuget.config @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/Attributes/FormatterOptionAttribute.cs b/src/Attributes/FormatterOptionAttribute.cs new file mode 100644 index 0000000..776b0e8 --- /dev/null +++ b/src/Attributes/FormatterOptionAttribute.cs @@ -0,0 +1,22 @@ +namespace Style.Attributes; + +/// +/// Represents a CLI formatter option. +/// +[AttributeUsage(AttributeTargets.Property)] +public class FormatterOptionAttribute : Attribute +{ + /// + /// Gets or initializes an identifier for this CLI formatter option. + /// + /// + /// This value should be unique so that other attributes can reference the expected attribute. + /// + public string Identifier { get; init; } + + /// + /// Initializes a new instance of . + /// + /// A unique identifier for this CLI formatter option. + public FormatterOptionAttribute(string identifier) => Identifier = identifier; +} diff --git a/src/Constants.cs b/src/Constants.cs new file mode 100644 index 0000000..06a4b06 --- /dev/null +++ b/src/Constants.cs @@ -0,0 +1,60 @@ +namespace Style; + +/// +/// A collection of commonly used, immutable values. +/// +public static class Constants +{ + /// + /// The format command name. + /// + public const string FormatCommand = "format"; + + /// + /// The verify command name. + /// + public const string VerifyCommand = "verify"; + + /// + /// The path CLI option. + /// + public const string PathOption = "path"; + + /// + /// The verbosity CLI option. + /// + public const string VerbosityOption = "verbosity"; + + /// + /// Format using CSharpier CLI option + /// + public const string CsharpierOption = "csharpier"; + + /// + /// Format using dotnet format style CLI option. + /// + public const string StyleOption = "style"; + + /// + /// Format using dotnet format analyzers CLI option. + /// + public const string AnalyzersOption = "analyzers"; + + /// + /// Form using dotnet format whitespace CLI option. + /// + public const string WhitespaceOption = "whitespace"; + + /// + /// The .NET CLI command. + /// + public const string DotnetCli = "dotnet"; + + /// + /// Specify a higher verbosity option message. + /// + /// The space is intentional as it will get appended to a sentence. + public const string UseHigherVerbosityMessage = + " For more information, try running the command " + + "again and specify a higher verbosity option."; +} diff --git a/src/Extensions/ConsoleExtensions.cs b/src/Extensions/ConsoleExtensions.cs new file mode 100644 index 0000000..7e1a336 --- /dev/null +++ b/src/Extensions/ConsoleExtensions.cs @@ -0,0 +1,139 @@ +using CliFx.Infrastructure; +using CliWrap.Buffered; + +namespace Style.Extensions; + +/// +/// Provides extension methods for the interface. +/// +public static class ConsoleExtensions +{ + /// + /// Asynchronously writes the CLI command to run to the console stream. + /// + /// The to write to standard output to. + /// The CLI command to run. + /// The CLI command arguments to attach to the CLI command. + /// + /// The output level which determines whether to write the message. + /// + /// A that represents the asynchronous write operation. + /// An empty parameter value was provided. + public static async Task WriteCliCommandToRunAsync( + this IConsole console, + string command, + string? arguments, + Verbosity currentVerbosity + ) + { + if (string.IsNullOrWhiteSpace(command)) + { + throw new ArgumentNullException( + nameof(command), + "The parameter must be a non-empty value" + ); + } + + await console.Output.WriteNormalLineAsync( + $"Running the command '{command.Trim()}" + + $"{(string.IsNullOrEmpty(arguments?.Trim()) ? "'" : $" {arguments.Trim()}'")}", + currentVerbosity + ); + } + + /// + /// Asynchronously writes the standard output from a completed CLI command to the console stream. + /// + /// The to write to standard output to. + /// The CLI command result with available output and statistics. + /// + /// The output level which determines whether to write the message. + /// + /// A that represents the asynchronous write operations. + public static async Task WriteCliCommandStandardOutputAsync( + this IConsole console, + BufferedCommandResult result, + Verbosity currentVerbosity + ) + { + // Write standard output if it is available. + if (!string.IsNullOrWhiteSpace(result.StandardOutput)) + { + await console.Output.WriteVerboseLineAsync("Command Output:", currentVerbosity); + await console.Output.WriteVerboseLineAsync("", currentVerbosity); + + // Switch the color of the output to help show it is not written by us. + console.ForegroundColor = ConsoleColor.Blue; + + await console.Output.WriteVerboseLineAsync( + result.StandardOutput.Trim(), + currentVerbosity + ); + await console.Output.WriteVerboseLineAsync("", currentVerbosity); + + // Reset the console colors now that we are writing our own output. + console.ResetColor(); + } + } + + /// + /// Asynchronously writes a quiet string to the console stream, followed by a line terminator + /// if the given maximum verbosity is greater than or equal to . + /// + /// The to write characters to the stream with. + /// The message to write to the stream. + /// + /// The output level which determines whether to write the message. + /// + /// A that represents the asynchronous write operation. + public static Task WriteQuietLineAsync( + this ConsoleWriter console, + string? message, + Verbosity currentVerbosity + ) => WriteLineAsync(console, message, Verbosity.Quiet, currentVerbosity); + + /// + /// Asynchronously writes a normal string to the console stream, followed by a line terminator + /// if the given maximum verbosity is greater than or equal to . + /// + /// The to write characters to the stream with. + /// The message to write to the stream. + /// + /// The output level which determines whether to write the message. + /// + /// A that represents the asynchronous write operation. + public static Task WriteNormalLineAsync( + this ConsoleWriter console, + string? message, + Verbosity currentVerbosity + ) => WriteLineAsync(console, message, Verbosity.Normal, currentVerbosity); + + /// + /// Asynchronously writes a verbose string to the console stream, followed by a line terminator if the + /// given maximum verbosity is greater than or equal to . + /// + /// The to write characters to the stream with. + /// The message to write to the stream. + /// + /// The output level which determines whether to write the message. + /// + /// A that represents the asynchronous write operation. + public static Task WriteVerboseLineAsync( + this ConsoleWriter console, + string? message, + Verbosity currentVerbosity + ) => WriteLineAsync(console, message, Verbosity.Verbose, currentVerbosity); + + private static async Task WriteLineAsync( + ConsoleWriter console, + string? message, + Verbosity messageVerbosity, + Verbosity currentVerbosity + ) + { + if (currentVerbosity >= messageVerbosity) + { + await console.WriteLineAsync(message); + } + } +} diff --git a/src/Format/FormatCommand.cs b/src/Format/FormatCommand.cs new file mode 100644 index 0000000..a845097 --- /dev/null +++ b/src/Format/FormatCommand.cs @@ -0,0 +1,248 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using CliWrap; +using Style.Attributes; +using Style.Extensions; +using Style.Utilities; + +namespace Style.Format; + +/// +/// Models the format command which formats C# files to comply with code style standards. +/// +[Command( + Constants.FormatCommand, + Description = "Formats C# files according to a defined coding style." +)] +public class FormatCommand : ICommand +{ + /// + /// Gets or initializes the path option which specifies the directory to recurisvely format. + /// + [CommandOption( + Constants.PathOption, + 'p', + Description = "The directory containing files " + "to recursively format.", + IsRequired = false + )] + public DirectoryInfo RootFormatPath { get; init; } = + new DirectoryInfo(Directory.GetCurrentDirectory()); + + /// + /// Gets or initializes the format with 'dotnet format style' option. + /// + [CommandOption( + Constants.StyleOption, + 's', + Description = "Whether to format code " + + "using the 'dotnet format' formatter to run code style analyzers and apply fixes.", + IsRequired = false + )] + [FormatterOption(Constants.StyleOption)] + public bool FormatStyle { get; init; } = true; + + /// + /// Gets or initializes the format with 'dotnet format analyzers' option. + /// + [CommandOption( + Constants.AnalyzersOption, + 'a', + Description = "Whether to format code " + + "using the 'dotnet format' formatter to run third party code style analyzers and apply fixes.", + IsRequired = false + )] + [FormatterOption(Constants.AnalyzersOption)] + public bool FormatAnalyzers { get; init; } = true; + + /// + /// Gets or initializes the format with 'dotnet format whitespace' option. + /// + [CommandOption( + Constants.WhitespaceOption, + 'w', + Description = "Whether to format code " + + "using the 'dotnet format' formatter to run whitespace formatting.", + IsRequired = false + )] + [FormatterOption(Constants.WhitespaceOption)] + public bool FormatWhitespace { get; init; } = false; + + /// + /// Gets or initializes the format with 'dotnet csharpier' option. + /// + [CommandOption( + Constants.CsharpierOption, + 'c', + Description = "Whether to format code " + + $"using the 'CSharpier' opinionated formatter. Note: If formatting with --{Constants.WhitespaceOption}, " + + "this option must be disabled to avoid conflicts as they both handle whitespace formatting.", + IsRequired = false + )] + [FormatterOption(Constants.CsharpierOption)] + public bool FormatCsharpier { get; init; } = true; + + /// + /// Gets or initializes the output verbosity option. + /// + [CommandOption( + Constants.VerbosityOption, + 'v', + Description = "The output verbosity level.", + IsRequired = false + )] + public Verbosity OutputVerbosity { get; init; } = Verbosity.Normal; + + /// + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + ValidateCommandOptions(); + + await console.Output.WriteNormalLineAsync( + "Formatting C# files within " + $"'{RootFormatPath.FullName}'", + OutputVerbosity + ); + + // Add cancellation token support. + var ct = console.RegisterCancellationHandler(); + + await FormatAsync(console, ct); + + await console.Output.WriteNormalLineAsync("Done", OutputVerbosity); + } + // Rethrow a command exception as is. + catch (CommandException) + { + throw; + } + // Wrap an unexpected exception with helpful text. + catch (Exception ex) + { + throw new CommandException( + $"The following error has occurred:{Environment.NewLine}" + + $" {ex.Message}{Environment.NewLine}" + + "Double-check the command options and try again.", + exitCode: 1, + showHelp: true, + innerException: ex + ); + } + } + + private void ValidateCommandOptions() + { + var formatterOptionProperties = typeof(FormatCommand) + .GetProperties() + .Where(p => Attribute.IsDefined(p, typeof(FormatterOptionAttribute))); + + // Ensure that at least one formatter is enabled. + if (!formatterOptionProperties.Any(p => p.GetValue(this) is true)) + { + throw new CommandException( + "You must enable at least one formatter to format code with.", + showHelp: true + ); + } + + // Ensure that CSharpier is not specified at the same time as 'dotnet format whitespace'. + if (FormatCsharpier && FormatWhitespace) + { + throw new CommandException( + "You may only enable one whitespace formatter to format code with " + + $"by specifying either the '--{Constants.CsharpierOption}' option or the " + + $"'--{Constants.WhitespaceOption}' option to avoid potential conflicts.", + showHelp: true + ); + } + } + + private async Task FormatAsync(IConsole console, CancellationToken ct = default) + { + if (FormatStyle) + { + var arguments = $"format style ."; + var result = await CliUtilities.RunCliCommandAsync( + console, + OutputVerbosity, + Constants.DotnetCli, + arguments, + RootFormatPath.FullName, + ct + ); + await CliUtilities.HandleCliResultAsync( + console, + OutputVerbosity, + result, + BuildVerificationFailureExceptionMessage + ); + } + + if (FormatAnalyzers) + { + var arguments = $"format analyzers ."; + var result = await CliUtilities.RunCliCommandAsync( + console, + OutputVerbosity, + Constants.DotnetCli, + arguments, + RootFormatPath.FullName, + ct + ); + await CliUtilities.HandleCliResultAsync( + console, + OutputVerbosity, + result, + BuildVerificationFailureExceptionMessage + ); + } + + if (FormatWhitespace) + { + var arguments = $"format whitespace ."; + var result = await CliUtilities.RunCliCommandAsync( + console, + OutputVerbosity, + Constants.DotnetCli, + arguments, + RootFormatPath.FullName, + ct + ); + await CliUtilities.HandleCliResultAsync( + console, + OutputVerbosity, + result, + BuildVerificationFailureExceptionMessage + ); + } + + if (FormatCsharpier) + { + var arguments = $"csharpier ."; + var result = await CliUtilities.RunCliCommandAsync( + console, + OutputVerbosity, + Constants.DotnetCli, + arguments, + RootFormatPath.FullName, + ct + ); + await CliUtilities.HandleCliResultAsync( + console, + OutputVerbosity, + result, + BuildVerificationFailureExceptionMessage + ); + } + } + + private string BuildVerificationFailureExceptionMessage(string stdError) => + string.IsNullOrWhiteSpace(stdError) + ? $"The command returned a non-zero exit code.{CliUtilities.GetUseHigherVerbosityMessage(OutputVerbosity)}" + : $"The command returned a non-zero exit code.{Environment.NewLine}{Environment.NewLine}" + + $"Standard Error:{Environment.NewLine}{Environment.NewLine}" + + $" {stdError}{Environment.NewLine}{Environment.NewLine}" + + CliUtilities.GetUseHigherVerbosityMessage(OutputVerbosity); +} diff --git a/src/Program.cs b/src/Program.cs new file mode 100644 index 0000000..390bc0f --- /dev/null +++ b/src/Program.cs @@ -0,0 +1,13 @@ +// CA1852 Type 'Program' can be sealed because it has no subtypes in its containing assembly and is not externally visible +// TODO: Remove this when the following issue is addressed as this is a workaround: https://github.com/dotnet/roslyn-analyzers/issues/6141 +#pragma warning disable CA1852 + +using CliFx; + +return await new CliApplicationBuilder() + .SetTitle("Style") + .SetExecutableName("dotnet style") + .SetDescription("A customizable dotnet tool that helps maintain a consistent C# coding style.") + .AddCommandsFromThisAssembly() + .Build() + .RunAsync(); diff --git a/src/Properties/launchSettings.json b/src/Properties/launchSettings.json new file mode 100644 index 0000000..585d12e --- /dev/null +++ b/src/Properties/launchSettings.json @@ -0,0 +1,149 @@ +{ + "profiles": { + "Style - Help Menu": { + "commandName": "Project", + "commandLineArgs": "--help", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Version": { + "commandName": "Project", + "commandLineArgs": "--version", + "workingDirectory": "$(SolutionDir)" + }, + "Style - No Specified Command": { + "commandName": "Project", + "commandLineArgs": "", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Invalid Option (Failure)": { + "commandName": "Project", + "commandLineArgs": "--i-do-not-exist true", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Invalid Parameter (Failure)": { + "commandName": "Project", + "commandLineArgs": "i-do-not-exist", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Help Menu": { + "commandName": "Project", + "commandLineArgs": "verify --help", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Default Values": { + "commandName": "Project", + "commandLineArgs": "verify", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Only dotnet-format Style (verbose)": { + "commandName": "Project", + "commandLineArgs": "verify -s true -a false -w false -c false -v verbose", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Only dotnet-format Analyzers (verbose)": { + "commandName": "Project", + "commandLineArgs": "verify -s false -a true -w false -c false -v verbose", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Only dotnet-format Whitespace (verbose)": { + "commandName": "Project", + "commandLineArgs": "verify -s false -a false -w true -c false -v verbose", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Only dotnet-format Formatters Enabled (verbose)": { + "commandName": "Project", + "commandLineArgs": "verify -s true -a true -w true -c false -v verbose", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Only CSharpier (verbose)": { + "commandName": "Project", + "commandLineArgs": "verify -s false -a false -w false -c true -v verbose", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Invalid Option (Failure)": { + "commandName": "Project", + "commandLineArgs": "verify --i-do-not-exist true", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Invalid Option Value (Failure)": { + "commandName": "Project", + "commandLineArgs": "verify -s invalid-value", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Invalid Parameter (Failure)": { + "commandName": "Project", + "commandLineArgs": "verify i-do-not-exist", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - No Formatters Enabled (Failure)": { + "commandName": "Project", + "commandLineArgs": "verify -s false -a false -w false -c false", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Verify - Both Whitespace Formatters Enabled (Failure)": { + "commandName": "Project", + "commandLineArgs": "verify -s false -a false -w true -c true", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Help Menu": { + "commandName": "Project", + "commandLineArgs": "format --help", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Default Values": { + "commandName": "Project", + "commandLineArgs": "format", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Only CSharpier Enabled (verbose)": { + "commandName": "Project", + "commandLineArgs": "format -s false -a false -w false -c true -v verbose", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Only dotnet-format Style Enabled (verbose)": { + "commandName": "Project", + "commandLineArgs": "format -s true -a false -w false -c false -v verbose", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Only dotnet-format Analyzers Enabled (verbose)": { + "commandName": "Project", + "commandLineArgs": "format -s false -a true -w false -c false", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Only dotnet-format Whitespace Enabled (verbose)": { + "commandName": "Project", + "commandLineArgs": "format -s false -a false -w true -c false -v verbose", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Only dotnet-format Formatters Enabled (verbose)": { + "commandName": "Project", + "commandLineArgs": "format -s true -a true -w true -c false -v verbose", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Invalid Option (Failure)": { + "commandName": "Project", + "commandLineArgs": "format --i-do-not-exist true", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Invalid Option Value (Failure)": { + "commandName": "Project", + "commandLineArgs": "format -s invalid-value", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Invalid Parameter (Failure)": { + "commandName": "Project", + "commandLineArgs": "format i-do-not-exist", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - No Formatters Enabled (Failure)": { + "commandName": "Project", + "commandLineArgs": "format -s false -a false -w false -c false", + "workingDirectory": "$(SolutionDir)" + }, + "Style - Format - Both Whitespace Formatters Enabled (Failure)": { + "commandName": "Project", + "commandLineArgs": "format -s false -a false -w true -c true", + "workingDirectory": "$(SolutionDir)" + } + } +} diff --git a/src/Style.csproj b/src/Style.csproj new file mode 100644 index 0000000..73ab08a --- /dev/null +++ b/src/Style.csproj @@ -0,0 +1,35 @@ + + + + Exe + net6.0 + + style + A customizable .NET tool that helps maintain a consistent C# coding style. + Brandon Foss + 1.0.0-alpha.1 + c#;csharp;code;style;verify;enforce;format;csharpier;.net;dotnet;tool;cli; + True + Copyright (c) 2023 Brandon Foss + MIT + README.md + https://github.com/fossbrandon/dotnet-style/releases + en + git + https://github.com/fossbrandon/dotnet-style + https://github.com/fossbrandon/dotnet-style + + + + + True + \ + + + + + + + + + diff --git a/src/Utilities/CliUtilities.cs b/src/Utilities/CliUtilities.cs new file mode 100644 index 0000000..85f545a --- /dev/null +++ b/src/Utilities/CliUtilities.cs @@ -0,0 +1,90 @@ +using CliFx.Exceptions; +using CliFx.Infrastructure; +using CliWrap; +using CliWrap.Buffered; +using Style.Extensions; + +namespace Style.Utilities; + +/// +/// Provides helpful methods to assist with CLI operations. +/// +public class CliUtilities +{ + /// + /// Provides a wrapper method for running CLI commands in a consistent way. + /// + /// The to write to standard output to. + /// + /// The output level which determines whether to write a message. + /// + /// The path to the CLI command to run. + /// The arguments to run the CLI command with. + /// The directory path from which to run the CLI command. + /// + /// A for the executed command. + public static async ValueTask RunCliCommandAsync( + IConsole console, + Verbosity currentVerbosity, + string cliExecutablePath, + string arguments, + string workingDirectory, + CancellationToken ct = default + ) + { + await console.WriteCliCommandToRunAsync(cliExecutablePath, arguments, currentVerbosity); + + return await Cli.Wrap(cliExecutablePath) + .WithArguments(arguments) + .WithWorkingDirectory(workingDirectory) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(ct); + } + + /// + /// Evaluates whether the CLI command was successful. + /// + /// The result of the CLI command. + /// True if the CLI command has a 0 exit code, otherwise false. + public static bool IsCliCommandSuccessful(BufferedCommandResult commandResult) => + commandResult.ExitCode is 0; + + /// + /// Provides consistent handling of a CLI command result. + /// + /// The to write to standard output to. + /// + /// The output level which determines whether to write a message. + /// + /// The result of the CLI command. + /// The message to display in the event of an exception. + /// A that represents the asynchronous operation. + /// The command result shows a non-zero exit code. + public static async ValueTask HandleCliResultAsync( + IConsole console, + Verbosity currentVerbosity, + BufferedCommandResult commandResult, + Func buildExceptionMessage + ) + { + await console.WriteCliCommandStandardOutputAsync(commandResult, currentVerbosity); + + if (!IsCliCommandSuccessful(commandResult)) + { + throw new CommandException(buildExceptionMessage(commandResult.StandardError)); + } + + await console.Output.WriteNormalLineAsync("Success", currentVerbosity); + } + + /// + /// Gets a message to provide users who are not specifying verbose verbosity if they could + /// benefit from doing so. + /// + /// + /// The output level which determines whether this message should be given. + /// + /// A helpful message if using a lower verbosity, otherwise nothing. + public static string GetUseHigherVerbosityMessage(Verbosity currentVerbosity) => + currentVerbosity < Verbosity.Verbose ? Constants.UseHigherVerbosityMessage : ""; +} diff --git a/src/Verbosity.cs b/src/Verbosity.cs new file mode 100644 index 0000000..57c283b --- /dev/null +++ b/src/Verbosity.cs @@ -0,0 +1,31 @@ +namespace Style; + +/// +/// The available verbosity levels for CLI output. +/// +public enum Verbosity +{ + /// + /// Supress all output. + /// + /// + /// The exit code will provide information on whether the command passed. + /// + Quiet = 0, + + /// + /// Output standard progress updates. + /// + /// + /// Suppresses debug style information that is not vital to understand program progress. + /// + Normal = 1, + + /// + /// Output all available information. + /// + /// + /// Provides debug level information that may help diagnose issues. + /// + Verbose = 2, +} diff --git a/src/Verify/VerifyCommand.cs b/src/Verify/VerifyCommand.cs new file mode 100644 index 0000000..9c6a91b --- /dev/null +++ b/src/Verify/VerifyCommand.cs @@ -0,0 +1,254 @@ +using CliFx; +using CliFx.Attributes; +using CliFx.Exceptions; +using CliFx.Infrastructure; +using CliWrap; +using Style.Attributes; +using Style.Extensions; +using Style.Utilities; + +namespace Style.Verify; + +/// +/// Models the verify command which verifies whether C# files currently comply with code style standards. +/// +[Command( + Constants.VerifyCommand, + Description = "Verifies that C# files comply with the defined coding style." +)] +public class VerifyCommand : ICommand +{ + /// + /// Gets or initializes the path option which specifies the directory to recurisvely verify the + /// coding style compliance of. + /// + [CommandOption( + Constants.PathOption, + 'p', + Description = "The directory containing files " + + "to recursively verify the coding style compliance of.", + IsRequired = false + )] + public DirectoryInfo RootFormatPath { get; init; } = + new DirectoryInfo(Directory.GetCurrentDirectory()); + + /// + /// Gets or initializes the verify with 'dotnet format style --verify-no-changes' option. + /// + [CommandOption( + Constants.StyleOption, + 's', + Description = "Whether to verify code " + + "complies with the coding style used by the 'dotnet format' formatter for code style " + + "analyzer settings.", + IsRequired = false + )] + [FormatterOption(Constants.StyleOption)] + public bool FormatStyle { get; init; } = true; + + /// + /// Gets or initializes the verify with 'dotnet format analyzers --verify-no-changes' option. + /// + [CommandOption( + Constants.AnalyzersOption, + 'a', + Description = "Whether to verify code " + + "complies with the coding style used by the 'dotnet format' formatter for third party " + + "code style analyzer settings.", + IsRequired = false + )] + [FormatterOption(Constants.AnalyzersOption)] + public bool FormatAnalyzers { get; init; } = true; + + /// + /// Gets or initializes the verify with 'dotnet format whitespace --verify-no-changes' option. + /// + [CommandOption( + Constants.WhitespaceOption, + 'w', + Description = "Whether to verify code " + + "complies with the coding style used by the 'dotnet format' formatter for whitespace settings.", + IsRequired = false + )] + [FormatterOption(Constants.WhitespaceOption)] + public bool FormatWhitespace { get; init; } = false; + + /// + /// Gets or initializes the verify with 'dotnet csharpier --check' option. + /// + [CommandOption( + Constants.CsharpierOption, + 'c', + Description = "Whether to verify code " + + "complies with the coding style used by the 'CSharpier' opinionated formatter. Note: If verifying with " + + $"--{Constants.WhitespaceOption}, this option must be disabled to avoid conflicts as they " + + "both handle whitespace formatting.", + IsRequired = false + )] + [FormatterOption(Constants.CsharpierOption)] + public bool FormatCsharpier { get; init; } = true; + + /// + /// Gets or initializes the output verbosity option. + /// + [CommandOption( + Constants.VerbosityOption, + 'v', + Description = "The output verbosity level.", + IsRequired = false + )] + public Verbosity OutputVerbosity { get; init; } = Verbosity.Normal; + + /// + public async ValueTask ExecuteAsync(IConsole console) + { + try + { + ValidateCommandOptions(); + + await console.Output.WriteNormalLineAsync( + "Verifying that C# files currently comply " + + $"with defined style standards within '{RootFormatPath.FullName}'", + OutputVerbosity + ); + + // Add cancellation token support. + var ct = console.RegisterCancellationHandler(); + + await VerifyAsync(console, ct); + + await console.Output.WriteNormalLineAsync("Done", OutputVerbosity); + } + // Rethrow a command exception as is. + catch (CommandException) + { + throw; + } + // Wrap an unexpected exception with helpful text. + catch (Exception ex) + { + throw new CommandException( + $"The following error has occurred:{Environment.NewLine}" + + $" {ex.Message}{Environment.NewLine}" + + "Double-check the command options and try again.", + exitCode: 1, + showHelp: true, + innerException: ex + ); + } + } + + private void ValidateCommandOptions() + { + var formatterOptionProperties = typeof(VerifyCommand) + .GetProperties() + .Where(p => Attribute.IsDefined(p, typeof(FormatterOptionAttribute))); + + // Ensure that at least one formatter is enabled. + if (!formatterOptionProperties.Any(p => p.GetValue(this) is true)) + { + throw new CommandException( + "You must enable at least one formatter to verify the code " + "style with.", + showHelp: true + ); + } + + // Ensure that CSharpier is not specified at the same time as 'dotnet format whitespace'. + if (FormatCsharpier && FormatWhitespace) + { + throw new CommandException( + "You may only enable one whitespace formatter to verify code " + + $"style compliance with by specifying either the '--{Constants.CsharpierOption}' " + + $"option or the '--{Constants.WhitespaceOption}' option to avoid potential conflicts.", + showHelp: true + ); + } + } + + private async Task VerifyAsync(IConsole console, CancellationToken ct = default) + { + if (FormatStyle) + { + var arguments = $"format style . --verify-no-changes"; + var result = await CliUtilities.RunCliCommandAsync( + console, + OutputVerbosity, + Constants.DotnetCli, + arguments, + RootFormatPath.FullName, + ct + ); + await CliUtilities.HandleCliResultAsync( + console, + OutputVerbosity, + result, + BuildVerificationFailureExceptionMessage + ); + } + + if (FormatAnalyzers) + { + var arguments = $"format analyzers . --verify-no-changes"; + var result = await CliUtilities.RunCliCommandAsync( + console, + OutputVerbosity, + Constants.DotnetCli, + arguments, + RootFormatPath.FullName, + ct + ); + await CliUtilities.HandleCliResultAsync( + console, + OutputVerbosity, + result, + BuildVerificationFailureExceptionMessage + ); + } + + if (FormatWhitespace) + { + var arguments = $"format whitespace . --verify-no-changes"; + var result = await CliUtilities.RunCliCommandAsync( + console, + OutputVerbosity, + Constants.DotnetCli, + arguments, + RootFormatPath.FullName, + ct + ); + await CliUtilities.HandleCliResultAsync( + console, + OutputVerbosity, + result, + BuildVerificationFailureExceptionMessage + ); + } + + if (FormatCsharpier) + { + var arguments = $"csharpier . --check"; + var result = await CliUtilities.RunCliCommandAsync( + console, + OutputVerbosity, + Constants.DotnetCli, + arguments, + RootFormatPath.FullName, + ct + ); + await CliUtilities.HandleCliResultAsync( + console, + OutputVerbosity, + result, + BuildVerificationFailureExceptionMessage + ); + } + } + + private string BuildVerificationFailureExceptionMessage(string stdError) => + string.IsNullOrWhiteSpace(stdError) + ? $"Code does not comply with the defined style standards.{CliUtilities.GetUseHigherVerbosityMessage(OutputVerbosity)}" + : $"Code does not comply with the defined style standards or an error occurred.{Environment.NewLine}{Environment.NewLine}" + + $"Standard Error:{Environment.NewLine}{Environment.NewLine}" + + $" {stdError}{Environment.NewLine}{Environment.NewLine}" + + CliUtilities.GetUseHigherVerbosityMessage(OutputVerbosity); +} diff --git a/test/.editorconfig b/test/.editorconfig new file mode 100644 index 0000000..8240fde --- /dev/null +++ b/test/.editorconfig @@ -0,0 +1,39 @@ +# Helps maintain consistent styling for various file types and coding languages +# More info: https://editorconfig.org/ +# This config overrides specific rules from the top-most EditorConfig file in regards to testing differences. + +################### +# Common Settings # +################### + +# This file works alongside the top-most EditorConfig file +root = false + +# C# Files (.NET Code Style Settings) +[*.cs] + +# IDE0058: Remove unnecessary expression value +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0058 +# Tests often have unused expressions as part of the test infrastructure so ignore this rule +dotnet_diagnostic.IDE0058.severity = none + +# CS1591: Missing XML comment for publicly visible type or member +# https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/cs1591 +# Test methods must be publice and are self documenting with descriptive names so ignore this rule +dotnet_diagnostic.CS1591.severity = none + +# Naming Styles +# ignored_style (lists a capitalization style since one is required) +dotnet_naming_style.ignored_style.capitalization = pascal_case + +# Naming Rules - More specific rules regarding accessibilities, modifiers, and symbols take precedence over less specific rules +# Methods in test files will all have different styles so don't add rules +# Note: Uses existing groups from the top-most EditorConfig +dotnet_naming_rule.methods_should_be_any_style.symbols = method_group +dotnet_naming_rule.methods_should_be_any_style.style = ignored_style +dotnet_naming_rule.methods_should_be_any_style.severity = none +# Async methods in test files will all have different styles so don't add rules +# Note: Uses existing groups from the top-most EditorConfig +dotnet_naming_rule.async_methods_should_be_any_style.symbols = async_method_group +dotnet_naming_rule.async_methods_should_be_any_style.style = ignored_style +dotnet_naming_rule.async_methods_should_be_any_style.severity = none diff --git a/test/Constants.cs b/test/Constants.cs new file mode 100644 index 0000000..e2dad67 --- /dev/null +++ b/test/Constants.cs @@ -0,0 +1,12 @@ +namespace Style.Tests; + +/// +/// A collection of commonly used, immutable values for tests. +/// +internal sealed class Constants +{ + internal const string CliTitle = "Test CLI"; + internal const string CliExecutableName = "tc"; + internal const string CliDescription = "CLI for tests"; + internal const string CliVersion = "1.0.0"; +} diff --git a/test/Extensions/ConsoleExtensionsUnitTests.cs b/test/Extensions/ConsoleExtensionsUnitTests.cs new file mode 100644 index 0000000..05b8fa5 --- /dev/null +++ b/test/Extensions/ConsoleExtensionsUnitTests.cs @@ -0,0 +1,316 @@ +using CliFx.Infrastructure; +using CliWrap.Buffered; +using FluentAssertions; +using Style.Extensions; + +namespace Style.Tests.Extensions; + +/// +/// Provides unit tests for the class. +/// +public class ConsoleExtensionsUnitTests +{ + private readonly string nl = Environment.NewLine; + private readonly List quietAndGreaterVerbosities = + new() { Verbosity.Quiet, Verbosity.Normal, Verbosity.Verbose }; + private readonly List normalAndGreaterVerbosities = + new() { Verbosity.Normal, Verbosity.Verbose }; + private readonly List verboseAndGreaterVerbosities = new() { Verbosity.Verbose }; + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" ", " ")] + [InlineData("\n", "\n")] + [InlineData("\t", $"\t")] + [InlineData("This should appear in the console", "This should appear in the console")] + [InlineData( + "This multiline message\nshould appear in the console", + "This multiline message\nshould appear in the console" + )] + public async Task WriteQuietLineAsync_writes_to_the_console_given_a_message_and_a_verbosity_of_quiet_or_higher( + string? message, + string expected + ) + { + foreach (var verbosity in quietAndGreaterVerbosities) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + var sanitizedExpected = $"{expected}{nl}"; + + // Act. + await console.Output.WriteQuietLineAsync(message, verbosity); + var output = console.ReadOutputString(); + + // Assert. + output.Should().Be(sanitizedExpected); + } + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" ", " ")] + [InlineData("\n", "\n")] + [InlineData("\t", $"\t")] + [InlineData("This should appear in the console", "This should appear in the console")] + [InlineData( + "This multiline message\nshould appear in the console", + "This multiline message\nshould appear in the console" + )] + public async Task WriteNormalLineAsync_writes_to_the_console_given_a_message_and_a_verbosity_of_normal_or_higher( + string? message, + string expected + ) + { + foreach (var verbosity in normalAndGreaterVerbosities) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + var sanitizedExpected = $"{expected}{nl}"; + + // Act. + await console.Output.WriteNormalLineAsync(message, verbosity); + var output = console.ReadOutputString(); + + // Assert. + output.Should().Be(sanitizedExpected); + } + } + + [Theory] + [InlineData(Verbosity.Quiet)] + public async Task WriteNormalLineAsync_does_not_write_to_the_console_given_a_message_and_a_verbosity_lower_than_normal( + Verbosity verbosity + ) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + const string message = "This should not appear in the console"; + + // Act. + await console.Output.WriteNormalLineAsync(message, verbosity); + var output = console.ReadOutputString(); + + // Assert. + output.Should().BeEmpty(); + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" ", " ")] + [InlineData("\n", "\n")] + [InlineData("\t", $"\t")] + [InlineData("This should appear in the console", "This should appear in the console")] + [InlineData( + "This multiline message\nshould appear in the console", + "This multiline message\nshould appear in the console" + )] + public async Task WriteVerboseLineAsync_writes_to_the_console_given_a_message_and_a_verbosity_of_verbose_or_higher( + string? message, + string expected + ) + { + foreach (var verbosity in verboseAndGreaterVerbosities) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + var sanitizedExpected = $"{expected}{nl}"; + + // Act. + await console.Output.WriteVerboseLineAsync(message, verbosity); + var output = console.ReadOutputString(); + + // Assert. + output.Should().Be(sanitizedExpected); + } + } + + [Theory] + [InlineData(Verbosity.Quiet)] + [InlineData(Verbosity.Normal)] + public async Task WriteVerboseLineAsync_does_not_write_to_the_console_given_a_message_and_a_verbosity_lower_than_verbose( + Verbosity verbosity + ) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + const string message = "This should not appear in the console"; + + // Act. + await console.Output.WriteVerboseLineAsync(message, verbosity); + var output = console.ReadOutputString(); + + // Assert. + output.Should().BeEmpty(); + } + + [Theory] + [InlineData( + Style.Constants.DotnetCli, + Style.Constants.CsharpierOption, + $"Running the command '{Style.Constants.DotnetCli} {Style.Constants.CsharpierOption}'" + )] + [InlineData("command", "", $"Running the command 'command'")] + [InlineData(" command ", " arguments ", $"Running the command 'command arguments'")] + [InlineData("command", null, $"Running the command 'command'")] + [InlineData("command", " ", $"Running the command 'command'")] + [InlineData("command", "\t", $"Running the command 'command'")] + [InlineData("command", "\n", $"Running the command 'command'")] + [InlineData( + "command", + "arguments --path-option \"/etc/example\"", + $"Running the command 'command arguments --path-option \"/etc/example\"'" + )] + [InlineData( + "command", + "--multiline\narguments", + $"Running the command 'command --multiline\narguments'" + )] + public async Task WriteCliCommandToRunAsync_writes_to_the_console_given_a_message_and_a_verbosity_of_normal_or_higher( + string command, + string arguments, + string expected + ) + { + foreach (var verbosity in normalAndGreaterVerbosities) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + var sanitizedExpected = $"{expected}{nl}"; + + // Act. + await console.WriteCliCommandToRunAsync(command, arguments, verbosity); + var output = console.ReadOutputString(); + + // Assert. + output.Should().Be(sanitizedExpected); + } + } + + [Theory] + [InlineData(Verbosity.Quiet)] + public async Task WriteCliCommandToRunAsync_does_not_write_to_the_console_given_a_message_and_a_verbosity_lower_than_normal( + Verbosity verbosity + ) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + const string command = "command"; + const string arguments = "arguments"; + + // Act. + await console.WriteCliCommandToRunAsync(command, arguments, verbosity); + var output = console.ReadOutputString(); + + // Assert. + output.Should().BeEmpty(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData("\n")] + public async Task WriteCliCommandToRunAsync_throws_exception_given_empty_command( + string? command + ) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + const string arguments = "arguments"; + + // Act. + Func actAsync = async () => + await console.WriteCliCommandToRunAsync(command!, arguments, Verbosity.Normal); + + // Assert. + await actAsync + .Should() + .ThrowAsync() + .WithMessage("The parameter must be a non-empty value (Parameter 'command')"); + } + + [Theory] + [InlineData("Should show up")] + public async Task WriteCliCommandStandardOutputAsync_writes_to_the_console_given_the_command_result_with_non_empty_standard_output_and_a_verbosity_of_verbose_or_higher( + string standardOuptut + ) + { + foreach (var verbosity in verboseAndGreaterVerbosities) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + var result = new BufferedCommandResult( + 0, + new DateTimeOffset(new DateTime(2023, 05, 04, 01, 00, 00)), + new DateTimeOffset(new DateTime(2023, 05, 04, 01, 00, 05)), + standardOuptut, + "" + ); + var sanitizedExpected = $"Command Output:{nl}{nl}{standardOuptut}{nl}{nl}"; + + // Act. + await console.WriteCliCommandStandardOutputAsync(result, verbosity); + var output = console.ReadOutputString(); + + // Assert. + output.Should().Be(sanitizedExpected); + } + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData("\n")] + public async Task WriteCliCommandStandardOutputAsync_does_not_write_to_the_console_given_result_with_empty_standard_output( + string? emptyStandardOutput + ) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + var result = new BufferedCommandResult( + 0, + new DateTimeOffset(new DateTime(2023, 05, 04, 01, 00, 00)), + new DateTimeOffset(new DateTime(2023, 05, 04, 01, 00, 05)), + emptyStandardOutput!, + "" + ); + + // Act. + await console.WriteCliCommandStandardOutputAsync(result, Verbosity.Verbose); + var output = console.ReadOutputString(); + + // Assert. + output.Should().BeEmpty(); + } + + [Theory] + [InlineData(Verbosity.Quiet)] + public async Task WriteCliCommandStandardOutputAsync_does_not_write_to_the_console_given_the_command_result_and_a_verbosity_lower_than_normal( + Verbosity verbosity + ) + { + // Arrange. + using var console = new FakeInMemoryConsole(); + var result = new BufferedCommandResult( + 0, + new DateTimeOffset(new DateTime(2023, 05, 04, 01, 00, 00)), + new DateTimeOffset(new DateTime(2023, 05, 04, 01, 00, 05)), + "Should not show up", + "Fake error message" + ); + + // Act. + await console.WriteCliCommandStandardOutputAsync(result, verbosity); + var output = console.ReadOutputString(); + + // Assert. + output.Should().BeEmpty(); + } +} diff --git a/test/Format/FormatCommandFunctionalTests.cs b/test/Format/FormatCommandFunctionalTests.cs new file mode 100644 index 0000000..7e1b4bd --- /dev/null +++ b/test/Format/FormatCommandFunctionalTests.cs @@ -0,0 +1,206 @@ +using CliFx.Infrastructure; +using FluentAssertions; +using Style.Format; +using Style.Tests.Utilities; + +namespace Style.Tests.Format; + +/// +/// Provides functional tests for . +/// +public class FormatCommandFunctionalTests +{ + private static readonly string nl = TestHelpers.NL; + private const string Command = Style.Constants.FormatCommand; + + [Theory] + [InlineData("--help")] + [InlineData("-h")] + public async Task FormatCommand_shows_help_menu_given_help_option(string input) + { + // Arrange. + var expectedStdOutputSegments = GetExpectedFormatHelpMenuOutputSegments(); + + using var console = new FakeInMemoryConsole(); + + var sut = TestHelpers.BuildStyleTestApp(console); + var args = new[] { Command, input }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(0); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task FormatCommand_returns_error_given_invalid_option() + { + // Arrange. + const string invalidOption = "--invalid-option"; + + var expectedStdOutputSegments = GetExpectedFormatHelpMenuOutputSegments(); + var expectedStdError = $"Unrecognized option(s):{nl}{invalidOption}{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] { Command, invalidOption }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + [Fact] + public async Task FormatCommand_returns_error_given_invalid_option_value() + { + // Arrange. + const string validOption = $"--{Style.Constants.CsharpierOption}"; + const string invalidValue = "invalid"; + + var expectedStdOutputSegments = GetExpectedFormatHelpMenuOutputSegments(); + var expectedStdError = + $"Option -c|{validOption} cannot be set from the provided argument(s):{nl}" + + $"<{invalidValue}>{nl}" + + $"Error: String '{invalidValue}' was not recognized as a valid Boolean.{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] { Command, validOption, invalidValue }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + [Fact] + public async Task FormatCommand_returns_error_given_invalid_parameter() + { + // Arrange. + const string invalidParameter = "invalid-parameter"; + + var expectedStdOutputSegments = GetExpectedFormatHelpMenuOutputSegments(); + var expectedStdError = $"Unexpected parameter(s):{nl}<{invalidParameter}>{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] { Command, invalidParameter }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + [Fact] + public async Task FormatCommand_returns_error_when_no_formatters_are_enabled() + { + // Arrange. + var expectedStdOutputSegments = GetExpectedFormatHelpMenuOutputSegments(); + var expectedStdError = $"You must enable at least one formatter to format code with.{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] + { + Command, + $"--{Style.Constants.StyleOption}", + "false", + $"--{Style.Constants.AnalyzersOption}", + "false", + $"--{Style.Constants.WhitespaceOption}", + "false", + $"--{Style.Constants.CsharpierOption}", + "false", + }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + [Fact] + public async Task FormatCommand_returns_error_when_multiple_whitespace_formatters_are_enabled() + { + // Arrange. + var expectedStdOutputSegments = GetExpectedFormatHelpMenuOutputSegments(); + var expectedStdError = + "You may only enable one whitespace formatter to format code with " + + $"by specifying either the '--{Style.Constants.CsharpierOption}' option or the " + + $"'--{Style.Constants.WhitespaceOption}' option to avoid potential conflicts.{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] + { + Command, + $"--{Style.Constants.StyleOption}", + "false", + $"--{Style.Constants.AnalyzersOption}", + "false", + $"--{Style.Constants.WhitespaceOption}", + "true", + $"--{Style.Constants.CsharpierOption}", + "true", + }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + internal static string[] GetExpectedFormatHelpMenuOutputSegments() => + new[] + { + Constants.CliTitle, + Constants.CliVersion, + Constants.CliDescription, + "USAGE", + $"{Constants.CliExecutableName}", + Style.Constants.FormatCommand, + "DESCRIPTION", + "OPTIONS", + "-v|--verbosity", + "The output verbosity level. Choices: \"Quiet\", \"Normal\", \"Verbose\". Default: \"Normal\".", + "-h|--help", + "Shows help text.", + }; +} diff --git a/test/Style.Tests.csproj b/test/Style.Tests.csproj new file mode 100644 index 0000000..90e9ce3 --- /dev/null +++ b/test/Style.Tests.csproj @@ -0,0 +1,26 @@ + + + + net6.0 + false + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/StyleCommandFunctionalTests.cs b/test/StyleCommandFunctionalTests.cs new file mode 100644 index 0000000..86ec399 --- /dev/null +++ b/test/StyleCommandFunctionalTests.cs @@ -0,0 +1,131 @@ +using CliFx.Infrastructure; +using FluentAssertions; +using Style.Tests.Utilities; + +namespace Style.Tests; + +/// +/// Provides functional tests for the default style command. +/// +public class StyleCommandFunctionalTests +{ + private static readonly string nl = TestHelpers.NL; + + [Theory] + [InlineData("--help")] + [InlineData("-h")] + public async Task StyleCommand_shows_help_menu_given_help_option(string input) + { + // Arrange. + var expectedStdOutputSegments = TestHelpers.GetExpectedDefaultHelpMenuOutputSegments(); + + using var console = new FakeInMemoryConsole(); + + var sut = TestHelpers.BuildStyleTestApp(console); + var args = new[] { input }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(0); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task StyleCommand_shows_help_menu_given_no_options() + { + // Arrange. + var expectedStdOutputSegments = TestHelpers.GetExpectedDefaultHelpMenuOutputSegments(); + + using var console = new FakeInMemoryConsole(); + + var sut = TestHelpers.BuildStyleTestApp(console); + var args = Array.Empty(); + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(0); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task StyleCommand_shows_tool_version_given_version_option() + { + // Arrange. + var expectedOutput = $"{Constants.CliVersion}{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] { "--version" }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(0); + stdOut.Should().Be(expectedOutput); + stdError.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task StyleCommand_returns_error_given_invalid_option() + { + // Arrange. + const string invalidOption = "--invalid-option"; + + var expectedStdOutputSegments = TestHelpers.GetExpectedDefaultHelpMenuOutputSegments(); + var expectedErrorSegments = $"Unrecognized option(s):{nl}{invalidOption}{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] { invalidOption }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedErrorSegments); + } + + [Fact] + public async Task StyleCommand_returns_error_given_invalid_parameter() + { + // Arrange. + const string invalidArgument = "invalid-parameter"; + + var expectedStdOutputSegments = TestHelpers.GetExpectedDefaultHelpMenuOutputSegments(); + var expectedErrorSegments = $"Unexpected parameter(s):{nl}<{invalidArgument}>{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] { invalidArgument }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedErrorSegments); + } +} diff --git a/test/Usings.cs b/test/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/test/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/Utilities/TestHelpers.cs b/test/Utilities/TestHelpers.cs new file mode 100644 index 0000000..d5af5b6 --- /dev/null +++ b/test/Utilities/TestHelpers.cs @@ -0,0 +1,91 @@ +using CliFx; +using CliFx.Infrastructure; + +namespace Style.Tests.Utilities; + +/// +/// Provides a collection of helper methods to assist tests. +/// +internal sealed class TestHelpers +{ + /// + /// Builds a suitable for running tests against. + /// + /// + /// + internal static CliApplication BuildStyleTestApp(IConsole console) => + new CliApplicationBuilder() + .SetTitle(Constants.CliTitle) + .SetExecutableName(Constants.CliExecutableName) + .SetDescription(Constants.CliDescription) + .AddCommandsFrom(typeof(Verbosity).Assembly) + .SetVersion(Constants.CliVersion) + .UseConsole(console) + .Build(); + + /// + /// Gets a collection of output segments that are expected when the default help menu text is displayed. + /// + /// + /// We do not care to check for exact accuracy on the help menu output, so these segments + /// check for a handful of known fields to be confident that we got our expected output. + /// + /// A collection of expected output segments. + internal static string[] GetExpectedDefaultHelpMenuOutputSegments() => + new[] + { + Constants.CliTitle, + Constants.CliVersion, + Constants.CliDescription, + "USAGE", + $"{Constants.CliExecutableName}", + "OPTIONS", + "-h|--help", + "Shows help text.", + "--version", + "Shows version information.", + "COMMANDS", + Style.Constants.FormatCommand, + Style.Constants.VerifyCommand, + }; + + /// + /// Gets a snippet of expected output when run with a quiet verbosity. + /// + /// + /// We do not care to check for every instance of quiet output, so we use this segment alone to + /// check that it appears in the output to be confident that others like it also appear and our + /// output verbosity mechanism works as expected. + /// + /// An expected quiet verbosity output segment. + internal static string GetExpectedQuietOutputSegment() => + "Preparing the base system upgrade files."; + + /// + /// Gets a snippet of expected output when run with a normal verbosity or below. + /// + /// + /// We do not care to check for every instance of normal output, so we use this segment alone to + /// check that it appears in the output to be confident that others like it also appear and our + /// output verbosity mechanism works as expected. + /// + /// An expected normal verbosity output segment. + internal static string GetExpectedNormalOutputSegment() => + "Starting the SEL UTM upgrade preparation process."; + + /// + /// Gets a snippet of expected output when run with a verbose verbosity or below. + /// + /// + /// We do not care to check for every instance of verbose output, so we use this segment alone to + /// check that it appears in the output to be confident that others like it also appear and our + /// output verbosity mechanism works as expected. + /// + /// An expected verbose verbosity output segment. + internal static string GetExpectedVerboseOutputSegment() => "Downloading '"; + + /// + /// Shorthand newline specifcific to the OS running tests. + /// + internal static readonly string NL = Environment.NewLine; +} diff --git a/test/Verify/VerifyCommandFunctionalTests.cs b/test/Verify/VerifyCommandFunctionalTests.cs new file mode 100644 index 0000000..f93ed46 --- /dev/null +++ b/test/Verify/VerifyCommandFunctionalTests.cs @@ -0,0 +1,207 @@ +using CliFx.Infrastructure; +using FluentAssertions; +using Style.Tests.Utilities; +using Style.Verify; + +namespace Style.Tests.Verify; + +/// +/// Provides functional tests for . +/// +public class VerifyCommandFunctionalTests +{ + private static readonly string nl = TestHelpers.NL; + private const string Command = Style.Constants.VerifyCommand; + + [Theory] + [InlineData("--help")] + [InlineData("-h")] + public async Task VerifyCommand_shows_help_menu_given_help_option(string input) + { + // Arrange. + var expectedStdOutputSegments = GetExpectedVerifyHelpMenuOutputSegments(); + + using var console = new FakeInMemoryConsole(); + + var sut = TestHelpers.BuildStyleTestApp(console); + var args = new[] { Command, input }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(0); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task VerifyCommand_returns_error_given_invalid_option() + { + // Arrange. + const string invalidOption = "--invalid-option"; + + var expectedStdOutputSegments = GetExpectedVerifyHelpMenuOutputSegments(); + var expectedStdError = $"Unrecognized option(s):{nl}{invalidOption}{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] { Command, invalidOption }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + [Fact] + public async Task VerifyCommand_returns_error_given_invalid_option_value() + { + // Arrange. + const string validOption = $"--{Style.Constants.CsharpierOption}"; + const string invalidValue = "invalid"; + + var expectedStdOutputSegments = GetExpectedVerifyHelpMenuOutputSegments(); + var expectedStdError = + $"Option -c|{validOption} cannot be set from the provided argument(s):{nl}" + + $"<{invalidValue}>{nl}" + + $"Error: String '{invalidValue}' was not recognized as a valid Boolean.{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] { Command, validOption, invalidValue }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + [Fact] + public async Task VerifyCommand_returns_error_given_invalid_parameter() + { + // Arrange. + const string invalidParameter = "invalid-parameter"; + + var expectedStdOutputSegments = GetExpectedVerifyHelpMenuOutputSegments(); + var expectedStdError = $"Unexpected parameter(s):{nl}<{invalidParameter}>{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] { Command, invalidParameter }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + [Fact] + public async Task VerifyCommand_returns_error_when_no_formatters_are_enabled() + { + // Arrange. + var expectedStdOutputSegments = GetExpectedVerifyHelpMenuOutputSegments(); + var expectedStdError = + $"You must enable at least one formatter to verify the code style with.{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] + { + Command, + $"--{Style.Constants.StyleOption}", + "false", + $"--{Style.Constants.AnalyzersOption}", + "false", + $"--{Style.Constants.WhitespaceOption}", + "false", + $"--{Style.Constants.CsharpierOption}", + "false", + }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + [Fact] + public async Task VerifyCommand_returns_error_when_multiple_whitespace_formatters_are_enabled() + { + // Arrange. + var expectedStdOutputSegments = GetExpectedVerifyHelpMenuOutputSegments(); + var expectedStdError = + "You may only enable one whitespace formatter to verify code style " + + $"compliance with by specifying either the '--{Style.Constants.CsharpierOption}' option or the " + + $"'--{Style.Constants.WhitespaceOption}' option to avoid potential conflicts.{nl}"; + + using var console = new FakeInMemoryConsole(); + var sut = TestHelpers.BuildStyleTestApp(console); + + var args = new[] + { + Command, + $"--{Style.Constants.StyleOption}", + "false", + $"--{Style.Constants.AnalyzersOption}", + "false", + $"--{Style.Constants.WhitespaceOption}", + "true", + $"--{Style.Constants.CsharpierOption}", + "true", + }; + + // Act. + var exitCode = await sut.RunAsync(args); + var stdOut = console.ReadOutputString(); + var stdError = console.ReadErrorString(); + + // Assert. + exitCode.Should().Be(1); + stdOut.Should().ContainAll(expectedStdOutputSegments); + stdError.Should().Be(expectedStdError); + } + + internal static string[] GetExpectedVerifyHelpMenuOutputSegments() => + new[] + { + Constants.CliTitle, + Constants.CliVersion, + Constants.CliDescription, + "USAGE", + $"{Constants.CliExecutableName}", + Style.Constants.VerifyCommand, + "DESCRIPTION", + "OPTIONS", + "-v|--verbosity", + "The output verbosity level. Choices: \"Quiet\", \"Normal\", \"Verbose\". Default: \"Normal\".", + "-h|--help", + "Shows help text.", + }; +}