Files
langkit/langkit/diagnostics.py
Raphaël AMIARD b61c67b4ee RA22-015: fix langkit bug in diagnostics
Introduced in 285825a by diagnostics refactorings.
2020-03-12 14:37:52 +01:00

630 lines
18 KiB
Python

from __future__ import absolute_import, division, print_function
import enum
import os
import os.path as P
import sys
import traceback
import langkit.documentation
from langkit.utils import Colors, assert_type, col
class DiagnosticStyle(enum.Enum):
"""Format for diagnostics that Langkit emits: location and text."""
default = 'default'
"""Human-readable tracebacks."""
gnu_full = 'gnu-full'
"""Standard GNU format with full paths."""
gnu_base = 'gnu-base'
"""Standard GNU format with basenames."""
class Diagnostics(object):
"""
Holder class that'll store the language definition source dir. Meant to
be called by manage before functions depending on knowing the language
source dir can be called.
"""
has_pending_error = False
style = DiagnosticStyle.default
"""
DiagnosticStyle instance to select the diagnostic representation format.
:type: DiagnosticStyle
"""
blacklisted_paths = [P.dirname(P.abspath(__file__))]
"""
List of blacklisted paths. Add to that list to keep paths out of
diagnostics.
"""
@classmethod
def is_langkit_dsl(cls, python_file):
"""
Return wether `python_file` is langkit DSL.
:type python_file: str
:rtype: bool
"""
# We check that the path of the file is not in the list of blacklisted
# paths.
python_file = P.normpath(python_file)
return all(path not in python_file for path in cls.blacklisted_paths)
@classmethod
def set_style(cls, style):
"""
Set the diagnostic output format.
:type style: DiagnosticStyle
"""
cls.style = style
class Location(object):
"""
Holder for a location in the source code.
"""
def __init__(self, file, line, column=0, text=''):
self.file = file
"""
Path to the file for this location.
:type: str
"""
self.line = line
"""
Location line number (1-based).
:type: int
"""
self.column = column
"""
Column number (1-based). Zero if unspecified.
:type: int
"""
self.text = text
"""
Optional text for the line this location targets.
:type: str
"""
self.previous_in_callstack = None
"""
If this location is created in the context of a call stack, link to the
location of the previous (i.e. caller) frame in this stack. Use the
`short_repr` property to format the whole call stack.
:type: None|Location
"""
@property
def as_tuple(self):
return (self.file, self.line, self.column)
def __eq__(self, other):
return self.as_tuple == other.as_tuple
def __lt__(self, other):
return self.as_tuple < other.as_tuple
def __repr__(self):
return '<Location {} {}:{}>'.format(self.file, self.line, self.column)
@property
def short_repr(self):
"""
Return a representation for this location (and its callers, if this
info is present) as a human readable string.
:rtype: str
"""
# Reconstruct the call stack
stack = []
loc = self
while loc is not None:
loc_str = '{}:{}'.format(P.basename(loc.file), loc.line)
if loc.column:
loc_str += ':{}'.format(loc.column)
stack.append(loc_str)
loc = loc.previous_in_callstack
return '[{}]'.format(', '.join(stack))
@classmethod
def from_lkt_node(cls, node):
"""
Create a Location based on a LKT node.
:param liblktlang.LKNode node: Node from which to extract the location
information.
"""
sloc = node.sloc_range.start
return cls(node.unit.filename, sloc.line, sloc.column)
def extract_library_location(stack=None):
"""
Extract the location of the definition of an entity in the language
specification from a stack trace. Use `traceback.extract_stack()` if no
stack is provided.
:rtype: Location|None
"""
stack = stack or traceback.extract_stack()
# Create Location instances for each stack frame
locs = [Location(t[0], t[1], t[3])
for t in stack
if Diagnostics.is_langkit_dsl(t[0]) and "manage.py" not in t[0]]
# Chain Location instances together
for prev_loc, loc in zip(locs[:-1], locs[1:]):
loc.previous_in_callstack = prev_loc
return locs[-1] if locs else None
context_stack = []
"""
:type: list[(str, Location, str)|liblktlang.LKNode]
"""
class Context(object):
"""
Add context for diagnostics. For the moment this context is constituted
of a message and a location.
"""
def __init__(self, message, location, id=""):
"""
:param str message: The message to display when displaying the
diagnostic, to contextualize the location.
:param Location location: The location associated to the context.
:param str id: A string that is meant to uniquely identify a category
of diagnostic. Only one message (the latest) will be shown for each
category when diagnostics are printed. If id is empty (the default)
then the context has no category, and it will be considered
unique, and always be shown.
"""
self.message = message
self.location = location
self.id = id
def __enter__(self):
context_stack.append((self.message, self.location, self.id))
def __exit__(self, exc_type, exc_value, traceback):
del traceback
del exc_type
context_stack.pop()
def __repr__(self):
return (
'<diagnostics.Context message={}, location={}, id={}>'.format(
self.message, self.location, self.id
)
)
class DiagnosticError(Exception):
pass
class Severity(enum.IntEnum):
"""
Severity of a diagnostic. For the moment we have two levels, warning and
error. A warning won't end the compilation process, and error will.
"""
warning = 1
error = 2
non_blocking_error = 3
SEVERITY_COLORS = {
Severity.warning: Colors.YELLOW,
Severity.error: Colors.RED,
Severity.non_blocking_error: Colors.RED,
}
def format_severity(severity):
"""
:param Severity severity:
"""
msg = ('Error'
if severity == Severity.non_blocking_error else
severity.name.capitalize())
return col(msg, Colors.BOLD + SEVERITY_COLORS[severity])
def get_structured_context():
"""
From the context global structures, return a structured context locations
list.
:rtype: list[(str, Location)] | liblktlang.LKNode
"""
def is_regular_context(context):
"""
Return whether "context" is a regular one (i.e. a tuple of file, column
and message). The other kind of context is LKNode instances from
liblktlang.
:rtype: bool
"""
return isinstance(context, tuple)
if context_stack and not is_regular_context(context_stack[-1]):
return context_stack[-1]
stack = [item for item in context_stack if is_regular_context(item)]
ids = set()
locs = set()
msgs = []
# We'll iterate once on diagnostic contexts, to:
# 1. Remove those with null locations.
# 2. Only keep one per registered id.
# 3. Only keep one per unique (msg, location) pair.
for msg, loc, id in reversed(stack):
if loc and (not id or id not in ids) and ((msg, loc) not in locs):
msgs.append((msg, loc))
ids.add(id)
locs.add((msg, loc))
return msgs
def print_context(context):
"""
Print the current error context.
Note that this makes sense only when `DiagnosticStyle.default` is enabled.
"""
assert Diagnostics.style == DiagnosticStyle.default
assert isinstance(context, list)
# Then we'll print the context we've kept
last_file_info = ''
for ctx_msg, ctx_loc in reversed(context):
# We only want to show the file information one time if it is the same
file_info = 'File "{}", '.format(col(get_filename(ctx_loc.file),
Colors.CYAN))
if last_file_info == file_info:
file_info = ' '
else:
last_file_info = file_info
print ('{file_info}line {line}, {msg}'.format(
file_info=file_info,
line=col(ctx_loc.line, Colors.CYAN),
msg=ctx_msg
))
def get_filename(f):
return (os.path.abspath(f)
if Diagnostics.style == DiagnosticStyle.gnu_full else
os.path.basename(f))
def get_parsable_location():
"""
Returns an error location in the common tool parsable format::
{file}:{line}:{column}
Depending on the diagnostic style enabled, `file` will be a base name or a
full path. Note that this should not be run when `DiagnosticStyle.default`
is enabled.
:rtype: str
"""
assert Diagnostics.style != DiagnosticStyle.default
ctx = get_structured_context()
if ctx:
loc = ctx[0][1]
return '{}:{}:1'.format(get_filename(loc.file), loc.line)
else:
return ""
def check_source_language(predicate, message, severity=Severity.error,
do_raise=True, ok_for_codegen=False):
"""
Check predicates related to the user's input in the input language
definition. Show error messages and eventually terminate if those error
messages are critical.
:param bool predicate: The predicate to check.
:param str message: The base message to display if predicate happens to
be false.
:param Severity severity: The severity of the diagnostic.
:param bool do_raise: If True, raise a DiagnosticError if predicate happens
to be false.
:param bool ok_for_codegen: If True, allow checks to be performed during
code generation. This is False by default as it should be an
exceptional situation: we want, when possible, most checks to be
performed before we attempt to emit the generated library (for
--check-only).
"""
from langkit.compile_context import get_context
def context_from_node(node):
return '{}:{}'.format(get_filename(node.unit.filename),
node.sloc_range.start)
if not ok_for_codegen:
ctx = get_context(or_none=True)
assert ctx is None or ctx.emitter is None
severity = assert_type(severity, Severity)
indent = ' ' * 4
if not predicate:
message_lines = message.splitlines()
message = '\n'.join(
message_lines[:1] + [indent + line for line in message_lines[1:]]
)
context = get_structured_context()
if not isinstance(context, list):
print('{}: {}: {}'.format(context_from_node(context),
format_severity(severity), message))
elif Diagnostics.style != DiagnosticStyle.default:
print('{}: {}'.format(get_parsable_location(), message))
else:
print_context(context)
print('{}{}: {}'.format(
indent if context_stack else '',
format_severity(severity),
message
))
if severity == Severity.error and do_raise:
raise DiagnosticError()
elif severity == Severity.non_blocking_error:
Diagnostics.has_pending_error = True
class WarningDescriptor(object):
"""
Embed information about a class of warnings. Allows to log warning messages
via the `warn_if` method.
"""
def __init__(self, name, enabled_by_default, description):
self.name = name
self.description = description
self.enabled_by_default = enabled_by_default
@property
def enabled(self):
"""
Return whether this warning is enabled in the current context.
:rtype: bool
"""
from langkit.compile_context import get_context
return self in get_context().warnings
def __repr__(self):
return '<WarningDescriptor {}>'.format(self.name)
def warn_if(self, predicate, message):
"""
Helper around check_source_language, to raise warnings, depending on
whether self is enabled or not in the current context.
:param bool predicate: The predicate to check.
:param str message: The base message to display if predicate happens to
be false.
"""
check_source_language(not self.enabled or not predicate, message,
severity=Severity.warning)
class WarningSet(object):
"""
Set of enabled warnings.
"""
prop_only_entities = WarningDescriptor(
'prop-only-entities', True,
'Warn about properties that return AST nodes.'
)
unused_bindings = WarningDescriptor(
'unused-bindings', True,
'Warn about bindings (in properties) that are unused, or the ones used'
' while they are declared as unused.'
)
unparser_bad_grammar = WarningDescriptor(
'unparser-bad-grammar', False,
'Warn if the grammar is not amenable to the automatic generation of an'
' unparser.'
)
unused_node_type = WarningDescriptor(
'unused-node-type', True,
'Warn if a node type is not used in the grammar, and is not marked as'
' abstract nor synthetic.'
)
undocumented_public_properties = WarningDescriptor(
'undocumented-public-properties', True,
'Warn if a public property is left undocumented.'
)
undocumented_nodes = WarningDescriptor(
'undocumented-nodes', True,
'Warn if a node is left undocumented.'
)
imprecise_field_type_annotations = WarningDescriptor(
'imprecise-field-type-annotations', True,
'Warn about parsing field type annotations that are not as precise as'
' they could be.'
)
available_warnings = [
prop_only_entities, unused_bindings, unparser_bad_grammar,
unused_node_type, undocumented_public_properties, undocumented_nodes,
imprecise_field_type_annotations,
]
def __init__(self):
self.enabled_warnings = {w for w in self.available_warnings
if w.enabled_by_default}
def __repr__(self):
return '<WarningSet [{}]>'.format(', '.join(
w.name for w in self.enabled_warnings
))
def enable(self, warning):
"""
Enable the given warning in this WarningSet instance.
:type warning: WarningDescriptor|str
"""
if isinstance(warning, str):
warning = self.lookup(warning)
self.enabled_warnings.add(warning)
def disable(self, warning):
"""
Disable the given warning in this WarningSet instance.
:type warning: WarningDescriptor|str
"""
if isinstance(warning, str):
warning = self.lookup(warning)
self.enabled_warnings.discard(warning)
def clone(self):
"""
Return a copy of this WarningSet instance.
:rtype: WarningSet
"""
other = WarningSet()
other.enabled_warnings = set(self.enabled_warnings)
return other
def with_enabled(self, warning):
"""
Return a copy of this WarningSet instance where `warning` is enabled.
:type warning: WarningDescriptor|str
:rtype WarningSet
"""
other = self.clone()
other.enable(warning)
return other
def with_disabled(self, warning):
"""
Return a copy of this WarningSet instance where `warning` is disabled.
:type warning: WarningDescriptor|str
:rtype WarningSet
"""
other = self.clone()
other.disable(warning)
return other
def __contains__(self, warning):
"""
Return whether `warning` is enabled:
:type: WarningDescriptor
:rtype: bool
"""
return warning in self.enabled_warnings
def lookup(self, name):
"""
Look for the WarningDescriptor whose name is `name`. Raise a ValueError
if none matches.
:type name: str
:rtype warning: WarningDescriptor
"""
for w in self.available_warnings:
if w.name == name:
return w
else:
raise ValueError('Invalid warning: {}'.format(name))
@classmethod
def print_list(cls, out=sys.stdout, width=None):
"""
Display the list of available warnings in `f`.
:param file out: File in which the list is displayed.
:param None|int width: Width of the message. If None, use
os.environ['COLUMNS'].
"""
if width is None:
try:
width = int(os.environ['COLUMNS'])
except (KeyError, ValueError):
width = 80
print('List of available warnings:', file=out)
for w in cls.available_warnings:
print('', file=out)
print('* {}:'.format(w.name), file=out)
if w.enabled_by_default:
print(' [enabled by default]', file=out)
print(langkit.documentation.format_text(w.description, 2, width),
file=out)
def check_multiple(predicates_and_messages, severity=Severity.error):
"""
Helper around check_source_language, check multiple predicates at once.
:param list[(bool, str)] predicates_and_messages: List of diagnostic
tuples.
:param Severity severity: The severity of the diagnostics.
"""
for predicate, message in predicates_and_messages:
check_source_language(predicate, message, severity)
def check_type(obj, typ, message=None):
"""
Like utils.assert_type, but produces a client error instead.
:param Any obj: The object to check.
:param T typ: The expected type of obj.
:param str|None message: The base message to display if type check fails.
:rtype: T
"""
try:
return assert_type(obj, typ)
except AssertionError as e:
message = "{}\n{}".format(e.message, message) if message else e.message
check_source_language(False, message)
def errors_checkpoint():
"""
If there was a non-blocking error, exit the compilation process.
"""
if Diagnostics.has_pending_error:
Diagnostics.has_pending_error = False
raise DiagnosticError()