diff --git a/language_server/server/__init__.py b/language_server/server/__init__.py index d07c6fd..f9a86d8 100644 --- a/language_server/server/__init__.py +++ b/language_server/server/__init__.py @@ -25,6 +25,7 @@ normalize_string, change_directory, local_import_path, + required_field ) from beet.contrib.load import load @@ -33,6 +34,7 @@ from mecha import AstNode, CompilationUnit, Mecha, DiagnosticErrorSummary from pygls.server import LanguageServer from pygls.workspace import TextDocument +from lsprotocol import types as lsp import os from pathlib import Path @@ -80,11 +82,18 @@ def require(self, *args: GenericPlugin[Context] | str): # We use this shadow of context in order to route calls to `ctx` # to our own methods, this allows us to bypass side effects without # having to break plugins -class ContextShadow(Context): +@dataclass(frozen=True) +class LanguageServerContext(Context): + ls: "MechaLanguageServer" = required_field() + def require(self, *args: PluginSpec): """Execute the specified plugin.""" - self.inject(PipelineShadow).require(*args) - + for arg in args: + try: + self.inject(PipelineShadow).require(arg) + except PluginError as exc: + self.ls.show_message(f"Failed to load plugin: {arg}\n{exc}", lsp.MessageType.Error) + @contextmanager def activate(self): """Push the context directory to sys.path and handle cleanup to allow module reloading.""" @@ -115,7 +124,7 @@ def bootstrap(self, ctx: Context): # This stripped down version of build only handles loading the plugins from config # all other operations are gone such as linking @contextmanager - def build(self) -> Iterator[Context]: + def build(self, ls: "MechaLanguageServer") -> Iterator[LanguageServerContext]: """Create the context, run the pipeline, and return the context.""" with ExitStack() as stack: name = self.config.name or self.project.directory.stem @@ -124,7 +133,8 @@ def build(self) -> Iterator[Context]: tmpdir = None cache = self.project.cache - ctx = ContextShadow( + ctx = LanguageServerContext( + ls=ls, project_id=self.config.id or normalize_string(name), project_name=name, project_description=self.config.description, @@ -171,110 +181,130 @@ def build(self) -> Iterator[Context]: yield ctx -def load_registry(minecraft_version: str): - if len(minecraft_version) <= 0: - minecraft_version = LATEST_MINECRAFT_VERSION - - cache_dir = Path("./.mls_cache") - if not cache_dir.exists(): - os.mkdir(cache_dir) - - if not (cache_dir / "registries").exists(): - os.mkdir(cache_dir / "registries") - - file_path = cache_dir / "registries" / (minecraft_version + ".json") - - logging.debug(minecraft_version) - - if not file_path.exists(): - request.urlretrieve(f"https://raw.githubusercontent.com/misode/mcmeta/refs/tags/{minecraft_version}-summary/registries/data.min.json", file_path) - - with open(file_path) as file: - registries = json.loads(file.read()) - for k in registries: - GAME_REGISTRIES[k] = registries[k] -def create_context(config: ProjectConfig, config_path: Path) -> Context: - project = Project(config, None, config_path) - with ProjectBuilderShadow(project, root=True).build() as ctx: - mc = ctx.inject(Mecha) - - logging.debug(f"Downloading game registries") +class MechaLanguageServer(LanguageServer): + instances: dict[Path, Context] = dict() + _sites: list[str] = [] - logging.debug(f"Mecha created for {config_path} successfully") - for pack in ctx.packs: - for provider in mc.providers: - for file_instance, compilation_unit in provider(pack, mc.match): - mc.database[file_instance] = compilation_unit - mc.database.enqueue(file_instance) + def set_sites(self, sites: list[str]): + self._sites = sites - for location, file in pack.all(): - try: - path = os.path.normpath(file.ensure_source_path()) - path = os.path.normcase(path) - PATH_TO_RESOURCE[str(path)] = (location, file) - except: - continue + def __init__(self, *args): + super().__init__(*args) + self.instances = {} + + def load_registry(self, minecraft_version: str): + """Load the game registry from Misode's mcmeta repository""" + global GAME_REGISTRIES - return ctx - return None + if len(minecraft_version) <= 0: + minecraft_version = LATEST_MINECRAFT_VERSION + cache_dir = Path("./.mls_cache") + if not cache_dir.exists(): + os.mkdir(cache_dir) -def create_instance( - config_path: Path, sites: list[str] -) -> Context | None: - config = load_config(config_path) - logging.debug(config) - # Ensure that we aren't loading in all project files - config.output = None - - config.pipeline = list(filter(lambda p: isinstance(p, str), config.pipeline)) + if not (cache_dir / "registries").exists(): + os.mkdir(cache_dir / "registries") + + file_path = cache_dir / "registries" / (minecraft_version + ".json") + + logging.debug(minecraft_version) + + if not file_path.exists(): + try: + request.urlretrieve(f"https://raw.githubusercontent.com/misode/mcmeta/refs/tags/{minecraft_version}-summary/registries/data.min.json", file_path) + except Exception as exc: + self.show_message(f"Failed to download registry for version {minecraft_version}, completions will be disabled\n{exc}", lsp.MessageType.Error) + + return + + with open(file_path) as file: + try: + registries = json.loads(file.read()) + for k in registries: + GAME_REGISTRIES[k] = registries[k] + + except json.JSONDecodeError as exc: + self.show_message(f"Failed to parse registry for version {minecraft_version}, completions will be disabled\n{exc}", lsp.MessageType.Error) + os.remove(file_path) + + except Exception as exc: + self.show_message(f"An unhandled exception occured loading registry for version {minecraft_version}\n{exc}", lsp.MessageType.Error) + os.remove(file_path) + + + def create_context(self, config: ProjectConfig, config_path: Path) -> Context: + """Attempt to configure the project's context and run necessary plugins""" + project = Project(config, None, config_path) + with ProjectBuilderShadow(project, root=True).build(self) as ctx: + mc = ctx.inject(Mecha) + + logging.debug(f"Mecha created for {config_path} successfully") + + for pack in ctx.packs: + # Load all files into the compilation database + for provider in mc.providers: + for file_instance, compilation_unit in provider(pack, mc.match): + mc.database[file_instance] = compilation_unit + mc.database.enqueue(file_instance) + + # Build a map of file path to resource location + for location, file in pack.all(): + try: + path = os.path.normpath(file.ensure_source_path()) + path = os.path.normcase(path) + PATH_TO_RESOURCE[str(path)] = (location, file) + except: + continue + + return ctx + return None - og_cwd = os.getcwd() - og_sys_path = sys.path - og_modules = sys.modules - - sys.path = [*sites, str(config_path.parent), *og_sys_path] - - os.chdir(config_path.parent) + + def create_instance( + self, config_path: Path + ) -> Context | None: + config = load_config(config_path) + logging.debug(config) + # Ensure that we aren't loading in all project files + config.output = None + config.pipeline = list(filter(lambda p: isinstance(p, str), config.pipeline)) - instance = None + og_cwd = os.getcwd() + og_sys_path = sys.path + og_modules = sys.modules - try: - instance = create_context(config, config_path) + sys.path = [*self._sites, str(config_path.parent), *og_sys_path] - except PluginError as plugin_error: - logging.error(plugin_error.__cause__) - raise plugin_error.__cause__ - except DiagnosticErrorSummary as summary: - logging.error("Errors found in the following:") - for diag in summary.diagnostics.exceptions: - logging.error("\t" + str(diag.file.source_path if diag.file is not None else "")) + os.chdir(config_path.parent) - except Exception as e: - logging.error(f"Error occured while running beet: {type(e)} {e}") - os.chdir(og_cwd) - sys.path = og_sys_path - sys.modules = og_modules + instance = None - load_registry(config.minecraft) + try: + instance = self.create_context(config, config_path) - return instance + except PluginError as plugin_error: + logging.error(plugin_error.__cause__) + raise plugin_error.__cause__ + except DiagnosticErrorSummary as summary: + logging.error("Errors found in the following:") + for diag in summary.diagnostics.exceptions: + logging.error("\t" + str(diag.file.source_path if diag.file is not None else "")) + except Exception as e: + logging.error(f"Error occured while running beet: {type(e)} {e}") -class MechaLanguageServer(LanguageServer): - instances: dict[Path, Context] = dict() - _sites: list[str] = [] + os.chdir(og_cwd) + sys.path = og_sys_path + sys.modules = og_modules - def set_sites(self, sites: list[str]): - self._sites = sites + self.load_registry(config.minecraft) - def __init__(self, *args): - super().__init__(*args) - self.instances = {} + return instance def setup_workspaces(self): config_paths: list[Path] = [] @@ -288,7 +318,7 @@ def setup_workspaces(self): logging.debug(config_path) self.instances = { # type: ignore - c.parent: create_instance(c, self._sites) for c in config_paths + c.parent: self.create_instance(c) for c in config_paths } # logging.debug(self.mecha_instances) @@ -304,7 +334,7 @@ def uri_to_path(self, uri: str): def get_instance(self, config_path: Path): if config_path not in self.instances or self.instances[config_path] is None: - instance = create_instance(config_path, self._sites) + instance = self.create_instance(config_path, self._sites) if instance is not None: self.instances[config_path] = instance