Skip to content

Commit e773b59

Browse files
authored
feat: add code href that links to the rule (#4355)
1 parent e3858d4 commit e773b59

3 files changed

Lines changed: 98 additions & 0 deletions

File tree

sqlmesh/core/linter/rule.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,20 @@
88

99
import typing as t
1010

11+
from sqlmesh.utils.pydantic import PydanticModel
12+
1113

1214
if t.TYPE_CHECKING:
1315
from sqlmesh.core.context import GenericContext
1416

1517

18+
class RuleLocation(PydanticModel):
19+
"""The location of a rule in a file."""
20+
21+
file_path: str
22+
start_line: t.Optional[int] = None
23+
24+
1625
class _Rule(abc.ABCMeta):
1726
def __new__(cls: Type[_Rule], clsname: str, bases: t.Tuple, attrs: t.Dict) -> _Rule:
1827
attrs["name"] = clsname.lower()
@@ -40,6 +49,31 @@ def violation(self, violation_msg: t.Optional[str] = None) -> RuleViolation:
4049
"""Create a RuleViolation instance for this rule"""
4150
return RuleViolation(rule=self, violation_msg=violation_msg or self.summary)
4251

52+
def get_definition_location(self) -> RuleLocation:
53+
"""Return the file path and position information for this rule.
54+
55+
This method returns information about where this rule is defined,
56+
which can be used in diagnostics to link to the rule's documentation.
57+
58+
Returns:
59+
A dictionary containing file path and position information.
60+
"""
61+
import inspect
62+
63+
# Get the file where the rule class is defined
64+
file_path = inspect.getfile(self.__class__)
65+
66+
try:
67+
# Get the source code and line number
68+
source_lines, start_line = inspect.getsourcelines(self.__class__)
69+
return RuleLocation(
70+
file_path=file_path,
71+
start_line=start_line,
72+
)
73+
except (IOError, TypeError):
74+
# Fall back to just returning the file path if we can't get source lines
75+
return RuleLocation(file_path=file_path)
76+
4377
def __repr__(self) -> str:
4478
return self.name
4579

sqlmesh/lsp/main.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,12 @@ def _diagnostic_to_lsp_diagnostic(
293293
return None
294294
with open(diagnostic.model._path, "r", encoding="utf-8") as file:
295295
lines = file.readlines()
296+
297+
# Get rule definition location for diagnostics link
298+
rule_location = diagnostic.rule.get_definition_location()
299+
rule_uri = f"file://{rule_location.file_path}#L{rule_location.start_line}"
300+
301+
# Use URI format to create a link for "related information"
296302
return types.Diagnostic(
297303
range=types.Range(
298304
start=types.Position(line=0, character=0),
@@ -302,6 +308,9 @@ def _diagnostic_to_lsp_diagnostic(
302308
severity=types.DiagnosticSeverity.Error
303309
if diagnostic.violation_type == "error"
304310
else types.DiagnosticSeverity.Warning,
311+
source="sqlmesh",
312+
code=diagnostic.rule.name,
313+
code_description=types.CodeDescription(href=rule_uri),
305314
)
306315

307316
@staticmethod

tests/core/test_rule.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from __future__ import annotations
2+
3+
import inspect
4+
import typing as t
5+
from unittest.mock import MagicMock
6+
7+
import pytest
8+
from sqlmesh.core.model import Model
9+
from sqlmesh.core.linter.rule import Rule, RuleViolation
10+
11+
12+
class TestRule(Rule):
13+
"""A test rule for testing the get_definition_location method."""
14+
15+
def check_model(self, model: Model) -> t.Optional[RuleViolation]:
16+
"""The evaluation function that'll check for a violation of this rule."""
17+
return None
18+
19+
20+
def test_get_definition_location():
21+
"""Test the get_definition_location method returns correct file and line information."""
22+
# Create a mock context
23+
mock_context = MagicMock()
24+
rule = TestRule(mock_context)
25+
26+
# Get the expected location using the inspect module
27+
expected_file = inspect.getfile(TestRule)
28+
expected_source, expected_start_line = inspect.getsourcelines(TestRule)
29+
expected_end_line = expected_start_line + len(expected_source) - 1
30+
31+
# Get the location using the Rule method
32+
location = rule.get_definition_location()
33+
34+
# Assert the file path matches
35+
assert location.file_path == expected_file
36+
37+
# Assert the line numbers match
38+
assert location.start_line == expected_start_line
39+
40+
# Test the fallback case for a class without source
41+
with pytest.MonkeyPatch.context() as mp:
42+
# Mock inspect.getsourcelines to raise an exception
43+
def mock_getsourcelines(*args, **kwargs):
44+
raise IOError("Mock error")
45+
46+
mp.setattr(inspect, "getsourcelines", mock_getsourcelines)
47+
48+
# Get the location with the mocked function
49+
fallback_location = rule.get_definition_location()
50+
51+
# It should still have the file path
52+
assert fallback_location.file_path == expected_file
53+
54+
# But not the line numbers
55+
assert fallback_location.start_line is None

0 commit comments

Comments
 (0)