Skip to content
Merged
4 changes: 2 additions & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 59 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -46,6 +92,9 @@ optional arguments:
- Python
- Javascript
- Java
- HTML
- CSS
- SQL

### Adding a new language

Expand Down
10 changes: 9 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -40,6 +48,6 @@
]
},
url="https://github.com/cs50/style50",
version="2.11.0",
version="3.0.0",
include_package_data=True,
)
42 changes: 36 additions & 6 deletions style50/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,52 @@ 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")
Comment thread
rongxin-liu marked this conversation as resolved.
parser.add_argument("-v", "--verbose", action="store_true",
help="print full tracebacks of errors")
parser.add_argument("-V", "--version", action="version",
version="%(prog)s {}".format(__version__))
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)



Expand Down
82 changes: 59 additions & 23 deletions style50/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Comment thread
rongxin-liu marked this conversation as resolved.
self.diff = lambda old, new: ""
elif output in ["json", "html"]:
self.diff = self.html_diff
Expand All @@ -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":
Expand All @@ -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
Expand Down Expand Up @@ -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
"""

Expand All @@ -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...")
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading