Skip to content

Commit

Permalink
sphinx-agent: Collect and report diagnostics
Browse files Browse the repository at this point in the history
Reusing the diagnostic collection code from the current version of the
language server, the sphinx agent now includes any project diagnostics
in the build result sent to the language server
  • Loading branch information
alcarney committed Sep 11, 2023
1 parent ec28781 commit b4d146d
Show file tree
Hide file tree
Showing 3 changed files with 78 additions and 30 deletions.
31 changes: 24 additions & 7 deletions lib/esbonio/esbonio/sphinx_agent/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ def create_sphinx_app(self, request: types.CreateApplicationRequest):
self.app.connect("env-before-read-docs", self._cb_env_before_read_docs)
self.app.connect("source-read", self._cb_source_read, priority=0)

# TODO: Sphinx 7.x has introduced a `include-read` event
# See: https://github.com/sphinx-doc/sphinx/pull/11657

if request.params.enable_sync_scrolling:
_enable_sync_scrolling(self.app)

Expand Down Expand Up @@ -149,9 +152,15 @@ def _cb_env_before_read_docs(self, app: Sphinx, env, docnames: List[str]):
docnames.append(docname)

def _cb_source_read(self, app: Sphinx, docname: str, source):
"""Used to inject unsaved content into a build."""
"""Called whenever sphinx reads a file from disk."""

filepath = app.env.doc2path(docname, base=True)

# Clear diagnostics
if self.log_handler:
self.log_handler.diagnostics.pop(filepath, None)

# Override file contents if necessary
if (content := self._content_overrides.get(filepath)) is not None:
source[0] = content

Expand All @@ -169,21 +178,21 @@ def setup_logging(self, config: SphinxConfig, app: Sphinx, status: IO, warning:
for handler in sphinx_logger.handlers:
if isinstance(handler, SphinxLogHandler):
sphinx_logger.handlers.remove(handler)
self.sphinx_log = None
self.log_handler = None

self.sphinx_log = SphinxLogHandler(app)
sphinx_logger.addHandler(self.sphinx_log)
self.log_handler = SphinxLogHandler(app)
sphinx_logger.addHandler(self.log_handler)

if config.quiet:
level = logging.WARNING
else:
level = VERBOSITY_MAP[app.verbosity]

sphinx_logger.setLevel(level)
self.sphinx_log.setLevel(level)
self.log_handler.setLevel(level)

formatter = logging.Formatter("%(message)s")
self.sphinx_log.setFormatter(formatter)
self.log_handler.setFormatter(formatter)

def build_sphinx_app(self, request: types.BuildRequest):
"""Trigger a Sphinx build."""
Expand All @@ -196,9 +205,17 @@ def build_sphinx_app(self, request: types.BuildRequest):

try:
self.app.build()

diagnostics = {}
if self.log_handler:
diagnostics = self.log_handler.diagnostics

response = types.BuildResponse(
id=request.id,
result=types.BuildResult(build_file_map=_build_file_mapping(self.app)),
result=types.BuildResult(
build_file_map=_build_file_mapping(self.app),
diagnostics=diagnostics,
),
jsonrpc=request.jsonrpc,
)
send_message(response)
Expand Down
47 changes: 24 additions & 23 deletions lib/esbonio/esbonio/sphinx_agent/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import sys
from types import ModuleType
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
Expand All @@ -14,11 +15,16 @@
from sphinx.util.logging import SphinxLogRecord
from sphinx.util.logging import WarningLogRecordTranslator

from .types import LogMessage
from .types import LogMessageParams
from . import types
from .util import logger
from .util import send_message

DIAGNOSTIC_SEVERITY = {
logging.ERROR: types.DiagnosticSeverity.Error,
logging.INFO: types.DiagnosticSeverity.Information,
logging.WARNING: types.DiagnosticSeverity.Warning,
}


class SphinxLogHandler(logging.Handler):
"""A logging handler that can extract errors from Sphinx's build output."""
Expand All @@ -29,7 +35,7 @@ def __init__(self, app, *args, **kwargs):
self.app = app
self.translator = WarningLogRecordTranslator(app)
self.only_once = OnceFilter()
# self.diagnostics: Dict[str, List[Diagnostic]] = {}
self.diagnostics: Dict[str, List[types.Diagnostic]] = {}

def get_location(self, location: str) -> Tuple[str, Optional[int]]:
if not location:
Expand Down Expand Up @@ -102,7 +108,7 @@ def emit(self, record: logging.LogRecord) -> None:
conditions = [
"sphinx" not in record.name,
record.levelno not in {logging.WARNING, logging.ERROR},
not self.translator,
# not self.translator,
]

if any(conditions):
Expand All @@ -120,7 +126,6 @@ def emit(self, record: logging.LogRecord) -> None:
loc = record.location if isinstance(record, SphinxLogRecord) else ""
doc, lineno = self.get_location(loc)
line = lineno or 1
logger.debug("Reporting diagnostic at %s:%s", doc, line)

try:
# Not every message contains a string...
Expand All @@ -137,24 +142,20 @@ def emit(self, record: logging.LogRecord) -> None:
message = str(record.msg)
logger.error("Unable to format diagnostic message: %s", exc_info=True)

# diagnostic = Diagnostic(
# range=Range(
# start=Position(line=line - 1, character=0),
# end=Position(line=line, character=0),
# ),
# message=message,
# severity=DIAGNOSTIC_SEVERITY.get(
# record.levelno, DiagnosticSeverity.Warning
# ),
# )

# if doc not in self.diagnostics:
# self.diagnostics[doc] = [diagnostic]
# else:
# self.diagnostics[doc].append(diagnostic)

diagnostic = types.Diagnostic(
range=types.Range(
start=types.Position(line=line - 1, character=0),
end=types.Position(line=line, character=0),
),
message=message,
severity=DIAGNOSTIC_SEVERITY.get(
record.levelno, types.DiagnosticSeverity.Warning
),
)

self.diagnostics.setdefault(doc, []).append(diagnostic)
self.do_emit(record)

def do_emit(self, record):
params = LogMessageParams(message=self.format(record).strip(), type=4)
send_message(LogMessage(params=params))
params = types.LogMessageParams(message=self.format(record).strip(), type=4)
send_message(types.LogMessage(params=params))
30 changes: 30 additions & 0 deletions lib/esbonio/esbonio/sphinx_agent/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,38 @@
For this reason this file *cannot* import anything from Sphinx.
"""
import dataclasses
import enum
from typing import Dict
from typing import List
from typing import Union


@dataclasses.dataclass
class Position:
line: int
character: int


@dataclasses.dataclass
class Range:
start: Position
end: Position


class DiagnosticSeverity(enum.IntEnum):
Error = 1
Warning = 2
Information = 3
Hint = 4


@dataclasses.dataclass
class Diagnostic:
range: Range
message: str
severity: DiagnosticSeverity


@dataclasses.dataclass
class CreateApplicationParams:
"""Parameters of a ``sphinx/createApp`` request."""
Expand Down Expand Up @@ -82,6 +109,9 @@ class BuildParams:
class BuildResult:
"""Results from a ``sphinx/build`` request."""

diagnostics: Dict[str, List[Diagnostic]] = dataclasses.field(default_factory=dict)
"""Any diagnostics associated with the project."""

build_file_map: Dict[str, str] = dataclasses.field(default_factory=dict)
"""A mapping of source files to the output files they contributed to."""

Expand Down

0 comments on commit b4d146d

Please sign in to comment.