-
Notifications
You must be signed in to change notification settings - Fork 2
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
feat: Add span and diagnostics base classes #548
Changes from all commits
c242571
aac39ac
aa0ccbc
f32eb58
5f610dc
ac90b7f
8ac369d
56dec3e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
from dataclasses import dataclass, field | ||
from enum import Enum, auto | ||
from typing import ClassVar, Literal, Protocol, runtime_checkable | ||
|
||
from typing_extensions import Self | ||
|
||
from guppylang.error import InternalGuppyError | ||
from guppylang.span import ToSpan, to_span | ||
|
||
|
||
class DiagnosticLevel(Enum): | ||
"""Severity levels for compiler diagnostics.""" | ||
|
||
#: An error that makes it impossible to proceed, causing an immediate abort. | ||
FATAL = auto() | ||
|
||
#: A regular error that is encountered during compilation. This is the most common | ||
#: diagnostic case. | ||
ERROR = auto() | ||
|
||
#: A warning about the code being compiled. Doesn't prevent compilation from | ||
#: finishing. | ||
WARNING = auto() | ||
|
||
#: A message giving some additional context. Usually used as a sub-diagnostic of | ||
#: errors. | ||
NOTE = auto() | ||
|
||
#: A message suggesting how to fix something. Usually used as a sub-diagnostic of | ||
#: errors. | ||
HELP = auto() | ||
|
||
|
||
@runtime_checkable | ||
@dataclass(frozen=True) | ||
class Diagnostic(Protocol): | ||
"""Abstract base class for compiler diagnostics that are reported to users. | ||
|
||
These could be fatal errors, regular errors, or warnings (see `DiagnosticLevel`). | ||
""" | ||
|
||
#: Severity level of the diagnostic. | ||
level: ClassVar[DiagnosticLevel] | ||
|
||
#: Primary span of the source location associated with this diagnostic. The span | ||
#: is optional, but provided in almost all cases. | ||
span: ToSpan | None | ||
|
||
#: Short title for the diagnostic that is displayed at the top. | ||
title: ClassVar[str] | ||
|
||
#: Longer message that is printed below the span. | ||
long_message: ClassVar[str | None] = None | ||
|
||
#: Label that is printed next to the span highlight. Can only be used if a span is | ||
#: provided. | ||
span_label: ClassVar[str | None] = None | ||
|
||
#: Optional sub-diagnostics giving some additional context. | ||
children: list["SubDiagnostic"] = field(default_factory=list, init=False) | ||
|
||
def __post_init__(self) -> None: | ||
if self.span_label and not self.span: | ||
raise InternalGuppyError("Diagnostic: Span label provided without span") | ||
|
||
def add_sub_diagnostic(self, sub: "SubDiagnostic") -> Self: | ||
"""Adds a new sub-diagnostic.""" | ||
if self.span and sub.span and to_span(sub.span).file != to_span(self.span).file: | ||
raise InternalGuppyError( | ||
"Diagnostic: Cross-file sub-diagnostics are not supported" | ||
) | ||
self.children.append(sub) | ||
return self | ||
|
||
|
||
@runtime_checkable | ||
@dataclass(frozen=True) | ||
class SubDiagnostic(Protocol): | ||
"""A sub-diagnostic attached to a parent diagnostic. | ||
|
||
Can be used to give some additional context, for example a note attached to an | ||
error. | ||
""" | ||
|
||
#: Severity level of the sub-diagnostic. | ||
level: ClassVar[DiagnosticLevel] | ||
|
||
#: Optional span of the source location associated with this sub-diagnostic. | ||
span: ToSpan | None | ||
|
||
#: Label that is printed next to the span highlight. Can only be used if a span is | ||
#: provided. | ||
span_label: ClassVar[str | None] = None | ||
|
||
#: Message that is printed if no span is provided. | ||
message: ClassVar[str | None] = None | ||
|
||
def __post_init__(self) -> None: | ||
if self.span_label and not self.span: | ||
raise InternalGuppyError("SubDiagnostic: Span label provided without span") | ||
if not self.span and not self.message: | ||
raise InternalGuppyError("SubDiagnostic: Empty diagnostic") | ||
|
||
|
||
@runtime_checkable | ||
@dataclass(frozen=True) | ||
class Error(Diagnostic, Protocol): | ||
"""Compiler diagnostic for regular errors that are encountered during | ||
compilation.""" | ||
|
||
level: ClassVar[Literal[DiagnosticLevel.ERROR]] = DiagnosticLevel.ERROR |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
"""Source spans representing locations in the code being compiled.""" | ||
|
||
import ast | ||
from dataclasses import dataclass | ||
from typing import TypeAlias | ||
|
||
from guppylang.ast_util import get_file, get_line_offset | ||
from guppylang.error import InternalGuppyError | ||
|
||
|
||
@dataclass(frozen=True, order=True) | ||
class Loc: | ||
"""A location in a source file.""" | ||
|
||
file: str | ||
|
||
#: Line number starting at 1 | ||
line: int | ||
|
||
#: Column number starting at 1 | ||
column: int | ||
|
||
|
||
@dataclass(frozen=True) | ||
class Span: | ||
"""A continuous sequence of source code within a file.""" | ||
|
||
#: Starting location of the span (inclusive) | ||
start: Loc | ||
|
||
# Ending location of the span (exclusive) | ||
end: Loc | ||
|
||
def __post_init__(self) -> None: | ||
if self.start.file != self.end.file: | ||
raise InternalGuppyError("Span: Source spans multiple files") | ||
if self.start > self.end: | ||
raise InternalGuppyError("Span: Start after end") | ||
|
||
def __contains__(self, x: "Span | Loc") -> bool: | ||
"""Determines whether another span or location is completely contained in this | ||
span.""" | ||
if self.file != x.file: | ||
return False | ||
if isinstance(x, Span): | ||
return self.start <= x.start <= self.end <= x.end | ||
return self.start <= x <= self.end | ||
|
||
def __and__(self, other: "Span") -> "Span | None": | ||
"""Returns the intersection with the given span or `None` if they don't | ||
intersect.""" | ||
if self.file != other.file: | ||
return None | ||
if self.start > other.end or other.start > self.end: | ||
return None | ||
return Span(max(self.start, other.start), min(self.end, other.end)) | ||
|
||
@property | ||
def file(self) -> str: | ||
"""The file containing this span.""" | ||
return self.start.file | ||
|
||
|
||
#: Objects in the compiler that are associated with a source span | ||
ToSpan: TypeAlias = ast.AST | Span | ||
|
||
|
||
def to_span(x: ToSpan) -> Span: | ||
"""Extracts a source span from an object.""" | ||
if isinstance(x, Span): | ||
return x | ||
file, line_offset = get_file(x), get_line_offset(x) | ||
assert file is not None | ||
assert line_offset is not None | ||
# x.lineno and line_offset both start at 1, so we have to subtract 1 | ||
start = Loc(file, x.lineno + line_offset - 1, x.col_offset) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But Both of these come from the python |
||
end = Loc( | ||
file, | ||
(x.end_lineno or x.lineno) + line_offset - 1, | ||
x.end_col_offset or x.col_offset, | ||
) | ||
return Span(start, end) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why have this be a ClassVar and not just a field?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Otherwise Python will complain that non-default fields follow default ones when defining a subclass. E.g.
This is because the field order is dermined by the base classes, so the
title
andspan_label
constructor arguments would actually come before theexpected
andactual
args. We would need to dowhich would be quite cumbersome and looks noisy. Making them
ClassVar
s tells the@dataclass
decorator that these fields are static and shouldn't be initialised by the constructor.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, I think the disconnect is that you're intending for these classes to be inherited from rather than used directly, so it makes sense for a the
TypeMismatchError
subclass to have a class variable.