Skip to content

Line-based ignores #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions squabble/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ def run_linter(base_config, paths, expanded):

issues = []
for file_name, contents in files:
file_config = config.apply_file_config(base_config, contents)
if file_config is None:
file_config = config.apply_file_config(base_config, file_name, contents)
# The file should be skipped if no specific line is ignored
if file_config.skip.get(file_name) == []:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So if I'm reading this right, line-based skips would take precedence over skipping the entire file? As a user, I think my expectation would be the opposite

continue
issues += lint.check_file(file_config, file_name, contents)

Expand Down
59 changes: 37 additions & 22 deletions squabble/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@
Config = collections.namedtuple('Config', [
'reporter',
'plugins',
'rules'
'rules',
'skip'
])


# TODO: Move these out somewhere else, feels gross to have them hardcoded.
DEFAULT_CONFIG = dict(
reporter='plain',
plugins=[],
rules={}
rules={},
skip={}
)

PRESETS = {
Expand Down Expand Up @@ -87,6 +89,11 @@
}
}

COMMENT_RE = re.compile(
r'--\s*'
r'(?:squabble-)?(enable|disable)?(-next-line)?'
r'(?::\s*(\w+)(.*?))?'
r'$', re.I)

class UnknownPresetException(SquabbleException):
"""Raised when user tries to apply a preset that isn't defined."""
Expand Down Expand Up @@ -189,33 +196,40 @@ def load_config(config_file, preset_names=None, reporter_name=None):
return Config(
reporter=reporter_name or config.get('reporter', base.reporter),
plugins=config.get('plugins', base.plugins),
rules=rules
rules=rules,
skip=config.get('skip', {})
)


def apply_file_config(base, contents):
def apply_file_config(base, file_name, contents):
"""
Given a base configuration object and the contents of a file,
return a new config that applies any file-specific rule
additions/deletions.

Returns ``None`` if the file should be skipped.
"""
# Operate on a copy so we don't mutate the base config
file_rules = copy.deepcopy(base.rules)
skips = copy.deepcopy(base.skip)

rules = _extract_file_rules(contents)

if rules['skip_file']:
return None

for rule, opts in rules['enable'].items():
file_rules[rule] = opts

for rule in rules['disable']:
del file_rules[rule]

return base._replace(rules=file_rules)
for line in rules['skip_lines']:
if skips.get(file_name) is None:
skips[file_name] = []
skips[file_name].append(line)

# Handle 'skip_file' after 'skip_lines', as it should be seen as
# an override
if rules['skip_file']:
skips = {**skips, file_name: []}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels a little off here to overload skips to hold meaning for both skipping lines and skipping the entire file.


return base._replace(rules=file_rules, skip=skips)


def _extract_file_rules(text):
Expand All @@ -238,26 +252,27 @@ def _extract_file_rules(text):
rules = {
'enable': {},
'disable': [],
'skip_file': False
'skip_file': False,
'skip_lines': []
}

comment_re = re.compile(
r'--\s*'
r'(?:squabble-)?(enable|disable)'
r'(?::\s*(\w+)(.*?))?'
r'$', re.I)

for line in text.splitlines():
for line_num, line in enumerate(text.splitlines()):
line = line.strip()

m = re.match(comment_re, line)
m = re.match(COMMENT_RE, line)
if m is None:
continue

action, rule, opts = m.groups()
action, ctx, rule, opts = m.groups()
line_specific = ctx is not None

if action == 'disable' and not rule:
rules['skip_file'] = True
if not line_specific:
rules['skip_file'] = True
else:
# Disregard specific rule for now
# Starts with 0, +1 additional line because
# we want to skip the *next* line
rules['skip_lines'].append(line_num + 2)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely bikeshedding, and I know you're just extending what I had for disabling files, but maybe this should be called disabled_lines to align with the flag that's used in the comment?


elif action == 'disable':
rules['disable'].append(rule)
Expand Down
80 changes: 74 additions & 6 deletions squabble/lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
'node',
'file',
'severity',
'location'
'line_text',
'line',
'column',
'abs_location'
])

# Make all the fields nullable
Expand Down Expand Up @@ -73,7 +76,7 @@ def check_file(config, name, contents):
``name``.
"""
rules = _configure_rules(config.rules)
s = Session(rules, contents, file_name=name)
s = Session(rules, config.skip, contents, name)
return s.lint()


Expand All @@ -83,15 +86,27 @@ class Session:
class exists mainly to hold the list of issues returned by the enabled
rules.
"""
def __init__(self, rules, sql_text, file_name):

def __init__(self, rules, skips, sql_text, file_name):
self._rules = rules
self._skips = skips
self._sql = sql_text
self._issues = []
self._file_name = file_name

def report_issue(self, issue):
i = issue._replace(file=self._file_name)
self._issues.append(i)
line_text, line, column = _resolve_location(
_location_for_issue(issue),
self._sql
)
i = issue._replace(file=self._file_name)._replace(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: instead of chaining, let's merge the two calls to _replace

line_text=line_text, line=line, column=column
)

should_skip = line in self._skips.get(self._file_name, [])

if not should_skip:
self._issues.append(i)

