diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 496fcdb..8be45c0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,8 +10,8 @@ jobs: - name: Run tests run: | - pip install . - style50 --help + pip install '.[test]' + python -m pytest - name: Install pypa/build run: python -m pip install build --user diff --git a/README.md b/README.md index d584b6f..2155289 100644 --- a/README.md +++ b/README.md @@ -17,25 +17,71 @@ Along with most of CS50's command line tools, `style50` supports being run on Wi ## Usage ``` -usage: style50 [-h] [-o MODE] [-v] [-V] [-E] [-i PATTERN] file [file ...] +usage: style50 [-h] [-o MODE] [-y] [-i] [-v] [-V] [-E] [--ignore PATTERN] + [--clang-format-style STYLE] + FILE [FILE ...] positional arguments: -file file or directory to lint +FILE file or directory to lint -optional arguments: +options: -h, --help show this help message and exit --o MODE, --output MODE - output mode, which can be character (default), split, - unified, score, or json +-o, --output MODE output mode, which can be character (default), split, + unified, score, json, html, or format +-y, --side-by-side show side-by-side diff (equivalent to -o split) +-i, --in-place rewrite files in place with style50 formatting -v, --verbose print full tracebacks of errors -V, --version show program's version number and exit -E, --extensions print supported file extensions (as JSON list) and - exit --i PATTERN, --ignore PATTERN - paths/patterns to be ignored + exit +--ignore PATTERN paths/patterns to be ignored +--clang-format-style STYLE + clang-format style string or file:// URI (overrides + default CS50 config) ``` -`character`, `split`, and `unified` modes output character-based, side-by-side, and unified (respectively) diffs between the inputted file and the correctly styled version. `score` outputs the raw percentage of correct (unchanged) lines, while `json` outputs a json object containing information pertinent to the CS50 IDE plugin (coming soon). +`STYLE50_IGNORE` is also supported as a comma-separated environment variable fallback for ignore patterns. + +`character`, `split`, and `unified` modes output character-based, side-by-side, and unified (respectively) diffs between the inputted file and the correctly styled version. `score` outputs the raw percentage of correct (unchanged) lines, `json` outputs a JSON object containing structured results, `html` outputs browser-readable results, and `format` outputs only the formatted code. + +### Common examples + +```bash +# Diff-only mode (default) +style50 foo.c +style50 foo.c bar.py +style50 *.c *.py + +# In-place rewrite +style50 -i foo.c +style50 -i foo.c bar.py +style50 -i *.c *.py + +# Side-by-side diff +style50 -y foo.c bar.py + +# Ignore files/patterns +style50 --ignore "*/.*" foo.c bar.py + +# Emit only formatted code (single file only) +style50 -o format foo.c +``` + +### `--output` modes + +`--output` supports: + +- `character` (default) +- `split` +- `unified` +- `score` +- `json` +- `html` +- `format` + +### `-i` and `--output` + +`-i/--in-place` rewrites files and therefore cannot be combined with `--output` or `--side-by-side`. ## Language Support @@ -46,6 +92,9 @@ optional arguments: - Python - Javascript - Java +- HTML +- CSS +- SQL ### Adding a new language diff --git a/setup.py b/setup.py index d366b66..15e78fb 100644 --- a/setup.py +++ b/setup.py @@ -20,13 +20,21 @@ install_requires=[ "autopep8>=2.1.0", "clang-format>=18.1.2", + "cssbeautifier>=1.15.4", + "djhtml>=3.0", "icdiff", "jsbeautifier", "pycodestyle==2.12.0", "python-magic", + "sqlparse>=0.5.0", "termcolor", "jinja2>=2.10", ], + extras_require={ + "test": [ + "pytest", + ], + }, dependency_links=["git+https://github.com/jeffkaufman/icdiff.git"], keywords=["style", "style50"], name="style50", @@ -40,6 +48,6 @@ ] }, url="https://github.com/cs50/style50", - version="2.11.0", + version="3.0.0", include_package_data=True, ) diff --git a/style50/__main__.py b/style50/__main__.py index 129e0b7..77c46a8 100644 --- a/style50/__main__.py +++ b/style50/__main__.py @@ -31,9 +31,13 @@ def main(): # Define command-line arguments. parser = argparse.ArgumentParser(prog="style50") parser.add_argument("file", metavar="FILE", nargs="+", help="file or directory to lint") - parser.add_argument("-o", "--output", action="store", default="character", - choices=["character", "split", "unified", "score", "json", "html"], metavar="MODE", - help="output mode, which can be character (default), split, unified, score, or json") + parser.add_argument("-o", "--output", action="store", default=None, + choices=["character", "split", "unified", "score", "json", "html", "format"], metavar="MODE", + help="output mode, which can be character (default), split, unified, score, json, html, or format") + parser.add_argument("-y", "--side-by-side", action="store_true", + help="show side-by-side diff (equivalent to -o split)") + parser.add_argument("-i", "--in-place", action="store_true", + help="rewrite files in place with style50 formatting") parser.add_argument("-v", "--verbose", action="store_true", help="print full tracebacks of errors") parser.add_argument("-V", "--version", action="version", @@ -41,12 +45,38 @@ def main(): parser.add_argument("-E", "--extensions", action="version", version=json.dumps(list(Style50.extension_map.keys())), help="print supported file extensions (as JSON list) and exit") - parser.add_argument("-i", "--ignore", action="append", metavar="PATTERN", + parser.add_argument("--ignore", action="append", metavar="PATTERN", help="paths/patterns to be ignored") + parser.add_argument("--clang-format-style", metavar="STYLE", + help="clang-format style string or file:// URI (overrides default CS50 config)") args = parser.parse_args() - ignore = args.ignore or filter(None, os.getenv("STYLE50_IGNORE", "").split(",")) - Style50(args.output).run(args.file, ignore=ignore) + ignore = args.ignore or list(filter(None, os.getenv("STYLE50_IGNORE", "").split(","))) + + if args.in_place and args.side_by_side: + sys.exit("--in-place cannot be combined with --side-by-side") + + if args.in_place and args.output is not None: + sys.exit("--in-place cannot be combined with --output") + + output = args.output or "character" + + if args.side_by_side: + output = "split" + + if args.in_place: + _, failures = Style50("format", clang_format_style=args.clang_format_style).format_files_in_place(args.file, ignore=ignore) + if failures: + sys.exit(1) + return + + if output == "format": + if len(args.file) != 1: + sys.exit("format mode requires exactly one file") + sys.stdout.write(Style50("format", clang_format_style=args.clang_format_style).format_file(args.file[0])) + return + + Style50(output, clang_format_style=args.clang_format_style).run(args.file, ignore=ignore) diff --git a/style50/_api.py b/style50/_api.py index a1779d4..b10aba1 100644 --- a/style50/_api.py +++ b/style50/_api.py @@ -33,12 +33,13 @@ class Style50: # Dict that maps substrings of libmagic's outputs to classes. Used as fallback when file extension unrecognized magic_map = {} - def __init__(self, output="character"): + def __init__(self, output="character", clang_format_style=None): self._warn_chars = set() + self.clang_format_style = clang_format_style - # Set run function as apropriate for output mode. - if output == "score": + # Set run function as appropriate for output mode. + if output in ["score", "format"]: self.diff = lambda old, new: "" elif output in ["json", "html"]: self.diff = self.html_diff @@ -54,8 +55,50 @@ def __init__(self, output="character"): self.output = output + def format_file(self, path): + """Format a single file and return the styled code.""" + results = self._check(path) + return results.styled + + def _files_for_paths(self, paths, ignore=None): + """Expand files from paths and apply ignore patterns.""" + ignore = ignore or [] + try: + # Translate each ignore pattern into a regex and compile it + ignore = [re.compile(fnmatch.translate(i)) for i in ignore] + except re.error: + raise Error("failed to parse ignore pattern") + + return list(filter(lambda p: not any(reg.match(p) for reg in ignore), + itertools.chain.from_iterable([path] if not os.path.isdir(path) + else (os.path.join(root, name) + for root, _, names in os.walk(path) + for name in names) + for path in paths))) + + def format_files_in_place(self, paths, ignore=None): + """Format files and rewrite each path in place.""" + formatted_count = 0 + failed_count = 0 + for path in self._files_for_paths(paths, ignore=ignore): + try: + results = self._check(path) + except Error as e: + termcolor.cprint(f"{path}: {e.msg}", "red", file=sys.stderr) + failed_count += 1 + continue + + if results.original != results.styled: + with open(path, "w", encoding="utf-8", newline="") as file: + file.write(results.styled) + formatted_count += 1 + return formatted_count, failed_count + def run(self, *args, **kwargs): """Wraps Style50.check and renders the results using the renderer determined by self.output""" + if self.output == "format": + raise Error("format mode does not support run(); use format_file() instead") + results = self.check(*args, **kwargs) if self.output == "html": @@ -76,23 +119,11 @@ def run(self, *args, **kwargs): print(render(**results)) - def check(self, paths, ignore=[]): + def check(self, paths, ignore=None): """ - Run checks on paths recursively, ignoring pataterns in ignore, returning a dict of results + Run checks on paths recursively, ignoring patterns in ignore, returning a dict of results """ - try: - # Translate each ignore pattern into a regex and compile it - ignore = [re.compile(fnmatch.translate(i)) for i in ignore] - except re.error: - raise Error("failed to parse ignore pattern") - - # Creates a generator of all the files found recursively in `paths`, filtering out any ignored paths. - files = list(filter(lambda p: not any(reg.match(p) for reg in ignore), - itertools.chain.from_iterable([path] if not os.path.isdir(path) - else (os.path.join(root, file) - for root, _, files in os.walk(path) - for file in files) - for path in paths))) + files = self._files_for_paths(paths, ignore=ignore) diffs = 0 lines = 0 @@ -132,7 +163,7 @@ def check(self, paths, ignore=[]): def _check(self, file): """ - Run apropriate check based on `file`'s extension and return it, + Run appropriate check based on `file`'s extension and return it, otherwise raise an Error """ @@ -152,7 +183,7 @@ def _check(self, file): raise Error("unknown file type \"{}\", skipping...".format(file)) try: - with open(file) as f: + with open(file, encoding="utf-8", newline="") as f: code = "\n".join(line.rstrip() for line in f) except UnicodeDecodeError: raise Error("file does not seem to contain text, skipping...") @@ -164,7 +195,7 @@ def _check(self, file): except IndexError: pass - return check(code) + return check(code, clang_format_style=self.clang_format_style) @staticmethod def split_diff(old, new): @@ -296,8 +327,13 @@ class StyleCheck(metaclass=StyleMeta): # Contains substrings to be matched against libmagic's output if file extension not recognized magic_names = [] + CONFIG_KEYS = {"clang_format_style"} - def __init__(self, code): + def __init__(self, code, **kwargs): + unknown = set(kwargs) - self.CONFIG_KEYS + if unknown: + raise Error("unknown configuration option(s): {}".format(", ".join(sorted(unknown)))) + self._config = kwargs self.original = code comments = self.count_comments(code) @@ -352,7 +388,7 @@ def run(command, input=None, exit=0, shell=False): def count_comments(self, code): """ - Returns number of coments in `code`. If not implemented by child, will not warn about comments. + Returns number of comments in `code`. If not implemented by child, will not warn about comments. """ @abstractmethod diff --git a/style50/languages.py b/style50/languages.py index a11a889..e649bf5 100644 --- a/style50/languages.py +++ b/style50/languages.py @@ -4,7 +4,9 @@ from tokenize import generate_tokens, STRING, INDENT, COMMENT, TokenError import autopep8 +import cssbeautifier import jsbeautifier +import sqlparse from . import StyleCheck, Error @@ -24,17 +26,22 @@ class C(StyleCheck): # Matches string literals. match_literals = re.compile(r'"(?:\\.|[^"\\])*"', re.DOTALL) - def __init__(self, code): - - # Call parent init. - StyleCheck.__init__(self, code) - def count_comments(self, code): # Remove all string literals. stripped = self.match_literals.sub("", code) return sum(1 for _ in self.match_comments.finditer(stripped)) def style(self, code): + clang_format_style = self._config.get("clang_format_style") + if isinstance(clang_format_style, str): + normalized = clang_format_style.strip().strip('"').strip("'").strip() + if normalized.lower() in {"", "undefined", "null", "none"}: + clang_format_style = None + + if clang_format_style: + cmd = [c for c in self.clangFormat if not str(c).startswith("-style=")] + cmd.append(f"-style={clang_format_style}") + return self.run(cmd, input=code) return self.run(self.clangFormat, input=code) @@ -83,9 +90,6 @@ class Js(C): ((?

