modelable 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of modelable might be problematic. Click here for more details.
- modelable/__init__.py +1 -0
- modelable/__main__.py +3 -0
- modelable/_pydantic_py314_compat.py +31 -0
- modelable/cli.py +41 -0
- modelable/commands/__init__.py +1 -0
- modelable/commands/apicurio.py +84 -0
- modelable/commands/codegen.py +241 -0
- modelable/commands/common.py +43 -0
- modelable/commands/compile.py +237 -0
- modelable/commands/create.py +164 -0
- modelable/commands/diff.py +82 -0
- modelable/commands/graph.py +53 -0
- modelable/commands/llm.py +564 -0
- modelable/commands/lsp.py +15 -0
- modelable/commands/runtime.py +37 -0
- modelable/commands/scenario.py +104 -0
- modelable/commands/spec.py +197 -0
- modelable/commands/workspace.py +240 -0
- modelable/compat/__init__.py +11 -0
- modelable/compat/checker.py +179 -0
- modelable/compat/diff.py +169 -0
- modelable/compiler/__init__.py +3 -0
- modelable/compiler/compiler.py +19 -0
- modelable/compiler/workspace.py +346 -0
- modelable/diagnostics/__init__.py +3 -0
- modelable/diagnostics/model.py +27 -0
- modelable/emitters/__init__.py +0 -0
- modelable/emitters/base.py +22 -0
- modelable/emitters/csharp.py +245 -0
- modelable/emitters/dbt_yaml.py +290 -0
- modelable/emitters/diagnostics.py +25 -0
- modelable/emitters/fhir.py +694 -0
- modelable/emitters/fhir_validator.py +36 -0
- modelable/emitters/go.py +334 -0
- modelable/emitters/java.py +264 -0
- modelable/emitters/json_schema.py +458 -0
- modelable/emitters/markdown.py +252 -0
- modelable/emitters/odcs.py +355 -0
- modelable/emitters/openlineage.py +315 -0
- modelable/emitters/openmetadata.py +258 -0
- modelable/emitters/python.py +282 -0
- modelable/emitters/rust.py +643 -0
- modelable/emitters/shapes.py +261 -0
- modelable/emitters/sql.py +266 -0
- modelable/emitters/targets.py +141 -0
- modelable/emitters/typescript.py +352 -0
- modelable/expressions/__init__.py +0 -0
- modelable/expressions/cel.py +547 -0
- modelable/governance/__init__.py +3 -0
- modelable/governance/checker.py +271 -0
- modelable/governance/por.py +46 -0
- modelable/grammar/__init__.py +1 -0
- modelable/grammar/modelable.lark +257 -0
- modelable/graph/__init__.py +5 -0
- modelable/graph/export.py +442 -0
- modelable/llm/__init__.py +43 -0
- modelable/llm/chat.py +255 -0
- modelable/llm/config.py +87 -0
- modelable/llm/context.py +194 -0
- modelable/llm/engine.py +976 -0
- modelable/llm/importers.py +1077 -0
- modelable/llm/provenance.py +84 -0
- modelable/llm/providers.py +182 -0
- modelable/llm/qa.py +126 -0
- modelable/llm/recommendations.py +33 -0
- modelable/llm/redaction.py +19 -0
- modelable/llm/render.py +279 -0
- modelable/llm/update_plan.py +101 -0
- modelable/llm/validation_help.py +10 -0
- modelable/lsp/__init__.py +3 -0
- modelable/lsp/__main__.py +4 -0
- modelable/lsp/code_actions.py +210 -0
- modelable/lsp/completion.py +480 -0
- modelable/lsp/definition.py +343 -0
- modelable/lsp/diagnostics.py +31 -0
- modelable/lsp/document_symbols.py +197 -0
- modelable/lsp/federation.py +261 -0
- modelable/lsp/folding.py +33 -0
- modelable/lsp/formatting.py +64 -0
- modelable/lsp/highlight.py +30 -0
- modelable/lsp/hover.py +370 -0
- modelable/lsp/inlay_hints.py +158 -0
- modelable/lsp/references.py +511 -0
- modelable/lsp/rename.py +564 -0
- modelable/lsp/semantic_tokens.py +412 -0
- modelable/lsp/server.py +370 -0
- modelable/lsp/workspace.py +83 -0
- modelable/lsp/workspace_symbols.py +104 -0
- modelable/parser/__init__.py +94 -0
- modelable/parser/ir.py +451 -0
- modelable/parser/parse.py +47 -0
- modelable/parser/transformer.py +798 -0
- modelable/parser/wire.py +68 -0
- modelable/planner/__init__.py +0 -0
- modelable/planner/lineage.py +91 -0
- modelable/planner/planner.py +134 -0
- modelable/planner/plans.py +122 -0
- modelable/py.typed +0 -0
- modelable/registry/__init__.py +9 -0
- modelable/registry/apicurio.py +166 -0
- modelable/registry/base.py +18 -0
- modelable/registry/factory.py +18 -0
- modelable/registry/index.py +419 -0
- modelable/registry/local.py +26 -0
- modelable/registry/oci.py +22 -0
- modelable/registry/resolver.py +213 -0
- modelable/registry/schema.sql +119 -0
- modelable/registry/signature.py +26 -0
- modelable/release.py +125 -0
- modelable/runtime/__init__.py +5 -0
- modelable/runtime/adapter/__init__.py +17 -0
- modelable/runtime/adapter/base.py +18 -0
- modelable/runtime/adapter/postgres.py +82 -0
- modelable/specs/__init__.py +23 -0
- modelable/specs/tracking.py +220 -0
- modelable/validation/__init__.py +3 -0
- modelable/validation/semantic.py +659 -0
- modelable-1.0.0.dist-info/METADATA +61 -0
- modelable-1.0.0.dist-info/RECORD +122 -0
- modelable-1.0.0.dist-info/WHEEL +4 -0
- modelable-1.0.0.dist-info/entry_points.txt +2 -0
- modelable-1.0.0.dist-info/licenses/LICENSE +201 -0
modelable/lsp/hover.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from lsprotocol import types
|
|
7
|
+
|
|
8
|
+
from modelable.llm.context import build_model_summary, build_projection_summary, parse_model_ref
|
|
9
|
+
from modelable.lsp.workspace import LspWorkspaceIndex
|
|
10
|
+
from modelable.registry.resolver import resolve_model_ref
|
|
11
|
+
|
|
12
|
+
_QUALIFIED_REF_PATTERN = re.compile(
|
|
13
|
+
r"(?P<domain>[A-Za-z_][A-Za-z0-9_]*)\.(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*@\s*(?P<version>\d+)"
|
|
14
|
+
)
|
|
15
|
+
_REF_TYPE_PATTERN = re.compile(r"ref\s*<\s*(?P<domain>[A-Za-z_][A-Za-z0-9_]*)\.(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*>")
|
|
16
|
+
_FIELD_REF_PATTERN = re.compile(r"(?P<alias>[A-Za-z_][A-Za-z0-9_]*)\.(?P<field>[A-Za-z_][A-Za-z0-9_]*)")
|
|
17
|
+
_DECL_PATTERN = re.compile(
|
|
18
|
+
r"^\s*(?P<kind>entity|aggregate|event|value|projection)\s+"
|
|
19
|
+
r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*@\s*(?P<version>\d+)"
|
|
20
|
+
)
|
|
21
|
+
_DOMAIN_PATTERN = re.compile(r'^\s*domain\s+(?:"(?P<quoted>[^"]+)"|(?P<name>[A-Za-z_][A-Za-z0-9_]*))')
|
|
22
|
+
_WORD_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class HoverInfo:
|
|
27
|
+
markdown: str
|
|
28
|
+
line: int
|
|
29
|
+
start: int
|
|
30
|
+
end: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_hover(index: LspWorkspaceIndex, uri: str, line: int, character: int) -> types.Hover | None:
|
|
34
|
+
source = index.documents.get(uri)
|
|
35
|
+
workspace = index.workspace
|
|
36
|
+
if source is None or workspace is None:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
lines = source.text.splitlines()
|
|
40
|
+
if line < 0 or line >= len(lines):
|
|
41
|
+
return None
|
|
42
|
+
text_line = lines[line]
|
|
43
|
+
|
|
44
|
+
for match in _QUALIFIED_REF_PATTERN.finditer(text_line):
|
|
45
|
+
if _contains(match.start(), match.end(), character):
|
|
46
|
+
ref = f"{match.group('domain')}.{match.group('name')}@{match.group('version')}"
|
|
47
|
+
return _make_ref_hover(workspace, ref, line, match.start(), match.end())
|
|
48
|
+
|
|
49
|
+
for match in _REF_TYPE_PATTERN.finditer(text_line):
|
|
50
|
+
if _contains(match.start(), match.end(), character):
|
|
51
|
+
hover = _make_unversioned_ref_hover(
|
|
52
|
+
workspace, match.group("domain"), match.group("name"), line, match.start(), match.end()
|
|
53
|
+
)
|
|
54
|
+
if hover is not None:
|
|
55
|
+
return hover
|
|
56
|
+
|
|
57
|
+
for match in _FIELD_REF_PATTERN.finditer(text_line):
|
|
58
|
+
if _contains(match.start(), match.end(), character):
|
|
59
|
+
info = _hover_for_field_reference(
|
|
60
|
+
workspace,
|
|
61
|
+
source.text,
|
|
62
|
+
line,
|
|
63
|
+
match.group("alias"),
|
|
64
|
+
match.group("field"),
|
|
65
|
+
match.start(),
|
|
66
|
+
match.end(),
|
|
67
|
+
)
|
|
68
|
+
if info is not None:
|
|
69
|
+
return _hover_from_info(info)
|
|
70
|
+
|
|
71
|
+
word = _word_at(text_line, character)
|
|
72
|
+
if word is None:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
info = _hover_for_bare_word(workspace, source.text, line, word)
|
|
76
|
+
if info is not None:
|
|
77
|
+
start, end = _word_span(text_line, character)
|
|
78
|
+
return _hover_from_info(HoverInfo(markdown=_markdown_block(info), line=line, start=start, end=end))
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _make_ref_hover(workspace, ref: str, line: int, start: int, end: int) -> types.Hover | None:
|
|
83
|
+
model_ref = parse_model_ref(ref)
|
|
84
|
+
domain = next((d for d in workspace.mdl.domains if d.name == model_ref.domain), None)
|
|
85
|
+
if domain is None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
if model_ref.name in domain.models:
|
|
89
|
+
summary = build_model_summary(workspace, ref)
|
|
90
|
+
elif model_ref.name in domain.projections:
|
|
91
|
+
summary = build_projection_summary(workspace, ref)
|
|
92
|
+
else:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
return _hover_from_info(HoverInfo(markdown=_markdown_block(summary), line=line, start=start, end=end))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _make_unversioned_ref_hover(
|
|
99
|
+
workspace, domain_name: str, name: str, line: int, start: int, end: int
|
|
100
|
+
) -> types.Hover | None:
|
|
101
|
+
domain = next((d for d in workspace.mdl.domains if d.name == domain_name), None)
|
|
102
|
+
if domain is None:
|
|
103
|
+
return None
|
|
104
|
+
if name in domain.models:
|
|
105
|
+
latest = max(domain.models[name], key=lambda v: v.version)
|
|
106
|
+
ref = f"{domain_name}.{name}@{latest.version}"
|
|
107
|
+
return _make_ref_hover(workspace, ref, line, start, end)
|
|
108
|
+
if name in domain.projections:
|
|
109
|
+
latest = max(domain.projections[name], key=lambda v: v.version)
|
|
110
|
+
ref = f"{domain_name}.{name}@{latest.version}"
|
|
111
|
+
return _make_ref_hover(workspace, ref, line, start, end)
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _hover_for_bare_word(workspace, text: str, line: int, word: str) -> str | None:
|
|
116
|
+
scope = _current_scope(text, line)
|
|
117
|
+
if scope is None:
|
|
118
|
+
return None
|
|
119
|
+
domain_name, kind, name, version = scope
|
|
120
|
+
domain = next((d for d in workspace.mdl.domains if d.name == domain_name), None)
|
|
121
|
+
if domain is None:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
if kind == "model":
|
|
125
|
+
versions = domain.models.get(name)
|
|
126
|
+
if not versions:
|
|
127
|
+
return None
|
|
128
|
+
model_version = next((item for item in versions if item.version == version), None)
|
|
129
|
+
if model_version is None:
|
|
130
|
+
return None
|
|
131
|
+
if word == name:
|
|
132
|
+
return build_model_summary(workspace, f"{domain_name}.{name}@{version}")
|
|
133
|
+
field = next((item for item in model_version.fields if item.name == word), None)
|
|
134
|
+
if field is None:
|
|
135
|
+
return None
|
|
136
|
+
flags = []
|
|
137
|
+
if field.is_key:
|
|
138
|
+
flags.append("key")
|
|
139
|
+
if field.is_pii:
|
|
140
|
+
flags.append("pii")
|
|
141
|
+
if field.classification:
|
|
142
|
+
flags.append(f"classification={field.classification.value}")
|
|
143
|
+
suffix = f" [{', '.join(flags)}]" if flags else ""
|
|
144
|
+
return (
|
|
145
|
+
f"{domain_name}.{name}@{version}.{field.name}\n"
|
|
146
|
+
f"type: {field.type.kind}\n"
|
|
147
|
+
f"optional: {'yes' if field.optional else 'no'}{suffix}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
versions = domain.projections.get(name)
|
|
151
|
+
if not versions:
|
|
152
|
+
return None
|
|
153
|
+
projection_version = next((item for item in versions if item.version == version), None)
|
|
154
|
+
if projection_version is None:
|
|
155
|
+
return None
|
|
156
|
+
if word == name:
|
|
157
|
+
return build_projection_summary(workspace, f"{domain_name}.{name}@{version}")
|
|
158
|
+
field = next((item for item in projection_version.fields if item.name == word), None)
|
|
159
|
+
if field is None:
|
|
160
|
+
return None
|
|
161
|
+
return f"{domain_name}.{name}@{version}.{field.name}\nmapping: {_mapping_text(field)}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _hover_for_field_reference(
|
|
165
|
+
workspace,
|
|
166
|
+
text: str,
|
|
167
|
+
line: int,
|
|
168
|
+
alias: str,
|
|
169
|
+
field_name: str,
|
|
170
|
+
start: int,
|
|
171
|
+
end: int,
|
|
172
|
+
) -> HoverInfo | None:
|
|
173
|
+
scope = _current_scope(text, line)
|
|
174
|
+
if scope is None:
|
|
175
|
+
return None
|
|
176
|
+
domain_name, kind, name, version = scope
|
|
177
|
+
domain = next((d for d in workspace.mdl.domains if d.name == domain_name), None)
|
|
178
|
+
if domain is None:
|
|
179
|
+
return None
|
|
180
|
+
if kind == "model":
|
|
181
|
+
versions = domain.models.get(name)
|
|
182
|
+
if not versions:
|
|
183
|
+
return None
|
|
184
|
+
model_version = next((item for item in versions if item.version == version), None)
|
|
185
|
+
if model_version is None:
|
|
186
|
+
return None
|
|
187
|
+
field = next((item for item in model_version.fields if item.name == field_name), None)
|
|
188
|
+
if field is None:
|
|
189
|
+
return None
|
|
190
|
+
flags = []
|
|
191
|
+
if field.is_key:
|
|
192
|
+
flags.append("key")
|
|
193
|
+
if field.is_pii:
|
|
194
|
+
flags.append("pii")
|
|
195
|
+
if field.classification:
|
|
196
|
+
flags.append(f"classification={field.classification.value}")
|
|
197
|
+
suffix = f" [{', '.join(flags)}]" if flags else ""
|
|
198
|
+
return HoverInfo(
|
|
199
|
+
markdown=_markdown_block(
|
|
200
|
+
f"{domain_name}.{name}@{version}.{field.name}\n"
|
|
201
|
+
f"type: {field.type.kind}\n"
|
|
202
|
+
f"optional: {'yes' if field.optional else 'no'}{suffix}"
|
|
203
|
+
),
|
|
204
|
+
line=line,
|
|
205
|
+
start=start,
|
|
206
|
+
end=end,
|
|
207
|
+
)
|
|
208
|
+
versions = domain.projections.get(name)
|
|
209
|
+
if not versions:
|
|
210
|
+
return None
|
|
211
|
+
projection_version = next((item for item in versions if item.version == version), None)
|
|
212
|
+
if projection_version is None:
|
|
213
|
+
return None
|
|
214
|
+
for source_ref in [projection_version.source, *projection_version.joins]:
|
|
215
|
+
if source_ref.alias != alias:
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
resolved = resolve_model_ref(
|
|
219
|
+
workspace.mdl,
|
|
220
|
+
source_ref.model,
|
|
221
|
+
source_ref.version,
|
|
222
|
+
)
|
|
223
|
+
except LookupError:
|
|
224
|
+
break
|
|
225
|
+
return _hover_for_source_field(
|
|
226
|
+
workspace,
|
|
227
|
+
resolved.domain_name,
|
|
228
|
+
resolved.model_name,
|
|
229
|
+
resolved.version.version,
|
|
230
|
+
field_name,
|
|
231
|
+
line,
|
|
232
|
+
start,
|
|
233
|
+
end,
|
|
234
|
+
)
|
|
235
|
+
field = next((item for item in projection_version.fields if item.name == field_name), None)
|
|
236
|
+
if field is None:
|
|
237
|
+
return None
|
|
238
|
+
return HoverInfo(
|
|
239
|
+
markdown=_markdown_block(f"{domain_name}.{name}@{version}.{field.name}\nmapping: {_mapping_text(field)}"),
|
|
240
|
+
line=line,
|
|
241
|
+
start=start,
|
|
242
|
+
end=end,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _hover_for_source_field(
|
|
247
|
+
workspace,
|
|
248
|
+
domain_name: str,
|
|
249
|
+
model_name: str,
|
|
250
|
+
version: int,
|
|
251
|
+
field_name: str,
|
|
252
|
+
line: int,
|
|
253
|
+
start: int,
|
|
254
|
+
end: int,
|
|
255
|
+
) -> HoverInfo | None:
|
|
256
|
+
source_version = _source_version(workspace, domain_name, model_name, version)
|
|
257
|
+
if source_version is None:
|
|
258
|
+
return None
|
|
259
|
+
source_field = next((item for item in getattr(source_version, "fields", []) if item.name == field_name), None)
|
|
260
|
+
if source_field is None:
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
if getattr(source_field, "mapping", None) is not None:
|
|
264
|
+
return HoverInfo(
|
|
265
|
+
markdown=_markdown_block(
|
|
266
|
+
f"{domain_name}.{model_name}@{version}.{source_field.name}\nmapping: {_mapping_text(source_field)}"
|
|
267
|
+
),
|
|
268
|
+
line=line,
|
|
269
|
+
start=start,
|
|
270
|
+
end=end,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
flags = []
|
|
274
|
+
if source_field.is_key:
|
|
275
|
+
flags.append("key")
|
|
276
|
+
if source_field.is_pii:
|
|
277
|
+
flags.append("pii")
|
|
278
|
+
if source_field.classification:
|
|
279
|
+
flags.append(f"classification={source_field.classification.value}")
|
|
280
|
+
suffix = f" [{', '.join(flags)}]" if flags else ""
|
|
281
|
+
return HoverInfo(
|
|
282
|
+
markdown=_markdown_block(
|
|
283
|
+
f"{domain_name}.{model_name}@{version}.{source_field.name}\n"
|
|
284
|
+
f"type: {source_field.type.kind}\n"
|
|
285
|
+
f"optional: {'yes' if source_field.optional else 'no'}{suffix}"
|
|
286
|
+
),
|
|
287
|
+
line=line,
|
|
288
|
+
start=start,
|
|
289
|
+
end=end,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _source_version(workspace, domain_name: str, model_name: str, version: int):
|
|
294
|
+
domain = next((d for d in workspace.mdl.domains if d.name == domain_name), None)
|
|
295
|
+
if domain is None:
|
|
296
|
+
return None
|
|
297
|
+
versions = domain.models.get(model_name, [])
|
|
298
|
+
source_version = next((item for item in versions if item.version == version), None)
|
|
299
|
+
if source_version is not None:
|
|
300
|
+
return source_version
|
|
301
|
+
versions = domain.projections.get(model_name, [])
|
|
302
|
+
return next((item for item in versions if item.version == version), None)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _hover_from_info(info: HoverInfo) -> types.Hover:
|
|
306
|
+
return types.Hover(
|
|
307
|
+
contents=types.MarkupContent(kind=types.MarkupKind.Markdown, value=info.markdown),
|
|
308
|
+
range=types.Range(
|
|
309
|
+
start=types.Position(line=info.line, character=info.start),
|
|
310
|
+
end=types.Position(line=info.line, character=info.end),
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _current_scope(text: str, line: int) -> tuple[str, str, str, int] | None:
|
|
316
|
+
lines = text.splitlines()
|
|
317
|
+
current_domain: str | None = None
|
|
318
|
+
current_kind: str | None = None
|
|
319
|
+
current_name: str | None = None
|
|
320
|
+
current_version: int | None = None
|
|
321
|
+
for item in lines[: line + 1]:
|
|
322
|
+
domain_match = _DOMAIN_PATTERN.match(item)
|
|
323
|
+
if domain_match:
|
|
324
|
+
current_domain = domain_match.group("quoted") or domain_match.group("name")
|
|
325
|
+
current_kind = None
|
|
326
|
+
current_name = None
|
|
327
|
+
current_version = None
|
|
328
|
+
continue
|
|
329
|
+
decl_match = _DECL_PATTERN.match(item)
|
|
330
|
+
if decl_match and current_domain is not None:
|
|
331
|
+
current_kind = "model" if decl_match.group("kind") != "projection" else "projection"
|
|
332
|
+
current_name = decl_match.group("name")
|
|
333
|
+
current_version = int(decl_match.group("version"))
|
|
334
|
+
if current_domain and current_kind and current_name and current_version is not None:
|
|
335
|
+
return current_domain, current_kind, current_name, current_version
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _word_at(text_line: str, character: int) -> str | None:
|
|
340
|
+
for match in _WORD_PATTERN.finditer(text_line):
|
|
341
|
+
if _contains(match.start(), match.end(), character):
|
|
342
|
+
return match.group(0)
|
|
343
|
+
return None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _word_span(text_line: str, character: int) -> tuple[int, int]:
|
|
347
|
+
for match in _WORD_PATTERN.finditer(text_line):
|
|
348
|
+
if _contains(match.start(), match.end(), character):
|
|
349
|
+
return match.start(), match.end()
|
|
350
|
+
return character, character
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _contains(start: int, end: int, character: int) -> bool:
|
|
354
|
+
return start <= character <= max(end - 1, start)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _markdown_block(text: str) -> str:
|
|
358
|
+
return f"```text\n{text}\n```"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _mapping_text(field) -> str:
|
|
362
|
+
mapping = getattr(field, "mapping", None)
|
|
363
|
+
if mapping is None:
|
|
364
|
+
return "unknown"
|
|
365
|
+
kind = getattr(mapping, "kind", None)
|
|
366
|
+
if kind == "direct":
|
|
367
|
+
return f"direct {mapping.source_alias}.{mapping.source_field}"
|
|
368
|
+
if kind == "computed":
|
|
369
|
+
return f"computed {mapping.expression}"
|
|
370
|
+
return "unknown"
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from lsprotocol import types
|
|
6
|
+
|
|
7
|
+
from modelable.lsp.workspace import LspWorkspaceIndex
|
|
8
|
+
from modelable.registry.resolver import resolve_model_ref
|
|
9
|
+
|
|
10
|
+
_DOMAIN_PATTERN = re.compile(r'^\s*domain\s+(?:"(?P<quoted>[^"]+)"|(?P<name>[A-Za-z_][A-Za-z0-9_]*))')
|
|
11
|
+
_DECL_PATTERN = re.compile(
|
|
12
|
+
r"^\s*(?P<kind>entity|aggregate|event|value|projection)\s+"
|
|
13
|
+
r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*@\s*(?P<version>\d+)"
|
|
14
|
+
)
|
|
15
|
+
_SOURCE_PATTERN = re.compile(
|
|
16
|
+
r"^\s*(?:left\s+)?(?:from|join)\s+"
|
|
17
|
+
r"(?P<domain>[A-Za-z_][A-Za-z0-9_.-]*)\.(?P<model>[A-Za-z_][A-Za-z0-9_.-]*)"
|
|
18
|
+
r"\s*@\s*(?P<version>\d+)(?:\s+as\s+(?P<alias>[A-Za-z_][A-Za-z0-9_]*))?"
|
|
19
|
+
)
|
|
20
|
+
_DIRECT_MAPPING_PATTERN = re.compile(
|
|
21
|
+
r"^\s*(?:@[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?\s+)*"
|
|
22
|
+
r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*<-\s*"
|
|
23
|
+
r"(?P<alias>[A-Za-z_][A-Za-z0-9_]*)\.(?P<field>[A-Za-z_][A-Za-z0-9_]*)"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_inlay_hints(
|
|
28
|
+
index: LspWorkspaceIndex,
|
|
29
|
+
uri: str,
|
|
30
|
+
range: types.Range,
|
|
31
|
+
) -> list[types.InlayHint] | None:
|
|
32
|
+
source = index.documents.get(uri)
|
|
33
|
+
workspace = index.workspace
|
|
34
|
+
if source is None or workspace is None:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
hints: list[types.InlayHint] = []
|
|
38
|
+
lines = source.text.splitlines()
|
|
39
|
+
current_domain: str | None = None
|
|
40
|
+
in_projection = False
|
|
41
|
+
alias_map: dict[str, tuple[str, str, int]] = {}
|
|
42
|
+
|
|
43
|
+
for line_no, line in enumerate(lines):
|
|
44
|
+
domain_match = _DOMAIN_PATTERN.match(line)
|
|
45
|
+
if domain_match:
|
|
46
|
+
current_domain = domain_match.group("quoted") or domain_match.group("name")
|
|
47
|
+
in_projection = False
|
|
48
|
+
alias_map = {}
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
decl_match = _DECL_PATTERN.match(line)
|
|
52
|
+
if decl_match and current_domain is not None:
|
|
53
|
+
in_projection = decl_match.group("kind") == "projection"
|
|
54
|
+
alias_map = {}
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
source_match = _SOURCE_PATTERN.match(line)
|
|
58
|
+
if source_match and in_projection:
|
|
59
|
+
domain_name = source_match.group("domain")
|
|
60
|
+
model_name = source_match.group("model")
|
|
61
|
+
version = int(source_match.group("version"))
|
|
62
|
+
alias = source_match.group("alias")
|
|
63
|
+
if alias:
|
|
64
|
+
try:
|
|
65
|
+
resolved = resolve_model_ref(workspace.mdl, f"{domain_name}.{model_name}", version)
|
|
66
|
+
alias_map[alias] = (resolved.domain_name, resolved.model_name, resolved.version.version)
|
|
67
|
+
except LookupError:
|
|
68
|
+
pass
|
|
69
|
+
if range.start.line <= line_no <= range.end.line:
|
|
70
|
+
kind_label = _resolve_model_kind(workspace, domain_name, model_name, version)
|
|
71
|
+
if kind_label is not None:
|
|
72
|
+
hints.append(
|
|
73
|
+
types.InlayHint(
|
|
74
|
+
position=types.Position(line=line_no, character=source_match.end("version")),
|
|
75
|
+
label=f" [{kind_label}]",
|
|
76
|
+
kind=types.InlayHintKind.Type,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if not in_projection or current_domain is None:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if range.start.line <= line_no <= range.end.line:
|
|
85
|
+
direct_match = _DIRECT_MAPPING_PATTERN.match(line)
|
|
86
|
+
if direct_match:
|
|
87
|
+
alias = direct_match.group("alias")
|
|
88
|
+
source_field_name = direct_match.group("field")
|
|
89
|
+
target = alias_map.get(alias)
|
|
90
|
+
if target is not None:
|
|
91
|
+
field_type = _resolve_field_type(workspace, *target, source_field_name)
|
|
92
|
+
if field_type is not None:
|
|
93
|
+
hints.append(
|
|
94
|
+
types.InlayHint(
|
|
95
|
+
position=types.Position(line=line_no, character=direct_match.end("name")),
|
|
96
|
+
label=f": {field_type}",
|
|
97
|
+
kind=types.InlayHintKind.Type,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return hints
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_model_kind(workspace, domain_name: str, model_name: str, version: int) -> str | None:
|
|
105
|
+
domain = next((d for d in workspace.mdl.domains if d.name == domain_name), None)
|
|
106
|
+
if domain is None:
|
|
107
|
+
return None
|
|
108
|
+
versions = domain.models.get(model_name, [])
|
|
109
|
+
model_version = next((item for item in versions if item.version == version), None)
|
|
110
|
+
if model_version is not None:
|
|
111
|
+
return model_version.model_kind.value
|
|
112
|
+
versions = domain.projections.get(model_name, [])
|
|
113
|
+
if any(item.version == version for item in versions):
|
|
114
|
+
return "projection"
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_field_type(
|
|
119
|
+
workspace,
|
|
120
|
+
domain_name: str,
|
|
121
|
+
model_name: str,
|
|
122
|
+
version: int,
|
|
123
|
+
field_name: str,
|
|
124
|
+
) -> str | None:
|
|
125
|
+
domain = next((d for d in workspace.mdl.domains if d.name == domain_name), None)
|
|
126
|
+
if domain is None:
|
|
127
|
+
return None
|
|
128
|
+
versions = domain.models.get(model_name, [])
|
|
129
|
+
model_version = next((item for item in versions if item.version == version), None)
|
|
130
|
+
if model_version is not None:
|
|
131
|
+
field = next((f for f in model_version.fields if f.name == field_name), None)
|
|
132
|
+
if field is None:
|
|
133
|
+
return None
|
|
134
|
+
return field.type.kind
|
|
135
|
+
# Source is a projection: follow a direct mapping one level to its backing model field
|
|
136
|
+
proj_versions = domain.projections.get(model_name, [])
|
|
137
|
+
proj_version = next((item for item in proj_versions if item.version == version), None)
|
|
138
|
+
if proj_version is None:
|
|
139
|
+
return None
|
|
140
|
+
proj_field = next((f for f in proj_version.fields if f.name == field_name), None)
|
|
141
|
+
if proj_field is None or proj_field.mapping.kind != "direct":
|
|
142
|
+
return None
|
|
143
|
+
mapping = proj_field.mapping
|
|
144
|
+
for source_ref in [proj_version.source, *proj_version.joins]:
|
|
145
|
+
if source_ref.alias != mapping.source_alias:
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
resolved = resolve_model_ref(workspace.mdl, source_ref.model, source_ref.version)
|
|
149
|
+
except LookupError:
|
|
150
|
+
return None
|
|
151
|
+
return _resolve_field_type(
|
|
152
|
+
workspace,
|
|
153
|
+
resolved.domain_name,
|
|
154
|
+
resolved.model_name,
|
|
155
|
+
resolved.version.version,
|
|
156
|
+
mapping.source_field,
|
|
157
|
+
)
|
|
158
|
+
return None
|