legesher-language-server 0.2.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.
@@ -0,0 +1,50 @@
1
+ """Legesher Language Server.
2
+
3
+ Language Server Protocol (LSP) implementation that gives IDEs multilingual
4
+ Python awareness — hover tooltips, completions, diagnostics, and syntax
5
+ highlighting that follow the user's configured language pack.
6
+
7
+ Launched by IDEs over stdio via the ``legesher-lsp`` console entry point;
8
+ not typically imported directly. Built on pygls 2.0 and supports Python
9
+ 3.10 and newer.
10
+
11
+ Capabilities:
12
+ Hover: Translated docstrings, keyword documentation, and cultural
13
+ comments displayed on hover.
14
+ Completion: Localized keyword and builtin completion with fuzzy
15
+ matching against the user's target language pack.
16
+ Diagnostics: Translated error messages with 41 templates across 17
17
+ error types, mapped from Python's syntax/runtime errors.
18
+ Semantic tokens: Syntax highlighting for translated keywords so
19
+ Spanish ``definir`` renders with the same color as English ``def``.
20
+
21
+ Configuration (via environment variables):
22
+ LEGESHER_SOURCE_LANG: Source language code (default: ``"en"``).
23
+ LEGESHER_TARGET_LANG: Target language code (default: ``"en"``).
24
+ LEGESHER_PYTHON_VERSION: Python version; auto-detected if unset.
25
+ LEGESHER_CACHE_SIZE: LRU cache size for pack lookups (default: 1000).
26
+ LEGESHER_CACHE_TTL: Cache TTL in seconds (default: 3600).
27
+ LEGESHER_LOG_LEVEL: Log level (default: ``"INFO"``).
28
+
29
+ Example:
30
+ Start the server directly from a shell::
31
+
32
+ $ LEGESHER_TARGET_LANG=es legesher-lsp
33
+
34
+ Then point your IDE at the ``legesher-lsp`` binary as an LSP backend
35
+ for Python.
36
+ """
37
+
38
+ from importlib.metadata import PackageNotFoundError
39
+ from importlib.metadata import version as _distribution_version
40
+
41
+ try:
42
+ #: Read from the installed distribution's metadata rather than hand-kept
43
+ #: here, so `--version`, the `initialize` handshake and the release train
44
+ #: all report the version in `pyproject.toml`, which is the wheel's own.
45
+ #: Three literals used to disagree (0.1.0, 0.1.2 and 0.1.3) (CORE-2203).
46
+ __version__ = _distribution_version("legesher-language-server")
47
+ except PackageNotFoundError: # pragma: no cover (running from an uninstalled tree)
48
+ __version__ = "0.0.0.dev0"
49
+
50
+ __all__ = ["__version__"]
@@ -0,0 +1,140 @@
1
+ """CLI entry point for Legesher Language Server."""
2
+
3
+ import argparse
4
+ import logging
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .config import LanguageServerConfig
9
+ from .logging_setup import LspLogHandler, configure_logging
10
+ from .server import create_server
11
+
12
+
13
+ def setup_logging(log_level: str) -> LspLogHandler:
14
+ """Configure logging for the language server.
15
+
16
+ Logs go to the client through ``window/logMessage`` with the level they
17
+ were written at, and to stderr only when no connection can carry them.
18
+ Sending them to stdout is not an option, because under stdio transport
19
+ that is the JSON-RPC channel itself, and sending them all to stderr is what
20
+ made a healthy startup look like forty errors (CORE-1979).
21
+
22
+ Args:
23
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
24
+
25
+ Returns:
26
+ The installed handler, which the server is bound to once it exists.
27
+ """
28
+ return configure_logging(log_level)
29
+
30
+
31
+ def main() -> None:
32
+ """Main entry point for language server CLI."""
33
+ parser = argparse.ArgumentParser(
34
+ description="Legesher Language Server - LSP for multilingual Python",
35
+ formatter_class=argparse.RawDescriptionHelpFormatter,
36
+ epilog="""
37
+ Examples:
38
+ # Start server with Spanish target language
39
+ legesher-lsp --target-lang es
40
+
41
+ # Start server with French, Python 3.11
42
+ legesher-lsp --target-lang fr --python-version 3.11
43
+
44
+ # Start server with debug logging
45
+ legesher-lsp --target-lang es --log-level DEBUG
46
+
47
+ Environment Variables:
48
+ LEGESHER_SOURCE_LANG Source code language (default: en)
49
+ LEGESHER_TARGET_LANG Target display language (default: es)
50
+ LEGESHER_PYTHON_VERSION Python version (auto-detected if not set)
51
+ LEGESHER_LOG_LEVEL Logging level (default: INFO)
52
+ """,
53
+ )
54
+
55
+ parser.add_argument(
56
+ "--source-lang", default=None, help="Source code language (ISO 639-1 code, default: en)"
57
+ )
58
+ parser.add_argument(
59
+ "--target-lang", default=None, help="Target display language (ISO 639-1 code, default: es)"
60
+ )
61
+ parser.add_argument(
62
+ "--python-version",
63
+ default=None,
64
+ help="Python version (e.g., '3.10', '3.11'). Auto-detected if not specified.",
65
+ )
66
+ parser.add_argument(
67
+ "--log-level",
68
+ default=None,
69
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
70
+ help="Logging level (default: INFO)",
71
+ )
72
+ parser.add_argument(
73
+ "--stdio",
74
+ action="store_true",
75
+ help="Use stdio for LSP communication (default mode for IDE integration)",
76
+ )
77
+ parser.add_argument("--version", action="version", version=f"legesher-lsp {__version__}")
78
+
79
+ args = parser.parse_args()
80
+
81
+ # Load config from environment first
82
+ config = LanguageServerConfig.from_env()
83
+
84
+ # Override with CLI arguments if provided
85
+ if args.source_lang:
86
+ config.source_language = args.source_lang
87
+ if args.target_lang:
88
+ # Seeds file_language and working_language too: the flag predates the
89
+ # role split and stands for both (CORE-1760).
90
+ config.apply_legacy_target_language(args.target_lang)
91
+ if args.python_version:
92
+ config.python_version = args.python_version
93
+ if args.log_level:
94
+ config.log_level = args.log_level
95
+
96
+ # Validate language codes
97
+ if not config.validate_languages():
98
+ print(
99
+ f"Error: Invalid language codes. "
100
+ f"Source: '{config.source_language}', Target: '{config.target_language}'. "
101
+ f"Must be 2-letter ISO 639-1 codes.",
102
+ file=sys.stderr,
103
+ )
104
+ sys.exit(1)
105
+
106
+ # Setup logging
107
+ handler = setup_logging(config.log_level)
108
+
109
+ # Named rather than ``__name__``: launched as ``python -m`` this module is
110
+ # ``__main__``, which sits outside the package logger the trace setting
111
+ # raises and lands in the output channel under a name that says nothing.
112
+ logger = logging.getLogger("legesher_language_server.__main__")
113
+ logger.info(f"Starting Legesher Language Server v{__version__}")
114
+ # Labelled "startup" on purpose. These are the languages the process was
115
+ # launched with, and a client's initialize options can still replace them;
116
+ # the server then reports what it resolved. Two unlabelled reports are what
117
+ # left a log reader guessing which language was actually in effect.
118
+ logger.info(
119
+ f"Startup configuration: source={config.source_language}, "
120
+ f"target={config.target_language}, python={config.detect_python_version()}"
121
+ )
122
+
123
+ # Create and start server
124
+ try:
125
+ server = create_server(config)
126
+ # Nothing logged up to here could have reached the client: the
127
+ # transport does not exist until start_io. Binding is what lets those
128
+ # held records be replayed with their real level once it does.
129
+ handler.bind(server)
130
+ server.start_io()
131
+ except KeyboardInterrupt:
132
+ logger.info("Server interrupted by user")
133
+ sys.exit(0)
134
+ except Exception as e:
135
+ logger.error(f"Server error: {e}", exc_info=True)
136
+ sys.exit(1)
137
+
138
+
139
+ if __name__ == "__main__":
140
+ main()
@@ -0,0 +1,333 @@
1
+ """Configuration management for Legesher Language Server.
2
+
3
+ This module provides configuration loading from environment variables,
4
+ command-line arguments, and defaults for the LSP server.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import re
10
+ import sys
11
+
12
+ from pydantic import BaseModel, Field, field_validator, model_validator
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # Settings renamed in CORE-1890, mapped to what replaced them. Deliberately not
18
+ # honoured — the rename ships without aliases because nothing is published and a
19
+ # compatibility layer would be debt with no debtor. Kept at module level rather
20
+ # than on the model: pydantic reinterprets underscore-prefixed class attributes
21
+ # as private attributes, which are not plain dicts.
22
+ RETIRED_ENV_VARS = {
23
+ "LEGESHER_PYLANCE_ENABLED": "LEGESHER_ENHANCED_INTELLIGENCE_ENABLED",
24
+ "LEGESHER_PYLANCE_PATH": "LEGESHER_LANGUAGE_SERVER_PATH",
25
+ }
26
+
27
+
28
+ class LanguageServerConfig(BaseModel):
29
+ """Configuration for Legesher Language Server.
30
+
31
+ Attributes:
32
+ source_language: Canonical language the project's code is stored in
33
+ (default: 'en' for English)
34
+ target_language: Deprecated single slot for "the other language"; kept
35
+ in lockstep with file_language so code written against it keeps
36
+ working (CORE-1760)
37
+ file_language: Language a file is written in unless the client says
38
+ otherwise. Each document's own language arrives in a
39
+ ``legesher/documentLanguage`` notification; this is the fallback
40
+ for any document the client has not named (CORE-1976)
41
+ working_language: Language the user reads and writes in right now —
42
+ what hover and other render surfaces should target
43
+ python_version: Python version for language pack selection (auto-detected if None)
44
+ cache_size: Maximum number of cached translations (LRU cache)
45
+ cache_ttl: Time-to-live for cache entries in seconds
46
+ enhanced_intelligence_enabled: Whether to enable enhanced Python intelligence
47
+ language_server_path: Path to the Python language server executable
48
+ log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
49
+ """
50
+
51
+ # Language configuration (CORE-1760: the roles are named — canonical /
52
+ # file / working — because one "target" slot cannot say both what a file
53
+ # is written in and what the user wants to read in.)
54
+ source_language: str = Field(default="en", description="Source code language (ISO 639-1 code)")
55
+ target_language: str = Field(
56
+ default="en",
57
+ description="Deprecated alias kept in lockstep with file_language (ISO 639-1 code)",
58
+ )
59
+ file_language: str = Field(
60
+ default="en",
61
+ description="Language of any document the client has not named (ISO 639-1 code)",
62
+ )
63
+ working_language: str = Field(
64
+ default="en",
65
+ description="Language the user reads and writes in right now (ISO 639-1 code)",
66
+ )
67
+
68
+ # Python configuration
69
+ python_version: str | None = Field(
70
+ default=None, description="Python version (e.g., '3.10', '3.11'). Auto-detected if None."
71
+ )
72
+
73
+ # Performance configuration
74
+ cache_size: int = Field(default=1000, ge=1, description="Maximum number of cached translations")
75
+ cache_ttl: int = Field(
76
+ default=3600, ge=0, description="Cache time-to-live in seconds (0 = no expiration)"
77
+ )
78
+
79
+ # Enhanced Python intelligence (CORE-360, CORE-1890)
80
+ enhanced_intelligence_enabled: bool = Field(
81
+ default=True, description="Enable enhanced Python intelligence"
82
+ )
83
+ language_server_path: str | None = Field(
84
+ default=None, description="Path to the Python language server (auto-detected if None)"
85
+ )
86
+
87
+ # Logging
88
+ log_level: str = Field(default="INFO", description="Logging level")
89
+
90
+ @field_validator("source_language", "target_language", "file_language", "working_language")
91
+ @classmethod
92
+ def validate_language_code(cls, v: str) -> str:
93
+ """Validate language code format (ISO 639-1/639-2).
94
+
95
+ Prevents cache pollution and path traversal attempts (CORE-446 security).
96
+
97
+ Args:
98
+ v: Language code to validate
99
+
100
+ Returns:
101
+ Validated and normalized language code (lowercase)
102
+
103
+ Raises:
104
+ ValueError: If language code format is invalid
105
+ """
106
+ if not v:
107
+ raise ValueError("Language code cannot be empty")
108
+
109
+ # Check length (2-3 chars for ISO 639-1/639-2)
110
+ if len(v) < 2 or len(v) > 3:
111
+ raise ValueError(f"Language code must be 2-3 characters (ISO 639): '{v}' has {len(v)}")
112
+
113
+ # Must be alphabetic only (no path separators, special chars)
114
+ if not v.isalpha():
115
+ raise ValueError(f"Language code must be alphabetic: '{v}' contains invalid characters")
116
+
117
+ # Convert to lowercase for consistency
118
+ return v.lower()
119
+
120
+ @model_validator(mode="after")
121
+ def keep_file_language_in_lockstep(self) -> "LanguageServerConfig":
122
+ """Hold the deprecated slot and the file-language role together from construction.
123
+
124
+ The entry points keep them in lockstep, but a model built directly with
125
+ only ``target_language`` left ``file_language`` at "en". That was
126
+ harmless while providers read the deprecated slot. They now serve each
127
+ document in its own language and fall back to ``file_language``
128
+ (CORE-1976), so a configuration written the old way would be served in
129
+ English with nothing to say so. Whichever of the two is given seeds
130
+ the other; when both are given, both are kept as given.
131
+ """
132
+ given = self.model_fields_set
133
+ if "target_language" in given and "file_language" not in given:
134
+ self.file_language = self.target_language
135
+ elif "file_language" in given and "target_language" not in given:
136
+ self.target_language = self.file_language
137
+ return self
138
+
139
+ @field_validator("python_version")
140
+ @classmethod
141
+ def validate_python_version(cls, v: str | None) -> str | None:
142
+ """Validate Python version format.
143
+
144
+ Args:
145
+ v: Python version string or None
146
+
147
+ Returns:
148
+ Validated version string or None
149
+
150
+ Raises:
151
+ ValueError: If version format is invalid
152
+ """
153
+ if v is None:
154
+ return v
155
+
156
+ # Must match X.Y or X.Y.Z format
157
+ if not re.match(r"^\d+\.\d+(\.\d+)?$", v):
158
+ raise ValueError(f"Invalid Python version format: '{v}' (expected X.Y or X.Y.Z)")
159
+
160
+ # Reasonable range check
161
+ parts = v.split(".")
162
+ major, minor = int(parts[0]), int(parts[1])
163
+ if major != 3 or minor < 8 or minor > 20:
164
+ raise ValueError(f"Python version out of supported range: {v}")
165
+
166
+ return v
167
+
168
+ @field_validator("cache_size")
169
+ @classmethod
170
+ def validate_cache_size(cls, v: int) -> int:
171
+ """Validate cache size to prevent resource exhaustion.
172
+
173
+ Args:
174
+ v: Cache size value
175
+
176
+ Returns:
177
+ Validated cache size
178
+
179
+ Raises:
180
+ ValueError: If cache size is out of acceptable range
181
+ """
182
+ if v < 1 or v > 10000:
183
+ logger.warning(f"Cache size out of range (1-10000): {v}, using 1000")
184
+ return 1000
185
+ return v
186
+
187
+ @field_validator("cache_ttl")
188
+ @classmethod
189
+ def validate_cache_ttl(cls, v: int) -> int:
190
+ """Validate cache TTL to prevent resource exhaustion.
191
+
192
+ Args:
193
+ v: Cache TTL in seconds
194
+
195
+ Returns:
196
+ Validated TTL
197
+
198
+ Raises:
199
+ ValueError: If TTL is out of acceptable range
200
+ """
201
+ if v < 0 or v > 86400: # Max 24 hours
202
+ logger.warning(f"Cache TTL out of range (0-86400): {v}, using 3600")
203
+ return 3600
204
+ return v
205
+
206
+ @classmethod
207
+ def _warn_about_retired_variables(cls) -> None:
208
+ """Say when a retired setting is present but no longer read.
209
+
210
+ Dropping the old names is the right call, but dropping them *silently*
211
+ is not: someone who set ``LEGESHER_PYLANCE_ENABLED=false`` would find
212
+ the feature switched back on, because the replacement defaults to true.
213
+ The value is not honoured — that would reintroduce the alias we
214
+ deliberately avoided — but its presence is announced, so the change is
215
+ something a user can see rather than something they discover.
216
+ """
217
+ for retired, replacement in RETIRED_ENV_VARS.items():
218
+ if retired in os.environ:
219
+ logger.warning(
220
+ f"{retired} is set but no longer read; it was renamed to "
221
+ f"{replacement} (CORE-1890). The old value is being ignored — "
222
+ "set the new variable to keep your configuration."
223
+ )
224
+
225
+ @classmethod
226
+ def from_env(cls) -> "LanguageServerConfig":
227
+ """Load configuration from environment variables with validation.
228
+
229
+ Environment variables:
230
+ LEGESHER_SOURCE_LANG: Source language code
231
+ LEGESHER_TARGET_LANG: Target language code
232
+ LEGESHER_PYTHON_VERSION: Python version
233
+ LEGESHER_CACHE_SIZE: Cache size
234
+ LEGESHER_CACHE_TTL: Cache TTL in seconds
235
+ LEGESHER_ENHANCED_INTELLIGENCE_ENABLED: Enable enhanced Python intelligence (true/false)
236
+ LEGESHER_LANGUAGE_SERVER_PATH: Path to the Python language server
237
+ LEGESHER_LOG_LEVEL: Logging level
238
+
239
+ Returns:
240
+ Configured LanguageServerConfig instance
241
+
242
+ Note:
243
+ Falls back to safe defaults if environment variables contain invalid values.
244
+ Validation errors are logged as warnings (CORE-446 security).
245
+ """
246
+ cls._warn_about_retired_variables()
247
+
248
+ try:
249
+ # Parse cache values with error handling
250
+ try:
251
+ cache_size = int(os.getenv("LEGESHER_CACHE_SIZE", "1000"))
252
+ except ValueError:
253
+ logger.warning("Invalid LEGESHER_CACHE_SIZE, using default 1000")
254
+ cache_size = 1000
255
+
256
+ try:
257
+ cache_ttl = int(os.getenv("LEGESHER_CACHE_TTL", "3600"))
258
+ except ValueError:
259
+ logger.warning("Invalid LEGESHER_CACHE_TTL, using default 3600")
260
+ cache_ttl = 3600
261
+
262
+ # LEGESHER_TARGET_LANG predates the role split and stands for all
263
+ # three of the slots it used to mean at once (CORE-1760); passing
264
+ # it once would leave file_language and working_language at "en"
265
+ # while the file is parsed as something else.
266
+ target_lang = os.getenv("LEGESHER_TARGET_LANG", "en")
267
+
268
+ return cls(
269
+ source_language=os.getenv("LEGESHER_SOURCE_LANG", "en"),
270
+ target_language=target_lang,
271
+ file_language=target_lang,
272
+ working_language=target_lang,
273
+ python_version=os.getenv("LEGESHER_PYTHON_VERSION"),
274
+ cache_size=cache_size,
275
+ cache_ttl=cache_ttl,
276
+ enhanced_intelligence_enabled=os.getenv(
277
+ "LEGESHER_ENHANCED_INTELLIGENCE_ENABLED", "true"
278
+ ).lower()
279
+ == "true",
280
+ language_server_path=os.getenv("LEGESHER_LANGUAGE_SERVER_PATH"),
281
+ log_level=os.getenv("LEGESHER_LOG_LEVEL", "INFO"),
282
+ )
283
+ except ValueError as e:
284
+ logger.error(f"Invalid configuration: {e}")
285
+ logger.info("Using safe default configuration")
286
+ # Fall back to safe defaults rather than crash
287
+ return cls()
288
+
289
+ def apply_legacy_target_language(self, code: str) -> None:
290
+ """Set the deprecated target slot and the roles it used to stand for.
291
+
292
+ ``target_language`` predates the role split and always meant the file
293
+ and working languages at once (CORE-1760). Every entry point that
294
+ still speaks the old vocabulary — the ``--target-lang`` flag,
295
+ ``LEGESHER_TARGET_LANG``, a client sending only ``targetLanguage`` —
296
+ therefore has to seed all three, or the roles this model advertises as
297
+ being in lockstep silently disagree: the file would be parsed as the
298
+ chosen language while ``file_language`` still read ``en``.
299
+
300
+ Args:
301
+ code: Validated ISO 639-1 language code.
302
+ """
303
+ self.target_language = code
304
+ self.file_language = code
305
+ self.working_language = code
306
+
307
+ def detect_python_version(self) -> str:
308
+ """Detect or return configured Python version.
309
+
310
+ Returns:
311
+ Python version string (e.g., '3.10', '3.11')
312
+ """
313
+ if self.python_version:
314
+ return self.python_version
315
+
316
+ # Use runtime version
317
+ return f"{sys.version_info.major}.{sys.version_info.minor}"
318
+
319
+ def validate_languages(self) -> bool:
320
+ """Validate that language codes are valid ISO 639-1 codes.
321
+
322
+ Returns:
323
+ True if every language role holds a valid 2-letter code
324
+ """
325
+ return all(
326
+ len(code) == 2 and code.isalpha()
327
+ for code in (
328
+ self.source_language,
329
+ self.target_language,
330
+ self.file_language,
331
+ self.working_language,
332
+ )
333
+ )