", suffix="") 232 | 233 | def char_diff(self, old, new): 234 | """ 235 | Return color-coded character-based diff between `old` and `new`. 236 | """ 237 | def color_transition(old_type, new_type): 238 | new_color = termcolor.colored("", None, "on_red" if new_type == 239 | "-" else "on_green" if new_type == "+" else None) 240 | return "{}{}".format(termcolor.RESET, new_color[:-len(termcolor.RESET)]) 241 | 242 | return self._char_diff(old, new, color_transition) 243 | 244 | def _char_diff(self, old, new, transition, fmt=lambda c: c, prefix=None, suffix=None): 245 | """ 246 | Returns a char-based diff between `old` and `new` where each character 247 | is formatted by `fmt` and transitions between blocks are determined by `transition`. 248 | """ 249 | if prefix is not None: 250 | yield prefix 251 | 252 | differ = difflib.ndiff(old, new) 253 | 254 | # Type of difference. 255 | dtype = None 256 | 257 | # Buffer for current line. 258 | line = [] 259 | while True: 260 | # Get next diff or None if we're at the end. 261 | d = next(differ, (None,)) 262 | if d[0] != dtype: 263 | line += transition(dtype, d[0]) 264 | dtype = d[0] 265 | 266 | if dtype is None: 267 | break 268 | 269 | if d[2] == "\n": 270 | if dtype != " ": 271 | self._warn_chars.add((dtype, "\\n")) 272 | # Show added/removed newlines. 273 | line += [fmt(r"\n"), transition(dtype, " ")] 274 | 275 | # Don't yield a line if we are removing a newline 276 | if dtype != "-": 277 | yield "".join(line) 278 | line.clear() 279 | 280 | line.append(transition(" ", dtype)) 281 | elif dtype != " " and d[2] == "\t": 282 | # Show added/removed tabs. 283 | line.append(fmt("\\t")) 284 | self._warn_chars.add((dtype, "\\t")) 285 | else: 286 | line.append(fmt(d[2])) 287 | 288 | # Flush buffer before quitting. 289 | last = "".join(line) 290 | # Only print last line if it contains non-ANSI characters. 291 | if re.sub(r"\x1b[^m]*m", "", last): 292 | yield last 293 | 294 | if suffix is not None: 295 | yield suffix 296 | 297 | 298 | class StyleMeta(ABCMeta): 299 | """ 300 | Metaclass which defines an abstract class and adds each extension that the 301 | class supports to the Style50's extension_map 302 | """ 303 | def __new__(mcls, name, bases, attrs): 304 | cls = ABCMeta.__new__(mcls, name, bases, attrs) 305 | try: 306 | # Register class as the check for each of its extensions. 307 | for ext in attrs.get("extensions", []): 308 | Style50.extension_map[ext] = cls 309 | for name in cls.magic_names: 310 | Style50.magic_map[name] = cls 311 | except TypeError: 312 | # If `extensions` property isn't iterable, skip it. 313 | pass 314 | return cls 315 | 316 | 317 | class StyleCheck(metaclass=StyleMeta): 318 | """ 319 | Abstact base class for all style checks. All children must define `extensions` and 320 | implement `style`. 321 | """ 322 | 323 | # Warn if less than 10% of code is comments. 324 | COMMENT_MIN = 0.10 325 | 326 | # Contains substrings to be matched against libmagic's output if file extension not recognized 327 | magic_names = [] 328 | 329 | def __init__(self, code): 330 | self.original = code 331 | 332 | comments = self.count_comments(code) 333 | 334 | try: 335 | # Avoid warning about comments if we don't knowhow to count them. 336 | self.comment_ratio = 1. if comments is None else comments / self.count_lines(code) 337 | except ZeroDivisionError: 338 | raise Error("file is empty") 339 | 340 | self.styled = self.style(code) 341 | 342 | # Count number of differences between styled and unstyled code (average of added and removed lines). 343 | self.diffs = sum(d[0] == "+" or d[0] == "-" 344 | for d in difflib.ndiff(code.splitlines(True), self.styled.splitlines(True))) / 2 345 | 346 | self.lines = self.count_lines(self.styled) 347 | try: 348 | self.score = max(1 - self.diffs / self.lines, 0.0) 349 | except ZeroDivisionError: 350 | raise Error("file is empty") 351 | 352 | def count_lines(self, code): 353 | """ 354 | Count lines of code (by default ignores empty lines, but child could override to do more). 355 | """ 356 | return sum(bool(line.strip()) for line in code.splitlines()) 357 | 358 | @staticmethod 359 | def run(command, input=None, exit=0, shell=False): 360 | """ 361 | Run `command` passing it stdin from `input`, throwing a DependencyError if comand is not found. 362 | Throws Error if exit code of command is not `exit` (unless `exit` is None). 363 | """ 364 | if isinstance(input, str): 365 | input = input.encode() 366 | 367 | # Only pipe stdin if we have input to pipe. 368 | stdin = {} if input is None else {"stdin": subprocess.PIPE} 369 | try: 370 | child = subprocess.Popen(command, stdout=subprocess.PIPE, 371 | stderr=subprocess.PIPE, **stdin) 372 | except FileNotFoundError as e: 373 | # Extract name of command. 374 | name = command.split(' ', 1)[0] if isinstance(command, str) else command[0] 375 | raise DependencyError(name) 376 | 377 | stdout, _ = child.communicate(input=input) 378 | if exit is not None and child.returncode != exit: 379 | raise Error("failed to stylecheck code") 380 | return stdout.decode() 381 | 382 | def count_comments(self, code): 383 | """ 384 | Returns number of coments in `code`. If not implemented by child, will not warn about comments. 385 | """ 386 | 387 | @abstractmethod 388 | def extensions(self): 389 | """ 390 | List of file extensions that check should be run on. 391 | """ 392 | 393 | @abstractmethod 394 | def style(self, code): 395 | """ 396 | Returns a styled version of `code`. 397 | """ 398 | 399 | 400 | class Error(Exception): 401 | def __init__(self, msg): 402 | self.msg = msg 403 | 404 | 405 | class DependencyError(Error): 406 | def __init__(self, dependency): 407 | self.msg = "style50 requires {}, but it does not seem to be installed".format(dependency) 408 | self.dependency = dependency 409 | -------------------------------------------------------------------------------- /style50/languages.py: -------------------------------------------------------------------------------- 1 | import io 2 | import re 3 | import sys 4 | from tokenize import generate_tokens, STRING, INDENT, COMMENT, TokenError 5 | 6 | import autopep8 7 | import jsbeautifier 8 | 9 | from . import StyleCheck, Error 10 | 11 | 12 | class C(StyleCheck): 13 | extensions = ["c", "h", "cpp", "hpp"] 14 | magic_names = [] # Only recognize C files by their extension 15 | 16 | styleConfig = '{ AllowShortFunctionsOnASingleLine: Empty, BraceWrapping: { AfterCaseLabel: true, AfterControlStatement: true, AfterFunction: true, AfterStruct: true, BeforeElse: true, BeforeWhile: true }, BreakBeforeBraces: Custom, ColumnLimit: 100, IndentCaseLabels: true, IndentWidth: 4, SpaceAfterCStyleCast: true, TabWidth: 4 }' 17 | clangFormat = [ 18 | "clang-format", f"-style={styleConfig}" 19 | ] 20 | 21 | # Match (1) /**/ comments, and (2) // comments. 22 | match_comments = re.compile(r"(\/\*.*?\*\/)|(\/\/[^\n]*)", re.DOTALL) 23 | 24 | # Matches string literals. 25 | match_literals = re.compile(r'"(?:\\.|[^"\\])*"', re.DOTALL) 26 | 27 | def __init__(self, code): 28 | 29 | # Call parent init. 30 | StyleCheck.__init__(self, code) 31 | 32 | def count_comments(self, code): 33 | # Remove all string literals. 34 | stripped = self.match_literals.sub("", code) 35 | return sum(1 for _ in self.match_comments.finditer(stripped)) 36 | 37 | def style(self, code): 38 | return self.run(self.clangFormat, input=code) 39 | 40 | 41 | class Python(StyleCheck): 42 | magic_names = ["Python script"] 43 | extensions = ["py"] 44 | 45 | def count_comments(self, code): 46 | # Make sure we count docstring at top of module 47 | prev_type = INDENT 48 | comments = 0 49 | 50 | code_lines = iter(code.splitlines(True)) 51 | try: 52 | for t_type, _, _, _, _ in generate_tokens(lambda: next(code_lines)): 53 | # Increment if token is comment or docstring 54 | comments += t_type == COMMENT or (t_type == STRING and prev_type == INDENT) 55 | prev_type = t_type 56 | except TokenError: 57 | raise Error("failed to parse code, check for syntax errors!") 58 | except IndentationError as e: 59 | raise Error("make sure indentation is consistent on line {}!".format(e.lineno)) 60 | return comments 61 | 62 | def count_lines(self, code): 63 | """ 64 | count_lines ignores blank lines by default, 65 | but blank lines are relevant to style per pep8 66 | """ 67 | return len(code.splitlines()) 68 | 69 | # TODO: Determine which options (if any) should be passed to autopep8 70 | def style(self, code): 71 | return autopep8.fix_code(code, options={"max_line_length": 100, "ignore_local_config": True}) 72 | 73 | 74 | class Js(C): 75 | extensions = ["js"] 76 | magic_names = [] 77 | 78 | # Taken from http://code.activestate.com/recipes/496882-javascript-code-compression/ 79 | match_literals = re.compile( 80 | r""" 81 | (\'.*?(?<=[^\\])\') | # single-quoted strings 82 | (\".*?(?<=[^\\])\") | # double-quoted strings 83 | ((? 1 else None 19 | 20 | for file in files: 21 | if header is not None: 22 | lines.append(header.format(file["name"])) 23 | 24 | try: 25 | error = file["error"] 26 | except KeyError: 27 | pass 28 | else: 29 | lines.append(termcolor.colored(error, "yellow")) 30 | continue 31 | 32 | if file["score"] != 1: 33 | lines.append("") 34 | lines.append(file["diff"]) 35 | lines.append("") 36 | conjunction = "And" 37 | else: 38 | lines.append(termcolor.colored("Looks good!", "green")) 39 | conjunction = "But" 40 | 41 | if file["score"] != 1: 42 | for type, c in file["warn_chars"]: 43 | color, verb = ("on_green", "insert") if type == "+" else ("on_red", "delete") 44 | char = termcolor.colored(c, None, color) 45 | lines.append(char + 46 | termcolor.colored(" means that you should {} a {}." 47 | .format(verb, "newline" if c == "\\n" else "tab"), "yellow")) 48 | 49 | if file["comments"]: 50 | lines.append(termcolor.colored("{} consider adding more comments!".format(conjunction), "yellow")) 51 | 52 | if (file["comments"] or file["warn_chars"]) and file["score"] != 1: 53 | lines.append("") 54 | return "\n".join(lines) 55 | 56 | 57 | def to_ansi_score(files, score, version): 58 | lines = [] 59 | for file in files: 60 | if file.get("error"): 61 | lines.append(termcolor.colored(file["error"], "yellow")) 62 | lines.append(str(score)) 63 | return "\n".join(lines) 64 | 65 | 66 | def to_json(files, score, version): 67 | return json.dumps({"files": files, "score": score, "version": version}, indent=4) 68 | 69 | 70 | def to_html(files, score, version): 71 | with open(TEMPLATES / "results.html") as f: 72 | content = f.read() 73 | 74 | template = jinja2.Template( 75 | content, autoescape=jinja2.select_autoescape(enabled_extensions=("html",))) 76 | html = template.render(files=files, version=version) 77 | 78 | return html 79 | -------------------------------------------------------------------------------- /style50/renderer/templates/results.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 20 | 21 |
{{file.error}}34 | {% elif file.score == 1.0 %} 35 |
Looks good!36 | {% if file.comments %} 37 |
But consider adding more comments!38 | {% endif %} 39 | {% else %} 40 |
{{ file.diff|safe }}41 | {% if file.comments %} 42 |
And consider adding more comments!43 | {% endif %} 44 | {% endif %} 45 |