Skip to content
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

Merged
merged 8 commits into from
Oct 9, 2024
Merged
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
111 changes: 111 additions & 0 deletions guppylang/diagnostic.py
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
Copy link
Collaborator

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?

Copy link
Collaborator Author

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.

@dataclass(frozen=True)
class TypeMismatchError(Error):
    expected: Type
    actual: Type

    title: str = "Type mismatch"
    span_label: str = "Expected `{expected}`, got `{actual}`"

This is because the field order is dermined by the base classes, so the title and span_label constructor arguments would actually come before the expected and actual args. We would need to do

@dataclass(frozen=True)
class TypeMismatchError(Error):
    expected: Type
    actual: Type

    title: str = field(default="Type mismatch", init=False)
    span_label: str = field(defaut="Expected `{expected}`, got `{actual}`", init=False)

which would be quite cumbersome and looks noisy. Making them ClassVars tells the @dataclass decorator that these fields are static and shouldn't be initialised by the constructor.

Copy link
Collaborator

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.


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
82 changes: 82 additions & 0 deletions guppylang/span.py
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)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Loc is meant to be 1-indexed, so it seems good that line_offset starts at 1?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

But x.lineno also starts at 1

Both of these come from the python ast module 😅

end = Loc(
file,
(x.end_lineno or x.lineno) + line_offset - 1,
x.end_col_offset or x.col_offset,
)
return Span(start, end)
Loading