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 2 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
102 changes: 102 additions & 0 deletions guppylang/diagnostic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Literal

from typing_extensions import Self

from guppylang.error import InternalGuppyError
from guppylang.span import ToSpan


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()


@dataclass(frozen=True)
class Diagnostic:
"""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: DiagnosticLevel

#: Primary span of the source location associated with this diagnostic. The span
#: is optional, but provided in almost all cases.
span: ToSpan | None = field(default=None, init=False)

#: Short title for the diagnostic that is displayed at the top.
title: str | None = field(default=None, init=False)

#: Label that is printed below the span.
label: str | None = field(default=None, init=False)

#: Label that is printed next to the span highlight. Can only be used if a span is
#: provided.
span_label: str | None = field(default=None, init=False)

#: 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."""
self.children.append(sub)
return self


@dataclass(frozen=True)
class SubDiagnostic:
"""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: DiagnosticLevel

#: Optional span of the source location associated with this sub-diagnostic.
span: ToSpan | None = field(default=None, init=False)

#: Short title for the diagnostic that is displayed at the top.
title: str | None = field(default=None, init=False)

#: Label that is printed below the span.
label: str | None = field(default=None, init=False)

#: Label that is printed next to the span highlight. Can only be used if a span is
#: provided.
span_label: str | None = field(default=None, init=False)


@dataclass(frozen=True)
class Error(Diagnostic):
"""Compiler diagnostic for regular error that are encountered during compilation."""

level: Literal[DiagnosticLevel.ERROR] = field(
default=DiagnosticLevel.ERROR, init=False
)
72 changes: 72 additions & 0 deletions guppylang/span.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""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: int
column: int


@dataclass(frozen=True)
class Span:
"""A continuous sequence of source code within a file."""

start: Loc
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
start = Loc(file, x.lineno + line_offset, x.col_offset)
end = Loc(
file, (x.end_lineno or x.lineno) + line_offset, x.end_col_offset or x.col_offset
)
return Span(start, end)
Loading