#!/usr/bin/env python3 """Tests for lint_csharp. Run with: python -m unittest test_lint_csharp (from this directory) """ import json import os import tempfile import unittest import lint_csharp as lint def padded(prefix, length): """Returns prefix followed by words of filler, exactly length columns long.""" text = (prefix + " word" * length)[:length] if text.endswith(" "): text = text[:-1] + "x" return text def rules(text, touched=None): return [issue.rule for issue in lint.lint(text, touched)] class FillTests(unittest.TestCase): def test_flags_a_line_ending_where_the_next_word_still_fits(self): source = "// The first line stops short.\n// next line continues the sentence.\n" self.assertEqual(["comment-fill"], rules(source)) def test_reports_the_column_and_the_word_that_fits(self): issue = lint.lint("// Short.\n// Then more.\n")[0] self.assertEqual(1, issue.line) self.assertIn("column 9", issue.message) self.assertIn('"Then"', issue.message) def test_passes_a_line_the_next_word_would_overflow(self): first = padded("// Filled", 115) source = first + "\n// overflowing continues.\n" self.assertEqual([], rules(source)) def test_flags_a_word_that_lands_exactly_on_the_limit(self): first = padded("// Filled", 114) source = first + "\n// abcde continues.\n" self.assertEqual(["comment-fill"], rules(source)) def test_passes_a_blank_comment_line_between_paragraphs(self): self.assertEqual([], rules("// One paragraph.\n//\n// Another paragraph.\n")) def test_passes_a_line_before_a_list_item(self): self.assertEqual([], rules("// Some things.\n// - a thing\n// - another thing\n")) def test_checks_a_list_item_continued_under_its_text(self): source = "// - a short item\n// continued here.\n" self.assertEqual(["comment-fill"], rules(source)) def test_passes_a_line_before_a_todo(self): self.assertEqual([], rules("// Does the thing.\n// TODO: do it faster.\n")) def test_passes_a_line_ending_in_a_colon(self): self.assertEqual([], rules("// The result is:\n// the answer itself.\n")) def test_passes_a_formula(self): self.assertEqual([], rules("// Solve it.\n// x = (a * b) / -c\n")) def test_passes_commented_out_code(self): self.assertEqual([], rules("// counter = 0;\n// counter++;\n")) def test_passes_doc_tags_that_open_or_close_a_block(self): source = ("/// \n/// Returns the thing.\n/// \n" "/// The a.\n/// The b.\n") self.assertEqual([], rules(source)) def test_passes_lines_inside_a_code_element(self): self.assertEqual([], rules("/// \n/// var thing = Make()\n/// Use(thing)\n/// \n")) def test_treats_two_markers_as_separate_blocks(self): self.assertEqual([], rules("/// Summary text.\n// Implementation note.\n")) def test_treats_two_indents_as_separate_blocks(self): self.assertEqual([], rules("// Outer note.\n // Inner note.\n")) def test_counts_a_tab_as_four_columns(self): first = "\t" + padded("// Filled", 112) source = first + "\n\t// abcdef continues.\n" self.assertEqual([], rules(source)) def test_skips_an_untouched_block(self): source = "// Short.\n// Then more.\n\nint x;\n" self.assertEqual([], rules(source, touched={3})) def test_checks_the_whole_block_when_one_line_is_touched(self): source = "// Short.\n// Then more.\n// And more.\n" self.assertEqual([1, 2], [issue.line for issue in lint.lint(source, touched={2})]) class WidthTests(unittest.TestCase): def test_flags_a_comment_past_the_limit(self): self.assertEqual(["comment-width"], rules(padded("// Long", 121) + "\n")) def test_passes_a_comment_at_the_limit(self): self.assertEqual([], rules(padded("// Long", 120) + "\n")) def test_passes_a_url(self): self.assertEqual([], rules("// See https://example.com/" + "a" * 120 + " for details.\n")) def test_passes_one_unbreakable_token(self): self.assertEqual([], rules("// " + "a" * 130 + "\n")) class LineWidthTests(unittest.TestCase): def test_flags_code_past_the_limit(self): self.assertEqual(["line-width"], rules(" " + padded("int x = Call(", 113) + "\n")) def test_passes_code_at_the_limit(self): self.assertEqual([], rules(" " + padded("int x = Call(", 112) + "\n")) def test_flags_code_with_a_trailing_comment_past_the_limit(self): self.assertEqual(["line-width"], rules("int x = 1; " + padded("// trailing", 120) + "\n")) def test_reports_the_column_count(self): issues = lint.lint("int x = Call(" + " a" * 60 + ");\n") self.assertEqual(1, len(issues)) self.assertIn("is 135 columns", issues[0].message) def test_counts_a_tab_as_four_columns(self): self.assertEqual(["line-width"], rules("\t\t" + padded("int x = Call(", 113) + "\n")) self.assertEqual([], rules("\t\t" + padded("int x = Call(", 112) + "\n")) def test_leaves_a_comment_line_to_the_comment_rule(self): self.assertEqual(["comment-width"], rules(padded("// Long", 121) + "\n")) def test_passes_a_line_with_no_spaces_to_split_at(self): self.assertEqual([], rules(" " + "a" * 130 + ";\n")) def test_passes_the_inside_of_a_multiline_string(self): text = 'var text = """\n' + padded("some words", 130) + '\n""";\n' self.assertEqual([], rules(text)) def test_skips_an_untouched_line(self): self.assertEqual([], rules("int x = Call(" + " a" * 60 + ");\n", touched=set())) class SummaryLineTests(unittest.TestCase): def test_flags_text_beside_summary_tags(self): self.assertEqual(["summary-own-line"], rules("/// Returns the thing.\nint X();\n")) def test_flags_a_closing_tag_after_text(self): source = "/// \n/// Returns the thing.\nint X();\n" self.assertEqual(["summary-own-line"], rules(source)) def test_passes_tags_on_their_own_lines(self): self.assertEqual([], rules("/// \n/// Returns the thing.\n/// \nint X();\n")) class DocStarterTests(unittest.TestCase): def test_flags_a_summary_naming_what_it_documents(self): source = "/// \n/// This method returns the thing.\n/// \nint X();\n" issues = lint.lint(source) self.assertEqual(["doc-starter"], [issue.rule for issue in issues]) self.assertEqual(2, issues[0].line) def test_passes_a_summary_starting_with_its_verb(self): self.assertEqual([], rules("/// \n/// Returns the thing.\n/// \nint X();\n")) def test_passes_a_comment_that_isnt_a_doc_comment(self): self.assertEqual([], rules("// This method is slow on purpose.\nint X();\n")) class FieldStarterTests(unittest.TestCase): def check(self, summary, declaration): return rules("/// \n/// %s\n/// \n%s\n" % (summary, declaration)) def test_flags_holds_on_a_field(self): self.assertEqual(["field-doc-starter"], self.check("Holds the count.", "public int Count;")) def test_flags_gets_on_a_constant(self): self.assertEqual(["field-doc-starter"], self.check("Gets the limit.", "public const int Limit = 4;")) def test_flags_a_field_initialized_by_a_call(self): self.assertEqual(["field-doc-starter"], self.check("Holds the names.", "private readonly List names = new List();")) def test_skips_attributes_to_find_the_field(self): self.assertEqual(["field-doc-starter"], self.check("Holds the count.", "[NoSave]\npublic int Count;")) def test_passes_a_bare_phrase_on_a_field(self): self.assertEqual([], self.check("The count of items.", "public int Count;")) def test_passes_gets_on_a_property(self): self.assertEqual([], self.check("Gets the count.", "public int Count { get; }")) def test_passes_returns_on_a_method(self): self.assertEqual([], self.check("Returns the count.", "public int Count();")) def test_passes_an_expression_bodied_member(self): self.assertEqual([], self.check("Gets the count.", "public int Count => items.Count;")) def test_passes_a_delegate(self): self.assertEqual([], self.check("Holds nothing.", "public delegate void Handler(int value);")) class JoinerTests(unittest.TestCase): def test_flags_a_semicolon(self): self.assertEqual(["comment-joiner"], rules("// It is fast; it is small.\n")) def test_flags_an_em_dash(self): self.assertEqual(["comment-joiner"], rules("// It is fast \u2014 and small.\n")) def test_flags_a_double_hyphen(self): self.assertEqual(["comment-joiner"], rules("// It is fast -- and small.\n")) def test_flags_a_spaced_hyphen_between_words(self): self.assertEqual(["comment-joiner"], rules("// It is fast - and small.\n")) def test_passes_a_minus_between_single_letters(self): self.assertEqual([], rules("// The offset is x - y from the start.\n")) def test_passes_a_minus_in_a_line_with_math(self): self.assertEqual([], rules("// So total = start - offset holds.\n")) def test_passes_a_semicolon_in_code_spans(self): source = "// Writes `a; b` and \"c; d\" and e; f and ';' alike, escaping < too.\n" self.assertEqual([], rules(source)) def test_passes_commented_out_code(self): self.assertEqual([], rules("// for (int i = 0; i < n; ++i)\n")) def test_passes_a_list_item_marker(self): self.assertEqual([], rules("// Things.\n// - a thing\n")) class MaskTests(unittest.TestCase): def code_left(self, source): """Returns what masking leaves of source, without whitespace, after checking it keeps every position.""" masked = lint.mask_code(source) self.assertEqual(len(source), len(masked)) self.assertEqual(source.count("\n"), masked.count("\n")) return "".join(masked.split()) def test_blanks_comments(self): self.assertEqual("x;", self.code_left("x; // if (a) b\n")) self.assertEqual("x;y;", self.code_left("x; /* {\n}*/y;")) def test_blanks_strings_and_chars(self): self.assertEqual("a(,);", self.code_left('a("if (x)", \'{\');')) def test_blanks_escaped_quotes(self): self.assertEqual("a(,);", self.code_left('a("\\"{\\"", \'\\\'\');')) def test_blanks_verbatim_and_interpolated_strings(self): self.assertEqual("a(,,);", self.code_left('a(@"x"" { y", $"{b} {{", $@"{c}");')) def test_blanks_a_raw_string_across_lines(self): self.assertEqual("a();", self.code_left('a("""\n{ "" \n""");')) def test_blanks_preprocessor_lines(self): self.assertEqual("x;", self.code_left("#if DEBUG\nx;\n #endregion")) class BracesTests(unittest.TestCase): def test_flags_an_unbraced_if(self): issues = lint.lint("if (a)\n b();\n") self.assertEqual(["braces"], [issue.rule for issue in issues]) self.assertEqual(1, issues[0].line) def test_flags_an_if_on_one_line(self): self.assertEqual(["braces"], rules("if (a) return;\n")) def test_flags_each_kind_of_statement(self): for statement in ("for (;;)", "foreach (var x in xs)", "while (a)", "using (var x = Open())", "lock (gate)", "fixed (int* p = array)", "else", "do"): self.assertEqual(["braces"], rules("%s\n b();\n" % statement), statement) def test_passes_braced_bodies(self): self.assertEqual([], rules("if (a)\n{\n b();\n}\n")) self.assertEqual([], rules("do\n{\n b();\n}\nwhile (a);\n")) def test_passes_else_if(self): self.assertEqual([], rules("if (a)\n{\n}\nelse if (b)\n{\n}\n")) def test_finds_the_end_of_a_condition_spanning_lines(self): self.assertEqual([], rules("if (a &&\n (b || c))\n{\n}\n")) self.assertEqual(["braces"], rules("if (a &&\n (b || c))\n d();\n")) def test_passes_a_do_while_tail(self): self.assertEqual([], rules("do\n{\n}\nwhile (a);\n")) def test_passes_stacked_usings(self): self.assertEqual([], rules("using (var a = A())\nusing (var b = B())\n{\n}\n")) def test_flags_the_last_of_stacked_usings_without_braces(self): self.assertEqual(["braces"], rules("using (var a = A())\nusing (var b = B())\n c();\n")) def test_passes_using_directives_and_declarations(self): self.assertEqual([], rules("using System;\nusing static System.Math;\nusing var x = Open();\n")) def test_passes_a_fixed_size_buffer(self): self.assertEqual([], rules("unsafe fixed byte buffer[16];\n")) def test_passes_keywords_in_strings_comments_and_names(self): self.assertEqual([], rules('Log("if (a) b");\n// if (a) b\nvar ifx = x.@if;\ndouble d;\n')) def test_passes_preprocessor_conditions(self): self.assertEqual([], rules("#if DEBUG\nx();\n#else\ny();\n#endif\n")) def test_reports_only_touched_statements(self): source = "if (a)\n b();\nif (c)\n d();\n" self.assertEqual([3], [issue.line for issue in lint.lint(source, touched={3})]) class BlankAfterBlockTests(unittest.TestCase): def test_flags_code_right_after_a_block(self): issues = lint.lint("if (a)\n{\n}\nb();\n") self.assertEqual(["blank-after-block"], [issue.rule for issue in issues]) self.assertEqual(3, issues[0].line) def test_passes_a_blank_line_after_a_block(self): self.assertEqual([], rules("if (a)\n{\n}\n\nb();\n")) def test_passes_what_continues_a_block(self): for following in ("}", "else", "catch (Exception)", "finally", "while (a);", "case 1:", "default:", "get", "set", ");", ", other", "]", "#endif", "#endregion"): source = "{\n {\n }\n %s\n}\n" % following self.assertNotIn("blank-after-block", rules(source), following) def test_flags_a_comment_right_after_a_block(self): self.assertEqual(["blank-after-block"], rules("{\n}\n// Next.\n")) def test_passes_a_brace_ending_an_initializer(self): self.assertEqual([], rules("var x = new X\n{\n};\nb();\n")) def test_reports_when_only_the_following_line_is_touched(self): self.assertEqual(["blank-after-block"], rules("{\n}\nb();\n", touched={2})) class InternalTests(unittest.TestCase): def test_flags_internal(self): self.assertEqual(["internal"], rules("internal class Thing\n{\n}\n")) self.assertEqual(["internal"], rules("protected internal void Run();\n")) def test_passes_internal_in_names_strings_and_comments(self): self.assertEqual([], rules('[assembly: InternalsVisibleTo("x")]\n// internal\nvar internalCount = 0;\n')) def test_passes_the_compiler_polyfill(self): self.assertEqual([], rules("internal static class IsExternalInit\n{\n}\n")) class GenericNameTests(unittest.TestCase): def test_flags_a_type_parameter_on_a_type(self): self.assertEqual(["generic-name"], rules("public class Box\n{\n}\n")) def test_flags_each_bad_parameter(self): self.assertEqual(["generic-name", "generic-name"], rules("public struct Pair\n{\n}\n\n" "public interface IMap\n{\n}\n")) def test_flags_a_type_parameter_on_a_method(self): self.assertEqual(["generic-name"], rules("public T Make(int count)\n")) self.assertEqual(["generic-name"], rules("void Local(string key);\n")) def test_flags_variance_and_attributes_by_name(self): self.assertEqual(["generic-name"], rules("public interface ISource\n{\n}\n")) def test_flags_the_bare_word_type(self): self.assertEqual(["generic-name"], rules("public class Box\n{\n}\n")) def test_passes_names_ending_in_type(self): self.assertEqual([], rules("public class Arc\n{\n}\n")) self.assertEqual([], rules("public ItemType Make(int count)\n")) def test_passes_generic_uses(self): source = ("var items = new List();\nreturn Make(1);\nvar x = Make(1);\n" "if (Is(x))\n{\n}\n\nthing.Make(1);\nList Build(int count)\n") self.assertEqual([], rules(source)) class TouchedTests(unittest.TestCase): def test_write_of_a_new_file_touches_everything(self): self.assertIsNone(lint.touched_by_write(None, "a\nb\n")) def test_write_touches_only_changed_lines(self): self.assertEqual({1, 3}, lint.touched_by_write("a\nb\nc\nd\n", "a\nB\nc\nD\n")) def test_write_ignores_line_ending_changes(self): self.assertEqual(set(), lint.touched_by_write("a\r\nb\r\n", "a\nb\n")) def test_edit_touches_where_the_new_string_landed(self): self.assertEqual({1, 2}, lint.touched_by_edit("a\nnew one\nnew two\nd\n", ["new one\nnew two"])) def test_edit_matches_an_lf_string_in_a_crlf_file(self): self.assertEqual({1, 2}, lint.touched_by_edit("a\r\nnew one\r\nnew two\r\n", ["new one\nnew two"])) def test_edit_touches_every_place_the_new_string_appears(self): self.assertEqual({0, 2}, lint.touched_by_edit("same\nother\nsame\n", ["same"])) def test_deletion_touches_nothing(self): self.assertEqual(set(), lint.touched_by_edit("a\nb\n", [""])) class PathTests(unittest.TestCase): def test_lints_source(self): self.assertTrue(lint.is_linted_path("C:/ElectricRoot/Modules/Glass/Glass/Control.cs")) def test_skips_other_languages(self): self.assertFalse(lint.is_linted_path("C:/ElectricRoot/Scripts/build.py")) def test_skips_generated_and_vendored_code(self): self.assertFalse(lint.is_linted_path("C:/ElectricRoot/Junk/Glass/Release/Thing.cs")) self.assertFalse(lint.is_linted_path("C:\\ElectricRoot\\3rdParty\\Lib\\Thing.cs")) self.assertFalse(lint.is_linted_path("C:/ElectricRoot/Modules/A/Form1.Designer.cs")) self.assertFalse(lint.is_linted_path("C:/ElectricRoot/Modules/A/Properties/AssemblyInfo.cs")) self.assertFalse(lint.is_linted_path("C:/ElectricRoot/Modules/Ariadne/A.UnitTests/Scripts.CodeBehind.cs")) class HookTests(unittest.TestCase): def setUp(self): handle, self.path = tempfile.mkstemp(suffix=".cs") os.close(handle) def tearDown(self): os.remove(self.path) def write(self, text): with open(self.path, "w", encoding="utf-8", newline="") as source: source.write(text) def test_edit_reports_its_own_lines(self): self.write("// Short.\n// Then more.\n\n// Also short.\n// And more.\n") payload = {"tool_name": "Edit", "tool_input": {"file_path": self.path, "new_string": "// Also short."}} report = lint.run_hook(payload) self.assertIn("line 4 [comment-fill]", report) self.assertNotIn("line 1 ", report) def test_write_of_a_new_file_reports_every_line(self): text = "// Short.\n// Then more.\n" self.write(text) payload = {"tool_name": "Write", "tool_input": {"file_path": self.path, "content": text}, "tool_response": {"originalFile": None}} self.assertIn("line 1 [comment-fill]", lint.run_hook(payload)) def test_multi_edit_reports_each_edit(self): self.write("// Short.\n// Then more.\n\n// Also short.\n// And more.\n") payload = {"tool_name": "MultiEdit", "tool_input": {"file_path": self.path, "edits": [ {"new_string": "// Short."}, {"new_string": "// And more."}]}} report = lint.run_hook(payload) self.assertIn("line 1 [comment-fill]", report) self.assertIn("line 4 [comment-fill]", report) def test_clean_edit_reports_nothing(self): self.write("// One line.\n") payload = {"tool_name": "Edit", "tool_input": {"file_path": self.path, "new_string": "// One line."}} self.assertIsNone(lint.run_hook(payload)) def test_other_tools_report_nothing(self): self.assertIsNone(lint.run_hook({"tool_name": "Bash", "tool_input": {"command": "ls"}})) def test_caps_a_long_report(self): self.write("".join("// Short %d.\n// More.\n//\n" % i for i in range(lint.MAX_REPORTED + 5))) payload = {"tool_name": "Write", "tool_input": {"file_path": self.path, "content": ""}, "tool_response": {"originalFile": None}} report = lint.run_hook(payload) self.assertIn("...and 5 more.", report) if __name__ == "__main__": unittest.main()