x

\n", encoding="utf-8") + css.write_text("body{color:red;}\n", encoding="utf-8") + sql.write_text("select * from users where id=1;\n", encoding="utf-8") + + proc_html = run_style50(["-o", "format", str(html)]) + # Skip only if djhtml is unavailable in this environment. + if proc_html.returncode != 0 and "requires djhtml" in (proc_html.stdout + proc_html.stderr): + pytest.skip("djhtml not installed in test environment") + assert proc_html.returncode == 0 + assert proc_html.stdout.strip() != "" + + proc_css = run_style50(["-o", "format", str(css)]) + assert proc_css.returncode == 0 + assert proc_css.stdout.strip() != "" + + proc_sql = run_style50(["-o", "format", str(sql)]) + assert proc_sql.returncode == 0 + assert "SELECT" in proc_sql.stdout + diff --git a/tests/test_languages.py b/tests/test_languages.py new file mode 100644 index 0000000..b9fc0ba --- /dev/null +++ b/tests/test_languages.py @@ -0,0 +1,104 @@ +import pytest + + +def test_python_formatter_runs(): + from style50.languages import Python + + original = "x=1\n\nprint( x)\n" + formatted = Python(original).styled + assert isinstance(formatted, str) + assert formatted.endswith("\n") + + +def test_js_formatter_runs(): + from style50.languages import Js + + original = "function f(){return 1;}\n" + formatted = Js(original).styled + assert isinstance(formatted, str) + assert formatted.endswith("\n") + + +def test_css_formatter_runs(): + from style50.languages import Css + + original = "body{color:red;}\n" + formatted = Css(original).styled + assert isinstance(formatted, str) + assert formatted.endswith("\n") + + +def test_sql_formatter_runs(): + from style50.languages import Sql + + original = "select * from foo;\n" + formatted = Sql(original).styled + assert isinstance(formatted, str) + assert "FROM" in formatted + assert formatted.endswith("\n") + + +def test_html_formatter_invokes_djhtml(monkeypatch): + from style50.languages import Html + from style50._api import StyleCheck + + calls = {"cmd": None} + + def fake_run(command, input=None, exit=0, shell=False): + calls["cmd"] = command + # Pretend the formatter normalizes whitespace, but keep it simple. + return "\n" + + monkeypatch.setattr(StyleCheck, "run", staticmethod(fake_run)) + formatted = Html("\n").styled + assert calls["cmd"] == ["djhtml", "-"] + assert formatted == "\n" + + +def test_clang_format_style_override_preserves_java_flags(monkeypatch): + """ + Ensure override style does not drop Java's '-assume-filename=.java' flag. + This is tested by capturing the command rather than invoking clang-format. + """ + from style50.languages import Java + from style50._api import StyleCheck + + seen = {"cmd": None} + + def fake_run(command, input=None, exit=0, shell=False): + seen["cmd"] = command + return input.decode() if isinstance(input, (bytes, bytearray)) else (input or "") + + monkeypatch.setattr(StyleCheck, "run", staticmethod(fake_run)) + _ = Java("class X{}\n", clang_format_style="{IndentWidth: 2}").styled + + assert isinstance(seen["cmd"], list) + assert "clang-format" in seen["cmd"][0] + assert any(arg.startswith("-style=") for arg in seen["cmd"]) + assert "-assume-filename=.java" in seen["cmd"] + + +@pytest.mark.parametrize("value", ["undefined", "null", "none", " 'undefined' "]) +def test_clang_format_style_override_ignores_stringified_nulls(monkeypatch, value): + from style50.languages import C + from style50._api import StyleCheck + + seen = {"cmd": None} + + def fake_run(command, input=None, exit=0, shell=False): + seen["cmd"] = command + return input.decode() if isinstance(input, (bytes, bytearray)) else (input or "") + + monkeypatch.setattr(StyleCheck, "run", staticmethod(fake_run)) + _ = C("int main(){}\n", clang_format_style=value).styled + + assert seen["cmd"] == C.clangFormat + + +def test_format_mode_run_raises_error(): + from style50._api import Style50, Error + + s = Style50("format") + with pytest.raises(Error, match="format mode does not support run"): + s.run(["nonexistent_file.py"]) +