skyforge-lsp 0.1.0__tar.gz

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.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: skyforge-lsp
3
+ Version: 0.1.0
4
+ Summary: Language Server Protocol для языка SkyForge
5
+ Author: SkyForge
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/skytech-alt/SkyForge-Docs
8
+ Project-URL: Repository, https://github.com/skytech-alt/SkyForge-Docs
9
+ Keywords: skyforge,lsp,language-server,ide,editor
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Natural Language :: Russian
12
+ Classifier: Topic :: Software Development :: Interpreters
13
+ Classifier: Topic :: Text Editors :: Integrated Development Environments (IDE)
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: pygls>=2.0.0
18
+ Requires-Dist: skyforge-lang>=1.0.6
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0; extra == "dev"
21
+
22
+ # skyforge-lsp
23
+
24
+ Language Server Protocol для языка [SkyForge](https://github.com/skytech-alt/SkyForge-Docs).
25
+
26
+ ## Возможности
27
+
28
+ - Диагностика — синтаксические ошибки подсвечиваются прямо при наборе
29
+ - Автодополнение — ключевые слова, встроенные функции, символы текущего файла
30
+ - Hover — сигнатура и описание при наведении
31
+ - Переход к определению — F12 на функции, классы, переменные
32
+ - Outline — структура файла в боковой панели (Ctrl+Shift+O)
33
+
34
+ ## Установка
35
+
36
+ pip install skyforge-lsp
37
+
38
+ ## Использование
39
+
40
+ LSP-клиент в редакторе запускает skyforge-lsp --stdio как фоновый процесс.
41
+
42
+ Ручной запуск для отладки:
43
+
44
+ skyforge-lsp --stdio
45
+ skyforge-lsp --tcp 2087
46
+
47
+ ## VS Code
48
+
49
+ Расширение с этим сервером — в репозитории SkyForge-Docs.
50
+
51
+ ## Разработка
52
+
53
+ git clone https://github.com/skytech-alt/SkyForge-Docs
54
+ cd SkyForge-Docs/skyforge-lsp
55
+ pip install -e ".[dev]"
56
+ pytest
57
+
58
+ ## Лицензия
59
+
60
+ MIT
@@ -0,0 +1,39 @@
1
+ # skyforge-lsp
2
+
3
+ Language Server Protocol для языка [SkyForge](https://github.com/skytech-alt/SkyForge-Docs).
4
+
5
+ ## Возможности
6
+
7
+ - Диагностика — синтаксические ошибки подсвечиваются прямо при наборе
8
+ - Автодополнение — ключевые слова, встроенные функции, символы текущего файла
9
+ - Hover — сигнатура и описание при наведении
10
+ - Переход к определению — F12 на функции, классы, переменные
11
+ - Outline — структура файла в боковой панели (Ctrl+Shift+O)
12
+
13
+ ## Установка
14
+
15
+ pip install skyforge-lsp
16
+
17
+ ## Использование
18
+
19
+ LSP-клиент в редакторе запускает skyforge-lsp --stdio как фоновый процесс.
20
+
21
+ Ручной запуск для отладки:
22
+
23
+ skyforge-lsp --stdio
24
+ skyforge-lsp --tcp 2087
25
+
26
+ ## VS Code
27
+
28
+ Расширение с этим сервером — в репозитории SkyForge-Docs.
29
+
30
+ ## Разработка
31
+
32
+ git clone https://github.com/skytech-alt/SkyForge-Docs
33
+ cd SkyForge-Docs/skyforge-lsp
34
+ pip install -e ".[dev]"
35
+ pytest
36
+
37
+ ## Лицензия
38
+
39
+ MIT
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "skyforge-lsp"
7
+ version = "0.1.0"
8
+ description = "Language Server Protocol для языка SkyForge"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "SkyForge" }]
13
+ keywords = ["skyforge", "lsp", "language-server", "ide", "editor"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Natural Language :: Russian",
17
+ "Topic :: Software Development :: Interpreters",
18
+ "Topic :: Text Editors :: Integrated Development Environments (IDE)",
19
+ "License :: OSI Approved :: MIT License",
20
+ ]
21
+
22
+ dependencies = [
23
+ "pygls>=2.0.0",
24
+ "skyforge-lang>=1.0.6",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/skytech-alt/SkyForge-Docs"
29
+ Repository = "https://github.com/skytech-alt/SkyForge-Docs"
30
+
31
+ [project.scripts]
32
+ skyforge-lsp = "skyforge_lsp.__main__:main"
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["pytest>=7.0"]
36
+
37
+ [tool.setuptools.packages.find]
38
+ where = ["src"]
39
+ include = ["skyforge_lsp*"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """SkyForge Language Server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,50 @@
1
+ """Точка входа: skyforge-lsp [--stdio | --tcp PORT] [--version] [--help]"""
2
+
3
+ import sys
4
+ from . import __version__
5
+
6
+
7
+ USAGE = """\
8
+ skyforge-lsp — Language Server Protocol для SkyForge.
9
+
10
+ Использование:
11
+ skyforge-lsp --stdio Запуск через stdio (как ждёт большинство редакторов)
12
+ skyforge-lsp --tcp PORT Запуск через TCP на 127.0.0.1:PORT (для отладки)
13
+ skyforge-lsp --version Показать версию
14
+ skyforge-lsp --help Показать эту справку
15
+ """
16
+
17
+
18
+ def main(argv=None) -> int:
19
+ argv = list(sys.argv[1:] if argv is None else argv)
20
+
21
+ if "--help" in argv or "-h" in argv:
22
+ print(USAGE)
23
+ return 0
24
+
25
+ if "--version" in argv:
26
+ print(f"skyforge-lsp {__version__}")
27
+ return 0
28
+
29
+ from .server import create_server
30
+ server = create_server()
31
+
32
+ if "--tcp" in argv:
33
+ idx = argv.index("--tcp")
34
+ if idx + 1 >= len(argv):
35
+ print("Ошибка: --tcp требует номер порта", file=sys.stderr)
36
+ return 2
37
+ try:
38
+ port = int(argv[idx + 1])
39
+ except ValueError:
40
+ print(f"Ошибка: '{argv[idx + 1]}' не является номером порта", file=sys.stderr)
41
+ return 2
42
+ print(f"skyforge-lsp {__version__} — TCP 127.0.0.1:{port}", flush=True)
43
+ server.start_tcp("127.0.0.1", port)
44
+ else:
45
+ server.start_io()
46
+ return 0
47
+
48
+
49
+ if __name__ == "__main__":
50
+ sys.exit(main())
@@ -0,0 +1,68 @@
1
+ """Кэш открытых .skf с ленивым парсингом."""
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+
6
+ class Document:
7
+ def __init__(self, uri: str, text: str, version: int = 0):
8
+ self.uri = uri
9
+ self.text = text
10
+ self.version = version
11
+ self._ast = self._tokens = self._parse_error = None
12
+ self._parsed_version = -1
13
+
14
+ def invalidate(self) -> None:
15
+ self._ast = self._tokens = self._parse_error = None
16
+ self._parsed_version = -1
17
+
18
+ @property
19
+ def ast(self):
20
+ self._ensure()
21
+ return self._ast
22
+
23
+ @property
24
+ def parse_error(self) -> Optional[Exception]:
25
+ self._ensure()
26
+ return self._parse_error
27
+
28
+ def _ensure(self) -> None:
29
+ if self._parsed_version == self.version:
30
+ return
31
+ try:
32
+ from skyforge.core import Lexer, Parser
33
+ self._tokens = Lexer(self.text).tokenize()
34
+ self._ast = Parser(self._tokens).parse()
35
+ self._parse_error = None
36
+ except Exception as e:
37
+ self._ast = self._tokens = None
38
+ self._parse_error = e
39
+ self._parsed_version = self.version
40
+
41
+
42
+ class DocumentStore:
43
+ def __init__(self):
44
+ self._docs: Dict[str, Document] = {}
45
+
46
+ def open(self, uri: str, text: str, version: int = 0) -> Document:
47
+ d = Document(uri, text, version)
48
+ self._docs[uri] = d
49
+ return d
50
+
51
+ def apply_change(self, uri: str, text: str, version: int) -> Optional[Document]:
52
+ """Применить изменение. В режиме Full text — это полный текст файла."""
53
+ d = self._docs.get(uri)
54
+ if d is None:
55
+ return self.open(uri, text, version)
56
+ d.text = text
57
+ d.version = version
58
+ d.invalidate()
59
+ return d
60
+
61
+ def close(self, uri: str) -> None:
62
+ self._docs.pop(uri, None)
63
+
64
+ def get(self, uri: str) -> Optional[Document]:
65
+ return self._docs.get(uri)
66
+
67
+ def all(self) -> List[Document]:
68
+ return list(self._docs.values())
@@ -0,0 +1 @@
1
+ """LSP-фичи."""
@@ -0,0 +1,76 @@
1
+ """Автодополнение."""
2
+
3
+ from lsprotocol.types import (
4
+ CompletionItem, CompletionItemKind, CompletionList, Position, Range,
5
+ )
6
+ from .. import reference
7
+
8
+
9
+ def _word(text: str, line: int, char: int):
10
+ lines = text.splitlines()
11
+ if line >= len(lines):
12
+ return "", char
13
+ src = lines[line]
14
+ char = min(char, len(src))
15
+ i = char
16
+ while i > 0 and (src[i - 1].isalnum() or src[i - 1] == "_"):
17
+ i -= 1
18
+ return src[i:char], i
19
+
20
+
21
+ def _after_dot(text: str, line: int, char: int) -> bool:
22
+ lines = text.splitlines()
23
+ if line >= len(lines):
24
+ return False
25
+ src = lines[line]
26
+ char = min(char, len(src))
27
+ i = char - 1
28
+ while i >= 0 and src[i] in " \t":
29
+ i -= 1
30
+ return i >= 0 and src[i] == "."
31
+
32
+
33
+ def complete(store, params) -> CompletionList:
34
+ doc = store.get(params.text_document.uri)
35
+ if doc is None:
36
+ return CompletionList(is_incomplete=False, items=[])
37
+ line, char = params.position.line, params.position.character
38
+ _, start = _word(doc.text, line, char)
39
+
40
+ items = []
41
+ if _after_dot(doc.text, line, char):
42
+ for r in reference.methods():
43
+ items.append(CompletionItem(
44
+ label=r.name,
45
+ kind=CompletionItemKind.Method,
46
+ detail=r.signature,
47
+ documentation=r.description or None,
48
+ sort_text="1_" + r.name,
49
+ ))
50
+ else:
51
+ for r in reference.keywords():
52
+ items.append(CompletionItem(
53
+ label=r.name,
54
+ kind=CompletionItemKind.Keyword,
55
+ detail=r.description or r.name,
56
+ sort_text="0_" + r.name,
57
+ ))
58
+ for r in reference.builtins():
59
+ items.append(CompletionItem(
60
+ label=r.name,
61
+ kind=CompletionItemKind.Function,
62
+ detail=r.description or r.name,
63
+ sort_text="2_" + r.name,
64
+ ))
65
+ from ..symbols import extract_symbols
66
+ for s in extract_symbols(doc.ast):
67
+ kind = {
68
+ "function": CompletionItemKind.Function,
69
+ "class": CompletionItemKind.Class,
70
+ "variable": CompletionItemKind.Variable,
71
+ }.get(s.kind, CompletionItemKind.Text)
72
+ items.append(CompletionItem(
73
+ label=s.name, kind=kind, detail=s.signature(),
74
+ sort_text="1_" + s.name,
75
+ ))
76
+ return CompletionList(is_incomplete=False, items=items)
@@ -0,0 +1,98 @@
1
+ """Переход к определению (F12)."""
2
+
3
+ import re
4
+ from typing import List, Optional
5
+ from lsprotocol.types import Location, Position, Range
6
+ from ..symbols import extract_symbols
7
+
8
+
9
+ def _word_at(text: str, line: int, char: int) -> Optional[str]:
10
+ lines = text.splitlines()
11
+ if line < 0 or line >= len(lines):
12
+ return None
13
+ src = lines[line]
14
+ char = min(char, len(src))
15
+ i = char
16
+ while i > 0 and (src[i - 1].isalnum() or src[i - 1] == "_"):
17
+ i -= 1
18
+ j = char
19
+ while j < len(src) and (src[j].isalnum() or src[j] == "_"):
20
+ j += 1
21
+ if i == j:
22
+ return None
23
+ return src[i:j]
24
+
25
+
26
+ def _pos_from_offset(text: str, offset: int):
27
+ line = text.count("\n", 0, offset)
28
+ last_nl = text.rfind("\n", 0, offset)
29
+ col = offset - (last_nl + 1)
30
+ return line, col
31
+
32
+
33
+ def _find_in_text(text: str, word: str) -> List[Location]:
34
+ """Regex-fallback: ищет объявления функции/класса/переменной в тексте.
35
+
36
+ Нужен, если AST не проставил _line/_col (в SkyForge 1.0.6 это часто так).
37
+ """
38
+ out = []
39
+ patterns = [
40
+ # функция имя(...)
41
+ rf"\bфункция\s+({re.escape(word)})\b",
42
+ # функ имя(...)
43
+ rf"\bфунк\s+({re.escape(word)})\b",
44
+ # класс Имя
45
+ rf"\bкласс\s+({re.escape(word)})\b",
46
+ # пусть имя = ...
47
+ rf"\bпусть\s+({re.escape(word)})\b",
48
+ # имя = ... (переприсваивание)
49
+ rf"(?<![\w]){re.escape(word)}(?=\s*=)",
50
+ ]
51
+ seen = set()
52
+ for pat in patterns:
53
+ for m in re.finditer(pat, text):
54
+ start = m.start(1)
55
+ if start in seen:
56
+ continue
57
+ seen.add(start)
58
+ line, col = _pos_from_offset(text, start)
59
+ out.append(Location(
60
+ uri=None, # подставим ниже
61
+ range=Range(
62
+ Position(line, col),
63
+ Position(line, col + len(word)),
64
+ ),
65
+ ))
66
+ return out
67
+
68
+
69
+ def goto_definition(store, params) -> List[Location]:
70
+ uri = params.text_document.uri
71
+ doc = store.get(uri)
72
+ if doc is None:
73
+ return []
74
+
75
+ word = _word_at(doc.text, params.position.line, params.position.character)
76
+ if not word:
77
+ return []
78
+
79
+ out: List[Location] = []
80
+
81
+ # Проход 1: через AST (если он есть и _line/_col корректные)
82
+ for s in extract_symbols(doc.ast):
83
+ if s.name == word:
84
+ out.append(Location(
85
+ uri=uri,
86
+ range=Range(
87
+ Position(s.line, s.col),
88
+ Position(s.end_line, s.end_col),
89
+ ),
90
+ ))
91
+
92
+ # Проход 2: regex по тексту — работает всегда, независимо от AST
93
+ if not out:
94
+ for loc in _find_in_text(doc.text, word):
95
+ loc.uri = uri # type: ignore
96
+ out.append(loc)
97
+
98
+ return out
@@ -0,0 +1,58 @@
1
+ """Синтаксические ошибки → LSP diagnostics."""
2
+
3
+ import re
4
+ from typing import List
5
+ from lsprotocol.types import (
6
+ Diagnostic, DiagnosticSeverity, Position, Range,
7
+ PublishDiagnosticsParams,
8
+ )
9
+
10
+ _POS = re.compile(r"на (\d+):(\d+)\s*$")
11
+
12
+
13
+ def build_diagnostics(doc) -> List[Diagnostic]:
14
+ if doc.parse_error is None:
15
+ return []
16
+ msg = str(doc.parse_error)
17
+ m = _POS.search(msg)
18
+ line, col = (int(m.group(1)), int(m.group(2))) if m else (1, 1)
19
+ clean = _POS.sub("", msg).strip() or "Синтаксическая ошибка"
20
+ l, c = max(0, line - 1), max(0, col - 1)
21
+ return [Diagnostic(
22
+ range=Range(Position(l, c), Position(l, c + 1)),
23
+ message=clean,
24
+ severity=DiagnosticSeverity.Error,
25
+ source="skyforge",
26
+ )]
27
+
28
+
29
+ def _send(server, params: PublishDiagnosticsParams) -> None:
30
+ """Отправить диагностику. Поддерживает pygls 1.x и 2.x."""
31
+ # pygls 2.x: у сервера есть .protocol с низкоуровневым notify
32
+ if hasattr(server, "protocol") and hasattr(server.protocol, "notify"):
33
+ server.protocol.notify("textDocument/publishDiagnostics", params)
34
+ return
35
+ # pygls 1.x: удобный метод publish_diagnostics
36
+ if hasattr(server, "publish_diagnostics"):
37
+ server.publish_diagnostics(
38
+ params.uri, params.diagnostics, version=params.version,
39
+ )
40
+ return
41
+ raise RuntimeError("Не могу отправить диагностику: сервер не поддерживает notify")
42
+
43
+
44
+ def publish(server, store, uri: str) -> None:
45
+ doc = store.get(uri)
46
+ if doc is None:
47
+ return
48
+ params = PublishDiagnosticsParams(
49
+ uri=uri,
50
+ diagnostics=build_diagnostics(doc),
51
+ version=doc.version,
52
+ )
53
+ _send(server, params)
54
+
55
+
56
+ def clear(server, uri: str) -> None:
57
+ params = PublishDiagnosticsParams(uri=uri, diagnostics=[], version=None)
58
+ _send(server, params)
@@ -0,0 +1,28 @@
1
+ """Outline документа."""
2
+
3
+ from typing import List
4
+ from lsprotocol.types import DocumentSymbol, SymbolKind, Range, Position
5
+ from ..symbols import extract_symbols
6
+
7
+ _KIND = {
8
+ "function": SymbolKind.Function,
9
+ "class": SymbolKind.Class,
10
+ "variable": SymbolKind.Variable,
11
+ }
12
+
13
+
14
+ def symbols(store, params) -> List[DocumentSymbol]:
15
+ doc = store.get(params.text_document.uri)
16
+ if doc is None:
17
+ return []
18
+ out = []
19
+ for s in extract_symbols(doc.ast):
20
+ rng = Range(Position(s.line, s.col), Position(s.end_line, s.end_col))
21
+ out.append(DocumentSymbol(
22
+ name=s.name,
23
+ kind=_KIND.get(s.kind, SymbolKind.Variable),
24
+ range=rng,
25
+ selection_range=rng,
26
+ detail=s.signature(),
27
+ ))
28
+ return out
@@ -0,0 +1,54 @@
1
+ """Hover-документация."""
2
+
3
+ from typing import Optional
4
+ from lsprotocol.types import Hover, MarkupContent, MarkupKind, Position, Range
5
+ from .. import reference
6
+
7
+
8
+ def _word(text: str, line: int, char: int):
9
+ lines = text.splitlines()
10
+ if line >= len(lines):
11
+ return None, 0, 0
12
+ src = lines[line]
13
+ char = min(char, len(src))
14
+ i = char
15
+ while i > 0 and (src[i - 1].isalnum() or src[i - 1] == "_"):
16
+ i -= 1
17
+ j = char
18
+ while j < len(src) and (src[j].isalnum() or src[j] == "_"):
19
+ j += 1
20
+ return (src[i:j] if i != j else None), i, j
21
+
22
+
23
+ def hover(store, params) -> Optional[Hover]:
24
+ doc = store.get(params.text_document.uri)
25
+ if doc is None:
26
+ return None
27
+ word, start, end = _word(doc.text, params.position.line, params.position.character)
28
+ if not word:
29
+ return None
30
+
31
+ rng = Range(
32
+ Position(params.position.line, start),
33
+ Position(params.position.line, end),
34
+ )
35
+
36
+ ref = reference.by_name().get(word)
37
+ if ref:
38
+ parts = [f"**{ref.name}**", "", ref.description or "_нет описания_"]
39
+ return Hover(
40
+ contents=MarkupContent(kind=MarkupKind.Markdown, value="\n".join(parts)),
41
+ range=rng,
42
+ )
43
+
44
+ from ..symbols import extract_symbols
45
+ for s in extract_symbols(doc.ast):
46
+ if s.name == word:
47
+ return Hover(
48
+ contents=MarkupContent(
49
+ kind=MarkupKind.Markdown,
50
+ value=f"**{s.signature()}**\n\nОпределено в этом файле",
51
+ ),
52
+ range=rng,
53
+ )
54
+ return None