def lint(self):
"""
Expand All @@ -111,7 +126,7 @@ def lint(self):
root_ctx.report_issue(LintIssue(
severity=Severity.CRITICAL,
message_text=exc.args[0],
location=exc.location
abs_location=exc.location
))

return self._issues
Expand Down Expand Up @@ -203,3 +218,56 @@ def report(self, message, node=None, severity=None):
# This is filled in later
file=None,
))

def _location_for_issue(issue):
"""
Return the offset into the file for this issue, or None if it
cannot be determined.
"""
if issue.node and issue.node.location != pglast.Missing:
return issue.node.location.value

if issue.abs_location is not None:
return issue.abs_location

return None


def _resolve_location(location, contents):
"""
Given a location and the contents of the file,
return the ``(line_str, line, column)`` that node is located at,
or ``('', 1, 0)``.

:param issue:
:type issue: :int:
:param contents: Full contents of the file being linted, as a string.
:type contents: str

>>> from squabble.lint import LintIssue

>>> sql = '1234\\n678\\nABCD'
>>> _resolve_location(8, sql)
('678', 2, 3)

>>> sql = '1\\r\\n\\r\\n678\\r\\nBCD'
>>> _resolve_location(7, sql)
('678', 3, 2)
"""
if location is None or location >= len(contents):
return ("", 1, 0)

# line number is number of newlines in the file before this
# locationation, 1 indexed.
line_num = contents[:location].count("\n") + 1

# Search forwards/backwards for the first newline before and first
# newline after this point.
line_start = contents.rfind("\n", 0, location) + 1
line_end = contents.find("\n", location)

# Strip out \r so we can treat \r\n and \n the same way
line = contents[line_start:line_end].replace("\r", "")
column = location - line_start

return (line, line_num, column)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: missing newline at EOF

68 changes: 4 additions & 64 deletions squabble/reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,62 +82,6 @@ def report(reporter_name, issues, files):
_print_err(line)


def _location_for_issue(issue):
"""
Return the offset into the file for this issue, or None if it
cannot be determined.
"""
if issue.node and issue.node.location != pglast.Missing:
return issue.node.location.value

return issue.location


def _issue_to_file_location(issue, contents):
"""
Given an issue (which may or may not have a :class:`pglast.Node` with a
``location`` field) and the contents of the file containing that
node, return the ``(line_str, line, column)`` that node is located at,
or ``('', 1, 0)``.

:param issue:
:type issue: :class:`squabble.lint.LintIssue`
:param contents: Full contents of the file being linted, as a string.
:type contents: str

>>> from squabble.lint import LintIssue

>>> issue = LintIssue(location=8, file='foo')
>>> sql = '1234\\n678\\nABCD'
>>> _issue_to_file_location(issue, sql)
('678', 2, 3)

>>> issue = LintIssue(location=7, file='foo')
>>> sql = '1\\r\\n\\r\\n678\\r\\nBCD'
>>> _issue_to_file_location(issue, sql)
('678', 3, 2)
"""
loc = _location_for_issue(issue)

if loc is None or loc >= len(contents):
return ('', 1, 0)

# line number is number of newlines in the file before this
# location, 1 indexed.
line_num = contents[:loc].count('\n') + 1

# Search forwards/backwards for the first newline before and first
# newline after this point.
line_start = contents.rfind('\n', 0, loc) + 1
line_end = contents.find('\n', loc)

# Strip out \r so we can treat \r\n and \n the same way
line = contents[line_start:line_end].replace('\r', '')
column = loc - line_start

return(line, line_num, column)


def _print_err(msg):
print(msg, file=sys.stderr)

Expand All @@ -149,17 +93,13 @@ def _format_message(issue):
return issue.message.format()


def _issue_info(issue, file_contents):
def _issue_info(issue):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this change, it makes sense to me that the Issue knows where it was raised from, rather than the reporting framework trying to figure it out after the fact

"""Return a dictionary of metadata for an issue."""
line, line_num, column = _issue_to_file_location(issue, file_contents)
formatted = _format_message(issue)

return {
**issue._asdict(),
**(issue.message.asdict() if issue.message else {}),
'line_text': line,
'line': line_num,
'column': column,
'message_formatted': formatted,
'severity': issue.severity.name,
}
Expand All @@ -176,7 +116,7 @@ def _issue_info(issue, file_contents):
@reporter("plain")
def plain_text_reporter(issue, file_contents):
"""Simple single-line output format that is easily parsed by editors."""
info = _issue_info(issue, file_contents)
info = _issue_info(issue)
return [
_SIMPLE_FORMAT.format(**info)
]
Expand All @@ -196,7 +136,7 @@ def color_reporter(issue, file_contents):
Extension of :func:`squabble.reporter.plain_text_reporter`, uses
ANSI color and shows error location.
"""
info = _issue_info(issue, file_contents)
info = _issue_info(issue)
info['severity'] = '{color}{severity}{reset}'.format(
color=_SEVERITY_COLOR[issue.severity],
severity=issue.severity.name,
Expand Down Expand Up @@ -254,7 +194,7 @@ def sqlint_reporter(issue, file_contents):

error_level = {Severity.HIGH, Severity.CRITICAL}

info = _issue_info(issue, file_contents)
info = _issue_info(issue)
info['severity'] = 'ERROR' if issue.severity in error_level else 'WARNING'

return [
Expand Down
Loading