#!/usr/bin/env python3 """PostToolUse hook: report C# style rules broken on the lines an Edit or Write touched. The rules are the ones CLAUDE.md and the user's feedback state that a script can check without judgment. Comments fill out to the column limit, doc-comment tags sit on their own lines, a doc comment starts with its verb, and comment sentences aren't joined with dashes or semicolons. In code, no line is past the column limit, every control-flow body is braced, a block is followed by a blank line, nothing is internal, and generic parameters are named XxxType. Only touched lines are reported, since CLAUDE.md says not to reflow comments nobody is otherwise editing. A comment block counts as touched when any of its lines is. It reports and never blocks: the user reviews every change anyway. Any failure inside the hook is silent. Run by hand to lint whole files: python lint_csharp.py ... [--count] """ import bisect import collections import difflib import json import os import re import sys COLUMN_LIMIT = 120 # Paths whose .cs files are generated or vendored, so they follow no house style. SKIPPED_PATH_PARTS = ("\\junk\\", "\\bin\\", "\\3rdparty\\", "/junk/", "/bin/", "/3rdparty/") SKIPPED_SUFFIXES = (".designer.cs", ".g.cs", ".g.i.cs", ".codebehind.cs", "assemblyinfo.cs") # Tabs count as this many columns. Only a few files use them. TAB_WIDTH = 4 # More than this many issues in one report is noise, and the first ones are enough to act on. MAX_REPORTED = 25 # Tags that start or end a block of doc-comment prose. Inline ones such as and are part of the prose. BLOCK_TAGS = ("summary", "remarks", "param", "typeparam", "returns", "value", "exception", "example", "para", "list", "item", "term", "description", "listheader", "inheritdoc", "include", "seealso", "code") _BLOCK_TAG_AT_START = re.compile(r"^|<(?:%s)\b[^>]*/>)$" % ("|".join(BLOCK_TAGS), "|".join(BLOCK_TAGS))) _TAG_ONLY = re.compile(r"^]*>$") _LIST_ITEM = re.compile(r"^(?:[-*•]|\d+[.)])\s+") _STANDALONE_COMMENT = re.compile(r"^(?P\s*)(?P///?)(?!/)(?P.*)$") _DOC_STARTER = re.compile( r"^This (?:method|function|class|struct|record|interface|enum|property|field|constructor|ctor|type|delegate|" r"event|member|operator|indexer|value|test|fixture)\b", re.IGNORECASE) _FIELD_STARTER = re.compile(r"^(?:Holds|Stores|Gets|Returns|Contains|Keeps)\b") # Spans whose characters are code or markup rather than prose, removed before looking for clause joiners. _CODE_SPANS = re.compile(r"`[^`]*`|.*?|<[^>]*>|&#?\w+;|\"[^\"]*\"|'.'") _CLAUSE_JOINER = re.compile(r"—|–| -- |;") # A spaced hyphen is a dash only between words. In a line with math in it, it's a minus sign. _SPACED_HYPHEN = re.compile(r"[A-Za-z]{2,}[,.)]?\s-\s[A-Za-z]{2,}") _MATH = re.compile(r"[=*+/^]") _WORD = re.compile(r"[A-Za-z]{3,}") class Issue: """One broken rule, at a 1-based line number.""" def __init__(self, line, rule, message): self.line = line self.rule = rule self.message = message def __repr__(self): return "L%d %s: %s" % (self.line, self.rule, self.message) class CommentLine: """A line holding nothing but a // or /// comment.""" def __init__(self, index, raw, indent, marker, body): self.index = index self.raw = raw.rstrip().expandtabs(TAB_WIDTH) self.indent = indent self.marker = marker # The text after the marker's single separating space, keeping any further indentation. if body.startswith(" "): body = body[1:] self.text = body.rstrip() @property def stripped(self): return self.text.strip() @property def leading_spaces(self): return len(self.text) - len(self.text.lstrip(" ")) @property def content_offset(self): """Where wrapped text continuing this line lines up: past a list marker, if it starts with one.""" list_item = _LIST_ITEM.match(self.text.lstrip(" ")) if list_item: return self.leading_spaces + list_item.end() return self.leading_spaces def looks_like_code(text): """Returns true for commented-out code, which doesn't wrap like prose.""" stripped = text.strip() return stripped.endswith((";", "{", "}", ")")) or stripped.startswith(("{", "}")) def is_prose(text): """Returns true unless the line is mostly symbols, such as a formula, a matrix or ASCII art.""" stripped = "".join(text.split()) if not stripped or not _WORD.search(text): return False letters = sum(1 for character in stripped if character.isalpha()) return letters * 2 >= len(stripped) def comment_blocks(lines): """Returns each run of consecutive comment-only lines that share an indent and a marker.""" blocks = [] current = [] for index, line in enumerate(lines): match = _STANDALONE_COMMENT.match(line) if not match: if current: blocks.append(current) current = [] continue comment = CommentLine(index, line, match.group("indent"), match.group("marker"), match.group("body")) if current and (comment.indent != current[-1].indent or comment.marker != current[-1].marker): blocks.append(current) current = [] current.append(comment) if current: blocks.append(current) return blocks def code_regions(block): """Returns the indexes of the lines inside a element, which are code samples, not prose.""" returnMe = set() inside = False for comment in block: if "" in comment.text: inside = True if inside: returnMe.add(comment.index) if "" in comment.text: inside = False return returnMe def is_paragraph_boundary(first, second): """Returns true when second starts something new, so first may end short.""" first_text = first.stripped second_text = second.stripped if not first_text or not second_text: return True if _LIST_ITEM.match(second_text) or second_text.startswith(("TODO", "|")) or first_text.startswith("|"): return True # A line ending in a colon introduces what follows, such as a formula or a list without markers. if first_text.endswith(":") or not is_prose(first_text) or not is_prose(second_text): return True if _BLOCK_TAG_AT_START.match(second_text) or _BLOCK_TAG_AT_END.search(first_text) or _TAG_ONLY.match(first_text): return True if second.leading_spaces != first.content_offset: return True return looks_like_code(first_text) or looks_like_code(second_text) def check_fill(block, touched, issues): """Flags a comment line that ends while the next line's first word would still fit on it.""" if not any(comment.index in touched for comment in block): return in_code = code_regions(block) for first, second in zip(block, block[1:]): if first.index in in_code or second.index in in_code or is_paragraph_boundary(first, second): continue next_word = second.stripped.split()[0] if len(first.raw) + 1 + len(next_word) <= COLUMN_LIMIT: issues.append(Issue(first.index + 1, "comment-fill", "ends at column %d, but the next line's first word \"%s\" fits here. Reflow the comment, or put a " "blank %s line here if a new paragraph starts." % (len(first.raw), next_word, first.marker))) def check_width(block, touched, issues): """Flags a comment line past the column limit, unless it's one long unbreakable token such as a URL.""" for comment in block: if comment.index not in touched or len(comment.raw) <= COLUMN_LIMIT: continue if " " not in comment.stripped or "://" in comment.text: continue issues.append(Issue(comment.index + 1, "comment-width", "is %d columns, past the %d limit." % (len(comment.raw), COLUMN_LIMIT))) def check_summary_lines(block, touched, issues): """Flags or sharing a line with text.""" for comment in block: if comment.index not in touched or comment.marker != "///": continue text = comment.stripped if ("" in text or "" in text) and text not in ("", ""): issues.append(Issue(comment.index + 1, "summary-own-line", " and each go on their own line.")) def summary_text(block): """Returns the first line of prose in a doc comment's summary, and its line index, or (None, None).""" in_summary = False for comment in block: text = comment.stripped if "" in text: in_summary = True text = text.split("", 1)[1].strip() if in_summary and text and text != "": return text.replace("", "").strip(), comment.index if "" in comment.stripped: return None, None return None, None def documented_line(lines, block): """Returns the first code line after a doc comment, skipping attributes, or an empty string.""" for line in lines[block[-1].index + 1:]: stripped = line.strip() if stripped and not stripped.startswith("["): return stripped return "" def is_field_declaration(code): """Returns true for a field or constant declaration, as opposed to a method, property, type or event.""" if not code.endswith(";") or "=>" in code or "{" in code: return False declaration = code.split("=", 1)[0] if "(" in declaration or re.search(r"\b(?:class|struct|interface|enum|record|delegate|event)\b", declaration): return False return True def check_doc_starter(lines, block, touched, issues): """Flags a summary that names what's documented instead of starting with its verb, or a phrase for a field.""" if block[0].marker != "///" or not any(comment.index in touched for comment in block): return text, index = summary_text(block) if text is None: return if _DOC_STARTER.match(text): issues.append(Issue(index + 1, "doc-starter", "starts with \"%s\". Start with the verb (Returns, Gets, Writes...), or for a field a bare phrase." % " ".join(text.split()[:2]))) return if is_field_declaration(documented_line(lines, block)) and _FIELD_STARTER.match(text): issues.append(Issue(index + 1, "field-doc-starter", "documents a field but starts with \"%s\". A field's doc comment is a bare phrase naming what it is." % text.split()[0])) def check_clause_joiners(block, touched, issues): """Flags a dash or semicolon joining clauses in comment prose, which should be separate sentences.""" in_code = code_regions(block) for comment in block: if comment.index not in touched or comment.index in in_code or looks_like_code(comment.text): continue prose = _CODE_SPANS.sub(" ", comment.text) prose = _LIST_ITEM.sub("", prose.lstrip(" ")) joiner = _CLAUSE_JOINER.search(prose) if not joiner and not _MATH.search(prose): joiner = _SPACED_HYPHEN.search(prose) if joiner: joiner = re.search(r"\s-\s", joiner.group()) if joiner: issues.append(Issue(comment.index + 1, "comment-joiner", "joins clauses with \"%s\". Split it into separate sentences." % joiner.group().strip())) def lines_of(text): return text.replace("\r\n", "\n").replace("\r", "\n").lstrip("").split("\n") # Each literal or comment the code checks must not look inside. Raw and verbatim strings come before plain ones, # since at any position the first alternative that matches wins. _NOT_CODE = re.compile( r"//[^\n]*" r"|/\*.*?(?:\*/|\Z)" r'|\$*(?P"{3,}).*?(?P=raw)' r'|(?:\$@|@\$|@)"(?:[^"]|"")*"' r'|\$?"(?:\\.|[^"\\\n])*"' r"|'(?:\\.|[^'\\\n])+'" r"|^[ \t]*#[^\n]*", re.DOTALL | re.MULTILINE) def mask_code(text): """Returns text with every comment, string, char and preprocessor line blanked to spaces, keeping newlines.""" def blank(match): return re.sub(r"[^\n]", " ", match.group()) return _NOT_CODE.sub(blank, text) _CONTROL_KEYWORD = re.compile(r"(?= len(code) or code[body] == "{": return None # A do-while's tail, or a while with an empty body, ends at the semicolon. if keyword == "while" and code[body] == ";": return None # Stacked usings share the last one's braces. if keyword == "using" and re.match(r"using\s*\(", code[body:]): return None return body def check_braces(code, line_of, touched, issues): """Flags an if, else, for, foreach, while, using, lock, fixed or do whose body has no braces.""" for match in _CONTROL_KEYWORD.finditer(code): body = unbraced_body(code, match) if body is None: continue line = line_of(match.start()) if line in touched or line_of(body) in touched: issues.append(Issue(line + 1, "braces", "the %s's body has no braces. Every control-flow body gets them." % match.group(1))) # What may follow a block's closing brace without a blank line between. An accessor's body isn't a control-flow # block, so a get followed by its set is fine. _FOLLOWS_A_BLOCK = re.compile( r"^(?:}|else\b|catch\b|finally\b|while\b|case\b|default\b|get\b|set\b|init\b|add\b|remove\b|[),;.\]#])") def check_blank_after_block(lines, code_lines, touched, issues): """Flags a line holding only a closing brace that's followed by a line other than a blank one.""" for index, line in enumerate(code_lines[:-1]): if line.strip() != "}": continue # A comment counts as a following line, so the masked code can't tell a blank line from one. following = lines[index + 1].strip() if not following or _FOLLOWS_A_BLOCK.match(following): continue if index in touched or index + 1 in touched: issues.append(Issue(index + 1, "blank-after-block", "a block ends here, so put a blank line before the next line.")) def check_internal(code_lines, touched, issues): """Flags the internal modifier, which hides members from the tests in their own assembly's test project.""" for index, line in enumerate(code_lines): # The compiler polyfill has to stay internal, or two assemblies that both define it clash in a third. if "IsExternalInit" in line: continue if index in touched and re.search(r"(?]*)>") _METHOD_DECLARATION = re.compile(r"^\s*(?:\[[^\]]*\]\s*)*(?P[\w<>\[\],.?]+(?:\s+[\w<>\[\],.?]+)*?)\s+\w+\s*<" r"(?P[^<>()]*)>\s*\(") def generic_parameters(line): """Returns the type parameters a declaration on this line introduces.""" returnMe = [] for match in _TYPE_DECLARATION.finditer(line): returnMe.extend(match.group(1).split(",")) method = _METHOD_DECLARATION.match(line) if method and "=" not in line[:method.start("parameters")]: words = method.group("type").split() if words[-1] not in _KEYWORDS_BEFORE_A_CALL and not words[-1].endswith("."): returnMe.extend(method.group("parameters").split(",")) names = [] for parameter in returnMe: parameter = re.sub(r"\[[^\]]*\]|\b(?:in|out)\s+", "", parameter).strip() if re.fullmatch(r"\w+", parameter): names.append(parameter) return names def check_generic_names(code_lines, touched, issues): """Flags a type parameter not named XxxType, such as T or TContext.""" for index, line in enumerate(code_lines): if index not in touched: continue for name in generic_parameters(line): if not name.endswith("Type") or name == "Type": issues.append(Issue(index + 1, "generic-name", "names a type parameter \"%s\". Name it for what it is, ending in Type, such as ContextType." % name)) def check_line_width(lines, code_lines, touched, issues): """Flags a line of code past the column limit. Comment lines have check_width, and an unsplittable one passes.""" for index, line in enumerate(lines): raw = line.rstrip().expandtabs(TAB_WIDTH) if index not in touched or len(raw) <= COLUMN_LIMIT: continue # Masked to nothing means a comment or the inside of a long string, neither of which can be wrapped as code. if not code_lines[index].strip() or " " not in raw.strip(): continue issues.append(Issue(index + 1, "line-width", "is %d columns, past the %d limit. Wrap it." % (len(raw), COLUMN_LIMIT))) def check_code(text, touched, issues): lines = lines_of(text) code = mask_code("\n".join(lines)) code_lines = code.split("\n") line_starts = [0] for line in code_lines[:-1]: line_starts.append(line_starts[-1] + len(line) + 1) def line_of(position): return bisect.bisect_right(line_starts, position) - 1 check_line_width(lines, code_lines, touched, issues) check_braces(code, line_of, touched, issues) check_blank_after_block(lines, code_lines, touched, issues) check_internal(code_lines, touched, issues) check_generic_names(code_lines, touched, issues) def lint(text, touched=None): """Returns the issues in C# source text, on the touched 0-based line indexes, or on every line if None.""" lines = lines_of(text) if touched is None: touched = set(range(len(lines))) issues = [] for block in comment_blocks(lines): check_fill(block, touched, issues) check_width(block, touched, issues) check_summary_lines(block, touched, issues) check_doc_starter(lines, block, touched, issues) check_clause_joiners(block, touched, issues) check_code(text, touched, issues) issues.sort(key=lambda issue: issue.line) return issues def touched_by_write(original, content): """Returns the 0-based indexes of the lines in content that differ from original.""" if original is None: return None new_lines = lines_of(content) matcher = difflib.SequenceMatcher(None, lines_of(original), new_lines, autojunk=False) returnMe = set() for tag, _, _, new_start, new_end in matcher.get_opcodes(): if tag != "equal": returnMe.update(range(new_start, new_end)) return returnMe def touched_by_edit(file_text, new_strings): """Returns the 0-based indexes of the lines where any of new_strings now sits in file_text.""" text = "\n".join(lines_of(file_text)) returnMe = set() for new_string in new_strings: needle = "\n".join(lines_of(new_string)) if not needle.strip(): continue start = text.find(needle) while start != -1: first_line = text.count("\n", 0, start) returnMe.update(range(first_line, first_line + needle.count("\n") + 1)) start = text.find(needle, start + 1) return returnMe def is_linted_path(path): lowered = path.lower() if not lowered.endswith(".cs") or lowered.endswith(SKIPPED_SUFFIXES): return False return not any(part in lowered for part in SKIPPED_PATH_PARTS) def format_report(path, issues): shown = issues[:MAX_REPORTED] lines = ["lint_csharp: %d issue(s) in %s on lines this edit touched:" % (len(issues), os.path.basename(path))] lines += [" line %d [%s] %s" % (issue.line, issue.rule, issue.message) for issue in shown] if len(issues) > len(shown): lines.append(" ...and %d more." % (len(issues) - len(shown))) return "\n".join(lines) def run_hook(payload): """Returns the hook's report for a PostToolUse payload, or None when there's nothing to say.""" if payload.get("tool_name") not in ("Edit", "Write", "MultiEdit"): return None tool_input = payload.get("tool_input") or {} path = tool_input.get("file_path") or "" if not is_linted_path(path): return None with open(path, encoding="utf-8-sig", newline="") as source: file_text = source.read() if "content" in tool_input: response = payload.get("tool_response") original = response.get("originalFile") if isinstance(response, dict) else None touched = touched_by_write(original, tool_input.get("content") or "") else: edits = tool_input.get("edits") if not isinstance(edits, list): edits = [tool_input] touched = touched_by_edit(file_text, [edit.get("new_string") or "" for edit in edits]) issues = lint(file_text, touched) if not issues: return None return format_report(path, issues) def files_under(paths): for path in paths: if os.path.isdir(path): for directory, _, names in os.walk(path): for name in sorted(names): full = os.path.join(directory, name) if is_linted_path(full): yield full elif is_linted_path(path): yield path def main_cli(arguments): count_only = "--count" in arguments paths = [argument for argument in arguments if argument != "--count"] tally = collections.Counter() found = 0 for path in files_under(paths): with open(path, encoding="utf-8-sig", newline="") as source: issues = lint(source.read()) found += len(issues) for issue in issues: tally[issue.rule] += 1 if not count_only: print("%s(%d): [%s] %s" % (path, issue.line, issue.rule, issue.message)) if count_only: for rule, count in tally.most_common(): print("%7d %s" % (count, rule)) return 1 if found else 0 def main_hook(): try: report = run_hook(json.load(sys.stdin)) except Exception: return if report: json.dump({"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": report}}, sys.stdout) if __name__ == "__main__": if len(sys.argv) > 1: sys.exit(main_cli(sys.argv[1:])) main_hook()