diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fc0f42cf..67f38ffe 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -115,6 +115,10 @@ jobs: with: distribution: 'temurin' java-version: '17' + - name: Setup .NET SDK (for F# and C# languages) + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' - name: Install Terraform uses: hashicorp/setup-terraform@v3 with: diff --git a/.gitignore b/.gitignore index 78b8833a..ec2ca442 100644 --- a/.gitignore +++ b/.gitignore @@ -251,4 +251,7 @@ test/resources/repos/scala/.bloop/ stack.yaml.lock dist-newstyle/ cabal.project.local* -.ghc.environment.* \ No newline at end of file +.ghc.environment.* + +zz-misc/ +vue-implementation/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d8137826..d8327d8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Status of the `main` branch. Changes prior to the next official version change w * **Add support for Julia** via LanguageServer.jl * **Add support for Haskell** via Haskell Language Server (HLS) with automatic discovery via ghcup, stack, or system PATH; supports both Stack and Cabal projects * **Add support for Scala** via Metals language server (requires some [manual setup](docs/03-special-guides/scala_setup_guide_for_serena.md)) + * **Add support for F#** via FsAutoComplete/Ionide LSP server. * **Add support for Elm** via @elm-tooling/elm-language-server (automatically downloads if not installed; requires Elm compiler) * **Add support for Perl** via Perl::LanguageServer with LSP integration for .pl, .pm, and .t files * **Add support for AL (Application Language)** for Microsoft Dynamics 365 Business Central development. Requires VS Code AL extension (ms-dynamics-smb.al). diff --git a/CLAUDE.md b/CLAUDE.md index edf91cd1..7e1f629f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,11 +9,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `uv run poe type-check` - Run mypy type checking - ONLY allowed type checking command - `uv run poe test` - Run tests with default markers (excludes java/rust by default) - `uv run poe test -m "python or go"` - Run specific language tests +- `uv run poe test -m vue` - Run Vue tests - `uv run poe lint` - Check code style without fixing **Test Markers:** Available pytest markers for selective testing: -- `python`, `go`, `java`, `rust`, `typescript`, `php`, `perl`, `csharp`, `elixir`, `terraform`, `clojure`, `swift`, `bash`, `ruby`, `ruby_solargraph` +- `python`, `go`, `java`, `rust`, `typescript`, `vue`, `php`, `perl`, `csharp`, `elixir`, `terraform`, `clojure`, `swift`, `bash`, `ruby`, `ruby_solargraph` - `snapshot` - for symbolic editing operation tests **Project Management:** @@ -100,7 +101,7 @@ Configuration is loaded from (in order of precedence): - **Symbol-based editing** - Uses LSP for precise code manipulation - **Caching strategy** - Reduces language server overhead - **Error recovery** - Automatic language server restart on crashes -- **Multi-language support** - 16+ languages with LSP integration +- **Multi-language support** - 19 languages with LSP integration (including Vue) - **MCP protocol** - Exposes tools to AI agents via Model Context Protocol - **Async operation** - Non-blocking language server interactions diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 764efd14..692f3b2a 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -25,7 +25,9 @@ With Serena, we provide direct, out-of-the-box support for: (requires Elm compiler) * **Erlang** (requires installation of beam and [erlang_ls](https://github.com/erlang-ls/erlang_ls); experimental, might be slow or hang) -* **Fortran** +* **F#** + (requires .NET SDK 8.0+; uses FsAutoComplete/Ionide, which is auto-installed; for Homebrew .NET on macOS, set DOTNET_ROOT in your environment) +* **Fortran** (requires installation of fortls: `pip install fortls`) * **Go** (requires installation of `gopls`) @@ -56,6 +58,9 @@ With Serena, we provide direct, out-of-the-box support for: (requires some [manual setup](../03-special-guides/scala_setup_guide_for_serena); uses Metals LSP) * **Swift** * **TypeScript** +* **Vue** + (3.x with TypeScript; requires Node.js v18+ and npm; supports .vue Single File Components with monorepo detection) +* **YAML** * **Zig** (requires installation of ZLS - Zig Language Server) diff --git a/docs/02-usage/030_clients.md b/docs/02-usage/030_clients.md index 79574dce..57b69151 100644 --- a/docs/02-usage/030_clients.md +++ b/docs/02-usage/030_clients.md @@ -24,7 +24,7 @@ claude mcp add serena -- uvx --from git+https://github.com/oraios/serena serena ``` Note: - * We use the `ide-assistant` context to disable unnecessary tools (avoiding duplication + * We use the `claude-code` context to disable unnecessary tools (avoiding duplication with Claude Code's built-in capabilities). * We specify the current directory as the project directory with `--project "$(pwd)"`, such that Serena is configured to work on the current project from the get-go, following @@ -111,7 +111,21 @@ Add the `serena` MCP server configuration, using a [run command](020_running.md) If you are using paths containing backslashes for paths on Windows (note that you can also just use forward slashes), be sure to escape them correctly (`\\`). -That's it! Save the config and then restart Claude Desktop. You are ready for activating your first project. +:::{note} +Some language servers may require additional environment variables to be set (e.g. F# on macOS with Homebrew), +which you may need to explicitly add to the MCP server configuration. +Note that the spawned processes will not inherit environment variables that are only configured in your shell +profile (e.g. `.bashrc`, `.zshrc`, etc.); they would need to be set system-wide instead. +An easy fix is to add them explicitly to the MCP server entry: Simply add an `env` key to the `serena` object, e.g. + +``` +"env": { + "DOTNET_ROOT": "/opt/homebrew/Cellar/dotnet/9.0.8/libexec" +} +``` +::: + +Once you have created the new MCP server entry, save the config and then restart Claude Desktop. :::{attention} Be sure to fully quit the Claude Desktop application via File / Exit, as regularly closing the application will just minimize it. @@ -151,7 +165,7 @@ by writing your own context, modes or prompts to adjust it to the client's respe Most of the popular existing coding assistants (e.g. IDE extensions) and AI-enabled IDEs themselves support connections to MCP Servers. Serena generally boosts performance by providing efficient tools for symbolic operations. -We generally **recommend to use the `ide-assistant` context** for these integrations by adding the arguments `--context ide-assistant` +We generally **recommend to use the `ide` context** for these integrations by adding the arguments `--context ide` in order to reduce tool duplication. ### Local GUIs and Agent Frameworks diff --git a/docs/02-usage/050_configuration.md b/docs/02-usage/050_configuration.md index 50a067f7..ce3de259 100644 --- a/docs/02-usage/050_configuration.md +++ b/docs/02-usage/050_configuration.md @@ -105,4 +105,31 @@ You can manage modes using the `mode` command, mode edit mode delete -where `` is [your way of running Serena](020_running). \ No newline at end of file +where `` is [your way of running Serena](020_running). + +## Advanced Configuration + +For advanced users, Serena's configuration can be further customized. + +### Serena Data Directory + +The Serena user data directory (where configuration, language server files, logs, etc. are stored) defaults to `~/.serena`. +You can change this location by setting the `SERENA_HOME` environment variable to your desired path. + +### Custom Prompts + +All of Serena's prompts can be fully customized. +We define prompt as jinja templates in yaml files, and you can inspect our default prompts [here](https://github.com/oraios/serena/tree/main/src/serena/resources/config/prompt_templates). + +To override a prompt, simply add a .yml file to the `prompt_templates` folder in your Serena data directory +which defines the prompt with the same name as the default prompt you want to override. +For example, to override the `system_prompt`, you could create a file `~/.serena/prompt_templates/system_prompt.yml` (assuming default Serena data folder location) +with content like: + +```yaml +prompts: + system_prompt: | + Whatever you want ... +``` + +It is advisable to use the default prompt as a starting point and modify it to suit your needs. diff --git a/pyproject.toml b/pyproject.toml index 2e6db42e..9fce285f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -275,6 +275,7 @@ markers = [ "kotlin: language server running for kotlin", "rust: language server running for Rust", "typescript: language server running for TypeScript", + "vue: language server running for Vue (uses TypeScript LSP)", "php: language server running for PHP", "perl: language server running for Perl", "csharp: language server running for C#", @@ -293,6 +294,7 @@ markers = [ "erlang: language server running for Erlang", "scala: language server running for Scala", "al: language server running for AL (Microsoft Dynamics 365 Business Central)", + "fsharp: language server running for F#", "rego: language server running for Rego", "markdown: language server running for Markdown", "julia: Julia language server tests", @@ -306,4 +308,4 @@ markers = [ skip = '.git*,*.svg,*.lock,*.min.*' check-hidden = true # ignore-regex = '' -# ignore-words-list = '' +ignore-words-list = 'paket' \ No newline at end of file diff --git a/src/serena/cli.py b/src/serena/cli.py index dc47d9b4..e7cca2be 100644 --- a/src/serena/cli.py +++ b/src/serena/cli.py @@ -21,14 +21,10 @@ from serena.config.serena_config import ProjectConfig, SerenaConfig, SerenaPaths from serena.constants import ( DEFAULT_CONTEXT, DEFAULT_MODES, - PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL, SERENA_LOG_FORMAT, - SERENA_MANAGED_DIR_IN_HOME, SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, - USER_CONTEXT_YAMLS_DIR, - USER_MODE_YAMLS_DIR, ) from serena.mcp import SerenaMCPFactory, SerenaMCPFactorySingleProcess from serena.project import Project @@ -270,7 +266,7 @@ class ModeCommands(AutoRegisteringGroup): if not (name or from_internal): raise click.UsageError("Provide at least one of --name or --from-internal.") mode_name = name or from_internal - dest = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") + dest = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") src = ( os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, f"{from_internal}.yml") if from_internal @@ -289,7 +285,7 @@ class ModeCommands(AutoRegisteringGroup): @click.command("edit", help="Edit a custom mode YAML file.") @click.argument("mode_name") def edit(mode_name: str) -> None: - path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") + path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") if not os.path.exists(path): if mode_name in SerenaAgentMode.list_registered_mode_names(include_user_modes=False): click.echo( @@ -305,7 +301,7 @@ class ModeCommands(AutoRegisteringGroup): @click.command("delete", help="Delete a custom mode file.") @click.argument("mode_name") def delete(mode_name: str) -> None: - path = os.path.join(USER_MODE_YAMLS_DIR, f"{mode_name}.yml") + path = os.path.join(SerenaPaths().user_modes_dir, f"{mode_name}.yml") if not os.path.exists(path): click.echo(f"Custom mode '{mode_name}' not found.") return @@ -347,7 +343,7 @@ class ContextCommands(AutoRegisteringGroup): if not (name or from_internal): raise click.UsageError("Provide at least one of --name or --from-internal.") ctx_name = name or from_internal - dest = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{ctx_name}.yml") + dest = os.path.join(SerenaPaths().user_contexts_dir, f"{ctx_name}.yml") src = ( os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, f"{from_internal}.yml") if from_internal @@ -366,7 +362,7 @@ class ContextCommands(AutoRegisteringGroup): @click.command("edit", help="Edit a custom context YAML file.") @click.argument("context_name") def edit(context_name: str) -> None: - path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml") + path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml") if not os.path.exists(path): if context_name in SerenaAgentContext.list_registered_context_names(include_user_contexts=False): click.echo( @@ -382,7 +378,7 @@ class ContextCommands(AutoRegisteringGroup): @click.command("delete", help="Delete a custom context file.") @click.argument("context_name") def delete(context_name: str) -> None: - path = os.path.join(USER_CONTEXT_YAMLS_DIR, f"{context_name}.yml") + path = os.path.join(SerenaPaths().user_contexts_dir, f"{context_name}.yml") if not os.path.exists(path): click.echo(f"Custom context '{context_name}' not found.") return @@ -401,10 +397,9 @@ class SerenaConfigCommands(AutoRegisteringGroup): "edit", help="Edit serena_config.yml in your default editor. Will create a config file from the template if no config is found." ) def edit() -> None: - config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, "serena_config.yml") - if not os.path.exists(config_path): - SerenaConfig.generate_config_file(config_path) - _open_in_editor(config_path) + serena_config = SerenaConfig.from_config_file() + assert serena_config.config_file_path is not None + _open_in_editor(serena_config.config_file_path) class ProjectCommands(AutoRegisteringGroup): @@ -811,8 +806,9 @@ class PromptCommands(AutoRegisteringGroup): @staticmethod def _get_user_prompt_yaml_path(prompt_yaml_name: str) -> str: - os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) - return os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, prompt_yaml_name) + templates_dir = SerenaPaths().user_prompt_templates_dir + os.makedirs(templates_dir, exist_ok=True) + return os.path.join(templates_dir, prompt_yaml_name) @staticmethod @click.command("list", help="Lists yamls that are used for defining prompts.") @@ -863,9 +859,10 @@ class PromptCommands(AutoRegisteringGroup): @staticmethod @click.command("list-overrides", help="List existing prompt override files") def list_overrides() -> None: - os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) + user_templates_dir = SerenaPaths().user_prompt_templates_dir + os.makedirs(user_templates_dir, exist_ok=True) serena_prompt_yaml_names = [os.path.basename(f) for f in glob.glob(PROMPT_TEMPLATES_DIR_INTERNAL + "/*.yml")] - override_files = glob.glob(os.path.join(PROMPT_TEMPLATES_DIR_IN_USER_HOME, "*.yml")) + override_files = glob.glob(os.path.join(user_templates_dir, "*.yml")) for file_path in override_files: if os.path.basename(file_path) in serena_prompt_yaml_names: click.echo(file_path) diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py index 6e9f65b1..7220ef90 100644 --- a/src/serena/code_editor.py +++ b/src/serena/code_editor.py @@ -267,15 +267,7 @@ class LanguageServerCodeEditor(CodeEditor[LanguageServerSymbol]): return lang_server.language_server.retrieve_full_file_content(relative_path) def _find_unique_symbol(self, name_path: str, relative_file_path: str) -> LanguageServerSymbol: - symbol_candidates = self._symbol_retriever.find_by_name(name_path, within_relative_path=relative_file_path) - if len(symbol_candidates) == 0: - raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}") - if len(symbol_candidates) > 1: - raise ValueError( - f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. " - "Their locations are: \n " + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2) - ) - return symbol_candidates[0] + return self._symbol_retriever.find_unique(name_path, within_relative_path=relative_file_path) def _apply_workspace_edit(self, workspace_edit: ls_types.WorkspaceEdit) -> list[str]: """ diff --git a/src/serena/config/context_mode.py b/src/serena/config/context_mode.py index dfff0937..f1f34fd9 100644 --- a/src/serena/config/context_mode.py +++ b/src/serena/config/context_mode.py @@ -11,7 +11,7 @@ import yaml from sensai.util import logging from sensai.util.string import ToStringMixin -from serena.config.serena_config import ToolInclusionDefinition +from serena.config.serena_config import SerenaPaths, ToolInclusionDefinition from serena.constants import ( DEFAULT_CONTEXT, DEFAULT_MODES, @@ -19,8 +19,6 @@ from serena.constants import ( SERENA_FILE_ENCODING, SERENAS_OWN_CONTEXT_YAMLS_DIR, SERENAS_OWN_MODE_YAMLS_DIR, - USER_CONTEXT_YAMLS_DIR, - USER_MODE_YAMLS_DIR, ) if TYPE_CHECKING: @@ -65,14 +63,14 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): def get_path(cls, name: str) -> str: """Get the path to the YAML file for a mode.""" fname = f"{name}.yml" - custom_mode_path = os.path.join(USER_MODE_YAMLS_DIR, fname) + custom_mode_path = os.path.join(SerenaPaths().user_modes_dir, fname) if os.path.exists(custom_mode_path): return custom_mode_path own_yaml_path = os.path.join(SERENAS_OWN_MODE_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Mode {name} not found in {USER_MODE_YAMLS_DIR} or in {SERENAS_OWN_MODE_YAMLS_DIR}." + f"Mode {name} not found in {SerenaPaths().user_modes_dir} or in {SERENAS_OWN_MODE_YAMLS_DIR}." f"Available modes:\n{cls.list_registered_mode_names()}" ) return own_yaml_path @@ -102,7 +100,7 @@ class SerenaAgentMode(ToolInclusionDefinition, ToStringMixin): @classmethod def list_custom_mode_names(cls) -> list[str]: """Names of all custom modes defined by the user.""" - return [f.stem for f in Path(USER_MODE_YAMLS_DIR).glob("*.yml")] + return [f.stem for f in Path(SerenaPaths().user_modes_dir).glob("*.yml")] @classmethod def load_default_modes(cls) -> list[Self]: @@ -166,14 +164,14 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): def get_path(cls, name: str) -> str: """Get the path to the YAML file for a context.""" fname = f"{name}.yml" - custom_context_path = os.path.join(USER_CONTEXT_YAMLS_DIR, fname) + custom_context_path = os.path.join(SerenaPaths().user_contexts_dir, fname) if os.path.exists(custom_context_path): return custom_context_path own_yaml_path = os.path.join(SERENAS_OWN_CONTEXT_YAMLS_DIR, fname) if not os.path.exists(own_yaml_path): raise FileNotFoundError( - f"Context {name} not found in {USER_CONTEXT_YAMLS_DIR} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}." + f"Context {name} not found in {SerenaPaths().user_contexts_dir} or in {SERENAS_OWN_CONTEXT_YAMLS_DIR}." f"Available contexts:\n{cls.list_registered_context_names()}" ) return own_yaml_path @@ -211,7 +209,7 @@ class SerenaAgentContext(ToolInclusionDefinition, ToStringMixin): @classmethod def list_custom_context_names(cls) -> list[str]: """Names of all custom contexts defined by the user.""" - return [f.stem for f in Path(USER_CONTEXT_YAMLS_DIR).glob("*.yml")] + return [f.stem for f in Path(SerenaPaths().user_contexts_dir).glob("*.yml")] @classmethod def load_default(cls) -> Self: diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 6008f3d7..83d6bcb4 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -25,7 +25,6 @@ from serena.constants import ( REPO_ROOT, SERENA_CONFIG_TEMPLATE_FILE, SERENA_FILE_ENCODING, - SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME, ) from serena.util.general import get_dataclass_default, load_yaml, save_yaml @@ -53,9 +52,32 @@ class SerenaPaths: """ def __init__(self) -> None: - self.user_config_dir: str = SERENA_MANAGED_DIR_IN_HOME + home_dir = os.getenv("SERENA_HOME") + if home_dir is None or home_dir.strip() == "": + home_dir = str(Path.home() / SERENA_MANAGED_DIR_NAME) + else: + home_dir = home_dir.strip() + self.serena_user_home_dir: str = home_dir """ - the path to the user's Serena configuration directory, which is typically ~/.serena + the path to the Serena home directory, where the user's configuration/data is stored. + This is ~/.serena by default, but it can be overridden via the SERENA_HOME environment variable. + """ + self.user_prompt_templates_dir: str = os.path.join(self.serena_user_home_dir, "prompt_templates") + """ + directory containing prompt templates defined by the user. + Prompts defined by the user take precedence over Serena's built-in prompt templates. + """ + self.user_contexts_dir: str = os.path.join(self.serena_user_home_dir, "contexts") + """ + directory containing contexts defined by the user. + If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR, + the user context will override the default context definition. + """ + self.user_modes_dir: str = os.path.join(self.serena_user_home_dir, "modes") + """ + directory containing modes defined by the user. + If a name of a mode matches a name of a mode in SERENAS_OWN_MODES_YAML_DIR, + the user mode will override the default mode definition. """ def get_next_log_file_path(self, prefix: str) -> str: @@ -63,7 +85,7 @@ class SerenaPaths: :param prefix: the filename prefix indicating the type of the log file :return: the full path to the log file to use """ - log_dir = os.path.join(self.user_config_dir, "logs", datetime.now().strftime("%Y-%m-%d")) + log_dir = os.path.join(self.serena_user_home_dir, "logs", datetime.now().strftime("%Y-%m-%d")) os.makedirs(log_dir, exist_ok=True) return os.path.join(log_dir, prefix + "_" + datetime_tag() + ".txt") @@ -152,15 +174,18 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): f"Read the documentation for more information." ) # sort languages by number of files found - languages_and_percentages = sorted(language_composition.items(), key=lambda item: item[1], reverse=True) + languages_and_percentages = sorted( + language_composition.items(), key=lambda item: (item[1], item[0].get_priority()), reverse=True + ) # find the language with the highest percentage and enable it top_language_pair = languages_and_percentages[0] other_language_pairs = languages_and_percentages[1:] - languages_to_use = [top_language_pair[0]] + languages_to_use: list[str] = [top_language_pair[0].value] # if in interactive mode, ask the user which other languages to enable if len(other_language_pairs) > 0 and interactive: print( - "Detected and enabled main language '%s' (%.2f%% of source files)." % (top_language_pair[0], top_language_pair[1]) + "Detected and enabled main language '%s' (%.2f%% of source files)." + % (top_language_pair[0].value, top_language_pair[1]) ) print(f"Additionally detected {len(other_language_pairs)} other language(s).\n") print("Note: Enable only languages you need symbolic retrieval/editing capabilities for.") @@ -168,9 +193,9 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): print(" system-level installations/configuration (see Serena documentation).") print("\nWhich additional languages do you want to enable?") for lang, perc in other_language_pairs: - enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang, perc), default=False) + enable = ask_yes_no("Enable %s (%.2f%% of source files)?" % (lang.value, perc), default=False) if enable: - languages_to_use.append(lang) + languages_to_use.append(lang.value) print() else: languages_to_use = [lang.value for lang in languages] @@ -383,7 +408,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): return ["config_file_path"] @classmethod - def generate_config_file(cls, config_file_path: str) -> None: + def _generate_config_file(cls, config_file_path: str) -> None: """ Generates a Serena configuration file at the specified path from the template file. @@ -401,7 +426,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): if is_running_in_docker(): return os.path.join(REPO_ROOT, cls.CONFIG_FILE_DOCKER) else: - config_path = os.path.join(SERENA_MANAGED_DIR_IN_HOME, cls.CONFIG_FILE) + config_path = os.path.join(SerenaPaths().serena_user_home_dir, cls.CONFIG_FILE) # if the config file does not exist, check if we can migrate it from the old location if not os.path.exists(config_path): @@ -425,7 +450,7 @@ class SerenaConfig(ToolInclusionDefinition, ToStringMixin): if not generate_if_missing: raise FileNotFoundError(f"Serena configuration file not found: {config_file_path}") log.info(f"Serena configuration file not found at {config_file_path}, autogenerating...") - cls.generate_config_file(config_file_path) + cls._generate_config_file(config_file_path) # load the configuration log.info(f"Loading Serena configuration from {config_file_path}") diff --git a/src/serena/constants.py b/src/serena/constants.py index e0ed29cf..f5ccd660 100644 --- a/src/serena/constants.py +++ b/src/serena/constants.py @@ -4,22 +4,14 @@ _repo_root_path = Path(__file__).parent.parent.parent.resolve() _serena_pkg_path = Path(__file__).parent.resolve() SERENA_MANAGED_DIR_NAME = ".serena" -_serena_in_home_managed_dir = Path.home() / ".serena" - -SERENA_MANAGED_DIR_IN_HOME = str(_serena_in_home_managed_dir) # TODO: Path-related constants should be moved to SerenaPaths; don't add further constants here. REPO_ROOT = str(_repo_root_path) PROMPT_TEMPLATES_DIR_INTERNAL = str(_serena_pkg_path / "resources" / "config" / "prompt_templates") -PROMPT_TEMPLATES_DIR_IN_USER_HOME = str(_serena_in_home_managed_dir / "prompt_templates") SERENAS_OWN_CONTEXT_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "contexts") """The contexts that are shipped with the Serena package, i.e. the default contexts.""" -USER_CONTEXT_YAMLS_DIR = str(_serena_in_home_managed_dir / "contexts") -"""Contexts defined by the user. If a name of a context matches a name of a context in SERENAS_OWN_CONTEXT_YAMLS_DIR, the user context will override the default one.""" SERENAS_OWN_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "modes") """The modes that are shipped with the Serena package, i.e. the default modes.""" -USER_MODE_YAMLS_DIR = str(_serena_in_home_managed_dir / "modes") -"""Modes defined by the user. If a name of a mode matches a name of a mode in SERENAS_OWN_MODE_YAMLS_DIR, the user mode will override the default one.""" INTERNAL_MODE_YAMLS_DIR = str(_serena_pkg_path / "resources" / "config" / "internal_modes") """Internal modes, never overridden by user modes.""" SERENA_DASHBOARD_DIR = str(_serena_pkg_path / "resources" / "dashboard") diff --git a/src/serena/ls_manager.py b/src/serena/ls_manager.py index 6ec224b8..8f3681bf 100644 --- a/src/serena/ls_manager.py +++ b/src/serena/ls_manager.py @@ -4,7 +4,8 @@ from collections.abc import Iterator from sensai.util.logging import LogTime -from serena.constants import SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME +from serena.config.serena_config import SerenaPaths +from serena.constants import SERENA_MANAGED_DIR_NAME from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language, LanguageServerConfig from solidlsp.settings import SolidLSPSettings @@ -43,7 +44,7 @@ class LanguageServerFactory: self.project_root, timeout=self.ls_timeout, solidlsp_settings=SolidLSPSettings( - solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, + solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_relative_path=SERENA_MANAGED_DIR_NAME, ls_specific_settings=self.ls_specific_settings or {}, ), diff --git a/src/serena/mcp.py b/src/serena/mcp.py index f8f75ae0..24c9bd38 100644 --- a/src/serena/mcp.py +++ b/src/serena/mcp.py @@ -14,6 +14,7 @@ import docstring_parser from mcp.server.fastmcp import server from mcp.server.fastmcp.server import FastMCP, Settings from mcp.server.fastmcp.tools.base import Tool as MCPTool +from mcp.types import ToolAnnotations from pydantic_settings import SettingsConfigDict from sensai.util import logging @@ -213,6 +214,8 @@ class SerenaMCPFactory: def execute_fn(**kwargs) -> str: # type: ignore return tool.apply_ex(log_call=True, catch_exceptions=True, **kwargs) + annotations = ToolAnnotations(readOnlyHint=not tool.can_edit()) + return MCPTool( fn=execute_fn, name=func_name, @@ -221,7 +224,7 @@ class SerenaMCPFactory: fn_metadata=func_arg_metadata, is_async=is_async, context_kwarg=None, - annotations=None, + annotations=annotations, title=None, ) diff --git a/src/serena/prompt_factory.py b/src/serena/prompt_factory.py index 40f51e7a..31be6f49 100644 --- a/src/serena/prompt_factory.py +++ b/src/serena/prompt_factory.py @@ -1,6 +1,7 @@ import os -from serena.constants import PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL +from serena.config.serena_config import SerenaPaths +from serena.constants import PROMPT_TEMPLATES_DIR_INTERNAL from serena.generated.generated_prompt_factory import PromptFactory @@ -10,5 +11,6 @@ class SerenaPromptFactory(PromptFactory): """ def __init__(self) -> None: - os.makedirs(PROMPT_TEMPLATES_DIR_IN_USER_HOME, exist_ok=True) - super().__init__(prompts_dir=[PROMPT_TEMPLATES_DIR_IN_USER_HOME, PROMPT_TEMPLATES_DIR_INTERNAL]) + user_templates_dir = SerenaPaths().user_prompt_templates_dir + os.makedirs(user_templates_dir, exist_ok=True) + super().__init__(prompts_dir=[user_templates_dir, PROMPT_TEMPLATES_DIR_INTERNAL]) diff --git a/src/serena/symbol.py b/src/serena/symbol.py index e3753041..ecc13353 100644 --- a/src/serena/symbol.py +++ b/src/serena/symbol.py @@ -2,7 +2,7 @@ import json import logging import os from abc import ABC, abstractmethod -from collections.abc import Iterator, Sequence +from collections.abc import Callable, Iterator, Sequence from dataclasses import asdict, dataclass from typing import TYPE_CHECKING, Any, NotRequired, Self, TypedDict, Union @@ -115,16 +115,31 @@ class Symbol(ToStringMixin, ABC): class NamePathMatcher(ToStringMixin): - def __init__(self, name_path_expr: str, substring_matching: bool) -> None: + """ + Matches name paths of symbols against search patterns. + + A name path is a path in the symbol tree *within a source file*. + For example, the method `my_method` defined in class `MyClass` would have the name path `MyClass/my_method`. + If a symbol is overloaded (e.g., in Java), a 0-based index is appended (e.g. "MyClass/my_method[0]") to + uniquely identify it. + + A matching pattern can be: + * a simple name (e.g. "method"), which will match any symbol with that name + * a relative path like "class/method", which will match any symbol with that name path suffix + * an absolute name path "/class/method" (absolute name path), which requires an exact match of the full name path within the source file. + Append an index `[i]` to match a specific overload only, e.g. "MyClass/my_method[1]". + """ + + def __init__(self, name_path_pattern: str, substring_matching: bool) -> None: """ - :param name_path_expr: the name path expression to match against + :param name_path_pattern: the name path expression to match against :param substring_matching: whether to use substring matching for the last segment """ - assert name_path_expr, "name_path must not be empty" - self._expr = name_path_expr + assert name_path_pattern, "name_path must not be empty" + self._expr = name_path_pattern self._substring_matching = substring_matching - self._is_absolute_pattern = name_path_expr.startswith(NAME_PATH_SEP) - self._pattern_parts = name_path_expr.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP) + self._is_absolute_pattern = name_path_pattern.startswith(NAME_PATH_SEP) + self._pattern_parts = name_path_pattern.lstrip(NAME_PATH_SEP).rstrip(NAME_PATH_SEP).split(NAME_PATH_SEP) # extract overload index "[idx]" if present at end of last part self._overload_idx: int | None = None @@ -194,6 +209,13 @@ class LanguageServerSymbol(Symbol, ToStringMixin): def symbol_kind(self) -> SymbolKind: return self.symbol_root["kind"] + def is_low_level(self) -> bool: + """ + :return: whether the symbol is a low-level symbol (variable, constant, etc.), which typically represents data + rather than structure and therefore is not relevant in a high-level overview of the code. + """ + return self.symbol_kind >= SymbolKind.Variable.value + @property def overload_idx(self) -> int | None: return self.symbol_root.get("overload_idx") @@ -323,34 +345,15 @@ class LanguageServerSymbol(Symbol, ToStringMixin): def find( self, - name_path: str, + name_path_pattern: str, substring_matching: bool = False, include_kinds: Sequence[SymbolKind] | None = None, exclude_kinds: Sequence[SymbolKind] | None = None, ) -> list[Self]: """ - Find all symbols within the symbol's subtree that match the given `name_path`. - The matching behavior is determined by the structure of `name_path`, which can - either be a simple name (e.g. "method") or a name path like "class/method" (relative name path) - or "/class/method" (absolute name path). + Find all symbols within the symbol's subtree that match the given name path pattern. - Key aspects of the name path matching behavior: - - Trailing slashes in `name_path` play no role and are ignored. - - The name of the retrieved symbols will match (either exactly or as a substring) - the last segment of `name_path`, while other segments will restrict the search to symbols that - have a desired sequence of ancestors. - - If there is no starting or intermediate slash in `name_path`, there is no - restriction on the ancestor symbols. For example, passing `method` will match - against symbols with name paths like `method`, `class/method`, `class/nested_class/method`, etc. - - If `name_path` contains a `/` but doesn't start with a `/`, the matching is restricted to symbols - with the same ancestors as the last segment of `name_path`. For example, passing `class/method` will match against - `class/method` as well as `nested_class/class/method` but not `method`. - - If `name_path` starts with a `/`, it will be treated as an absolute name path pattern, meaning - that the first segment of it must match the first segment of the symbol's name path. - For example, passing `/class` will match only against top-level symbols like `class` but not against `nested_class/class`. - Passing `/class/method` will match against `class/method` but not `nested_class/class/method` or `method`. - - :param name_path: the name path expression to match against + :param name_path_pattern: the name path pattern to match against (see class :class:`NamePathMatcher` for details) :param substring_matching: whether to use substring matching (as opposed to exact matching) of the last segment of `name_path` against the symbol name. :param include_kinds: an optional sequence of ints representing the LSP symbol kind. @@ -358,7 +361,7 @@ class LanguageServerSymbol(Symbol, ToStringMixin): :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. """ result = [] - name_path_matcher = NamePathMatcher(name_path, substring_matching) + name_path_matcher = NamePathMatcher(name_path_pattern, substring_matching) def should_include(s: "LanguageServerSymbol") -> bool: if include_kinds is not None and s.symbol_kind not in include_kinds: @@ -384,13 +387,14 @@ class LanguageServerSymbol(Symbol, ToStringMixin): include_body: bool = False, include_children_body: bool = False, include_relative_path: bool = True, + child_inclusion_predicate: Callable[[Self], bool] | None = None, ) -> dict[str, Any]: """ Converts the symbol to a dictionary. :param kind: whether to include the kind of the symbol :param location: whether to include the location of the symbol - :param depth: the depth of the symbol + :param depth: the depth up to which to include child symbols (0 = do not include children) :param include_body: whether to include the body of the top-level symbol. :param include_children_body: whether to also include the body of the children. Note that the body of the children is part of the body of the parent symbol, @@ -398,6 +402,8 @@ class LanguageServerSymbol(Symbol, ToStringMixin): and pass the children without passing the parent body to the LM. :param include_relative_path: whether to include the relative path of the symbol in the location entry. Relative paths of the symbol's children are always excluded. + :param child_inclusion_predicate: an optional predicate that decides whether a child symbol + should be included. :return: a dictionary representation of the symbol """ result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()} @@ -415,14 +421,20 @@ class LanguageServerSymbol(Symbol, ToStringMixin): log.warning("Requested body for symbol, but it is not present. The symbol might have been loaded with include_body=False.") result["body"] = self.body - def add_children(s: Self) -> list[dict[str, Any]]: + if child_inclusion_predicate is None: + child_inclusion_predicate = lambda s: True + + def included_children(s: Self) -> list[dict[str, Any]]: children = [] for c in s.iter_children(): + if not child_inclusion_predicate(c): + continue children.append( c.to_dict( kind=kind, location=location, depth=depth - 1, + child_inclusion_predicate=child_inclusion_predicate, include_body=include_children_body, include_children_body=include_children_body, # all children have the same relative path as the parent @@ -432,7 +444,9 @@ class LanguageServerSymbol(Symbol, ToStringMixin): return children if depth > 0: - result["children"] = add_children(self) + children = included_children(self) + if len(children) > 0: + result["children"] = included_children(self) return result @@ -488,18 +502,17 @@ class LanguageServerSymbolRetriever: def get_language_server(self, relative_path: str) -> SolidLanguageServer: return self._ls_manager.get_language_server(relative_path) - def find_by_name( + def find( self, - name_path: str, + name_path_pattern: str, include_kinds: Sequence[SymbolKind] | None = None, exclude_kinds: Sequence[SymbolKind] | None = None, substring_matching: bool = False, within_relative_path: str | None = None, ) -> list[LanguageServerSymbol]: """ - Find all symbols that match the given name. See docstring of `Symbol.find` for more details. - The only parameter not mentioned there is `within_relative_path`, which can be used to restrict the search - to symbols within a specific file or directory. + Finds all symbols that match the given name path pattern (see class :class:`NamePathMatcher` for details), + optionally limited to a specific file and filtered by kind. """ symbols: list[LanguageServerSymbol] = [] for lang_server in self._ls_manager.iter_language_servers(): @@ -507,11 +520,44 @@ class LanguageServerSymbolRetriever: for root in symbol_roots: symbols.extend( LanguageServerSymbol(root).find( - name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching + name_path_pattern, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching ) ) return symbols + def find_unique( + self, + name_path_pattern: str, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + substring_matching: bool = False, + within_relative_path: str | None = None, + ) -> LanguageServerSymbol: + symbol_candidates = self.find( + name_path_pattern, + include_kinds=include_kinds, + exclude_kinds=exclude_kinds, + substring_matching=substring_matching, + within_relative_path=within_relative_path, + ) + if len(symbol_candidates) == 1: + return symbol_candidates[0] + elif len(symbol_candidates) == 0: + raise ValueError(f"No symbol matching '{name_path_pattern}' found") + else: + # There are multiple candidates. + # If only one of the candidates has the given pattern as its exact name path, return that one + exact_matches = [s for s in symbol_candidates if s.get_name_path() == name_path_pattern] + if len(exact_matches) == 1: + return exact_matches[0] + # otherwise, raise an error + include_rel_path = within_relative_path is not None + raise ValueError( + f"Found multiple {len(symbol_candidates)} symbols matching '{name_path_pattern}'. " + "They are: \n" + + json.dumps([s.to_dict(kind=True, include_relative_path=include_rel_path) for s in symbol_candidates], indent=2) + ) + def find_by_location(self, location: LanguageServerSymbolLocation) -> LanguageServerSymbol | None: if location.relative_path is None: return None @@ -532,28 +578,17 @@ class LanguageServerSymbolRetriever: exclude_kinds: Sequence[SymbolKind] | None = None, ) -> list[ReferenceInLanguageServerSymbol]: """ - Find all symbols that reference the symbol with the given name. - If multiple symbols fit the name (e.g. for variables that are overwritten), will use the first one. + Find all symbols that reference the specified symbol, which is assumed to be unique. - :param name_path: the name path of the symbol to find + :param name_path: the name path of the symbol to find. (While this can be a matching pattern, it should + usually be the full path to ensure uniqueness.) :param relative_file_path: the relative path of the file in which the referenced symbol is defined. :param include_body: whether to include the body of all symbols in the result. Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long. :param include_kinds: which kinds of symbols to include in the result. :param exclude_kinds: which kinds of symbols to exclude from the result. """ - symbol_candidates = self.find_by_name(name_path, substring_matching=False, within_relative_path=relative_file_path) - if len(symbol_candidates) == 0: - log.warning(f"No symbol with name {name_path} found in file {relative_file_path}") - return [] - if len(symbol_candidates) > 1: - log.error( - f"Found {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}." - f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the first one. " - f"Found symbols for {name_path=} in {relative_file_path=}: \n" - f"{json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)}" - ) - symbol = symbol_candidates[0] + symbol = self.find_unique(name_path, substring_matching=False, within_relative_path=relative_file_path) return self.find_referencing_symbols_by_location( symbol.location, include_body=include_body, include_kinds=include_kinds, exclude_kinds=exclude_kinds ) @@ -604,23 +639,35 @@ class LanguageServerSymbolRetriever: return [ReferenceInLanguageServerSymbol.from_lsp_reference(r) for r in references] - @dataclass - class SymbolOverviewElement: - name_path: str - kind: int - - @classmethod - def from_symbol(cls, symbol: LanguageServerSymbol) -> Self: - return cls(name_path=symbol.get_name_path(), kind=int(symbol.symbol_kind)) - - def get_symbol_overview(self, relative_path: str) -> dict[str, list[SymbolOverviewElement]]: + def get_symbol_overview(self, relative_path: str, depth: int = 0) -> dict[str, list[dict]]: + """ + :param relative_path: the path of the file or directory for which to get the symbol overview + :param depth: the depth up to which to include child symbols (0 = only top-level symbols) + :return: a mapping from file paths to lists of symbol dictionaries. + For the case where a file is passed, the mapping will contain a single entry. + """ lang_server = self.get_language_server(relative_path) path_to_unified_symbols = lang_server.request_overview(relative_path) + + def child_inclusion_predicate(s: LanguageServerSymbol) -> bool: + return not s.is_low_level() + result = {} for file_path, unified_symbols in path_to_unified_symbols.items(): - # TODO: maybe include not just top-level symbols? We could filter by kind to exclude variables - # The language server methods would need to be adjusted for this. - result[file_path] = [self.SymbolOverviewElement.from_symbol(LanguageServerSymbol(s)) for s in unified_symbols] + symbols_in_file = [] + for us in unified_symbols: + symbol = LanguageServerSymbol(us) + symbols_in_file.append( + symbol.to_dict( + depth=depth, + kind=True, + include_relative_path=False, + location=False, + child_inclusion_predicate=child_inclusion_predicate, + ) + ) + result[file_path] = symbols_in_file + return result diff --git a/src/serena/tools/jetbrains_plugin_client.py b/src/serena/tools/jetbrains_plugin_client.py index 47efb30a..15452711 100644 --- a/src/serena/tools/jetbrains_plugin_client.py +++ b/src/serena/tools/jetbrains_plugin_client.py @@ -133,7 +133,13 @@ class JetBrainsPluginClient(ToStringMixin): return response["project_root"] def find_symbol( - self, name_path: str, relative_path: str | None = None, include_body: bool = False, depth: int = 0, include_location: bool = False + self, + name_path: str, + relative_path: str | None = None, + include_body: bool = False, + depth: int = 0, + include_location: bool = False, + search_deps: bool = False, ) -> dict[str, Any]: """ Finds symbols by name. @@ -142,6 +148,8 @@ class JetBrainsPluginClient(ToStringMixin): :param relative_path: the relative path to which to restrict the search :param include_body: whether to include symbol body content :param depth: depth of children to include (0 = no children) + :param include_location: whether to include symbol location information + :param search_deps: whether to also search in dependencies :return: Dictionary containing 'symbols' list with matching symbols """ @@ -151,6 +159,7 @@ class JetBrainsPluginClient(ToStringMixin): "includeBody": include_body, "depth": depth, "includeLocation": include_location, + "searchDeps": search_deps, } return self._make_request("POST", "/findSymbol", request_data) diff --git a/src/serena/tools/jetbrains_tools.py b/src/serena/tools/jetbrains_tools.py index 858ab204..e103854b 100644 --- a/src/serena/tools/jetbrains_tools.py +++ b/src/serena/tools/jetbrains_tools.py @@ -13,6 +13,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): depth: int = 0, relative_path: str | None = None, include_body: bool = False, + search_deps: bool = False, max_answer_chars: int = -1, ) -> str: """ @@ -42,6 +43,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): If you have some knowledge about the codebase, you should use this parameter, as it will significantly speed up the search as well as reduce the number of results. :param include_body: If True, include the symbol's source code. Use judiciously. + :param search_deps: If True, also search in project dependencies (e.g., libraries). :param max_answer_chars: max characters for the JSON result. If exceeded, no content is returned. -1 means the default value from the config will be used. :return: JSON string: a list of symbols (with locations) matching the name. @@ -52,6 +54,7 @@ class JetBrainsFindSymbolTool(Tool, ToolMarkerSymbolicRead, ToolMarkerOptional): relative_path=relative_path, depth=depth, include_body=include_body, + search_deps=search_deps, ) result = self._to_json(response_dict) return self._limit_length(result, max_answer_chars) diff --git a/src/serena/tools/symbol_tools.py b/src/serena/tools/symbol_tools.py index 9d8cffaa..75d0e5c9 100644 --- a/src/serena/tools/symbol_tools.py +++ b/src/serena/tools/symbol_tools.py @@ -2,7 +2,6 @@ Language server-related tools """ -import dataclasses import os from collections.abc import Sequence from copy import copy @@ -50,13 +49,15 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): Gets an overview of the top-level symbols defined in a given file. """ - def apply(self, relative_path: str, max_answer_chars: int = -1) -> str: + def apply(self, relative_path: str, depth: int = 0, max_answer_chars: int = -1) -> str: """ Use this tool to get a high-level understanding of the code symbols in a file. This should be the first tool to call when you want to understand a new file, unless you already know what you are looking for. :param relative_path: the relative path to the file to get the overview of + :param depth: depth up to which descendants of top-level symbols shall be retrieved + (e.g. 1 retrieves immediate children). Default 0. :param max_answer_chars: if the overview is longer than this number of characters, no content will be returned. -1 means the default value from the config will be used. Don't adjust unless there is really no other way to get the content required for the task. @@ -71,8 +72,8 @@ class GetSymbolsOverviewTool(Tool, ToolMarkerSymbolicRead): raise FileNotFoundError(f"File or directory {relative_path} does not exist in the project.") if os.path.isdir(file_path): raise ValueError(f"Expected a file path, but got a directory path: {relative_path}. ") - result = symbol_retriever.get_symbol_overview(relative_path)[relative_path] - result_json_str = self._to_json([dataclasses.asdict(i) for i in result]) + result = symbol_retriever.get_symbol_overview(relative_path, depth=depth)[relative_path] + result_json_str = self._to_json(result) return self._limit_length(result_json_str, max_answer_chars) @@ -136,7 +137,7 @@ class FindSymbolTool(Tool, ToolMarkerSymbolicRead): parsed_include_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in include_kinds] if include_kinds else None parsed_exclude_kinds: Sequence[SymbolKind] | None = [SymbolKind(k) for k in exclude_kinds] if exclude_kinds else None symbol_retriever = self.create_language_server_symbol_retriever() - symbols = symbol_retriever.find_by_name( + symbols = symbol_retriever.find( name_path_pattern, include_kinds=parsed_include_kinds, exclude_kinds=parsed_exclude_kinds, diff --git a/src/serena/util/inspection.py b/src/serena/util/inspection.py index 668cf545..c67cc1f3 100644 --- a/src/serena/util/inspection.py +++ b/src/serena/util/inspection.py @@ -19,13 +19,13 @@ def iter_subclasses(cls: type[T], recursive: bool = True) -> Generator[type[T], yield from iter_subclasses(subclass, recursive) -def determine_programming_language_composition(repo_path: str) -> dict[str, float]: +def determine_programming_language_composition(repo_path: str) -> dict[Language, float]: """ Determine the programming language composition of a repository. :param repo_path: Path to the repository to analyze - :return: Dictionary mapping language names to percentages of files matching each language + :return: Dictionary mapping languages to percentages of files matching each language """ all_files = find_all_non_ignored_files(repo_path) @@ -33,7 +33,7 @@ def determine_programming_language_composition(repo_path: str) -> dict[str, floa return {} # Count files for each language - language_counts: dict[str, int] = {} + language_counts: dict[Language, int] = {} total_files = len(all_files) for language in Language.iter_all(include_experimental=False): @@ -47,12 +47,12 @@ def determine_programming_language_composition(repo_path: str) -> dict[str, floa count += 1 if count > 0: - language_counts[str(language)] = count + language_counts[language] = count # Convert counts to percentages - language_percentages: dict[str, float] = {} - for language_name, count in language_counts.items(): + language_percentages: dict[Language, float] = {} + for language, count in language_counts.items(): percentage = (count / total_files) * 100 - language_percentages[language_name] = round(percentage, 2) + language_percentages[language] = round(percentage, 2) return language_percentages diff --git a/src/solidlsp/language_servers/csharp_language_server.py b/src/solidlsp/language_servers/csharp_language_server.py index b2aec7db..1371e726 100644 --- a/src/solidlsp/language_servers/csharp_language_server.py +++ b/src/solidlsp/language_servers/csharp_language_server.py @@ -532,7 +532,7 @@ class CSharpLanguageServer(SolidLanguageServer): # Map LSP message types to Python logging levels level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} # Error # Warning # Info # Log - log.info(f"LSP: {message_text}", level_map.get(level, logging.DEBUG)) + log.log(level_map.get(level, logging.DEBUG), f"LSP: {message_text}") def handle_progress(params: dict) -> None: """Handle progress notifications from the language server.""" @@ -627,10 +627,14 @@ class CSharpLanguageServer(SolidLanguageServer): def handle_project_needs_restore(params: dict) -> None: return + def handle_workspace_indexing_complete(params: dict) -> None: + self.completions_available.set() + # Set up notification handlers self.server.on_notification("window/logMessage", window_log_message) self.server.on_notification("$/progress", handle_progress) self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + self.server.on_notification("workspace/projectInitializationComplete", handle_workspace_indexing_complete) self.server.on_request("workspace/configuration", handle_workspace_configuration) self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create) self.server.on_request("client/registerCapability", handle_register_capability) @@ -679,7 +683,6 @@ class CSharpLanguageServer(SolidLanguageServer): self._open_solution_and_projects() self.initialization_complete.set() - self.completions_available.set() log.info( "Microsoft.CodeAnalysis.LanguageServer initialized and ready\n" @@ -687,6 +690,12 @@ class CSharpLanguageServer(SolidLanguageServer): "This may take a while for large projects" ) + if self.completions_available.wait(30): # Wait up to 30 seconds for indexing + log.info("Indexing complete") + else: + log.warning("Timeout waiting for indexing to complete, proceeding anyway") + self.completions_available.set() + def _force_pull_diagnostics(self, init_response: dict | InitializeResult) -> None: """ Apply the diagnostic capabilities hack. diff --git a/src/solidlsp/language_servers/fsharp_language_server.py b/src/solidlsp/language_servers/fsharp_language_server.py new file mode 100644 index 00000000..e4ee3e2c --- /dev/null +++ b/src/solidlsp/language_servers/fsharp_language_server.py @@ -0,0 +1,382 @@ +""" +Provides F# specific instantiation of the LanguageServer class. +""" + +import logging +import os +import pathlib +import shutil +import threading +from pathlib import Path + +from overrides import override + +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection +from solidlsp.ls import SolidLanguageServer +from solidlsp.ls_config import LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class FSharpLanguageServer(SolidLanguageServer): + """ + Provides F# specific instantiation of the LanguageServer class using Ionide LSP (FsAutoComplete). + Contains various configurations and settings specific to F# development. + """ + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + """ + Creates an FSharpLanguageServer instance. This class is not meant to be instantiated directly. + Use LanguageServer.create() instead. + """ + fsharp_lsp_executable_path = self._setup_runtime_dependencies(config, solidlsp_settings) + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=fsharp_lsp_executable_path, cwd=repository_root_path), + "fsharp", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "bin", + "obj", + "packages", + ".paket", + "paket-files", + ".fake", + ".ionide", + ] + + @classmethod + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> str: + """ + Setup runtime dependencies for F# Language Server and return the command to start the server. + """ + # First check if .NET SDK is installed + dotnet_exe = shutil.which("dotnet") + if not dotnet_exe: + raise RuntimeError( + ".NET SDK is not installed or not in PATH. Please install .NET SDK 8.0 or later and ensure 'dotnet' is in your PATH." + ) + + # Verify dotnet version + import subprocess + + try: + result = subprocess.run([dotnet_exe, "--version"], capture_output=True, text=True, check=True) + log.info(f"Found .NET SDK version: {result.stdout.strip()}") + except subprocess.CalledProcessError: + raise RuntimeError("Failed to get .NET SDK version. Please ensure .NET SDK is properly installed.") + + RuntimeDependencyCollection( + [ + RuntimeDependency( + id="fsautocomplete", + description="FsAutoComplete (Ionide F# Language Server)", + command="dotnet tool install --tool-path ./ fsautocomplete", + platform_id="any", + ), + ] + ) + + # Install FsAutoComplete if not already installed + fsharp_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "fsharp-lsp") + fsautocomplete_path = os.path.join(fsharp_ls_dir, "fsautocomplete") + + # Handle Windows executable extension + if os.name == "nt": + fsautocomplete_path += ".exe" + + if not os.path.exists(fsautocomplete_path): + log.info(f"FsAutoComplete executable not found at {fsautocomplete_path}. Installing...") + + # Ensure the directory exists + os.makedirs(fsharp_ls_dir, exist_ok=True) + + # Install FsAutoComplete using dotnet tool install + try: + import subprocess + + result = subprocess.run( + [dotnet_exe, "tool", "install", "--tool-path", fsharp_ls_dir, "fsautocomplete"], + cwd=fsharp_ls_dir, + capture_output=True, + text=True, + check=True, + ) + log.info("FsAutoComplete installed successfully") + log.debug(f"Installation output: {result.stdout}") + except subprocess.CalledProcessError as e: + log.error(f"Failed to install FsAutoComplete: {e.stderr}") + raise RuntimeError(f"Failed to install FsAutoComplete: {e.stderr}") + + if not os.path.exists(fsautocomplete_path): + raise FileNotFoundError( + f"FsAutoComplete executable not found at {fsautocomplete_path}, something went wrong with the installation." + ) + + # FsAutoComplete uses --lsp flag for LSP mode + return f"{fsautocomplete_path} --adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" + + def _get_initialize_params(self) -> InitializeParams: + """ + Returns the initialize params for the F# Language Server. + """ + root_uri = pathlib.Path(self.repository_root_path).as_uri() + + initialize_params = { + "processId": os.getpid(), + "rootPath": self.repository_root_path, + "rootUri": root_uri, + "workspaceFolders": [{"name": "workspace", "uri": root_uri}], + "capabilities": { + "workspace": { + "applyEdit": True, + "workspaceEdit": {"documentChanges": True}, + "didChangeConfiguration": {"dynamicRegistration": True}, + "didChangeWatchedFiles": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + "executeCommand": {"dynamicRegistration": True}, + "configuration": True, + "workspaceFolders": True, + }, + "textDocument": { + "synchronization": { + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True, + "didSave": True, + }, + "completion": { + "dynamicRegistration": True, + "contextSupport": True, + "completionItem": { + "snippetSupport": True, + "commitCharactersSupport": True, + "documentationFormat": ["markdown", "plaintext"], + "deprecatedSupport": True, + }, + }, + "hover": { + "dynamicRegistration": True, + "contentFormat": ["markdown", "plaintext"], + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": {"documentationFormat": ["markdown", "plaintext"]}, + }, + "definition": {"dynamicRegistration": True}, + "references": {"dynamicRegistration": True}, + "documentHighlight": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": {"valueSet": list(range(1, 26))}, # All SymbolKind values + "hierarchicalDocumentSymbolSupport": True, + }, + "codeAction": { + "dynamicRegistration": True, + "codeActionLiteralSupport": { + "codeActionKind": { + "valueSet": [ + "", + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + ] + } + }, + }, + "codeLens": {"dynamicRegistration": True}, + "formatting": {"dynamicRegistration": True}, + "rangeFormatting": {"dynamicRegistration": True}, + "onTypeFormatting": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True}, + "documentLink": {"dynamicRegistration": True}, + "publishDiagnostics": { + "relatedInformation": True, + "versionSupport": False, + "tagSupport": {"valueSet": [1, 2]}, + }, + "implementation": {"dynamicRegistration": True}, + "typeDefinition": {"dynamicRegistration": True}, + "colorProvider": {"dynamicRegistration": True}, + "foldingRange": { + "dynamicRegistration": True, + "rangeLimit": 5000, + "lineFoldingOnly": True, + }, + "declaration": {"dynamicRegistration": True}, + "selectionRange": {"dynamicRegistration": True}, + }, + "window": { + "workDoneProgress": True, + }, + }, + "initializationOptions": { + # F# specific initialization options + "automaticWorkspaceInit": True, + "abstractClassStubGeneration": True, + "abstractClassStubGenerationObjectIdentifier": "this", + "abstractClassStubGenerationMethodBody": 'failwith "Not Implemented"', + "addFsiWatcher": False, + "codeLenses": {"signature": {"enabled": True}, "references": {"enabled": True}}, + "disableInMemoryProjectReferences": False, + "dotNetRoot": self._get_dotnet_root(), + "enableMSBuildProjectGraph": False, + "excludeProjectDirectories": ["paket-files"], + "externalAutocomplete": False, + "fsac": {"attachDebugger": False, "silencedLogs": [], "conserveMemory": False, "netCoreDllPath": ""}, + "fsiExtraParameters": [], + "generateBinlog": False, + "interfaceStubGeneration": True, + "interfaceStubGenerationObjectIdentifier": "this", + "interfaceStubGenerationMethodBody": 'failwith "Not Implemented"', + "keywordsAutocomplete": True, + "linter": True, + "pipelineHints": {"enabled": True}, + "recordStubGeneration": True, + "recordStubGenerationBody": 'failwith "Not Implemented"', + "resolveNamespaces": True, + "saveOnlyOpenFiles": False, + "showProjectExplorerIn": ["ionide", "solution"], + "simplifyNameAnalyzer": True, + "smartIndent": False, + "suggestGitignore": True, + "suggestSdkScripts": True, + "unionCaseStubGeneration": True, + "unionCaseStubGenerationBody": 'failwith "Not Implemented"', + "unusedDeclarationsAnalyzer": True, + "unusedOpensAnalyzer": True, + "verboseLogging": False, + "workspaceModePeekDeepLevel": 2, + "workspacePath": self.repository_root_path, + }, + "trace": "off", + } + + return initialize_params # type: ignore + + def _get_dotnet_root(self) -> str: + """ + Get the .NET root directory. + """ + dotnet_exe = shutil.which("dotnet") + if dotnet_exe: + # Try to get the installation path + try: + import subprocess + + result = subprocess.run([dotnet_exe, "--info"], capture_output=True, text=True, check=True) + lines = result.stdout.split("\n") + for line in lines: + if "Base Path:" in line or "Base path:" in line: + base_path = line.split(":", 1)[1].strip() + # Get the parent directory (remove 'sdk/version' part) + return str(Path(base_path).parent.parent) + except (subprocess.CalledProcessError, Exception): + pass + + # Fallback: use the directory containing dotnet executable + if dotnet_exe: + return str(Path(dotnet_exe).parent) + + return "" + + def _start_server(self) -> None: + """ + Start the F# Language Server with custom handlers. + """ + + def handle_window_log_message(params: dict) -> None: + """Handle window/logMessage from the LSP server.""" + message = params.get("message", "") + message_type = params.get("type", 1) + + # Map LSP log levels to Python logging levels + level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} + level = level_map.get(message_type, logging.INFO) + + log.log(level, f"FsAutoComplete: {message}") + + def handle_window_show_message(params: dict) -> None: + """Handle window/showMessage from the LSP server.""" + message = params.get("message", "") + message_type = params.get("type", 1) + + # Map LSP message types to Python logging levels + level_map = {1: logging.ERROR, 2: logging.WARNING, 3: logging.INFO, 4: logging.DEBUG} + level = level_map.get(message_type, logging.INFO) + + log.log(level, f"FsAutoComplete Message: {message}") + + def handle_workspace_configuration(params: dict) -> list: + """Handle workspace/configuration requests from the LSP server.""" + # Return empty configuration for now + items = params.get("items", []) + return [None] * len(items) + + def handle_client_register_capability(params: dict) -> None: + """Handle client/registerCapability requests from the LSP server.""" + # For now, just acknowledge the registration + return + + def handle_client_unregister_capability(params: dict) -> None: + """Handle client/unregisterCapability requests from the LSP server.""" + # For now, just acknowledge the unregistration + return + + def handle_work_done_progress_create(params: dict) -> None: + """Handle window/workDoneProgress/create requests from the LSP server.""" + # Just acknowledge the request - we don't need to track progress for now + return + + # Register custom handlers + self.server.on_notification("window/logMessage", handle_window_log_message) + self.server.on_notification("window/showMessage", handle_window_show_message) + self.server.on_request("workspace/configuration", handle_workspace_configuration) + self.server.on_request("client/registerCapability", handle_client_register_capability) + self.server.on_request("client/unregisterCapability", handle_client_unregister_capability) + self.server.on_request("window/workDoneProgress/create", handle_work_done_progress_create) + + log.info("Starting FsAutoComplete F# language server process") + + try: + self.server.start() + except Exception as e: + log.error(f"Failed to start F# language server process: {e}") + raise SolidLSPException(f"Failed to start F# language server: {e}") + + # Send initialization + initialize_params = self._get_initialize_params() + + log.info("Sending initialize request to F# language server") + try: + self.server.send.initialize(initialize_params) + log.debug("Received initialize response from F# language server") + except Exception as e: + raise SolidLSPException(f"Failed to initialize F# language server for {self.repository_root_path}: {e}") from e + + # Complete initialization + self.server.notify.initialized({}) + + log.info("F# language server initialized successfully") + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + """ + F# projects can be large and may need more time for cross-file analysis. + """ + return 15.0 # 15 seconds should be sufficient for most F# projects diff --git a/src/solidlsp/language_servers/scala_language_server.py b/src/solidlsp/language_servers/scala_language_server.py index 79a0e205..a274f652 100644 --- a/src/solidlsp/language_servers/scala_language_server.py +++ b/src/solidlsp/language_servers/scala_language_server.py @@ -61,11 +61,12 @@ class ScalaLanguageServer(SolidLanguageServer): os.makedirs(metals_home, exist_ok=True) metals_executable = os.path.join(metals_home, "metals") coursier_command_path = shutil.which("coursier") - assert coursier_command_path is not None, "coursier is not installed or not in PATH." cs_command_path = shutil.which("cs") + assert cs_command_path is not None or coursier_command_path is not None, "coursier is not installed or not in PATH." if not os.path.exists(metals_executable): if not cs_command_path: + assert coursier_command_path is not None log.info("'cs' command not found. Trying to install it using 'coursier'.") try: log.info("Running 'coursier setup --yes' to install 'cs'...") diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 10c5cc69..4734da45 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -12,6 +12,7 @@ from typing import Any, cast from overrides import override from sensai.util.logging import LogTime +from solidlsp import ls_types from solidlsp.ls import SolidLanguageServer from solidlsp.ls_config import LanguageServerConfig from solidlsp.ls_utils import PlatformId, PlatformUtils @@ -39,9 +40,32 @@ if not PlatformUtils.get_platform_id().value.startswith("win"): pass +def prefer_non_node_modules_definition(definitions: list[ls_types.Location]) -> ls_types.Location: + """ + Select the preferred definition, preferring source files over type definitions. + + TypeScript language servers often return both type definitions (.d.ts files + in node_modules) and source definitions. This function prefers: + 1. Files not in node_modules + 2. Falls back to first definition if all are in node_modules + + :param definitions: A non-empty list of definition locations. + :return: The preferred definition location. + """ + for d in definitions: + rel_path = d.get("relativePath", "") + if rel_path and "node_modules" not in rel_path: + return d + return definitions[0] + + class TypeScriptLanguageServer(SolidLanguageServer): """ Provides TypeScript specific instantiation of the LanguageServer class. Contains various configurations and settings specific to TypeScript. + + You can pass the following entries in ls_specific_settings["typescript"]: + - typescript_version: Version of TypeScript to install (default: "5.9.3") + - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") """ def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): @@ -91,18 +115,23 @@ class TypeScriptLanguageServer(SolidLanguageServer): ] assert platform_id in valid_platforms, f"Platform {platform_id} is not supported for multilspy javascript/typescript at the moment" + # Get version settings from ls_specific_settings or use defaults + language_specific_config = solidlsp_settings.get_ls_specific_settings(cls.get_language_enum_instance()) + typescript_version = language_specific_config.get("typescript_version", "5.9.3") + typescript_language_server_version = language_specific_config.get("typescript_language_server_version", "5.1.3") + deps = RuntimeDependencyCollection( [ RuntimeDependency( id="typescript", description="typescript package", - command=["npm", "install", "--prefix", "./", "typescript@5.5.4"], + command=["npm", "install", "--prefix", "./", f"typescript@{typescript_version}"], platform_id="any", ), RuntimeDependency( id="typescript-language-server", description="typescript-language-server package", - command=["npm", "install", "--prefix", "./", "typescript-language-server@4.3.3"], + command=["npm", "install", "--prefix", "./", f"typescript-language-server@{typescript_language_server_version}"], platform_id="any", ), ] @@ -114,19 +143,39 @@ class TypeScriptLanguageServer(SolidLanguageServer): is_npm_installed = shutil.which("npm") is not None assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - # Verify both node and npm are installed - is_node_installed = shutil.which("node") is not None - assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." - is_npm_installed = shutil.which("npm") is not None - assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." - - # Install typescript and typescript-language-server if not already installed + # Install typescript and typescript-language-server if not already installed or version mismatch tsserver_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "ts-lsp") tsserver_executable_path = os.path.join(tsserver_ls_dir, "node_modules", ".bin", "typescript-language-server") + + # Check if installation is needed based on executable AND version + version_file = os.path.join(tsserver_ls_dir, ".installed_version") + expected_version = f"{typescript_version}_{typescript_language_server_version}" + + needs_install = False if not os.path.exists(tsserver_executable_path): - log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}. Installing...") + log.info(f"Typescript Language Server executable not found at {tsserver_executable_path}.") + needs_install = True + elif os.path.exists(version_file): + with open(version_file) as f: + installed_version = f.read().strip() + if installed_version != expected_version: + log.info( + f"TypeScript Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..." + ) + needs_install = True + else: + # No version file exists, assume old installation needs refresh + log.info("TypeScript Language Server version file not found. Reinstalling to ensure correct version...") + needs_install = True + + if needs_install: + log.info("Installing TypeScript Language Server dependencies...") with LogTime("Installation of TypeScript language server dependencies", logger=log): deps.install(tsserver_ls_dir) + # Write version marker file + with open(version_file, "w") as f: + f.write(expected_version) + log.info("TypeScript language server dependencies installed successfully") if not os.path.exists(tsserver_executable_path): raise FileNotFoundError( @@ -134,8 +183,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): ) return [tsserver_executable_path, "--stdio"] - @staticmethod - def _get_initialize_params(repository_absolute_path: str) -> InitializeParams: + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the TypeScript Language Server. """ @@ -156,6 +204,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, "signatureHelp": {"dynamicRegistration": True}, "codeAction": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, }, "workspace": { "workspaceFolders": True, @@ -252,3 +301,7 @@ class TypeScriptLanguageServer(SolidLanguageServer): @override def _get_wait_time_for_cross_file_referencing(self) -> float: return 1 + + @override + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + return prefer_non_node_modules_definition(definitions) diff --git a/src/solidlsp/language_servers/vue_language_server.py b/src/solidlsp/language_servers/vue_language_server.py new file mode 100644 index 00000000..ee419345 --- /dev/null +++ b/src/solidlsp/language_servers/vue_language_server.py @@ -0,0 +1,809 @@ +""" +Vue Language Server implementation using @vue/language-server (Volar) with companion TypeScript LS. +Operates in hybrid mode: Vue LS handles .vue files, TypeScript LS handles .ts/.js files. +""" + +import logging +import os +import pathlib +import shutil +import threading +from pathlib import Path +from time import sleep +from typing import Any + +from overrides import override + +from solidlsp import ls_types +from solidlsp.language_servers.common import RuntimeDependency, RuntimeDependencyCollection +from solidlsp.language_servers.typescript_language_server import ( + TypeScriptLanguageServer, + prefer_non_node_modules_definition, +) +from solidlsp.ls import LSPFileBuffer, SolidLanguageServer +from solidlsp.ls_config import Language, LanguageServerConfig +from solidlsp.ls_exceptions import SolidLSPException +from solidlsp.ls_types import Location +from solidlsp.ls_utils import PathUtils +from solidlsp.lsp_protocol_handler import lsp_types +from solidlsp.lsp_protocol_handler.lsp_types import DocumentSymbol, ExecuteCommandParams, InitializeParams, SymbolInformation +from solidlsp.lsp_protocol_handler.server import ProcessLaunchInfo +from solidlsp.settings import SolidLSPSettings + +log = logging.getLogger(__name__) + + +class VueTypeScriptServer(TypeScriptLanguageServer): + """TypeScript LS configured with @vue/typescript-plugin for Vue file support.""" + + _pending_ts_ls_executable: list[str] | None = None + + @classmethod + @override + def get_language_enum_instance(cls) -> Language: + """Return TYPESCRIPT since this is a TypeScript language server variant. + + Note: VueTypeScriptServer is a companion server that uses TypeScript's language server + with the Vue TypeScript plugin. It reports as TYPESCRIPT to maintain compatibility + with the TypeScript language server infrastructure. + """ + return Language.TYPESCRIPT + + @classmethod + @override + def _setup_runtime_dependencies(cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings) -> list[str]: + if cls._pending_ts_ls_executable is not None: + return cls._pending_ts_ls_executable + return ["typescript-language-server", "--stdio"] + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + """Return the correct language ID for files. + + Vue files must be opened with language ID "vue" for the @vue/typescript-plugin + to process them correctly. The plugin is configured with "languages": ["vue"] + in the initialization options. + """ + ext = os.path.splitext(relative_file_path)[1].lower() + if ext == ".vue": + return "vue" + elif ext in (".ts", ".tsx", ".mts", ".cts"): + return "typescript" + elif ext in (".js", ".jsx", ".mjs", ".cjs"): + return "javascript" + else: + return "typescript" + + def __init__( + self, + config: LanguageServerConfig, + repository_root_path: str, + solidlsp_settings: SolidLSPSettings, + vue_plugin_path: str, + tsdk_path: str, + ts_ls_executable_path: list[str], + ): + self._vue_plugin_path = vue_plugin_path + self._custom_tsdk_path = tsdk_path + VueTypeScriptServer._pending_ts_ls_executable = ts_ls_executable_path + super().__init__(config, repository_root_path, solidlsp_settings) + VueTypeScriptServer._pending_ts_ls_executable = None + + @override + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + params = super()._get_initialize_params(repository_absolute_path) + + params["initializationOptions"] = { + "plugins": [ + { + "name": "@vue/typescript-plugin", + "location": self._vue_plugin_path, + "languages": ["vue"], + } + ], + "tsserver": { + "path": self._custom_tsdk_path, + }, + } + + if "workspace" in params["capabilities"]: + params["capabilities"]["workspace"]["executeCommand"] = {"dynamicRegistration": True} + + return params + + @override + def _start_server(self) -> None: + def workspace_configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + + self.server.on_request("workspace/configuration", workspace_configuration_handler) + super()._start_server() + + +class VueLanguageServer(SolidLanguageServer): + """ + Language server for Vue Single File Components using @vue/language-server (Volar) with companion TypeScript LS. + + You can pass the following entries in ls_specific_settings["vue"]: + - vue_language_server_version: Version of @vue/language-server to install (default: "3.1.5") + + Note: TypeScript versions are configured via ls_specific_settings["typescript"]: + - typescript_version: Version of TypeScript to install (default: "5.9.3") + - typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3") + """ + + TS_SERVER_READY_TIMEOUT = 5.0 + VUE_SERVER_READY_TIMEOUT = 3.0 + # Windows requires more time due to slower I/O and process operations. + VUE_INDEXING_WAIT_TIME = 4.0 if os.name == "nt" else 2.0 + + def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings): + vue_lsp_executable_path, self.tsdk_path, self._ts_ls_cmd = self._setup_runtime_dependencies(config, solidlsp_settings) + self._vue_ls_dir = os.path.join(self.ls_resources_dir(solidlsp_settings), "vue-lsp") + super().__init__( + config, + repository_root_path, + ProcessLaunchInfo(cmd=vue_lsp_executable_path, cwd=repository_root_path), + "vue", + solidlsp_settings, + ) + self.server_ready = threading.Event() + self.initialize_searcher_command_available = threading.Event() + self._ts_server: VueTypeScriptServer | None = None + self._ts_server_started = False + self._vue_files_indexed = False + self._indexed_vue_file_uris: list[str] = [] + + @override + def is_ignored_dirname(self, dirname: str) -> bool: + return super().is_ignored_dirname(dirname) or dirname in [ + "node_modules", + "dist", + "build", + "coverage", + ".nuxt", + ".output", + ] + + @override + def _get_language_id_for_file(self, relative_file_path: str) -> str: + ext = os.path.splitext(relative_file_path)[1].lower() + if ext == ".vue": + return "vue" + elif ext in (".ts", ".tsx", ".mts", ".cts"): + return "typescript" + elif ext in (".js", ".jsx", ".mjs", ".cjs"): + return "javascript" + else: + return "vue" + + def _is_typescript_file(self, file_path: str) -> bool: + ext = os.path.splitext(file_path)[1].lower() + return ext in (".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs") + + def _find_all_vue_files(self) -> list[str]: + vue_files = [] + repo_path = Path(self.repository_root_path) + + for vue_file in repo_path.rglob("*.vue"): + try: + relative_path = str(vue_file.relative_to(repo_path)) + if "node_modules" not in relative_path and not relative_path.startswith("."): + vue_files.append(relative_path) + except Exception as e: + log.debug(f"Error processing Vue file {vue_file}: {e}") + + return vue_files + + def _ensure_vue_files_indexed_on_ts_server(self) -> None: + if self._vue_files_indexed: + return + + assert self._ts_server is not None + log.info("Indexing .vue files on TypeScript server for cross-file references") + vue_files = self._find_all_vue_files() + log.debug(f"Found {len(vue_files)} .vue files to index") + + for vue_file in vue_files: + try: + with self._ts_server.open_file(vue_file) as file_buffer: + file_buffer.ref_count += 1 + self._indexed_vue_file_uris.append(file_buffer.uri) + except Exception as e: + log.debug(f"Failed to open {vue_file} on TS server: {e}") + + self._vue_files_indexed = True + log.info("Vue file indexing on TypeScript server complete") + + sleep(self._get_vue_indexing_wait_time()) + log.debug("Wait period after Vue file indexing complete") + + def _get_vue_indexing_wait_time(self) -> float: + return self.VUE_INDEXING_WAIT_TIME + + def _send_references_request(self, relative_file_path: str, line: int, column: int) -> list[lsp_types.Location] | None: + uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path)) + request_params = { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": column}, + "context": {"includeDeclaration": False}, + } + + return self.server.send.references(request_params) # type: ignore[arg-type] + + def _send_ts_references_request(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + assert self._ts_server is not None + uri = PathUtils.path_to_uri(os.path.join(self.repository_root_path, relative_file_path)) + request_params = { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": column}, + "context": {"includeDeclaration": True}, + } + + with self._ts_server.open_file(relative_file_path): + response = self._ts_server.handler.send.references(request_params) # type: ignore[arg-type] + + result: list[ls_types.Location] = [] + if response is not None: + for item in response: + abs_path = PathUtils.uri_to_path(item["uri"]) + if not Path(abs_path).is_relative_to(self.repository_root_path): + log.debug(f"Found reference outside repository: {abs_path}, skipping") + continue + + rel_path = Path(abs_path).relative_to(self.repository_root_path) + if self.is_ignored_path(str(rel_path)): + log.debug(f"Ignoring reference in {rel_path}") + continue + + new_item: dict = {} + new_item.update(item) # type: ignore[arg-type] + new_item["absolutePath"] = str(abs_path) + new_item["relativePath"] = str(rel_path) + result.append(ls_types.Location(**new_item)) # type: ignore + + return result + + def request_file_references(self, relative_file_path: str) -> list: + if not self.server_started: + log.error("request_file_references called before Language Server started") + raise SolidLSPException("Language Server not started") + + absolute_file_path = os.path.join(self.repository_root_path, relative_file_path) + uri = PathUtils.path_to_uri(absolute_file_path) + + request_params = {"textDocument": {"uri": uri}} + + log.info(f"Sending volar/client/findFileReference request for {relative_file_path}") + log.info(f"Request URI: {uri}") + log.info(f"Request params: {request_params}") + + try: + with self.open_file(relative_file_path): + log.debug(f"Sending volar/client/findFileReference for {relative_file_path}") + log.debug(f"Request params: {request_params}") + + response = self.server.send_request("volar/client/findFileReference", request_params) + + log.debug(f"Received response type: {type(response)}") + + log.info(f"Received file references response: {response}") + log.info(f"Response type: {type(response)}") + + if response is None: + log.debug(f"No file references found for {relative_file_path}") + return [] + + # Response should be an array of Location objects + if not isinstance(response, list): + log.warning(f"Unexpected response format from volar/client/findFileReference: {type(response)}") + return [] + + ret: list[Location] = [] + for item in response: + if not isinstance(item, dict) or "uri" not in item: + log.debug(f"Skipping invalid location item: {item}") + continue + + abs_path = PathUtils.uri_to_path(item["uri"]) # type: ignore[arg-type] + if not Path(abs_path).is_relative_to(self.repository_root_path): + log.warning(f"Found file reference outside repository: {abs_path}, skipping") + continue + + rel_path = Path(abs_path).relative_to(self.repository_root_path) + if self.is_ignored_path(str(rel_path)): + log.debug(f"Ignoring file reference in {rel_path}") + continue + + new_item: dict = {} + new_item.update(item) # type: ignore[arg-type] + new_item["absolutePath"] = str(abs_path) + new_item["relativePath"] = str(rel_path) + ret.append(Location(**new_item)) # type: ignore + + log.debug(f"Found {len(ret)} file references for {relative_file_path}") + return ret + + except Exception as e: + log.warning(f"Error requesting file references for {relative_file_path}: {e}") + return [] + + @override + def request_references(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + if not self.server_started: + log.error("request_references called before Language Server started") + raise SolidLSPException("Language Server not started") + + if not self._has_waited_for_cross_file_references: + sleep(self._get_wait_time_for_cross_file_referencing()) + self._has_waited_for_cross_file_references = True + + self._ensure_vue_files_indexed_on_ts_server() + symbol_refs = self._send_ts_references_request(relative_file_path, line=line, column=column) + + if relative_file_path.endswith(".vue"): + log.info(f"Attempting to find file-level references for Vue component {relative_file_path}") + file_refs = self.request_file_references(relative_file_path) + log.info(f"file_refs result: {len(file_refs)} references found") + + seen = set() + for ref in symbol_refs: + key = (ref["uri"], ref["range"]["start"]["line"], ref["range"]["start"]["character"]) + seen.add(key) + + for file_ref in file_refs: + key = (file_ref["uri"], file_ref["range"]["start"]["line"], file_ref["range"]["start"]["character"]) + if key not in seen: + symbol_refs.append(file_ref) + seen.add(key) + + log.info(f"Total references for {relative_file_path}: {len(symbol_refs)} (symbol refs + file refs, deduplicated)") + + return symbol_refs + + @override + def request_definition(self, relative_file_path: str, line: int, column: int) -> list[ls_types.Location]: + if not self.server_started: + log.error("request_definition called before Language Server started") + raise SolidLSPException("Language Server not started") + + assert self._ts_server is not None + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_definition(relative_file_path, line, column) + + @override + def request_rename_symbol_edit(self, relative_file_path: str, line: int, column: int, new_name: str) -> ls_types.WorkspaceEdit | None: + if not self.server_started: + log.error("request_rename_symbol_edit called before Language Server started") + raise SolidLSPException("Language Server not started") + + assert self._ts_server is not None + with self._ts_server.open_file(relative_file_path): + return self._ts_server.request_rename_symbol_edit(relative_file_path, line, column, new_name) + + @classmethod + def _setup_runtime_dependencies( + cls, config: LanguageServerConfig, solidlsp_settings: SolidLSPSettings + ) -> tuple[list[str], str, list[str]]: + is_node_installed = shutil.which("node") is not None + assert is_node_installed, "node is not installed or isn't in PATH. Please install NodeJS and try again." + is_npm_installed = shutil.which("npm") is not None + assert is_npm_installed, "npm is not installed or isn't in PATH. Please install npm and try again." + + # Get TypeScript version settings from TypeScript language server settings + typescript_config = solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT) + typescript_version = typescript_config.get("typescript_version", "5.9.3") + typescript_language_server_version = typescript_config.get("typescript_language_server_version", "5.1.3") + vue_config = solidlsp_settings.get_ls_specific_settings(Language.VUE) + vue_language_server_version = vue_config.get("vue_language_server_version", "3.1.5") + + deps = RuntimeDependencyCollection( + [ + RuntimeDependency( + id="vue-language-server", + description="Vue language server package (Volar)", + command=["npm", "install", "--prefix", "./", f"@vue/language-server@{vue_language_server_version}"], + platform_id="any", + ), + RuntimeDependency( + id="typescript", + description="TypeScript (required for tsdk)", + command=["npm", "install", "--prefix", "./", f"typescript@{typescript_version}"], + platform_id="any", + ), + RuntimeDependency( + id="typescript-language-server", + description="TypeScript language server (for Vue LS 3.x tsserver forwarding)", + command=[ + "npm", + "install", + "--prefix", + "./", + f"typescript-language-server@{typescript_language_server_version}", + ], + platform_id="any", + ), + ] + ) + + vue_ls_dir = os.path.join(cls.ls_resources_dir(solidlsp_settings), "vue-lsp") + vue_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "vue-language-server") + ts_ls_executable_path = os.path.join(vue_ls_dir, "node_modules", ".bin", "typescript-language-server") + + if os.name == "nt": + vue_executable_path += ".cmd" + ts_ls_executable_path += ".cmd" + + tsdk_path = os.path.join(vue_ls_dir, "node_modules", "typescript", "lib") + + # Check if installation is needed based on executables AND version + version_file = os.path.join(vue_ls_dir, ".installed_version") + expected_version = f"{vue_language_server_version}_{typescript_version}_{typescript_language_server_version}" + + needs_install = False + if not os.path.exists(vue_executable_path) or not os.path.exists(ts_ls_executable_path): + log.info("Vue/TypeScript Language Server executables not found.") + needs_install = True + elif os.path.exists(version_file): + with open(version_file) as f: + installed_version = f.read().strip() + if installed_version != expected_version: + log.info( + f"Vue Language Server version mismatch: installed={installed_version}, expected={expected_version}. Reinstalling..." + ) + needs_install = True + else: + # No version file exists, assume old installation needs refresh + log.info("Vue Language Server version file not found. Reinstalling to ensure correct version...") + needs_install = True + + if needs_install: + log.info("Installing Vue/TypeScript Language Server dependencies...") + deps.install(vue_ls_dir) + # Write version marker file + with open(version_file, "w") as f: + f.write(expected_version) + log.info("Vue language server dependencies installed successfully") + + if not os.path.exists(vue_executable_path): + raise FileNotFoundError( + f"vue-language-server executable not found at {vue_executable_path}, something went wrong with the installation." + ) + + if not os.path.exists(ts_ls_executable_path): + raise FileNotFoundError( + f"typescript-language-server executable not found at {ts_ls_executable_path}, something went wrong with the installation." + ) + + return [vue_executable_path, "--stdio"], tsdk_path, [ts_ls_executable_path, "--stdio"] + + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: + root_uri = pathlib.Path(repository_absolute_path).as_uri() + initialize_params = { + "locale": "en", + "capabilities": { + "textDocument": { + "synchronization": {"didSave": True, "dynamicRegistration": True}, + "completion": {"dynamicRegistration": True, "completionItem": {"snippetSupport": True}}, + "definition": {"dynamicRegistration": True, "linkSupport": True}, + "references": {"dynamicRegistration": True}, + "documentSymbol": { + "dynamicRegistration": True, + "hierarchicalDocumentSymbolSupport": True, + "symbolKind": {"valueSet": list(range(1, 27))}, + }, + "hover": {"dynamicRegistration": True, "contentFormat": ["markdown", "plaintext"]}, + "signatureHelp": {"dynamicRegistration": True}, + "codeAction": {"dynamicRegistration": True}, + "rename": {"dynamicRegistration": True, "prepareSupport": True}, + }, + "workspace": { + "workspaceFolders": True, + "didChangeConfiguration": {"dynamicRegistration": True}, + "symbol": {"dynamicRegistration": True}, + }, + }, + "processId": os.getpid(), + "rootPath": repository_absolute_path, + "rootUri": root_uri, + "workspaceFolders": [ + { + "uri": root_uri, + "name": os.path.basename(repository_absolute_path), + } + ], + "initializationOptions": { + "vue": { + "hybridMode": True, + }, + "typescript": { + "tsdk": self.tsdk_path, + }, + }, + } + return initialize_params # type: ignore + + def _start_typescript_server(self) -> None: + try: + vue_ts_plugin_path = os.path.join(self._vue_ls_dir, "node_modules", "@vue", "typescript-plugin") + + ts_config = LanguageServerConfig( + code_language=Language.TYPESCRIPT, + trace_lsp_communication=False, + ) + + log.info("Creating companion VueTypeScriptServer") + self._ts_server = VueTypeScriptServer( + config=ts_config, + repository_root_path=self.repository_root_path, + solidlsp_settings=self._solidlsp_settings, + vue_plugin_path=vue_ts_plugin_path, + tsdk_path=self.tsdk_path, + ts_ls_executable_path=self._ts_ls_cmd, + ) + + log.info("Starting companion TypeScript server") + self._ts_server.start() + + log.info("Waiting for companion TypeScript server to be ready...") + if not self._ts_server.server_ready.wait(timeout=self.TS_SERVER_READY_TIMEOUT): + log.warning( + f"Timeout waiting for companion TypeScript server to be ready after {self.TS_SERVER_READY_TIMEOUT} seconds, proceeding anyway" + ) + self._ts_server.server_ready.set() + + self._ts_server_started = True + log.info("Companion TypeScript server ready") + except Exception as e: + log.error(f"Error starting TypeScript server: {e}") + self._ts_server = None + self._ts_server_started = False + raise + + def _forward_tsserver_request(self, method: str, params: dict) -> Any: + if self._ts_server is None: + log.error("Cannot forward tsserver request - TypeScript server not started") + return None + + try: + execute_params: ExecuteCommandParams = { + "command": "typescript.tsserverRequest", + "arguments": [method, params, {"isAsync": True, "lowPriority": True}], + } + result = self._ts_server.handler.send.execute_command(execute_params) + log.debug(f"TypeScript server raw response for {method}: {result}") + + if isinstance(result, dict) and "body" in result: + return result["body"] + return result + except Exception as e: + log.error(f"Error forwarding tsserver request {method}: {e}") + return None + + def _cleanup_indexed_vue_files(self) -> None: + if not self._indexed_vue_file_uris or self._ts_server is None: + return + + log.debug(f"Cleaning up {len(self._indexed_vue_file_uris)} indexed Vue files") + for uri in self._indexed_vue_file_uris: + try: + if uri in self._ts_server.open_file_buffers: + file_buffer = self._ts_server.open_file_buffers[uri] + file_buffer.ref_count -= 1 + + if file_buffer.ref_count == 0: + self._ts_server.server.notify.did_close_text_document({"textDocument": {"uri": uri}}) + del self._ts_server.open_file_buffers[uri] + log.debug(f"Closed indexed Vue file: {uri}") + except Exception as e: + log.debug(f"Error closing indexed Vue file {uri}: {e}") + + self._indexed_vue_file_uris.clear() + + def _stop_typescript_server(self) -> None: + if self._ts_server is not None: + try: + log.info("Stopping companion TypeScript server") + self._ts_server.stop() + except Exception as e: + log.warning(f"Error stopping TypeScript server: {e}") + finally: + self._ts_server = None + self._ts_server_started = False + + @override + def _start_server(self) -> None: + self._start_typescript_server() + + def register_capability_handler(params: dict) -> None: + assert "registrations" in params + for registration in params["registrations"]: + if registration["method"] == "workspace/executeCommand": + self.initialize_searcher_command_available.set() + return + + def configuration_handler(params: dict) -> list: + items = params.get("items", []) + return [{} for _ in items] + + def do_nothing(params: dict) -> None: + return + + def window_log_message(msg: dict) -> None: + log.info(f"LSP: window/logMessage: {msg}") + message_text = msg.get("message", "") + if "initialized" in message_text.lower() or "ready" in message_text.lower(): + log.info("Vue language server ready signal detected") + self.server_ready.set() + self.completions_available.set() + + def tsserver_request_notification_handler(params: list) -> None: + try: + if params and len(params) > 0 and len(params[0]) >= 2: + request_id = params[0][0] + method = params[0][1] + method_params = params[0][2] if len(params[0]) > 2 else {} + log.debug(f"Received tsserver/request: id={request_id}, method={method}") + + if method == "_vue:projectInfo": + file_path = method_params.get("file", "") + tsconfig_path = self._find_tsconfig_for_file(file_path) + result = {"configFileName": tsconfig_path} if tsconfig_path else None + response = [[request_id, result]] + self.server.notify.send_notification("tsserver/response", response) + log.debug(f"Sent tsserver/response for projectInfo: {tsconfig_path}") + else: + result = self._forward_tsserver_request(method, method_params) + response = [[request_id, result]] + self.server.notify.send_notification("tsserver/response", response) + log.debug(f"Forwarded tsserver/response for {method}: {result}") + else: + log.warning(f"Unexpected tsserver/request params format: {params}") + except Exception as e: + log.error(f"Error handling tsserver/request: {e}") + + self.server.on_request("client/registerCapability", register_capability_handler) + self.server.on_request("workspace/configuration", configuration_handler) + self.server.on_notification("tsserver/request", tsserver_request_notification_handler) + self.server.on_notification("window/logMessage", window_log_message) + self.server.on_notification("$/progress", do_nothing) + self.server.on_notification("textDocument/publishDiagnostics", do_nothing) + + log.info("Starting Vue server process") + self.server.start() + initialize_params = self._get_initialize_params(self.repository_root_path) + + log.info("Sending initialize request from LSP client to LSP server and awaiting response") + init_response = self.server.send.initialize(initialize_params) + log.debug(f"Received initialize response from Vue server: {init_response}") + + assert init_response["capabilities"]["textDocumentSync"] in [1, 2] + + self.server.notify.initialized({}) + + log.info("Waiting for Vue language server to be ready...") + if not self.server_ready.wait(timeout=self.VUE_SERVER_READY_TIMEOUT): + log.info("Timeout waiting for Vue server ready signal, proceeding anyway") + self.server_ready.set() + self.completions_available.set() + else: + log.info("Vue server initialization complete") + + def _find_tsconfig_for_file(self, file_path: str) -> str | None: + if not file_path: + tsconfig_path = os.path.join(self.repository_root_path, "tsconfig.json") + return tsconfig_path if os.path.exists(tsconfig_path) else None + + current_dir = os.path.dirname(file_path) + repo_root = os.path.abspath(self.repository_root_path) + + while current_dir and current_dir.startswith(repo_root): + tsconfig_path = os.path.join(current_dir, "tsconfig.json") + if os.path.exists(tsconfig_path): + return tsconfig_path + parent = os.path.dirname(current_dir) + if parent == current_dir: + break + current_dir = parent + + tsconfig_path = os.path.join(repo_root, "tsconfig.json") + return tsconfig_path if os.path.exists(tsconfig_path) else None + + @override + def _get_wait_time_for_cross_file_referencing(self) -> float: + return 5.0 + + @override + def stop(self, shutdown_timeout: float = 5.0) -> None: + self._cleanup_indexed_vue_files() + self._stop_typescript_server() + super().stop(shutdown_timeout) + + @override + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + return prefer_non_node_modules_definition(definitions) + + @override + def _request_document_symbols( + self, relative_file_path: str, file_data: LSPFileBuffer | None + ) -> list[SymbolInformation] | list[DocumentSymbol] | None: + """ + Override to filter out shorthand property references in Vue files. + + In Vue, when using shorthand syntax in defineExpose like `defineExpose({ pressCount })`, + the Vue LSP returns both: + - The Variable definition (e.g., `const pressCount = ref(0)`) + - A Property symbol for the shorthand reference (e.g., `pressCount` in defineExpose) + + This causes duplicate symbols with the same name, which breaks symbol lookup. + We filter out Property symbols that have a matching Variable with the same name + at a different location (the definition), keeping only the definition. + """ + symbols = super()._request_document_symbols(relative_file_path, file_data) + + if symbols is None or len(symbols) == 0: + return symbols + + # Only process DocumentSymbol format (hierarchical symbols with children) + # SymbolInformation format doesn't have the same issue + if not isinstance(symbols[0], dict) or "range" not in symbols[0]: + return symbols + + return self._filter_shorthand_property_duplicates(symbols) + + def _filter_shorthand_property_duplicates( + self, symbols: list[DocumentSymbol] | list[SymbolInformation] + ) -> list[DocumentSymbol] | list[SymbolInformation]: + """ + Filter out Property symbols that have a matching Variable symbol with the same name. + + This handles Vue's shorthand property syntax in defineExpose, where the same + identifier appears as both a Variable definition and a Property reference. + """ + VARIABLE_KIND = 13 # SymbolKind.Variable + PROPERTY_KIND = 7 # SymbolKind.Property + + def filter_symbols(syms: list[dict]) -> list[dict]: + # Collect all Variable symbol names with their line numbers + variable_names: dict[str, set[int]] = {} + for sym in syms: + if sym.get("kind") == VARIABLE_KIND: + name = sym.get("name", "") + line = sym.get("range", {}).get("start", {}).get("line", -1) + if name not in variable_names: + variable_names[name] = set() + variable_names[name].add(line) + + # Filter: keep symbols that are either: + # 1. Not a Property, or + # 2. A Property without a matching Variable name at a different location + filtered = [] + for sym in syms: + name = sym.get("name", "") + kind = sym.get("kind") + line = sym.get("range", {}).get("start", {}).get("line", -1) + + # If it's a Property with a matching Variable name at a DIFFERENT line, skip it + if kind == PROPERTY_KIND and name in variable_names: + # Check if there's a Variable definition at a different line + var_lines = variable_names[name] + if any(var_line != line for var_line in var_lines): + # This is a shorthand reference, skip it + log.debug( + f"Filtering shorthand property reference '{name}' at line {line} " + f"(Variable definition exists at line(s) {var_lines})" + ) + continue + + # Recursively filter children + children = sym.get("children", []) + if children: + sym = dict(sym) # Create a copy to avoid mutating the original + sym["children"] = filter_symbols(children) + + filtered.append(sym) + + return filtered + + return filter_symbols(list(symbols)) # type: ignore diff --git a/src/solidlsp/ls.py b/src/solidlsp/ls.py index 30e3949c..f9ce89b8 100644 --- a/src/solidlsp/ls.py +++ b/src/solidlsp/ls.py @@ -463,6 +463,14 @@ class SolidLanguageServer(ABC): def _start_server(self) -> None: pass + def _get_language_id_for_file(self, relative_file_path: str) -> str: + """Return the language ID for a file. + + Override in subclasses to return file-specific language IDs. + Default implementation returns self.language_id. + """ + return self.language_id + @contextmanager def open_file(self, relative_file_path: str) -> Iterator[LSPFileBuffer]: """ @@ -488,13 +496,14 @@ class SolidLanguageServer(ABC): contents = FileUtils.read_file(absolute_file_path, self._encoding) version = 0 - self.open_file_buffers[uri] = LSPFileBuffer(uri, contents, version, self.language_id, 1) + language_id = self._get_language_id_for_file(relative_file_path) + self.open_file_buffers[uri] = LSPFileBuffer(uri, contents, version, language_id, 1) self.server.notify.did_open_text_document( { LSPConstants.TEXT_DOCUMENT: { # type: ignore LSPConstants.URI: uri, - LSPConstants.LANGUAGE_ID: self.language_id, + LSPConstants.LANGUAGE_ID: language_id, LSPConstants.VERSION: 0, LSPConstants.TEXT: contents, } @@ -1630,6 +1639,23 @@ class SolidLanguageServer(ABC): include_body=include_body, ) + def _get_preferred_definition(self, definitions: list[ls_types.Location]) -> ls_types.Location: + """ + Select the preferred definition from a list of definitions. + + When multiple definitions are returned (e.g., both source and type definitions), + this method determines which one to use. The base implementation simply returns + the first definition. + + Subclasses can override this method to implement language-specific preferences. + For example, TypeScript/Vue servers may prefer source files over .d.ts type + definition files. + + :param definitions: A non-empty list of definition locations. + :return: The preferred definition location. + """ + return definitions[0] + def request_defining_symbol( self, relative_file_path: str, @@ -1658,8 +1684,8 @@ class SolidLanguageServer(ABC): if not definitions: return None - # Use the first definition location - definition = definitions[0] + # Select the preferred definition (subclasses can override _get_preferred_definition) + definition = self._get_preferred_definition(definitions) def_path = definition["relativePath"] assert def_path is not None def_line = definition["range"]["start"]["line"] @@ -1874,5 +1900,14 @@ class SolidLanguageServer(ABC): def language_server(self) -> Self: return self + @property + def handler(self) -> SolidLanguageServerHandler: + """Access the underlying language server handler. + + Useful for advanced operations like sending custom commands + or registering notification handlers. + """ + return self.server + def is_running(self) -> bool: return self.server.is_running() diff --git a/src/solidlsp/ls_config.py b/src/solidlsp/ls_config.py index 160205e1..faf00055 100644 --- a/src/solidlsp/ls_config.py +++ b/src/solidlsp/ls_config.py @@ -55,11 +55,13 @@ class Language(str, Enum): NIX = "nix" ERLANG = "erlang" AL = "al" + FSHARP = "fsharp" REGO = "rego" SCALA = "scala" JULIA = "julia" FORTRAN = "fortran" HASKELL = "haskell" + VUE = "vue" # Experimental or deprecated Language Servers TYPESCRIPT_VTS = "typescript_vts" """Use the typescript language server through the natively bundled vscode extension via https://github.com/yioneko/vtsls""" @@ -98,6 +100,23 @@ class Language(str, Enum): def __str__(self) -> str: return self.value + def get_priority(self) -> int: + """ + :return: priority of the language for breaking ties between languages; higher is more important. + """ + # experimental languages have the lowest priority + if self.is_experimental(): + return 0 + # We assign lower priority to languages that are supersets of others, such that + # the "larger" language is only chosen when it matches more strongly + match self: + # languages that are supersets of others (Vue is superset of TypeScript/JavaScript) + case self.VUE: + return 1 + # regular languages + case _: + return 2 + def get_source_fn_matcher(self) -> FilenameMatcher: match self: case self.PYTHON | self.PYTHON_JEDI: @@ -158,6 +177,8 @@ class Language(str, Enum): return FilenameMatcher("*.erl", "*.hrl", "*.escript", "*.config", "*.app", "*.app.src") case self.AL: return FilenameMatcher("*.al", "*.dal") + case self.FSHARP: + return FilenameMatcher("*.fs", "*.fsx", "*.fsi") case self.REGO: return FilenameMatcher("*.rego") case self.MARKDOWN: @@ -172,6 +193,13 @@ class Language(str, Enum): ) case self.HASKELL: return FilenameMatcher("*.hs", "*.lhs") + case self.VUE: + path_patterns = ["*.vue"] + for prefix in ["c", "m", ""]: + for postfix in ["x", ""]: + for base_pattern in ["ts", "js"]: + path_patterns.append(f"*.{prefix}{base_pattern}{postfix}") + return FilenameMatcher(*path_patterns) case _: raise ValueError(f"Unhandled language: {self}") @@ -213,6 +241,10 @@ class Language(str, Enum): from solidlsp.language_servers.vts_language_server import VtsLanguageServer return VtsLanguageServer + case self.VUE: + from solidlsp.language_servers.vue_language_server import VueLanguageServer + + return VueLanguageServer case self.GO: from solidlsp.language_servers.gopls import Gopls @@ -317,6 +349,10 @@ class Language(str, Enum): from solidlsp.language_servers.haskell_language_server import HaskellLanguageServer return HaskellLanguageServer + case self.FSHARP: + from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer + + return FSharpLanguageServer case _: raise ValueError(f"Unhandled language: {self}") diff --git a/test/conftest.py b/test/conftest.py index c9fc31ce..84aa80f3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -7,7 +7,8 @@ import pytest from blib2to3.pgen2.parse import contextmanager from sensai.util.logging import configure -from serena.constants import SERENA_MANAGED_DIR_IN_HOME, SERENA_MANAGED_DIR_NAME +from serena.config.serena_config import SerenaPaths +from serena.constants import SERENA_MANAGED_DIR_NAME from serena.project import Project from serena.util.file_system import GitignoreParser from solidlsp.ls import SolidLanguageServer @@ -49,7 +50,9 @@ def _create_ls( return SolidLanguageServer.create( config, repo_path, - solidlsp_settings=SolidLSPSettings(solidlsp_dir=SERENA_MANAGED_DIR_IN_HOME, project_data_relative_path=SERENA_MANAGED_DIR_NAME), + solidlsp_settings=SolidLSPSettings( + solidlsp_dir=SerenaPaths().serena_user_home_dir, project_data_relative_path=SERENA_MANAGED_DIR_NAME + ), ) diff --git a/test/resources/repos/fsharp/test_repo/.gitignore b/test/resources/repos/fsharp/test_repo/.gitignore new file mode 100644 index 00000000..975cf30f --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +*.user +.vscode/ +.ionide/ \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/Calculator.fs b/test/resources/repos/fsharp/test_repo/Calculator.fs new file mode 100644 index 00000000..e332945b --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Calculator.fs @@ -0,0 +1,32 @@ +module Calculator + +/// Simple calculator functions +let add a b = a + b + +let subtract a b = a - b + +let multiply a b = a * b + +let divide a b = + if b = 0 then + failwith "Cannot divide by zero" + else + (float a) / (float b) + +/// More complex operations +let square x = x * x + +let factorial n = + if n <= 0 then 1 + else + let rec factorialHelper acc n = + if n <= 1 then acc + else factorialHelper (acc * n) (n - 1) + factorialHelper 1 n + +/// Calculator type with instance methods +type CalculatorClass() = + member this.Add(a, b) = add a b + member this.Subtract(a, b) = subtract a b + member this.Multiply(a, b) = multiply a b + member this.Divide(a, b) = divide a b \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/Models/Person.fs b/test/resources/repos/fsharp/test_repo/Models/Person.fs new file mode 100644 index 00000000..a2966923 --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Models/Person.fs @@ -0,0 +1,44 @@ +namespace Models + +/// Person record type +type Person = { + Name: string + Age: int + Email: string option +} + +module PersonModule = + /// Create a new person + let createPerson name age email = + { Name = name; Age = age; Email = email } + + /// Check if person is an adult + let isAdult person = + person.Age >= 18 + + /// Get display name + let getDisplayName person = + match person.Email with + | Some email -> $"{person.Name} ({email})" + | None -> person.Name + + /// Update person age + let updateAge newAge person = + { person with Age = newAge } + +/// Address type +type Address = { + Street: string + City: string + ZipCode: string + Country: string +} + +/// Employee type that extends Person concept +type Employee = { + Person: Person + EmployeeId: int + Department: string + Salary: decimal + Address: Address option +} \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/Program.fs b/test/resources/repos/fsharp/test_repo/Program.fs new file mode 100644 index 00000000..e5328ceb --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/Program.fs @@ -0,0 +1,37 @@ +module Program + +open Calculator +open Models + +[] +let main argv = + printfn "Hello, F# World!" + + // Test calculator functions + let result1 = add 5 3 + printfn "5 + 3 = %d" result1 + + let result2 = subtract 10 4 + printfn "10 - 4 = %d" result2 + + let result3 = multiply 6 7 + printfn "6 * 7 = %d" result3 + + let result4 = divide 15 3 + printfn "15 / 3 = %.2f" result4 + + // Test calculator class + let calc = CalculatorClass() + let classResult = calc.Add(20, 5) + printfn "Calculator class: 20 + 5 = %d" classResult + + // Test person module + let person = PersonModule.createPerson "Alice Smith" 25 (Some "alice@example.com") + printfn "Person: %s" (PersonModule.getDisplayName person) + printfn "Is adult: %b" (PersonModule.isAdult person) + + // Test factorial + let fact5 = factorial 5 + printfn "5! = %d" fact5 + + 0 // return success \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/README.md b/test/resources/repos/fsharp/test_repo/README.md new file mode 100644 index 00000000..fdef17cb --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/README.md @@ -0,0 +1,21 @@ +# F# Test Project + +This is a test F# project for testing Serena's F# language support. + +## Project Structure + +- `Program.fs` - Main program entry point +- `Calculator.fs` - Calculator functions and types +- `Models/Person.fs` - Person and Employee data models + +## Build + +```bash +dotnet build +``` + +## Run + +```bash +dotnet run +``` \ No newline at end of file diff --git a/test/resources/repos/fsharp/test_repo/TestProject.fsproj b/test/resources/repos/fsharp/test_repo/TestProject.fsproj new file mode 100644 index 00000000..39b621ff --- /dev/null +++ b/test/resources/repos/fsharp/test_repo/TestProject.fsproj @@ -0,0 +1,14 @@ + + + + Exe + net8.0 + + + + + + + + + \ No newline at end of file diff --git a/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java index a2c6459b..5a6a044e 100644 --- a/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java +++ b/test/resources/repos/java/test_repo/src/main/java/test_repo/Model.java @@ -10,4 +10,12 @@ public class Model { public String getName() { return name; } + + public String getName(int maxChars) { + if (name.length() <= maxChars) { + return name; + } else { + return name.substring(0, maxChars) + "..."; + } + } } diff --git a/test/resources/repos/vue/test_repo/.gitignore b/test/resources/repos/vue/test_repo/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/test/resources/repos/vue/test_repo/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/test/resources/repos/vue/test_repo/index.html b/test/resources/repos/vue/test_repo/index.html new file mode 100644 index 00000000..75e52237 --- /dev/null +++ b/test/resources/repos/vue/test_repo/index.html @@ -0,0 +1,13 @@ + + + + + + + Vue Calculator + + +
+ + + diff --git a/test/resources/repos/vue/test_repo/package-lock.json b/test/resources/repos/vue/test_repo/package-lock.json new file mode 100644 index 00000000..7a73f505 --- /dev/null +++ b/test/resources/repos/vue/test_repo/package-lock.json @@ -0,0 +1,1486 @@ +{ + "name": "vue-calculator-test-fixture", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vue-calculator-test-fixture", + "version": "1.0.0", + "dependencies": { + "pinia": "^2.1.0", + "vue": "^3.4.0" + }, + "devDependencies": { + "@vue/language-server": "^2.0.0", + "typescript": "~5.5.4" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/language-core/node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/language-server": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.15.tgz", + "integrity": "sha512-aSzvL3lgQ+RPU3uWA9wW85sfZ0tb+oKplfnOwG/c1iRMuVEJRofmcnjyN0JEOKbBR7GuPSbeUdLAI0AIL+TFew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@volar/language-service": "2.4.15", + "@volar/typescript": "2.4.15", + "path-browserify": "^1.0.1", + "request-light": "^0.7.0", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/language-server/node_modules/@volar/language-service": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.15.tgz", + "integrity": "sha512-o7ctGyQNQAZqT15xHamE0fTzPZHeDnHWz0m/KJekSPc2W4AHiEbJ2RNDLzEK4e0EjrpdeEe3FB9KQvOvjq+I6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/language-server/node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/language-server/node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/language-server/node_modules/request-light": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz", + "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/language-server/node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/@volar/language-server/node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/test-utils": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/test-utils/-/test-utils-2.4.15.tgz", + "integrity": "sha512-TgFzv+0HWKS9ZjA6p4kuXgnzGJR2wt5f+0L97o7LYOdBSaAIn+EgWPqTAYWZC4Z2PYEKS0jkt5waOyak18k6/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@volar/language-server": "2.4.15", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/test-utils/node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz", + "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-dom/node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/compiler-dom/node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/compiler-dom/node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@vue/compiler-dom/node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/compiler-dom/node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom/node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz", + "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.25", + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@vue/compiler-sfc/node_modules/@vue/compiler-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz", + "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.25", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-sfc/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/@vue/compiler-sfc/node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core/node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core/node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/language-core/node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-core/node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-server": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-server/-/language-server-2.2.12.tgz", + "integrity": "sha512-sh64ihjDn1nHmLez5m/UuXiTmiTU10Jt/QvhjMPllPeUwCvck89ndXp1nXkvEDoRGm7xdV9zopZM2IiEZFQTPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@volar/language-server": "2.4.15", + "@volar/test-utils": "2.4.15", + "@vue/language-core": "2.2.12", + "@vue/language-service": "2.2.12", + "@vue/typescript-plugin": "2.2.12", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-uri": "^3.0.8" + }, + "bin": { + "vue-language-server": "bin/vue-language-server.js" + } + }, + "node_modules/@vue/language-service": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-service/-/language-service-2.2.12.tgz", + "integrity": "sha512-+YBLP82vyuXzO5Wkw23vMl+EPCZicv1EQtEjp/n/Wt4PjbPBSgR5KCuc4edcIA1rL70fsEPlSna3K+ibyZ2QIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@volar/language-service": "2.4.15", + "@volar/typescript": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/language-core": "2.2.12", + "@vue/shared": "^3.5.0", + "@vue/typescript-plugin": "2.2.12", + "alien-signals": "^1.0.3", + "path-browserify": "^1.0.1", + "volar-service-css": "0.0.64", + "volar-service-emmet": "0.0.64", + "volar-service-html": "0.0.64", + "volar-service-json": "0.0.64", + "volar-service-pug": "0.0.64", + "volar-service-pug-beautify": "0.0.64", + "volar-service-typescript": "0.0.64", + "volar-service-typescript-twoslash-queries": "0.0.64", + "vscode-css-languageservice": "^6.3.1", + "vscode-html-languageservice": "^5.2.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/language-service/node_modules/@emmetio/abbreviation": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", + "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@vue/language-service/node_modules/@emmetio/css-abbreviation": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", + "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@vue/language-service/node_modules/@emmetio/css-parser": { + "version": "0.4.0", + "resolved": "git+ssh://git@github.com/ramya-rao-a/css-parser.git#370c480ac103bd17c7bcfb34bf5d577dc40d3660", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/stream-reader": "^2.2.0", + "@emmetio/stream-reader-utils": "^0.1.0" + } + }, + "node_modules/@vue/language-service/node_modules/@emmetio/html-matcher": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz", + "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@emmetio/scanner": "^1.0.0" + } + }, + "node_modules/@vue/language-service/node_modules/@emmetio/scanner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", + "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/@emmetio/stream-reader": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz", + "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/@emmetio/stream-reader-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz", + "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/@johnsoncodehk/pug-beautify": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@johnsoncodehk/pug-beautify/-/pug-beautify-0.2.2.tgz", + "integrity": "sha512-qqNS/YD0Nck5wtQLCPHAfGVgWbbGafxSPjNh0ekYPFSNNqnDH2kamnduzYly8IiADmeVx/MfAE1njMEjVeHTMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/@volar/language-service": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.15.tgz", + "integrity": "sha512-o7ctGyQNQAZqT15xHamE0fTzPZHeDnHWz0m/KJekSPc2W4AHiEbJ2RNDLzEK4e0EjrpdeEe3FB9KQvOvjq+I6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/language-service/node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/language-service/node_modules/@vscode/emmet-helper": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz", + "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "emmet": "^2.4.3", + "jsonc-parser": "^2.3.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.15.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/language-service/node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/@vue/language-service/node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@vue/language-service/node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@vue/language-service/node_modules/character-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz", + "integrity": "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-regex": "^1.0.3" + } + }, + "node_modules/@vue/language-service/node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@vue/language-service/node_modules/emmet": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", + "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "./packages/scanner", + "./packages/abbreviation", + "./packages/css-abbreviation", + "./" + ], + "dependencies": { + "@emmetio/abbreviation": "^2.3.3", + "@emmetio/css-abbreviation": "^2.1.8" + } + }, + "node_modules/@vue/language-service/node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@vue/language-service/node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@vue/language-service/node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@vue/language-service/node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@vue/language-service/node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@vue/language-service/node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@vue/language-service/node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@vue/language-service/node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@vue/language-service/node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@vue/language-service/node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@vue/language-service/node_modules/is-expression": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz", + "integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^7.1.1", + "object-assign": "^4.1.1" + } + }, + "node_modules/@vue/language-service/node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@vue/language-service/node_modules/jsonc-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/@vue/language-service/node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@vue/language-service/node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/pug-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.1.0.tgz", + "integrity": "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/pug-lexer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz", + "integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-parser": "^2.2.0", + "is-expression": "^4.0.0", + "pug-error": "^2.0.0" + } + }, + "node_modules/@vue/language-service/node_modules/pug-parser": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz", + "integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pug-error": "^2.0.0", + "token-stream": "1.0.0" + } + }, + "node_modules/@vue/language-service/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@vue/language-service/node_modules/token-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz", + "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/typescript-auto-import-cache": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz", + "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.8" + } + }, + "node_modules/@vue/language-service/node_modules/volar-service-css": { + "version": "0.0.64", + "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.64.tgz", + "integrity": "sha512-BtLq85adIft+Q0PZqllKXMVH7HHk6UmM7Opuj43IhR4AOVkqbNIcsiXhuh12ITVJGTkCGa9xXAPzClorPya6EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-css-languageservice": "^6.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/@vue/language-service/node_modules/volar-service-emmet": { + "version": "0.0.64", + "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.64.tgz", + "integrity": "sha512-cIGKpyOXKB9Dsqz3MJD1DF1GYEPfLE4VBoEtqewfx88qpf0dr/WbDnRhuljZ+VFlavkre4MvELzkCt8m9F6GqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/css-parser": "ramya-rao-a/css-parser#vscode", + "@emmetio/html-matcher": "^1.3.0", + "@vscode/emmet-helper": "^2.9.3", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/@vue/language-service/node_modules/volar-service-html": { + "version": "0.0.64", + "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.64.tgz", + "integrity": "sha512-5xknMYKmZBFzp2399RlsnGce25PfNu9ViXa1s63Q8NP6xeXcF3lInFsV+1o2DWBoXZdnXcuRvWOA+K+JIZLEcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-html-languageservice": "^5.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/@vue/language-service/node_modules/volar-service-json": { + "version": "0.0.64", + "resolved": "https://registry.npmjs.org/volar-service-json/-/volar-service-json-0.0.64.tgz", + "integrity": "sha512-kZsqmeDR0w/iVkkoxfp4/DcblYmyFXBImVLzgIgyd685XZrb21wVmKcRsD/V+6l0N7/pGwN6m2scxt/mJmFkGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-json-languageservice": "^5.4.0", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/@vue/language-service/node_modules/volar-service-pug": { + "version": "0.0.64", + "resolved": "https://registry.npmjs.org/volar-service-pug/-/volar-service-pug-0.0.64.tgz", + "integrity": "sha512-rVJ2ySENJFPzzEr4fVlC81ANR4dTh3Axr6Az56KyZ5GV0C9yRk5QqCj5+eOYb2GSoVtnEbzOyYpDTRSuH++lfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-service": "~2.4.0", + "muggle-string": "^0.4.1", + "pug-lexer": "^5.0.1", + "pug-parser": "^6.0.0", + "volar-service-html": "0.0.64", + "vscode-html-languageservice": "^5.3.0", + "vscode-languageserver-textdocument": "^1.0.11" + } + }, + "node_modules/@vue/language-service/node_modules/volar-service-pug-beautify": { + "version": "0.0.64", + "resolved": "https://registry.npmjs.org/volar-service-pug-beautify/-/volar-service-pug-beautify-0.0.64.tgz", + "integrity": "sha512-HNVTYGjGKaUkvryCQvhN/kYZ6aP6I1ySECMA7I8SpMqraYN/BDKI185tsRsm5tGmPrEyCgRDKn/GGIAsSj7I7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@johnsoncodehk/pug-beautify": "^0.2.2" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/@vue/language-service/node_modules/volar-service-typescript": { + "version": "0.0.64", + "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.64.tgz", + "integrity": "sha512-FN2H97iqjR1id8AM4fH7lTXuTx2on9zD6QlUFllaiHKqgNrEITlQwm/9Ujrd9ST7MUzhgIKyUsa2WlanX9kkMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-browserify": "^1.0.1", + "semver": "^7.6.2", + "typescript-auto-import-cache": "^0.3.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-nls": "^5.2.0", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/@vue/language-service/node_modules/volar-service-typescript-twoslash-queries": { + "version": "0.0.64", + "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.64.tgz", + "integrity": "sha512-DQlArCVUwbH3Ym0Uc/qSrgus/ngQa4LbTNbLsIkWMovxwziPA1c2yCFFY7a6s4Qs8pPXO757ryu99IeX0UK+4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/@vue/language-service/node_modules/vscode-css-languageservice": { + "version": "6.3.8", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.8.tgz", + "integrity": "sha512-dBk/9ullEjIMbfSYAohGpDOisOVU1x2MQHOeU12ohGJQI7+r0PCimBwaa/pWpxl/vH4f7ibrBfxIZY3anGmHKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/@vue/language-service/node_modules/vscode-html-languageservice": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.0.tgz", + "integrity": "sha512-FIVz83oGw2tBkOr8gQPeiREInnineCKGCz3ZD1Pi6opOuX3nSRkc4y4zLLWsuop+6ttYX//XZCI6SLzGhRzLmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/@vue/language-service/node_modules/vscode-json-languageservice": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.6.3.tgz", + "integrity": "sha512-UDF7sJF5t7mzUzXL6dsClkvnHS4xnDL/gOMKGQiizRHmswlk/xSPGZxEvAtszWQF0ImNcJ0j9l+rHuefGzit1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "jsonc-parser": "^3.3.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/@vue/language-service/node_modules/vscode-json-languageservice/node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/language-service/node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz", + "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/runtime-core": "3.5.25", + "@vue/shared": "3.5.25", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/runtime-dom/node_modules/@vue/reactivity": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz", + "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-dom/node_modules/@vue/runtime-core": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz", + "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/runtime-dom/node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz", + "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "vue": "3.5.25" + } + }, + "node_modules/@vue/server-renderer/node_modules/@vue/compiler-ssr": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz", + "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/shared": "3.5.25" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz", + "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==", + "license": "MIT" + }, + "node_modules/@vue/typescript-plugin": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/typescript-plugin/-/typescript-plugin-2.2.12.tgz", + "integrity": "sha512-HFByqrJ7+kWDpAoGiTcWe0Gxy9R0MkEtE+QTlLnEgDS7+l7uSaPnK4ZfzpX7rMfNwOZK0fUD58mMvmCWUnBCSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12", + "@vue/shared": "^3.5.0" + } + }, + "node_modules/@vue/typescript-plugin/node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/typescript-plugin/node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.25", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", + "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.25", + "@vue/compiler-sfc": "3.5.25", + "@vue/runtime-dom": "3.5.25", + "@vue/server-renderer": "3.5.25", + "@vue/shared": "3.5.25" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + } + } +} diff --git a/test/resources/repos/vue/test_repo/package.json b/test/resources/repos/vue/test_repo/package.json new file mode 100644 index 00000000..846a5b1b --- /dev/null +++ b/test/resources/repos/vue/test_repo/package.json @@ -0,0 +1,14 @@ +{ + "name": "vue-calculator-test-fixture", + "version": "1.0.0", + "type": "module", + "description": "Vue 3 + Pinia + TypeScript test fixtures for Serena LSP testing", + "dependencies": { + "vue": "^3.4.0", + "pinia": "^2.1.0" + }, + "devDependencies": { + "@vue/language-server": "^2.0.0", + "typescript": "~5.5.4" + } +} diff --git a/test/resources/repos/vue/test_repo/src/App.vue b/test/resources/repos/vue/test_repo/src/App.vue new file mode 100644 index 00000000..a5ece8a0 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/App.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue b/test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue new file mode 100644 index 00000000..622709b0 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/components/CalculatorDisplay.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue b/test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue new file mode 100644 index 00000000..2d7bc86d --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/components/CalculatorInput.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/test/resources/repos/vue/test_repo/src/composables/useFormatter.ts b/test/resources/repos/vue/test_repo/src/composables/useFormatter.ts new file mode 100644 index 00000000..9c64106f --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/composables/useFormatter.ts @@ -0,0 +1,126 @@ +import { ref, computed } from 'vue' +import type { Ref, ComputedRef } from 'vue' +import type { FormatOptions } from '@/types' + +/** + * Composable for formatting numbers with various options. + * Demonstrates: composable pattern, refs, computed, type imports + */ +export function useFormatter(initialPrecision: number = 2) { + // State + const precision = ref(initialPrecision) + const useGrouping = ref(true) + const locale = ref('en-US') + + // Computed properties + const formatOptions = computed((): FormatOptions => ({ + maxDecimals: precision.value, + useGrouping: useGrouping.value + })) + + // Methods + const formatNumber = (value: number): string => { + return value.toLocaleString(locale.value, { + minimumFractionDigits: precision.value, + maximumFractionDigits: precision.value, + useGrouping: useGrouping.value + }) + } + + const formatCurrency = (value: number, currency: string = 'USD'): string => { + return value.toLocaleString(locale.value, { + style: 'currency', + currency, + minimumFractionDigits: precision.value, + maximumFractionDigits: precision.value + }) + } + + const formatPercentage = (value: number): string => { + return `${(value * 100).toFixed(precision.value)}%` + } + + const setPrecision = (newPrecision: number): void => { + if (newPrecision >= 0 && newPrecision <= 10) { + precision.value = newPrecision + } + } + + const toggleGrouping = (): void => { + useGrouping.value = !useGrouping.value + } + + const setLocale = (newLocale: string): void => { + locale.value = newLocale + } + + // Return composable API + return { + // State (readonly) + precision: computed(() => precision.value), + useGrouping: computed(() => useGrouping.value), + locale: computed(() => locale.value), + formatOptions, + + // Methods + formatNumber, + formatCurrency, + formatPercentage, + setPrecision, + toggleGrouping, + setLocale + } +} + +/** + * Composable for time formatting. + * Demonstrates: simpler composable, pure functions + */ +export function useTimeFormatter() { + const formatTime = (date: Date): string => { + return date.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + const formatDate = (date: Date): string => { + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }) + } + + const formatDateTime = (date: Date): string => { + return `${formatDate(date)} ${formatTime(date)}` + } + + const getRelativeTime = (date: Date): string => { + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSecs = Math.floor(diffMs / 1000) + const diffMins = Math.floor(diffSecs / 60) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSecs < 60) return 'just now' + if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago` + if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago` + return `${diffDays} day${diffDays > 1 ? 's' : ''} ago` + } + + return { + formatTime, + formatDate, + formatDateTime, + getRelativeTime + } +} + +/** + * Type definitions for return types + */ +export type UseFormatterReturn = ReturnType +export type UseTimeFormatterReturn = ReturnType diff --git a/test/resources/repos/vue/test_repo/src/composables/useTheme.ts b/test/resources/repos/vue/test_repo/src/composables/useTheme.ts new file mode 100644 index 00000000..706f1fe7 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/composables/useTheme.ts @@ -0,0 +1,113 @@ +import { ref, computed, watch, inject, provide, type InjectionKey, type Ref } from 'vue' + +/** + * Theme configuration type + */ +export interface ThemeConfig { + isDark: boolean + primaryColor: string + fontSize: number +} + +/** + * Injection key for theme - demonstrates provide/inject pattern + */ +export const ThemeKey: InjectionKey> = Symbol('theme') + +/** + * Composable for theme management with watchers. + * Demonstrates: watch, provide/inject, localStorage interaction + */ +export function useThemeProvider() { + // Initialize theme from localStorage or defaults + const loadThemeFromStorage = (): ThemeConfig => { + const stored = localStorage.getItem('app-theme') + if (stored) { + try { + return JSON.parse(stored) + } catch { + // Fall through to defaults + } + } + return { + isDark: false, + primaryColor: '#667eea', + fontSize: 16 + } + } + + const theme = ref(loadThemeFromStorage()) + + // Computed properties + const isDarkMode = computed(() => theme.value.isDark) + const themeClass = computed(() => theme.value.isDark ? 'dark-theme' : 'light-theme') + + // Watch for theme changes and persist to localStorage + watch( + theme, + (newTheme) => { + localStorage.setItem('app-theme', JSON.stringify(newTheme)) + document.documentElement.className = newTheme.isDark ? 'dark' : 'light' + }, + { deep: true } + ) + + // Methods + const toggleDarkMode = (): void => { + theme.value.isDark = !theme.value.isDark + } + + const setPrimaryColor = (color: string): void => { + theme.value.primaryColor = color + } + + const setFontSize = (size: number): void => { + if (size >= 12 && size <= 24) { + theme.value.fontSize = size + } + } + + const resetTheme = (): void => { + theme.value = { + isDark: false, + primaryColor: '#667eea', + fontSize: 16 + } + } + + // Provide theme to child components + provide(ThemeKey, theme) + + return { + theme, + isDarkMode, + themeClass, + toggleDarkMode, + setPrimaryColor, + setFontSize, + resetTheme + } +} + +/** + * Composable for consuming theme in child components. + * Demonstrates: inject pattern + */ +export function useTheme() { + const theme = inject(ThemeKey) + + if (!theme) { + throw new Error('useTheme must be used within a component that provides ThemeKey') + } + + const isDark = computed(() => theme.value.isDark) + const primaryColor = computed(() => theme.value.primaryColor) + const fontSize = computed(() => theme.value.fontSize) + + return { + theme, + isDark, + primaryColor, + fontSize + } +} diff --git a/test/resources/repos/vue/test_repo/src/main.ts b/test/resources/repos/vue/test_repo/src/main.ts new file mode 100644 index 00000000..764d9587 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/main.ts @@ -0,0 +1,9 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.mount('#app') diff --git a/test/resources/repos/vue/test_repo/src/stores/calculator.ts b/test/resources/repos/vue/test_repo/src/stores/calculator.ts new file mode 100644 index 00000000..4c8a4415 --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/stores/calculator.ts @@ -0,0 +1,179 @@ +import { defineStore } from 'pinia' +import type { HistoryEntry, Operation, CalculatorState } from '@/types' + +export const useCalculatorStore = defineStore('calculator', { + state: (): CalculatorState => ({ + currentValue: 0, + previousValue: null, + operation: null, + history: [], + displayValue: '0' + }), + + getters: { + /** + * Get the most recent history entries (last 10) + */ + recentHistory: (state): HistoryEntry[] => { + return state.history.slice(-10).reverse() + }, + + /** + * Check if calculator has any history + */ + hasHistory: (state): boolean => { + return state.history.length > 0 + }, + + /** + * Get the current display text + */ + display: (state): string => { + return state.displayValue + } + }, + + actions: { + /** + * Set a number value + */ + setNumber(value: number) { + this.currentValue = value + this.displayValue = value.toString() + }, + + /** + * Append a digit to the current value + */ + appendDigit(digit: number) { + if (this.displayValue === '0') { + this.displayValue = digit.toString() + } else { + this.displayValue += digit.toString() + } + this.currentValue = parseFloat(this.displayValue) + }, + + /** + * Add two numbers + */ + add() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'add' + this.displayValue = '0' + }, + + /** + * Subtract two numbers + */ + subtract() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'subtract' + this.displayValue = '0' + }, + + /** + * Multiply two numbers + */ + multiply() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'multiply' + this.displayValue = '0' + }, + + /** + * Divide two numbers + */ + divide() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'divide' + this.displayValue = '0' + }, + + /** + * Execute the pending operation + */ + executeOperation() { + if (this.previousValue === null || this.operation === null) { + return + } + + let result = 0 + const prev = this.previousValue + const current = this.currentValue + let expression = '' + + switch (this.operation) { + case 'add': + result = prev + current + expression = `${prev} + ${current}` + break + case 'subtract': + result = prev - current + expression = `${prev} - ${current}` + break + case 'multiply': + result = prev * current + expression = `${prev} × ${current}` + break + case 'divide': + if (current === 0) { + this.displayValue = 'Error' + this.clear() + return + } + result = prev / current + expression = `${prev} ÷ ${current}` + break + } + + // Add to history + this.history.push({ + expression, + result, + timestamp: new Date() + }) + + this.currentValue = result + this.displayValue = result.toString() + this.previousValue = null + this.operation = null + }, + + /** + * Calculate the equals operation + */ + equals() { + this.executeOperation() + }, + + /** + * Clear the calculator state + */ + clear() { + this.currentValue = 0 + this.previousValue = null + this.operation = null + this.displayValue = '0' + }, + + /** + * Clear all history + */ + clearHistory() { + this.history = [] + } + } +}) diff --git a/test/resources/repos/vue/test_repo/src/types/index.ts b/test/resources/repos/vue/test_repo/src/types/index.ts new file mode 100644 index 00000000..2766ee2a --- /dev/null +++ b/test/resources/repos/vue/test_repo/src/types/index.ts @@ -0,0 +1,32 @@ +/** + * Represents a single calculation in the history + */ +export interface HistoryEntry { + expression: string + result: number + timestamp: Date +} + +/** + * Valid calculator operations + */ +export type Operation = 'add' | 'subtract' | 'multiply' | 'divide' | null + +/** + * The complete state of the calculator + */ +export interface CalculatorState { + currentValue: number + previousValue: number | null + operation: Operation + history: HistoryEntry[] + displayValue: string +} + +/** + * Format options for displaying numbers + */ +export interface FormatOptions { + maxDecimals?: number + useGrouping?: boolean +} diff --git a/test/resources/repos/vue/test_repo/tsconfig.json b/test/resources/repos/vue/test_repo/tsconfig.json new file mode 100644 index 00000000..8c9fa9fc --- /dev/null +++ b/test/resources/repos/vue/test_repo/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "plugins": [ + { + "name": "@vue/typescript-plugin" + } + ], + "include": ["src/**/*.ts", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/test/resources/repos/vue/test_repo/tsconfig.node.json b/test/resources/repos/vue/test_repo/tsconfig.node.json new file mode 100644 index 00000000..97ede7ee --- /dev/null +++ b/test/resources/repos/vue/test_repo/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/test/resources/repos/vue/test_repo/vite.config.ts b/test/resources/repos/vue/test_repo/vite.config.ts new file mode 100644 index 00000000..4343664e --- /dev/null +++ b/test/resources/repos/vue/test_repo/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + } +}) diff --git a/test/serena/__snapshots__/test_symbol_editing.ambr b/test/serena/__snapshots__/test_symbol_editing.ambr index 92603c2d..347862ca 100644 --- a/test/serena/__snapshots__/test_symbol_editing.ambr +++ b/test/serena/__snapshots__/test_symbol_editing.ambr @@ -78,6 +78,229 @@ ''' # --- +# name: test_delete_symbol_vue[test_case0] + ''' + + + + + + + ''' +# --- # name: test_insert_in_rel_to_symbol[test_case0-after] ''' """ @@ -590,6 +813,466 @@ ''' # --- +# name: test_insert_in_rel_to_symbol_vue[test_case0-after] + ''' + + + + + + + ''' +# --- +# name: test_insert_in_rel_to_symbol_vue[test_case0-before] + ''' + + + + + + + ''' +# --- # name: test_insert_python_class_after ''' """ @@ -1207,3 +1890,641 @@ ''' # --- +# name: test_replace_body_vue[test_case0] + ''' + + + + + + + ''' +# --- +# name: test_replace_body_vue_ts_file[test_case0] + ''' + import { defineStore } from 'pinia' + import type { HistoryEntry, Operation, CalculatorState } from '@/types' + + export const useCalculatorStore = defineStore('calculator', { + state: (): CalculatorState => ({ + currentValue: 0, + previousValue: null, + operation: null, + history: [], + displayValue: '0' + }), + + getters: { + /** + * Get the most recent history entries (last 10) + */ + recentHistory: (state): HistoryEntry[] => { + return state.history.slice(-10).reverse() + }, + + /** + * Check if calculator has any history + */ + hasHistory: (state): boolean => { + return state.history.length > 0 + }, + + /** + * Get the current display text + */ + display: (state): string => { + return state.displayValue + } + }, + + actions: { + /** + * Set a number value + */ + setNumber(value: number) { + this.currentValue = value + this.displayValue = value.toString() + }, + + /** + * Append a digit to the current value + */ + appendDigit(digit: number) { + if (this.displayValue === '0') { + this.displayValue = digit.toString() + } else { + this.displayValue += digit.toString() + } + this.currentValue = parseFloat(this.displayValue) + }, + + /** + * Add two numbers + */ + add() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'add' + this.displayValue = '0' + }, + + /** + * Subtract two numbers + */ + subtract() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'subtract' + this.displayValue = '0' + }, + + /** + * Multiply two numbers + */ + multiply() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'multiply' + this.displayValue = '0' + }, + + /** + * Divide two numbers + */ + divide() { + if (this.previousValue !== null && this.operation) { + this.executeOperation() + } + this.previousValue = this.currentValue + this.operation = 'divide' + this.displayValue = '0' + }, + + /** + * Execute the pending operation + */ + executeOperation() { + if (this.previousValue === null || this.operation === null) { + return + } + + let result = 0 + const prev = this.previousValue + const current = this.currentValue + let expression = '' + + switch (this.operation) { + case 'add': + result = prev + current + expression = `${prev} + ${current}` + break + case 'subtract': + result = prev - current + expression = `${prev} - ${current}` + break + case 'multiply': + result = prev * current + expression = `${prev} × ${current}` + break + case 'divide': + if (current === 0) { + this.displayValue = 'Error' + this.clear() + return + } + result = prev / current + expression = `${prev} ÷ ${current}` + break + } + + // Add to history + this.history.push({ + expression, + result, + timestamp: new Date() + }) + + this.currentValue = result + this.displayValue = result.toString() + this.previousValue = null + this.operation = null + }, + + /** + * Calculate the equals operation + */ + equals() { + this.executeOperation() + }, + + /** + * Clear the calculator state + */ + function clear() { + // Modified: Reset to initial state with a log + console.log('Clearing calculator state'); + displayValue.value = '0'; + expression.value = ''; + operationHistory.value = []; + lastResult.value = undefined; + }, + + /** + * Clear all history + */ + clearHistory() { + this.history = [] + } + } + }) + + ''' +# --- +# name: test_replace_body_vue_with_disambiguation[test_case0] + ''' + + + + + + + ''' +# --- diff --git a/test/serena/config/test_serena_config.py b/test/serena/config/test_serena_config.py index f22973ee..048b3dbc 100644 --- a/test/serena/config/test_serena_config.py +++ b/test/serena/config/test_serena_config.py @@ -43,6 +43,16 @@ class TestProjectConfigAutogenerate: assert config.project_name == self.project_path.name assert config.languages == [Language.PYTHON] + def test_autogenerate_with_js_files(self): + """Test successful autogeneration with JavaScript source files.""" + # Create files for multiple languages + (self.project_path / "small.js").write_text("console.log('JS');") + + # Run autogenerate - should pick Python as dominant + config = ProjectConfig.autogenerate(self.project_path, save_to_disk=False) + + assert config.languages == [Language.TYPESCRIPT] + def test_autogenerate_with_multiple_languages(self): """Test autogeneration picks dominant language when multiple are present.""" # Create files for multiple languages diff --git a/test/serena/test_serena_agent.py b/test/serena/test_serena_agent.py index cb2c905e..2b9060b7 100644 --- a/test/serena/test_serena_agent.py +++ b/test/serena/test_serena_agent.py @@ -9,7 +9,7 @@ import pytest from serena.agent import SerenaAgent from serena.config.serena_config import ProjectConfig, RegisteredProject, SerenaConfig from serena.project import Project -from serena.tools import FindReferencingSymbolsTool, FindSymbolTool +from serena.tools import FindReferencingSymbolsTool, FindSymbolTool, ReplaceSymbolBodyTool from solidlsp.ls_config import Language from test.conftest import get_repo_path, language_tests_enabled from test.solidlsp import clojure as clj @@ -30,6 +30,7 @@ def serena_config(): Language.PHP, Language.CSHARP, Language.CLOJURE, + Language.FSHARP, ]: repo_path = get_repo_path(language) if repo_path.exists(): @@ -63,6 +64,10 @@ def serena_agent(request: pytest.FixtureRequest, serena_config) -> Iterator[Sere project_name = f"test_repo_{language}" agent = SerenaAgent(project=project_name, serena_config=serena_config) + + # wait for agent to be ready + agent.execute_task(lambda: None) + yield agent # explicitly delete to free resources agent.shutdown(timeout=5) @@ -81,6 +86,7 @@ class TestSerenaAgent: pytest.param(Language.PHP, "helperFunction", "Function", "helper.php", marks=pytest.mark.php), pytest.param(Language.CLOJURE, "greet", "Function", clj.CORE_PATH, marks=pytest.mark.clojure), pytest.param(Language.CSHARP, "Calculator", "Class", "Program.cs", marks=pytest.mark.csharp), + pytest.param(Language.FSHARP, "Calculator", "Module", "Calculator.fs", marks=pytest.mark.fsharp), ], indirect=["serena_agent"], ) @@ -131,6 +137,7 @@ class TestSerenaAgent: marks=pytest.mark.clojure, ), pytest.param(Language.CSHARP, "Calculator", "Program.cs", "Program.cs", marks=pytest.mark.csharp), + pytest.param(Language.FSHARP, "add", "Calculator.fs", "Program.fs", marks=pytest.mark.fsharp), ], indirect=["serena_agent"], ) @@ -285,3 +292,63 @@ class TestSerenaAgent: symbols = json.loads(result) assert not symbols, f"Expected to find no symbols for {name_path}. Symbols found: {symbols}" + + @pytest.mark.parametrize( + "serena_agent,name_path,num_expected", + [ + pytest.param( + Language.JAVA, + "Model/getName", + 2, + id="overloaded_java_method", + marks=pytest.mark.java, + ), + ], + indirect=["serena_agent"], + ) + def test_find_symbol_overloaded_function(self, serena_agent: SerenaAgent, name_path: str, num_expected: int): + """ + Tests whether the FindSymbolTool can find all overloads of a function/method + (provided that the overload id remains unspecified in the name path) + """ + agent = serena_agent + + find_symbol_tool = agent.get_tool(FindSymbolTool) + result = find_symbol_tool.apply_ex( + name_path_pattern=name_path, + depth=0, + substring_matching=False, + ) + + symbols = json.loads(result) + assert ( + len(symbols) == num_expected + ), f"Expected to find {num_expected} symbols for overloaded function {name_path}. Symbols found: {symbols}" + + @pytest.mark.parametrize( + "serena_agent,name_path,relative_path", + [ + pytest.param( + Language.JAVA, + "Model/getName", + os.path.join("src", "main", "java", "test_repo", "Model.java"), + id="overloaded_java_method", + marks=pytest.mark.java, + ), + ], + indirect=["serena_agent"], + ) + def test_non_unique_symbol_reference_error(self, serena_agent: SerenaAgent, name_path: str, relative_path: str): + """ + Tests whether the tools operating on a well-defined symbol raises an error when the symbol reference is non-unique. + We exemplarily test a retrieval tool (FindReferencingSymbolsTool) and an editing tool (ReplaceSymbolBodyTool). + """ + match_text = "multiple" + + find_refs_tool = serena_agent.get_tool(FindReferencingSymbolsTool) + with pytest.raises(ValueError, match=match_text): + find_refs_tool.apply(name_path=name_path, relative_path=relative_path) + + replace_symbol_body_tool = serena_agent.get_tool(ReplaceSymbolBodyTool) + with pytest.raises(ValueError, match=match_text): + replace_symbol_body_tool.apply(name_path=name_path, relative_path=relative_path, body="") diff --git a/test/serena/test_symbol_editing.py b/test/serena/test_symbol_editing.py index a0b577e7..b885c558 100644 --- a/test/serena/test_symbol_editing.py +++ b/test/serena/test_symbol_editing.py @@ -509,3 +509,142 @@ def test_rename_symbol(snapshot: SnapshotAssertion): "renamed_typed_module_var", ) test_case.run_test(content_after_ground_truth=snapshot) + + +# ===== VUE WRITE OPERATIONS TESTS ===== + +VUE_TEST_FILE = os.path.join("src", "components", "CalculatorButton.vue") +VUE_STORE_FILE = os.path.join("src", "stores", "calculator.ts") + +NEW_VUE_HANDLER = """const handleDoubleClick = () => { + pressCount.value++; + emit('click', props.label); +}""" + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + DeleteSymbolTest( + Language.VUE, + VUE_TEST_FILE, + "handleMouseEnter", + ), + marks=pytest.mark.vue, + ), + ], +) +def test_delete_symbol_vue(test_case: DeleteSymbolTest, snapshot: SnapshotAssertion) -> None: + test_case.run_test(content_after_ground_truth=snapshot) + + +@pytest.mark.parametrize("mode", ["before", "after"]) +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + InsertInRelToSymbolTest( + Language.VUE, + VUE_TEST_FILE, + "handleClick", + NEW_VUE_HANDLER, + ), + marks=pytest.mark.vue, + ), + ], +) +def test_insert_in_rel_to_symbol_vue( + test_case: InsertInRelToSymbolTest, + mode: Literal["before", "after"], + snapshot: SnapshotAssertion, +) -> None: + test_case.set_mode(mode) + test_case.run_test(content_after_ground_truth=snapshot) + + +VUE_REPLACED_HANDLECLICK_BODY = """const handleClick = () => { + if (!props.disabled) { + pressCount.value = 0; // Reset instead of incrementing + emit('click', props.label); + } +}""" + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + ReplaceBodyTest( + Language.VUE, + VUE_TEST_FILE, + "handleClick", + VUE_REPLACED_HANDLECLICK_BODY, + ), + marks=pytest.mark.vue, + ), + ], +) +def test_replace_body_vue(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None: + test_case.run_test(content_after_ground_truth=snapshot) + + +VUE_REPLACED_PRESSCOUNT_BODY = """const pressCount = ref(100)""" + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + ReplaceBodyTest( + Language.VUE, + VUE_TEST_FILE, + "pressCount", + VUE_REPLACED_PRESSCOUNT_BODY, + ), + marks=pytest.mark.vue, + ), + ], +) +def test_replace_body_vue_with_disambiguation(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None: + """Test symbol disambiguation when replacing body in Vue files. + + This test verifies the fix for the Vue LSP symbol duplication issue. + When the LSP returns two symbols with the same name (e.g., pressCount appears both as + a definition `const pressCount = ref(0)` and as a shorthand property in `defineExpose({ pressCount })`), + the _find_unique_symbol method should prefer the symbol with the larger range (the definition). + + The test exercises this by calling replace_body on 'pressCount', which internally calls + _find_unique_symbol and should correctly select the definition (line 40, 19 chars) over + the reference (line 97, 10 chars). + """ + test_case.run_test(content_after_ground_truth=snapshot) + + +VUE_STORE_REPLACED_CLEAR_BODY = """function clear() { + // Modified: Reset to initial state with a log + console.log('Clearing calculator state'); + displayValue.value = '0'; + expression.value = ''; + operationHistory.value = []; + lastResult.value = undefined; +}""" + + +@pytest.mark.parametrize( + "test_case", + [ + pytest.param( + ReplaceBodyTest( + Language.VUE, + VUE_STORE_FILE, + "clear", + VUE_STORE_REPLACED_CLEAR_BODY, + ), + marks=pytest.mark.vue, + ), + ], +) +def test_replace_body_vue_ts_file(test_case: ReplaceBodyTest, snapshot: SnapshotAssertion) -> None: + """Test that TypeScript files within Vue projects can be edited.""" + test_case.run_test(content_after_ground_truth=snapshot) diff --git a/test/solidlsp/csharp/test_csharp_basic.py b/test/solidlsp/csharp/test_csharp_basic.py index 065490df..5bd7beb1 100644 --- a/test/solidlsp/csharp/test_csharp_basic.py +++ b/test/solidlsp/csharp/test_csharp_basic.py @@ -119,13 +119,9 @@ class TestCSharpLanguageServer: # Check that we have references from both files assert any("Program.cs" in ref_file for ref_file in ref_files), "Should find reference in Program.cs" - # TODO: The assertion below fails randomly. Try to fix this. GitHub issue #814 - # In CI, it sometimes passed; on my local machine (opcode81), it seems to fail consistently. - """ assert any( os.path.join("Models", "Person.cs") in ref_file for ref_file in ref_files ), "Should find reference in Models/Person.cs where Calculator.Subtract is called" - """ # check for a second time, since the first call may trigger initialization and change the state of the LS refs_second_call = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) diff --git a/test/solidlsp/fsharp/test_fsharp_basic.py b/test/solidlsp/fsharp/test_fsharp_basic.py new file mode 100644 index 00000000..15de4ad7 --- /dev/null +++ b/test/solidlsp/fsharp/test_fsharp_basic.py @@ -0,0 +1,219 @@ +import os +import tempfile +import threading +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.language_servers.fsharp_language_server import FSharpLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +@pytest.mark.fsharp +class TestFSharpLanguageServer: + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_symbol(self, language_server: SolidLanguageServer) -> None: + """Test finding symbols in the full symbol tree.""" + symbols = language_server.request_full_symbol_tree() + + # Check for main program module symbols + assert SymbolUtils.symbol_tree_contains_name(symbols, "Program"), "Program module not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "main"), "main function not found in symbol tree" + + # Check for Calculator module symbols + assert SymbolUtils.symbol_tree_contains_name(symbols, "Calculator"), "Calculator module not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "add"), "add function not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorClass"), "CalculatorClass not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_get_document_symbols_program(self, language_server: SolidLanguageServer) -> None: + """Test getting document symbols from the main Program.fs file.""" + file_path = os.path.join("Program.fs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0] + + # Look for expected functions and modules + symbol_names = [s.get("name") for s in symbols] + assert "main" in symbol_names, "main function not found in Program.fs symbols" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_get_document_symbols_calculator(self, language_server: SolidLanguageServer) -> None: + """Test getting document symbols from Calculator.fs file.""" + file_path = os.path.join("Calculator.fs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0] + + # Look for expected functions + symbol_names = [s.get("name") for s in symbols] + expected_symbols = ["add", "subtract", "multiply", "divide", "square", "factorial", "CalculatorClass"] + + for expected in expected_symbols: + assert expected in symbol_names, f"{expected} function not found in Calculator.fs symbols" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + """Test finding references using symbol selection range.""" + file_path = os.path.join("Calculator.fs") + symbols = language_server.request_document_symbols(file_path) + + # Find the 'add' function symbol + add_symbol = None + + for sym in symbols.iter_symbols(): + if sym.get("name") == "add": + add_symbol = sym + break + + assert add_symbol is not None, "Could not find 'add' function symbol in Calculator.fs" + + # Try to find references to the add function + sel_start = add_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + + # The add function should be referenced in Program.fs + assert any("Program.fs" in ref.get("relativePath", "") for ref in refs), "Program.fs should reference add function" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_nested_module_symbols(self, language_server: SolidLanguageServer) -> None: + """Test getting symbols from nested Models namespace.""" + file_path = os.path.join("Models", "Person.fs") + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()[0] + + # Check for expected types and modules + symbol_names = [s.get("name") for s in symbols] + expected_symbols = ["Person", "PersonModule", "Address", "Employee"] + + for expected in expected_symbols: + assert expected in symbol_names, f"{expected} not found in Person.fs symbols" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_find_referencing_symbols_across_files(self, language_server: SolidLanguageServer) -> None: + """Test finding references to Calculator functions across files.""" + # Find the subtract function in Calculator.fs + file_path = os.path.join("Calculator.fs") + symbols = language_server.request_document_symbols(file_path) + + subtract_symbol = None + for sym in symbols.iter_symbols(): + if sym.get("name") == "subtract": + subtract_symbol = sym + break + + assert subtract_symbol is not None, "Could not find 'subtract' function symbol" + + # Find references to subtract function + sel_start = subtract_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"] + 1) + + # The subtract function should be referenced in Program.fs + assert any("Program.fs" in ref.get("relativePath", "") for ref in refs), "Program.fs should reference subtract function" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_go_to_definition(self, language_server: SolidLanguageServer) -> None: + """Test go-to-definition functionality.""" + # Test going to definition of 'add' function from Program.fs + program_file = os.path.join("Program.fs") + + # Try to find definition of 'add' function used in Program.fs + # This would typically be at the line where 'add 5 3' is called + definitions = language_server.request_definition(program_file, 10, 20) # Approximate position + + # We should get at least some definitions + assert len(definitions) >= 0, "Should get definitions (even if empty for complex cases)" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_hover_information(self, language_server: SolidLanguageServer) -> None: + """Test hover information functionality.""" + file_path = os.path.join("Calculator.fs") + + # Try to get hover information for a function + hover_info = language_server.request_hover(file_path, 5, 10) # Approximate position of a function + + # Hover info might be None or contain information + # This is acceptable as it depends on the LSP server's capabilities and timing + assert hover_info is None or isinstance(hover_info, dict), "Hover info should be None or dict" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_completion(self, language_server: SolidLanguageServer) -> None: + """Test code completion functionality.""" + file_path = os.path.join("Program.fs") + + # Use threading for cross-platform timeout (signal.SIGALRM is Unix-only) + result = [None] + exception = [None] + + def run_completion(): + try: + result[0] = language_server.request_completions(file_path, 15, 10) + except Exception as e: + exception[0] = e + + thread = threading.Thread(target=run_completion, daemon=True) + thread.start() + thread.join(timeout=5) # 5 second timeout + + if thread.is_alive(): + # Completion timed out, but this is acceptable for F# in some cases + # The important thing is that the language server doesn't crash + return + + if exception[0]: + raise exception[0] + + assert isinstance(result[0], list), "Completions should be a list" + + @pytest.mark.parametrize("language_server", [Language.FSHARP], indirect=True) + def test_diagnostics(self, language_server: SolidLanguageServer) -> None: + """Test getting diagnostics (errors, warnings) from F# files.""" + file_path = os.path.join("Program.fs") + + # FsAutoComplete uses publishDiagnostics notifications instead of textDocument/diagnostic requests + # So we'll test that the language server can handle files without crashing + # In real usage, diagnostics would come through the publishDiagnostics notification handler + + # Test that we can at least work with the file (open/close cycle) + with language_server.open_file(file_path) as _: + # If we can open and close the file without errors, basic diagnostics support is working + pass + + # This is a successful test - FsAutoComplete is working with F# files + assert True, "F# language server can handle files successfully" + + +@pytest.mark.fsharp +class TestFSharpLanguageServerSetup: + """Test F# language server setup and configuration.""" + + def test_runtime_dependency_setup_without_dotnet(self) -> None: + """Test that setup fails gracefully when .NET is not installed.""" + with patch("shutil.which", return_value=None): + with pytest.raises(RuntimeError, match=r"\.NET SDK is not installed"): + FSharpLanguageServer._setup_runtime_dependencies(Mock(), Mock()) + + def test_runtime_dependency_setup_with_dotnet(self) -> None: + """Test that setup works when .NET is available.""" + mock_config = Mock() + mock_settings = Mock() + + # Mock the ls_resources_dir to return a temp directory + with tempfile.TemporaryDirectory() as temp_dir: + with patch("shutil.which", return_value="/usr/bin/dotnet"): + with patch.object(FSharpLanguageServer, "ls_resources_dir", return_value=temp_dir): + with patch("subprocess.run") as mock_run: + # Mock successful dotnet version check + mock_run.return_value.stdout = "8.0.100" + mock_run.return_value.returncode = 0 + + # Create a fake fsautocomplete executable + fsharp_dir = os.path.join(temp_dir, "fsharp-lsp") + os.makedirs(fsharp_dir, exist_ok=True) + # Use .exe extension on Windows, matching production code + exe_name = "fsautocomplete.exe" if os.name == "nt" else "fsautocomplete" + fsautocomplete_path = os.path.join(fsharp_dir, exe_name) + Path(fsautocomplete_path).touch() + + result = FSharpLanguageServer._setup_runtime_dependencies(mock_config, mock_settings) + + assert fsautocomplete_path in result + assert "--adaptive-lsp-server-enabled --project-graph-enabled --use-fcs-transparent-compiler" in result diff --git a/test/solidlsp/vue/__init__.py b/test/solidlsp/vue/__init__.py new file mode 100644 index 00000000..a587a13a --- /dev/null +++ b/test/solidlsp/vue/__init__.py @@ -0,0 +1,15 @@ +"""Vue language server tests.""" + +import shutil + + +def _test_npm_available() -> str: + """Test if npm is available and return error reason if not.""" + # Check if npm is installed + if not shutil.which("npm"): + return "npm is not installed or not in PATH" + return "" # No error, npm is available + + +NPM_UNAVAILABLE_REASON = _test_npm_available() +NPM_UNAVAILABLE = bool(NPM_UNAVAILABLE_REASON) diff --git a/test/solidlsp/vue/test_vue_basic.py b/test/solidlsp/vue/test_vue_basic.py new file mode 100644 index 00000000..c46f35aa --- /dev/null +++ b/test/solidlsp/vue/test_vue_basic.py @@ -0,0 +1,362 @@ +import os + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language +from solidlsp.ls_utils import SymbolUtils + + +@pytest.mark.vue +class TestVueLanguageServer: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_vue_files_in_symbol_tree(self, language_server: SolidLanguageServer) -> None: + symbols = language_server.request_full_symbol_tree() + assert SymbolUtils.symbol_tree_contains_name(symbols, "App"), "App not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorButton"), "CalculatorButton not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorInput"), "CalculatorInput not found in symbol tree" + assert SymbolUtils.symbol_tree_contains_name(symbols, "CalculatorDisplay"), "CalculatorDisplay not found in symbol tree" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_find_referencing_symbols(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + + # Find useCalculatorStore function + store_symbol = None + for sym in symbols[0]: + if sym.get("name") == "useCalculatorStore": + store_symbol = sym + break + + assert store_symbol is not None, "useCalculatorStore function not found" + + # Get references + sel_start = store_symbol["selectionRange"]["start"] + refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) + + # Should have multiple references: definition + usage in App.vue, CalculatorInput.vue, CalculatorDisplay.vue + assert len(refs) >= 4, f"useCalculatorStore should have at least 4 references (definition + 3 usages), got {len(refs)}" + + # Verify we have references from .vue files + vue_refs = [ref for ref in refs if ".vue" in ref.get("relativePath", "")] + assert len(vue_refs) >= 3, f"Should have at least 3 Vue component references, got {len(vue_refs)}" + + +@pytest.mark.vue +class TestVueDualLspArchitecture: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_typescript_server_coordination(self, language_server: SolidLanguageServer) -> None: + ts_file = os.path.join("src", "stores", "calculator.ts") + ts_symbols = language_server.request_document_symbols(ts_file).get_all_symbols_and_roots() + ts_symbol_names = [s.get("name") for s in ts_symbols[0]] + + assert len(ts_symbols[0]) >= 5, f"TypeScript server should return multiple symbols for calculator.ts, got {len(ts_symbols[0])}" + assert "useCalculatorStore" in ts_symbol_names, "TypeScript server should extract store function" + + # Verify Vue server can parse .vue files + vue_file = os.path.join("src", "App.vue") + vue_symbols = language_server.request_document_symbols(vue_file).get_all_symbols_and_roots() + vue_symbol_names = [s.get("name") for s in vue_symbols[0]] + + assert len(vue_symbols[0]) >= 15, f"Vue server should return at least 15 symbols for App.vue, got {len(vue_symbols[0])}" + assert "appTitle" in vue_symbol_names, "Vue server should extract ref declarations from script setup" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_cross_file_references_vue_to_typescript(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + store_symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + + store_symbol = None + for sym in store_symbols[0]: + if sym.get("name") == "useCalculatorStore": + store_symbol = sym + break + + if not store_symbol or "selectionRange" not in store_symbol: + pytest.skip("useCalculatorStore symbol not found - test fixture may need updating") + + # Request references for this symbol + sel_start = store_symbol["selectionRange"]["start"] + refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) + + # Verify we found references: definition + usage in App.vue, CalculatorInput.vue, CalculatorDisplay.vue + assert len(refs) >= 4, f"useCalculatorStore should have at least 4 references (definition + 3 usages), found {len(refs)} references" + + # Verify references include .vue files (components that import the store) + vue_refs = [ref for ref in refs if ".vue" in ref.get("uri", "")] + assert ( + len(vue_refs) >= 3 + ), f"Should find at least 3 references in Vue components, found {len(vue_refs)}: {[ref.get('uri', '') for ref in vue_refs]}" + + # Verify specific components that use the store + expected_vue_files = ["App.vue", "CalculatorInput.vue", "CalculatorDisplay.vue"] + found_components = [] + for expected_file in expected_vue_files: + matching_refs = [ref for ref in vue_refs if expected_file in ref.get("uri", "")] + if matching_refs: + found_components.append(expected_file) + + assert len(found_components) > 0, ( + f"Should find references in at least one component that uses the store. " + f"Expected any of {expected_vue_files}, found references in: {[ref.get('uri', '') for ref in vue_refs]}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_cross_file_references_typescript_to_vue(self, language_server: SolidLanguageServer) -> None: + types_file = os.path.join("src", "types", "index.ts") + types_symbols = language_server.request_document_symbols(types_file).get_all_symbols_and_roots() + types_symbol_names = [s.get("name") for s in types_symbols[0]] + + # Operation type is used in calculator.ts and CalculatorInput.vue + assert "Operation" in types_symbol_names, "Operation type should exist in types file" + + operation_symbol = None + for sym in types_symbols[0]: + if sym.get("name") == "Operation": + operation_symbol = sym + break + + if not operation_symbol or "selectionRange" not in operation_symbol: + pytest.skip("Operation type symbol not found - test fixture may need updating") + + # Request references for the Operation type + sel_start = operation_symbol["selectionRange"]["start"] + refs = language_server.request_references(types_file, sel_start["line"], sel_start["character"]) + + # Verify we found references: definition + usage in calculator.ts and Vue files + assert len(refs) >= 2, f"Operation type should have at least 2 references (definition + usages), found {len(refs)} references" + + # The Operation type should be referenced in both .ts files (calculator.ts) and potentially .vue files + all_ref_uris = [ref.get("uri", "") for ref in refs] + has_ts_refs = any(".ts" in uri and "types" not in uri for uri in all_ref_uris) + + assert ( + has_ts_refs + ), f"Operation type should be referenced in TypeScript files like calculator.ts. Found references in: {all_ref_uris}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_reference_deduplication(self, language_server: SolidLanguageServer) -> None: + store_file = os.path.join("src", "stores", "calculator.ts") + store_symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots() + + # Find a commonly-used symbol (useCalculatorStore) + store_symbol = None + for sym in store_symbols[0]: + if sym.get("name") == "useCalculatorStore": + store_symbol = sym + break + + if not store_symbol or "selectionRange" not in store_symbol: + pytest.skip("useCalculatorStore symbol not found - test fixture may need updating") + + # Request references + sel_start = store_symbol["selectionRange"]["start"] + refs = language_server.request_references(store_file, sel_start["line"], sel_start["character"]) + + # Check for duplicate references (same file, line, and character) + seen_locations = set() + duplicates = [] + + for ref in refs: + # Create a unique key for this reference location + uri = ref.get("uri", "") + if "range" in ref: + line = ref["range"]["start"]["line"] + character = ref["range"]["start"]["character"] + location_key = (uri, line, character) + + if location_key in seen_locations: + duplicates.append(location_key) + else: + seen_locations.add(location_key) + + assert len(duplicates) == 0, ( + f"Found {len(duplicates)} duplicate reference locations. " + f"The dual-LSP architecture should deduplicate references from both servers. " + f"Duplicates: {duplicates}" + ) + + +@pytest.mark.vue +class TestVueEdgeCases: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_symbol_tree_structure(self, language_server: SolidLanguageServer) -> None: + full_tree = language_server.request_full_symbol_tree() + + # Helper to extract all file paths from symbol tree + def extract_paths_from_tree(symbols, paths=None): + """Recursively extract file paths from symbol tree.""" + if paths is None: + paths = [] + + if isinstance(symbols, list): + for symbol in symbols: + extract_paths_from_tree(symbol, paths) + elif isinstance(symbols, dict): + # Check if this symbol has a location + if "location" in symbols and "uri" in symbols["location"]: + uri = symbols["location"]["uri"] + # Extract the path after file:// + if uri.startswith("file://"): + file_path = uri[7:] # Remove "file://" + paths.append(file_path) + + # Recurse into children + if "children" in symbols: + extract_paths_from_tree(symbols["children"], paths) + + return paths + + all_paths = extract_paths_from_tree(full_tree) + + # Verify we have files from expected directories + # Note: Symbol tree may include duplicate paths (one per symbol in file) + components_files = list({p for p in all_paths if "components" in p and ".vue" in p}) + stores_files = list({p for p in all_paths if "stores" in p and ".ts" in p}) + composables_files = list({p for p in all_paths if "composables" in p and ".ts" in p}) + + assert len(components_files) == 3, ( + f"Symbol tree should include exactly 3 unique Vue components (CalculatorButton, CalculatorInput, CalculatorDisplay). " + f"Found {len(components_files)} unique component files: {[p.split('/')[-1] for p in sorted(components_files)]}" + ) + + assert len(stores_files) == 1, ( + f"Symbol tree should include exactly 1 unique store file (calculator.ts). " + f"Found {len(stores_files)} unique store files: {[p.split('/')[-1] for p in sorted(stores_files)]}" + ) + + assert len(composables_files) == 2, ( + f"Symbol tree should include exactly 2 unique composable files (useFormatter.ts, useTheme.ts). " + f"Found {len(composables_files)} unique composable files: {[p.split('/')[-1] for p in sorted(composables_files)]}" + ) + + # Verify specific expected files exist in the tree + expected_files = [ + "CalculatorButton.vue", + "CalculatorInput.vue", + "CalculatorDisplay.vue", + "App.vue", + "calculator.ts", + "useFormatter.ts", + "useTheme.ts", + ] + + for expected_file in expected_files: + matching_files = [p for p in all_paths if expected_file in p] + assert len(matching_files) > 0, f"Expected file '{expected_file}' should be in symbol tree. All paths: {all_paths}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_document_overview(self, language_server: SolidLanguageServer) -> None: + app_file = os.path.join("src", "App.vue") + overview = language_server.request_document_overview(app_file) + + # Overview should return a list of top-level symbols + assert isinstance(overview, list), f"Overview should be a list, got: {type(overview)}" + assert len(overview) >= 1, f"App.vue should have at least 1 top-level symbol in overview, got {len(overview)}" + + # Extract symbol names from overview + symbol_names = [s.get("name") for s in overview if isinstance(s, dict)] + + # Vue LSP returns SFC structure (template/script/style sections) for .vue files + # This is expected behavior - overview shows the file's high-level structure + assert ( + len(symbol_names) >= 1 + ), f"Should have at least 1 symbol name in overview (e.g., 'App' or SFC section), got {len(symbol_names)}: {symbol_names}" + + # Test overview for a TypeScript file + store_file = os.path.join("src", "stores", "calculator.ts") + store_overview = language_server.request_document_overview(store_file) + + assert isinstance(store_overview, list), f"Store overview should be a list, got: {type(store_overview)}" + assert len(store_overview) >= 1, f"calculator.ts should have at least 1 top-level symbol in overview, got {len(store_overview)}" + + store_symbol_names = [s.get("name") for s in store_overview if isinstance(s, dict)] + assert ( + "useCalculatorStore" in store_symbol_names + ), f"useCalculatorStore should be in store file overview. Found {len(store_symbol_names)} symbols: {store_symbol_names}" + + # Test overview for another Vue component + button_file = os.path.join("src", "components", "CalculatorButton.vue") + button_overview = language_server.request_document_overview(button_file) + + assert isinstance(button_overview, list), f"Button overview should be a list, got: {type(button_overview)}" + assert ( + len(button_overview) >= 1 + ), f"CalculatorButton.vue should have at least 1 top-level symbol in overview, got {len(button_overview)}" + + # For Vue files, overview provides SFC structure which is useful for navigation + # The detailed symbols are available via request_document_symbols + button_symbol_names = [s.get("name") for s in button_overview if isinstance(s, dict)] + assert len(button_symbol_names) >= 1, ( + f"CalculatorButton.vue should have at least 1 symbol in overview (e.g., 'CalculatorButton' or SFC section). " + f"Found {len(button_symbol_names)} symbols: {button_symbol_names}" + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_directory_overview(self, language_server: SolidLanguageServer) -> None: + components_dir = os.path.join("src", "components") + dir_overview = language_server.request_dir_overview(components_dir) + + # Directory overview should be a dict mapping file paths to symbol lists + assert isinstance(dir_overview, dict), f"Directory overview should be a dict, got: {type(dir_overview)}" + assert len(dir_overview) == 3, f"src/components directory should have exactly 3 files in overview, got {len(dir_overview)}" + + # Verify all component files are included + expected_components = ["CalculatorButton.vue", "CalculatorInput.vue", "CalculatorDisplay.vue"] + + for expected_component in expected_components: + # Find files that match this component name + matching_files = [path for path in dir_overview.keys() if expected_component in path] + assert len(matching_files) == 1, ( + f"Component '{expected_component}' should appear exactly once in directory overview. " + f"Found {len(matching_files)} matches. All files: {list(dir_overview.keys())}" + ) + + # Verify the matched file has symbols + file_path = matching_files[0] + symbols = dir_overview[file_path] + assert isinstance(symbols, list), f"Symbols for {file_path} should be a list, got {type(symbols)}" + assert len(symbols) >= 1, f"Component {expected_component} should have at least 1 symbol in overview, got {len(symbols)}" + + # Test overview for stores directory + stores_dir = os.path.join("src", "stores") + stores_overview = language_server.request_dir_overview(stores_dir) + + assert isinstance(stores_overview, dict), f"Stores overview should be a dict, got: {type(stores_overview)}" + assert ( + len(stores_overview) == 1 + ), f"src/stores directory should have exactly 1 file (calculator.ts) in overview, got {len(stores_overview)}" + + # Verify calculator.ts is included + calculator_files = [path for path in stores_overview.keys() if "calculator.ts" in path] + assert len(calculator_files) == 1, ( + f"calculator.ts should appear exactly once in stores directory overview. " + f"Found {len(calculator_files)} matches. All files: {list(stores_overview.keys())}" + ) + + # Verify the store file has symbols + store_path = calculator_files[0] + store_symbols = stores_overview[store_path] + store_symbol_names = [s.get("name") for s in store_symbols if isinstance(s, dict)] + assert ( + "useCalculatorStore" in store_symbol_names + ), f"calculator.ts should have useCalculatorStore in overview. Found {len(store_symbol_names)} symbols: {store_symbol_names}" + + # Test overview for composables directory + composables_dir = os.path.join("src", "composables") + composables_overview = language_server.request_dir_overview(composables_dir) + + assert isinstance(composables_overview, dict), f"Composables overview should be a dict, got: {type(composables_overview)}" + assert ( + len(composables_overview) == 2 + ), f"src/composables directory should have exactly 2 files in overview, got {len(composables_overview)}" + + # Verify composable files are included + expected_composables = ["useFormatter.ts", "useTheme.ts"] + for expected_composable in expected_composables: + matching_files = [path for path in composables_overview.keys() if expected_composable in path] + assert len(matching_files) == 1, ( + f"Composable '{expected_composable}' should appear exactly once in directory overview. " + f"Found {len(matching_files)} matches. All files: {list(composables_overview.keys())}" + ) diff --git a/test/solidlsp/vue/test_vue_error_cases.py b/test/solidlsp/vue/test_vue_error_cases.py new file mode 100644 index 00000000..5396666f --- /dev/null +++ b/test/solidlsp/vue/test_vue_error_cases.py @@ -0,0 +1,422 @@ +import os +import sys + +import pytest + +from solidlsp import SolidLanguageServer +from solidlsp.ls_config import Language + +pytestmark = pytest.mark.vue + +IS_WINDOWS = sys.platform == "win32" + + +class TypeScriptServerBehavior: + """Platform-specific TypeScript language server behavior for invalid positions. + + On Windows: TS server returns empty results for invalid positions + On macOS/Linux: TS server raises exceptions with "Bad line number" or "Debug Failure" + """ + + @staticmethod + def raises_on_invalid_position() -> bool: + return not IS_WINDOWS + + @staticmethod + def returns_empty_on_invalid_position() -> bool: + return IS_WINDOWS + + +class TestVueInvalidPositions: + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_negative_line_number(self, language_server: SolidLanguageServer) -> None: + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + result = language_server.request_containing_symbol(file_path, -1, 0) + + assert result is None or result == {}, f"Negative line number should return None or empty dict, got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_negative_character_number(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol with negative character number. + + Expected behavior: Should return None or empty dict, not crash. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at invalid negative character + result = language_server.request_containing_symbol(file_path, 10, -1) + + # Should handle gracefully - return None or empty dict + assert result is None or result == {}, f"Negative character number should return None or empty dict, got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol beyond file length. + + Expected behavior: Raises IndexError when trying to access line beyond file bounds. + This happens in the wrapper code before even reaching the language server. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at line 99999 (way beyond file length) + # The wrapper code will raise an IndexError when checking if the line is empty + with pytest.raises(IndexError) as exc_info: + language_server.request_containing_symbol(file_path, 99999, 0) + + # Verify it's an index error for list access + assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_character_number_beyond_line_length(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol beyond line length. + + Expected behavior: Should return None or empty dict, not crash. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Request containing symbol at character 99999 (way beyond line length) + result = language_server.request_containing_symbol(file_path, 10, 99999) + + # Should handle gracefully - return None or empty dict + assert result is None or result == {}, f"Character beyond line length should return None or empty dict, got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_references_at_negative_line(self, language_server: SolidLanguageServer) -> None: + """Test requesting references with negative line number.""" + from solidlsp.ls_exceptions import SolidLSPException + + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + if TypeScriptServerBehavior.returns_empty_on_invalid_position(): + result = language_server.request_references(file_path, -1, 0) + assert result == [], f"Expected empty list on Windows, got: {result}" + else: + with pytest.raises(SolidLSPException) as exc_info: + language_server.request_references(file_path, -1, 0) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_definition_at_invalid_position(self, language_server: SolidLanguageServer) -> None: + """Test requesting definition at invalid position.""" + from solidlsp.ls_exceptions import SolidLSPException + + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + if TypeScriptServerBehavior.returns_empty_on_invalid_position(): + result = language_server.request_definition(file_path, -1, 0) + assert result == [], f"Expected empty list on Windows, got: {result}" + else: + with pytest.raises(SolidLSPException) as exc_info: + language_server.request_definition(file_path, -1, 0) + assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value) + + +class TestVueNonExistentFiles: + """Tests for handling non-existent files.""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_document_symbols_nonexistent_file(self, language_server: SolidLanguageServer) -> None: + """Test requesting document symbols from non-existent file. + + Expected behavior: Should raise FileNotFoundError or return empty result. + """ + nonexistent_file = os.path.join("src", "components", "NonExistent.vue") + + # Should raise an appropriate exception or return empty result + try: + result = language_server.request_document_symbols(nonexistent_file) + # If no exception, verify result is empty or indicates file not found + symbols = result.get_all_symbols_and_roots() + assert len(symbols[0]) == 0, f"Non-existent file should return empty symbols, got {len(symbols[0])} symbols" + except (FileNotFoundError, Exception) as e: + # Expected - file doesn't exist + assert True, f"Appropriately raised exception for non-existent file: {e}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_containing_symbol_nonexistent_file(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol from non-existent file. + + Expected behavior: Should raise FileNotFoundError or return None. + """ + nonexistent_file = os.path.join("src", "components", "NonExistent.vue") + + # Should raise an appropriate exception or return None + try: + result = language_server.request_containing_symbol(nonexistent_file, 10, 10) + # If no exception, verify result indicates file not found + assert result is None or result == {}, f"Non-existent file should return None or empty dict, got: {result}" + except (FileNotFoundError, Exception) as e: + # Expected - file doesn't exist + assert True, f"Appropriately raised exception for non-existent file: {e}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_references_nonexistent_file(self, language_server: SolidLanguageServer) -> None: + """Test requesting references from non-existent file. + + Expected behavior: Should raise FileNotFoundError or return empty list. + """ + nonexistent_file = os.path.join("src", "components", "NonExistent.vue") + + # Should raise an appropriate exception or return empty list + try: + result = language_server.request_references(nonexistent_file, 10, 10) + # If no exception, verify result is empty + assert result is None or isinstance(result, list), f"Non-existent file should return None or list, got: {result}" + if isinstance(result, list): + assert len(result) == 0, f"Non-existent file should return empty list, got {len(result)} references" + except (FileNotFoundError, Exception) as e: + # Expected - file doesn't exist + assert True, f"Appropriately raised exception for non-existent file: {e}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_definition_nonexistent_file(self, language_server: SolidLanguageServer) -> None: + """Test requesting definition from non-existent file. + + Expected behavior: Should raise FileNotFoundError or return empty list. + """ + nonexistent_file = os.path.join("src", "components", "NonExistent.vue") + + # Should raise an appropriate exception or return empty list + try: + result = language_server.request_definition(nonexistent_file, 10, 10) + # If no exception, verify result is empty + assert isinstance(result, list), f"request_definition should return a list, got: {type(result)}" + assert len(result) == 0, f"Non-existent file should return empty list, got {len(result)} definitions" + except (FileNotFoundError, Exception) as e: + # Expected - file doesn't exist + assert True, f"Appropriately raised exception for non-existent file: {e}" + + +class TestVueUndefinedSymbols: + """Tests for handling undefined or unreferenced symbols.""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_references_for_unreferenced_symbol(self, language_server: SolidLanguageServer) -> None: + """Test requesting references for a symbol that has no references. + + Expected behavior: Should return empty list (only the definition itself if include_self=True). + """ + # Find a symbol that likely has no external references + file_path = os.path.join("src", "components", "CalculatorButton.vue") + + # Get document symbols + symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots() + + # Find pressCount - this is exposed but may not be referenced elsewhere + press_count_symbol = next((s for s in symbols[0] if s.get("name") == "pressCount"), None) + + if not press_count_symbol or "selectionRange" not in press_count_symbol: + pytest.skip("pressCount symbol not found - test fixture may need updating") + + # Request references without include_self + sel_start = press_count_symbol["selectionRange"]["start"] + refs = language_server.request_references(file_path, sel_start["line"], sel_start["character"]) + + # Should return a list (may be empty or contain only definition) + assert isinstance(refs, list), f"request_references should return a list, got {type(refs)}" + + # For an unreferenced symbol, should have 0-1 references (0 without include_self, 1 with) + # The exact count depends on the language server implementation + assert len(refs) <= 5, ( + f"pressCount should have few or no external references. " + f"Got {len(refs)} references. This is not necessarily an error, just documenting behavior." + ) + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_containing_symbol_at_whitespace_only_line(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol at a whitespace-only line. + + Expected behavior: Should return None, empty dict, or the parent symbol. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Try position at line 1 (typically a blank line or template start in Vue SFCs) + result = language_server.request_containing_symbol(file_path, 1, 0) + + # Should handle gracefully - return None, empty dict, or a valid parent symbol + assert ( + result is None or result == {} or isinstance(result, dict) + ), f"Whitespace line should return None, empty dict, or valid symbol. Got: {result}" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_definition_at_keyword_position(self, language_server: SolidLanguageServer) -> None: + """Test requesting definition at language keyword position. + + Expected behavior: Should return empty list or handle gracefully. + """ + file_path = os.path.join("src", "components", "CalculatorInput.vue") + + # Try to get definition at a keyword like "const", "import", etc. + # Line 2 typically has "import" statement - try position on "import" keyword + result = language_server.request_definition(file_path, 2, 0) + + # Should handle gracefully - return empty list or valid definitions + assert isinstance(result, list), f"request_definition should return a list, got {type(result)}" + + +class TestVueEdgeCasePositions: + """Tests for edge case positions (0,0 and file boundaries).""" + + @pytest.mark.parametrize("language_server", [Language.VUE], indirect=True) + def test_containing_symbol_at_file_start(self, language_server: SolidLanguageServer) -> None: + """Test requesting containing symbol at position (0,0). + + Expected behavior: Should return None, empty dict, or a valid symbol. + This position typically corresponds to the start of the file (e.g.,