mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-12 20:15:56 +00:00
Add Vue Language Support (#784)
* Adds full Vue.js language support to Serena using the official @vue/language-server.
* Integrates @vue/language-server for Vue SFC (.vue) file support
* Manages a companion TypeScript server for cross-file references between Vue components and TypeScript modules
* Implemenet logic for tie-breaks when languages match the same number of files
(adding priority to the Language enum, deprioritising languages that are supersets of others)
---------
Co-authored-by: Michael Panchenko <michael.panchenko@oraios-ai.de>
Co-authored-by: Dominik Jain <dominik.jain@oraios-ai.de>
This commit is contained in:
34 files changed
+6754
-31
No files matched your search
+4
-1
@@ -251,4 +251,7 @@ test/resources/repos/scala/.bloop/
|
||||
stack.yaml.lock
|
||||
dist-newstyle/
|
||||
cabal.project.local*
|
||||
.ghc.environment.*
|
||||
.ghc.environment.*
|
||||
|
||||
zz-misc/
|
||||
vue-implementation/
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ 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)
|
||||
|
||||
@@ -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#",
|
||||
|
||||
@@ -174,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.")
|
||||
@@ -190,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]
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
+39
-4
@@ -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()
|
||||
@@ -61,6 +61,7 @@ class Language(str, Enum):
|
||||
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"""
|
||||
@@ -99,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:
|
||||
@@ -175,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}")
|
||||
|
||||
@@ -216,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
|
||||
|
||||
|
||||
@@ -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?
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vue Calculator</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1486
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, watchEffect, onMounted } from 'vue'
|
||||
import { useCalculatorStore } from '@/stores/calculator'
|
||||
import { useThemeProvider } from '@/composables/useTheme'
|
||||
import { useTimeFormatter } from '@/composables/useFormatter'
|
||||
import CalculatorInput from '@/components/CalculatorInput.vue'
|
||||
import CalculatorDisplay from '@/components/CalculatorDisplay.vue'
|
||||
|
||||
// Get the calculator store
|
||||
const store = useCalculatorStore()
|
||||
|
||||
// Use theme composable with provide/inject - provides theme to all child components
|
||||
const themeManager = useThemeProvider()
|
||||
|
||||
// Use time formatter composable
|
||||
const timeFormatter = useTimeFormatter()
|
||||
|
||||
// Local ref for app title
|
||||
const appTitle = ref('Vue Calculator')
|
||||
|
||||
// Computed property for app version
|
||||
const appVersion = computed((): string => {
|
||||
return '1.0.0'
|
||||
})
|
||||
|
||||
// Computed property for greeting message
|
||||
const greetingMessage = computed((): string => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Good morning'
|
||||
if (hour < 18) return 'Good afternoon'
|
||||
return 'Good evening'
|
||||
})
|
||||
|
||||
// Get statistics from store
|
||||
const totalCalculations = computed((): number => {
|
||||
return store.history.length
|
||||
})
|
||||
|
||||
// Check if calculator is active
|
||||
const isCalculatorActive = computed((): boolean => {
|
||||
return store.currentValue !== 0 || store.previousValue !== null
|
||||
})
|
||||
|
||||
// Get last calculation time
|
||||
const lastCalculationTime = computed((): string => {
|
||||
if (store.history.length === 0) return 'No calculations yet'
|
||||
const lastEntry = store.history[store.history.length - 1]
|
||||
return timeFormatter.getRelativeTime(lastEntry.timestamp)
|
||||
})
|
||||
|
||||
// Watch for calculation count changes - demonstrates watchEffect
|
||||
watchEffect(() => {
|
||||
document.title = `Calculator (${totalCalculations.value} calculations)`
|
||||
})
|
||||
|
||||
// Watch for theme changes - demonstrates watch
|
||||
watch(
|
||||
() => themeManager.isDarkMode.value,
|
||||
(isDark) => {
|
||||
console.log(`Theme changed to ${isDark ? 'dark' : 'light'} mode`)
|
||||
}
|
||||
)
|
||||
|
||||
// Lifecycle hook
|
||||
onMounted(() => {
|
||||
console.log('App component mounted')
|
||||
console.log(`Initial calculations: ${totalCalculations.value}`)
|
||||
})
|
||||
|
||||
// Toggle theme method
|
||||
const toggleTheme = () => {
|
||||
themeManager.toggleDarkMode()
|
||||
}
|
||||
|
||||
<template>
|
||||
<div :class="['app', { 'dark-mode': themeManager.isDarkMode }]">
|
||||
<header class="app-header">
|
||||
<h1>{{ appTitle }}</h1>
|
||||
<div class="header-info">
|
||||
<span class="greeting">{{ greetingMessage }}!</span>
|
||||
<span class="version">v{{ appVersion }}</span>
|
||||
<button @click="toggleTheme" class="btn-theme">
|
||||
{{ themeManager.isDarkMode ? '☀️' : '🌙' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="calculator-container">
|
||||
<div class="stats-bar">
|
||||
<span>Total Calculations: {{ totalCalculations }}</span>
|
||||
<span :class="{ 'active-indicator': isCalculatorActive }">
|
||||
{{ isCalculatorActive ? 'Active' : 'Idle' }}
|
||||
</span>
|
||||
<span class="last-calc-time">{{ lastCalculationTime }}</span>
|
||||
</div>
|
||||
|
||||
<div class="calculator-grid">
|
||||
<CalculatorInput />
|
||||
<CalculatorDisplay />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<p>Built with Vue 3 + Pinia + TypeScript</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.app.dark-mode {
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 2rem;
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.greeting {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.version {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-theme {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-theme:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.calculator-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.last-calc-time {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
color: #4caf50;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.active-indicator:not(.active-indicator) {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.calculator-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.calculator-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: white;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.app-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Props interface for CalculatorButton.
|
||||
* Demonstrates: defineProps with TypeScript interface
|
||||
*/
|
||||
interface Props {
|
||||
label: string | number
|
||||
variant?: 'digit' | 'operation' | 'equals' | 'clear'
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits interface for CalculatorButton.
|
||||
* Demonstrates: defineEmits with TypeScript
|
||||
*/
|
||||
interface Emits {
|
||||
click: [value: string | number]
|
||||
hover: [isHovering: boolean]
|
||||
focus: []
|
||||
blur: []
|
||||
}
|
||||
|
||||
// Define props with defaults
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'digit',
|
||||
disabled: false,
|
||||
active: false,
|
||||
size: 'medium'
|
||||
})
|
||||
|
||||
// Define emits
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Local state
|
||||
const isHovered = ref(false)
|
||||
const isFocused = ref(false)
|
||||
const pressCount = ref(0)
|
||||
|
||||
// Computed classes based on props and state
|
||||
const buttonClass = computed(() => {
|
||||
const classes = ['calc-button', `calc-button--${props.variant}`, `calc-button--${props.size}`]
|
||||
|
||||
if (props.active) classes.push('calc-button--active')
|
||||
if (props.disabled) classes.push('calc-button--disabled')
|
||||
if (isHovered.value) classes.push('calc-button--hovered')
|
||||
if (isFocused.value) classes.push('calc-button--focused')
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
|
||||
// Computed aria label for accessibility
|
||||
const ariaLabel = computed(() => {
|
||||
const variantText = {
|
||||
digit: 'Number',
|
||||
operation: 'Operation',
|
||||
equals: 'Equals',
|
||||
clear: 'Clear'
|
||||
}[props.variant]
|
||||
|
||||
return `${variantText}: ${props.label}`
|
||||
})
|
||||
|
||||
// Event handlers that emit events
|
||||
const handleClick = () => {
|
||||
if (!props.disabled) {
|
||||
pressCount.value++
|
||||
emit('click', props.label)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
isHovered.value = true
|
||||
emit('hover', true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
isHovered.value = false
|
||||
emit('hover', false)
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
isFocused.value = true
|
||||
emit('focus')
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocused.value = false
|
||||
emit('blur')
|
||||
}
|
||||
|
||||
// Expose internal state for parent access via template refs
|
||||
// Demonstrates: defineExpose
|
||||
defineExpose({
|
||||
pressCount,
|
||||
isHovered,
|
||||
isFocused,
|
||||
simulateClick: handleClick
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="buttonClass"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
@click="handleClick"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
>
|
||||
<span class="calc-button__label">{{ label }}</span>
|
||||
<span v-if="pressCount > 0" class="calc-button__badge">{{ pressCount }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calc-button {
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
font-size: 1.2rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.calc-button--small {
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.calc-button--medium {
|
||||
padding: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.calc-button--large {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.calc-button--digit {
|
||||
background: white;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.calc-button--digit:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.calc-button--operation {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calc-button--operation:hover:not(:disabled) {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
.calc-button--operation.calc-button--active {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
.calc-button--equals {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calc-button--equals:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.calc-button--clear {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.calc-button--clear:hover:not(:disabled) {
|
||||
background: #da190b;
|
||||
}
|
||||
|
||||
.calc-button--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.calc-button--hovered {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.calc-button--focused {
|
||||
outline: 2px solid #2196f3;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.calc-button__label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.calc-button__badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: #ff5722;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 0.7rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useCalculatorStore } from '@/stores/calculator'
|
||||
import type { HistoryEntry } from '@/types'
|
||||
|
||||
// Get the calculator store
|
||||
const store = useCalculatorStore()
|
||||
|
||||
// Use storeToRefs to get reactive references to store state
|
||||
const { recentHistory, hasHistory, currentValue, operation } = storeToRefs(store)
|
||||
|
||||
// Local ref for display options
|
||||
const showFullHistory = ref(false)
|
||||
const maxHistoryItems = ref(5)
|
||||
|
||||
// Computed property for filtered history
|
||||
const displayedHistory = computed((): HistoryEntry[] => {
|
||||
if (showFullHistory.value) {
|
||||
return recentHistory.value
|
||||
}
|
||||
return recentHistory.value.slice(0, maxHistoryItems.value)
|
||||
})
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (date: Date): string => {
|
||||
return new Date(date).toLocaleTimeString()
|
||||
}
|
||||
|
||||
// Format the current calculation status
|
||||
const currentCalculation = computed((): string => {
|
||||
if (operation.value && store.previousValue !== null) {
|
||||
const opSymbol = {
|
||||
add: '+',
|
||||
subtract: '-',
|
||||
multiply: '×',
|
||||
divide: '÷'
|
||||
}[operation.value]
|
||||
return `${store.previousValue} ${opSymbol} ${currentValue.value}`
|
||||
}
|
||||
return currentValue.value.toString()
|
||||
})
|
||||
|
||||
// Toggle history view
|
||||
const toggleHistoryView = () => {
|
||||
showFullHistory.value = !showFullHistory.value
|
||||
}
|
||||
|
||||
// Clear history handler
|
||||
const clearHistory = () => {
|
||||
store.clearHistory()
|
||||
}
|
||||
|
||||
// Check if history is empty
|
||||
const isHistoryEmpty = computed((): boolean => {
|
||||
return !hasHistory.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calculator-display">
|
||||
<div class="current-calculation">
|
||||
<h3>Current Calculation</h3>
|
||||
<div class="calculation-value">
|
||||
{{ currentCalculation }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="history-section">
|
||||
<div class="history-header">
|
||||
<h3>History</h3>
|
||||
<button
|
||||
v-if="hasHistory"
|
||||
@click="toggleHistoryView"
|
||||
class="btn-toggle"
|
||||
>
|
||||
{{ showFullHistory ? 'Show Less' : 'Show All' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasHistory"
|
||||
@click="clearHistory"
|
||||
class="btn-clear-history"
|
||||
>
|
||||
Clear History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isHistoryEmpty" class="empty-history">
|
||||
No calculations yet
|
||||
</div>
|
||||
|
||||
<div v-else class="history-list">
|
||||
<div
|
||||
v-for="(entry, index) in displayedHistory"
|
||||
:key="`${entry.timestamp}-${index}`"
|
||||
class="history-item"
|
||||
>
|
||||
<span class="expression">{{ entry.expression }}</span>
|
||||
<span class="result">= {{ entry.result }}</span>
|
||||
<span class="timestamp">{{ formatDate(entry.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!showFullHistory && recentHistory.length > maxHistoryItems" class="history-count">
|
||||
Showing {{ maxHistoryItems }} of {{ recentHistory.length }} entries
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calculator-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.current-calculation {
|
||||
padding: 1rem;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.current-calculation h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.calculation-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.history-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.history-header h3 {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-toggle,
|
||||
.btn-clear-history {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
background: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
background: #1976d2;
|
||||
}
|
||||
|
||||
.btn-clear-history {
|
||||
background: #f44336;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-clear-history:hover {
|
||||
background: #da190b;
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expression {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result {
|
||||
font-weight: bold;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.history-count {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useCalculatorStore } from '@/stores/calculator'
|
||||
import { useFormatter } from '@/composables/useFormatter'
|
||||
import CalculatorButton from './CalculatorButton.vue'
|
||||
import type { Operation } from '@/types'
|
||||
|
||||
// Get the calculator store
|
||||
const store = useCalculatorStore()
|
||||
|
||||
// Use composable for formatting
|
||||
const formatter = useFormatter(2)
|
||||
|
||||
// Local refs for component state
|
||||
const isOperationPending = ref(false)
|
||||
const lastOperation = ref<Operation>(null)
|
||||
const keyboardEnabled = ref(true)
|
||||
const operationHistory = ref<string[]>([])
|
||||
|
||||
// Template refs - demonstrates template ref pattern
|
||||
const displayRef = ref<HTMLDivElement | null>(null)
|
||||
const equalsButtonRef = ref<InstanceType<typeof CalculatorButton> | null>(null)
|
||||
|
||||
// Computed property for button styling
|
||||
const getOperationClass = computed(() => (op: Operation) => {
|
||||
return lastOperation.value === op ? 'active' : ''
|
||||
})
|
||||
|
||||
// Computed formatted display value using composable
|
||||
const formattedDisplay = computed(() => {
|
||||
const value = parseFloat(store.display)
|
||||
return isNaN(value) ? store.display : formatter.formatNumber(value)
|
||||
})
|
||||
|
||||
// Watch for operation changes - demonstrates watch
|
||||
watch(lastOperation, (newOp, oldOp) => {
|
||||
if (newOp !== oldOp && newOp) {
|
||||
operationHistory.value.push(newOp)
|
||||
// Keep only last 10 operations
|
||||
if (operationHistory.value.length > 10) {
|
||||
operationHistory.value.shift()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Watch store display changes - demonstrates watch with callback
|
||||
watch(
|
||||
() => store.display,
|
||||
(newDisplay) => {
|
||||
if (displayRef.value) {
|
||||
// Trigger animation on display change
|
||||
displayRef.value.classList.add('display-updated')
|
||||
setTimeout(() => {
|
||||
displayRef.value?.classList.remove('display-updated')
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Lifecycle hook - demonstrates onMounted
|
||||
onMounted(() => {
|
||||
console.log('CalculatorInput mounted')
|
||||
// Add keyboard event listener
|
||||
window.addEventListener('keydown', handleKeyboard)
|
||||
|
||||
// Focus on the display element
|
||||
if (displayRef.value) {
|
||||
displayRef.value.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Lifecycle hook - demonstrates onBeforeUnmount
|
||||
onBeforeUnmount(() => {
|
||||
console.log('CalculatorInput unmounting')
|
||||
// Clean up keyboard event listener
|
||||
window.removeEventListener('keydown', handleKeyboard)
|
||||
})
|
||||
|
||||
// Handle number button clicks
|
||||
const handleDigit = (digit: number) => {
|
||||
store.appendDigit(digit)
|
||||
isOperationPending.value = false
|
||||
}
|
||||
|
||||
// Handle operation button clicks
|
||||
const handleOperation = (operation: Operation) => {
|
||||
isOperationPending.value = true
|
||||
lastOperation.value = operation
|
||||
|
||||
switch (operation) {
|
||||
case 'add':
|
||||
store.add()
|
||||
break
|
||||
case 'subtract':
|
||||
store.subtract()
|
||||
break
|
||||
case 'multiply':
|
||||
store.multiply()
|
||||
break
|
||||
case 'divide':
|
||||
store.divide()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Handle equals button
|
||||
const handleEquals = () => {
|
||||
store.equals()
|
||||
isOperationPending.value = false
|
||||
lastOperation.value = null
|
||||
|
||||
// Access exposed method from child component
|
||||
if (equalsButtonRef.value) {
|
||||
console.log('Equals button press count:', equalsButtonRef.value.pressCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle clear button
|
||||
const handleClear = () => {
|
||||
store.clear()
|
||||
isOperationPending.value = false
|
||||
lastOperation.value = null
|
||||
operationHistory.value = []
|
||||
}
|
||||
|
||||
// Keyboard handler - demonstrates event handling
|
||||
const handleKeyboard = (event: KeyboardEvent) => {
|
||||
if (!keyboardEnabled.value) return
|
||||
|
||||
const key = event.key
|
||||
|
||||
if (key >= '0' && key <= '9') {
|
||||
handleDigit(parseInt(key))
|
||||
} else if (key === '+') {
|
||||
handleOperation('add')
|
||||
} else if (key === '-') {
|
||||
handleOperation('subtract')
|
||||
} else if (key === '*') {
|
||||
handleOperation('multiply')
|
||||
} else if (key === '/') {
|
||||
event.preventDefault()
|
||||
handleOperation('divide')
|
||||
} else if (key === 'Enter' || key === '=') {
|
||||
handleEquals()
|
||||
} else if (key === 'Escape' || key === 'c' || key === 'C') {
|
||||
handleClear()
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle keyboard input
|
||||
const toggleKeyboard = () => {
|
||||
keyboardEnabled.value = !keyboardEnabled.value
|
||||
}
|
||||
|
||||
// Array of digits for rendering
|
||||
const digits = [7, 8, 9, 4, 5, 6, 1, 2, 3, 0]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calculator-input">
|
||||
<div ref="displayRef" class="display" tabindex="0">
|
||||
{{ formattedDisplay }}
|
||||
</div>
|
||||
|
||||
<div class="keyboard-toggle">
|
||||
<label>
|
||||
<input type="checkbox" v-model="keyboardEnabled" @change="toggleKeyboard" />
|
||||
Enable Keyboard Input
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<CalculatorButton
|
||||
v-for="digit in digits"
|
||||
:key="digit"
|
||||
:label="digit"
|
||||
variant="digit"
|
||||
@click="handleDigit"
|
||||
/>
|
||||
|
||||
<CalculatorButton
|
||||
label="+"
|
||||
variant="operation"
|
||||
:active="lastOperation === 'add'"
|
||||
@click="() => handleOperation('add')"
|
||||
/>
|
||||
|
||||
<CalculatorButton
|
||||
label="-"
|
||||
variant="operation"
|
||||
:active="lastOperation === 'subtract'"
|
||||
@click="() => handleOperation('subtract')"
|
||||
/>
|
||||
|
||||
<CalculatorButton
|
||||
label="×"
|
||||
variant="operation"
|
||||
:active="lastOperation === 'multiply'"
|
||||
@click="() => handleOperation('multiply')"
|
||||
/>
|
||||
|
||||
<CalculatorButton
|
||||
label="÷"
|
||||
variant="operation"
|
||||
:active="lastOperation === 'divide'"
|
||||
@click="() => handleOperation('divide')"
|
||||
/>
|
||||
|
||||
<CalculatorButton
|
||||
ref="equalsButtonRef"
|
||||
label="="
|
||||
variant="equals"
|
||||
size="large"
|
||||
@click="handleEquals"
|
||||
/>
|
||||
|
||||
<CalculatorButton
|
||||
label="C"
|
||||
variant="clear"
|
||||
@click="handleClear"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isOperationPending" class="pending-indicator">
|
||||
Operation pending: {{ lastOperation }}
|
||||
</div>
|
||||
|
||||
<div v-if="operationHistory.length > 0" class="operation-history">
|
||||
Recent operations: {{ operationHistory.join(', ') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calculator-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.display {
|
||||
font-size: 2rem;
|
||||
text-align: right;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
min-height: 3rem;
|
||||
transition: background-color 0.3s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.display:focus {
|
||||
box-shadow: 0 0 0 2px #2196f3;
|
||||
}
|
||||
|
||||
.display.display-updated {
|
||||
background-color: #e3f2fd;
|
||||
}
|
||||
|
||||
.keyboard-toggle {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.keyboard-toggle label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pending-indicator {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.operation-history {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
max-height: 3rem;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -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<number>(initialPrecision)
|
||||
const useGrouping = ref<boolean>(true)
|
||||
const locale = ref<string>('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<typeof useFormatter>
|
||||
export type UseTimeFormatterReturn = ReturnType<typeof useTimeFormatter>
|
||||
@@ -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<Ref<ThemeConfig>> = 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<ThemeConfig>(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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 = []
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["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))
|
||||
}
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())}"
|
||||
)
|
||||
@@ -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., <template> tag).
|
||||
"""
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
# Request containing symbol at position 0,0 (file start)
|
||||
result = language_server.request_containing_symbol(file_path, 0, 0)
|
||||
|
||||
# Should handle gracefully
|
||||
assert (
|
||||
result is None or result == {} or isinstance(result, dict)
|
||||
), f"Position 0,0 should return None, empty dict, or valid symbol. Got: {result}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_references_at_file_start(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test requesting references at position (0,0).
|
||||
|
||||
Expected behavior: Should return None or empty list.
|
||||
"""
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
# Request references at position 0,0 (file start)
|
||||
result = language_server.request_references(file_path, 0, 0)
|
||||
|
||||
# Should handle gracefully
|
||||
assert result is None or isinstance(result, list), f"Position 0,0 should return None or list. Got: {type(result)}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_definition_at_file_start(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test requesting definition at position (0,0).
|
||||
|
||||
Expected behavior: Should return empty list.
|
||||
"""
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
# Request definition at position 0,0 (file start)
|
||||
result = language_server.request_definition(file_path, 0, 0)
|
||||
|
||||
# Should handle gracefully
|
||||
assert isinstance(result, list), f"request_definition should return a list. Got: {type(result)}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_containing_symbol_in_template_section(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test requesting containing symbol in the template section.
|
||||
|
||||
Expected behavior: Template positions typically have no containing symbol (return None or empty).
|
||||
The Vue language server may not track template symbols the same way as script symbols.
|
||||
"""
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
# Position likely in template section (early in file, before <script setup>)
|
||||
# Exact line depends on file structure, but line 5-10 is often template
|
||||
result = language_server.request_containing_symbol(file_path, 5, 10)
|
||||
|
||||
# Should handle gracefully - template doesn't have containing symbols in the same way
|
||||
assert (
|
||||
result is None or result == {} or isinstance(result, dict)
|
||||
), f"Template position should return None, empty dict, or valid symbol. Got: {result}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_zero_character_positions(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test requesting symbols at character position 0 (start of lines).
|
||||
|
||||
Expected behavior: Should handle gracefully, may or may not find symbols.
|
||||
"""
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
# Test multiple lines at character 0
|
||||
for line in [0, 10, 20, 30]:
|
||||
result = language_server.request_containing_symbol(file_path, line, 0)
|
||||
|
||||
# Should handle gracefully
|
||||
assert (
|
||||
result is None or result == {} or isinstance(result, dict)
|
||||
), f"Line {line}, character 0 should return None, empty dict, or valid symbol. Got: {result}"
|
||||
|
||||
|
||||
class TestVueTypescriptFileErrors:
|
||||
"""Tests for error handling in TypeScript files within Vue projects."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_typescript_file_invalid_position(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test requesting symbols from TypeScript file at invalid position.
|
||||
|
||||
Expected behavior: Should handle gracefully.
|
||||
"""
|
||||
file_path = os.path.join("src", "stores", "calculator.ts")
|
||||
|
||||
# Request containing symbol at invalid position
|
||||
result = language_server.request_containing_symbol(file_path, -1, -1)
|
||||
|
||||
# Should handle gracefully
|
||||
assert result is None or result == {}, f"Invalid position in .ts file should return None or empty dict. Got: {result}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_typescript_file_beyond_bounds(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test requesting symbols from TypeScript file beyond file bounds.
|
||||
|
||||
Expected behavior: Raises IndexError when trying to access line beyond file bounds.
|
||||
"""
|
||||
file_path = os.path.join("src", "stores", "calculator.ts")
|
||||
|
||||
# Request containing symbol beyond file bounds
|
||||
# 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, 99999)
|
||||
|
||||
# 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}"
|
||||
|
||||
|
||||
class TestVueReferenceEdgeCases:
|
||||
"""Tests for edge cases in reference finding."""
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_referencing_symbols_at_invalid_position(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test requesting referencing symbols at invalid position."""
|
||||
from solidlsp.ls_exceptions import SolidLSPException
|
||||
|
||||
file_path = os.path.join("src", "stores", "calculator.ts")
|
||||
|
||||
if TypeScriptServerBehavior.returns_empty_on_invalid_position():
|
||||
result = list(language_server.request_referencing_symbols(file_path, -1, -1, include_self=False))
|
||||
assert result == [], f"Expected empty list on Windows, got: {result}"
|
||||
else:
|
||||
with pytest.raises(SolidLSPException) as exc_info:
|
||||
list(language_server.request_referencing_symbols(file_path, -1, -1, include_self=False))
|
||||
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_defining_symbol_at_invalid_position(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test requesting defining symbol 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_defining_symbol(file_path, -1, -1)
|
||||
assert result is None, f"Expected None on Windows, got: {result}"
|
||||
else:
|
||||
with pytest.raises(SolidLSPException) as exc_info:
|
||||
language_server.request_defining_symbol(file_path, -1, -1)
|
||||
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_referencing_symbols_beyond_file_bounds(self, language_server: SolidLanguageServer) -> None:
|
||||
"""Test requesting referencing symbols beyond file bounds."""
|
||||
from solidlsp.ls_exceptions import SolidLSPException
|
||||
|
||||
file_path = os.path.join("src", "stores", "calculator.ts")
|
||||
|
||||
if TypeScriptServerBehavior.returns_empty_on_invalid_position():
|
||||
result = list(language_server.request_referencing_symbols(file_path, 99999, 99999, include_self=False))
|
||||
assert result == [], f"Expected empty list on Windows, got: {result}"
|
||||
else:
|
||||
with pytest.raises(SolidLSPException) as exc_info:
|
||||
list(language_server.request_referencing_symbols(file_path, 99999, 99999, include_self=False))
|
||||
assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value)
|
||||
@@ -0,0 +1,241 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
|
||||
pytestmark = pytest.mark.vue
|
||||
|
||||
|
||||
class TestVueRename:
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_rename_function_within_single_file(self, language_server: SolidLanguageServer) -> None:
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
|
||||
handle_digit_symbol = next((s for s in symbols[0] if s.get("name") == "handleDigit"), None)
|
||||
|
||||
if not handle_digit_symbol or "selectionRange" not in handle_digit_symbol:
|
||||
pytest.skip("handleDigit symbol not found - test fixture may need updating")
|
||||
|
||||
sel_start = handle_digit_symbol["selectionRange"]["start"]
|
||||
|
||||
workspace_edit = language_server.request_rename_symbol_edit(file_path, sel_start["line"], sel_start["character"], "processDigit")
|
||||
|
||||
assert workspace_edit is not None, "Should return WorkspaceEdit for rename operation"
|
||||
|
||||
has_changes = "changes" in workspace_edit and workspace_edit["changes"]
|
||||
has_document_changes = "documentChanges" in workspace_edit and workspace_edit["documentChanges"]
|
||||
|
||||
assert has_changes or has_document_changes, "WorkspaceEdit should contain either 'changes' or 'documentChanges'"
|
||||
|
||||
if has_changes:
|
||||
changes = workspace_edit["changes"]
|
||||
assert len(changes) > 0, "Should have at least one file with changes"
|
||||
|
||||
calculator_input_files = [uri for uri in changes.keys() if "CalculatorInput.vue" in uri]
|
||||
assert len(calculator_input_files) > 0, f"Should have edits for CalculatorInput.vue. Found edits for: {list(changes.keys())}"
|
||||
|
||||
file_edits = changes[calculator_input_files[0]]
|
||||
assert len(file_edits) > 0, "Should have at least one TextEdit for the renamed symbol"
|
||||
|
||||
for edit in file_edits:
|
||||
assert "range" in edit, "TextEdit should have a range"
|
||||
assert "newText" in edit, "TextEdit should have newText"
|
||||
assert edit["newText"] == "processDigit", f"newText should be 'processDigit', got {edit['newText']}"
|
||||
|
||||
assert "start" in edit["range"], "Range should have start position"
|
||||
assert "end" in edit["range"], "Range should have end position"
|
||||
assert "line" in edit["range"]["start"], "Start position should have line number"
|
||||
assert "character" in edit["range"]["start"], "Start position should have character offset"
|
||||
|
||||
elif has_document_changes:
|
||||
document_changes = workspace_edit["documentChanges"]
|
||||
assert isinstance(document_changes, list), "documentChanges should be a list"
|
||||
assert len(document_changes) > 0, "Should have at least one document change"
|
||||
|
||||
calculator_input_changes = [dc for dc in document_changes if "CalculatorInput.vue" in dc.get("textDocument", {}).get("uri", "")]
|
||||
assert len(calculator_input_changes) > 0, "Should have edits for CalculatorInput.vue"
|
||||
|
||||
for change in calculator_input_changes:
|
||||
assert "textDocument" in change, "Document change should have textDocument"
|
||||
assert "edits" in change, "Document change should have edits"
|
||||
|
||||
edits = change["edits"]
|
||||
assert len(edits) > 0, "Should have at least one TextEdit for the renamed symbol"
|
||||
|
||||
for edit in edits:
|
||||
assert "range" in edit, "TextEdit should have a range"
|
||||
assert "newText" in edit, "TextEdit should have newText"
|
||||
assert edit["newText"] == "processDigit", f"newText should be 'processDigit', got {edit['newText']}"
|
||||
|
||||
assert "start" in edit["range"], "Range should have start position"
|
||||
assert "end" in edit["range"], "Range should have end position"
|
||||
assert "line" in edit["range"]["start"], "Start position should have line number"
|
||||
assert "character" in edit["range"]["start"], "Start position should have character offset"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_rename_composable_function_cross_file(self, language_server: SolidLanguageServer) -> None:
|
||||
composable_file = os.path.join("src", "composables", "useFormatter.ts")
|
||||
|
||||
symbols = language_server.request_document_symbols(composable_file).get_all_symbols_and_roots()
|
||||
use_formatter_symbol = next((s for s in symbols[0] if s.get("name") == "useFormatter"), None)
|
||||
|
||||
if not use_formatter_symbol or "selectionRange" not in use_formatter_symbol:
|
||||
pytest.skip("useFormatter symbol not found - test fixture may need updating")
|
||||
|
||||
sel_start = use_formatter_symbol["selectionRange"]["start"]
|
||||
|
||||
workspace_edit = language_server.request_rename_symbol_edit(
|
||||
composable_file, sel_start["line"], sel_start["character"], "useNumberFormatter"
|
||||
)
|
||||
|
||||
assert workspace_edit is not None, "Should return WorkspaceEdit for cross-file rename"
|
||||
|
||||
has_changes = "changes" in workspace_edit and workspace_edit["changes"]
|
||||
has_document_changes = "documentChanges" in workspace_edit and workspace_edit["documentChanges"]
|
||||
|
||||
assert has_changes or has_document_changes, "WorkspaceEdit should contain either 'changes' or 'documentChanges'"
|
||||
|
||||
if has_changes:
|
||||
changes = workspace_edit["changes"]
|
||||
assert len(changes) > 0, "Should have at least one file with changes"
|
||||
|
||||
composable_files = [uri for uri in changes.keys() if "useFormatter.ts" in uri]
|
||||
assert len(composable_files) > 0, f"Should have edits for useFormatter.ts (definition). Found edits for: {list(changes.keys())}"
|
||||
|
||||
for uri, edits in changes.items():
|
||||
assert len(edits) > 0, f"File {uri} should have at least one edit"
|
||||
|
||||
for edit in edits:
|
||||
assert "range" in edit, f"TextEdit in {uri} should have a range"
|
||||
assert "newText" in edit, f"TextEdit in {uri} should have newText"
|
||||
assert edit["newText"] == "useNumberFormatter", f"newText should be 'useNumberFormatter', got {edit['newText']}"
|
||||
assert "start" in edit["range"], f"Range in {uri} should have start position"
|
||||
assert "end" in edit["range"], f"Range in {uri} should have end position"
|
||||
|
||||
elif has_document_changes:
|
||||
document_changes = workspace_edit["documentChanges"]
|
||||
assert isinstance(document_changes, list), "documentChanges should be a list"
|
||||
assert len(document_changes) > 0, "Should have at least one document change"
|
||||
|
||||
composable_changes = [dc for dc in document_changes if "useFormatter.ts" in dc.get("textDocument", {}).get("uri", "")]
|
||||
assert (
|
||||
len(composable_changes) > 0
|
||||
), f"Should have edits for useFormatter.ts (definition). Found changes for: {[dc.get('textDocument', {}).get('uri', '') for dc in document_changes]}"
|
||||
|
||||
for change in document_changes:
|
||||
assert "textDocument" in change, "Document change should have textDocument"
|
||||
assert "edits" in change, "Document change should have edits"
|
||||
|
||||
uri = change["textDocument"]["uri"]
|
||||
edits = change["edits"]
|
||||
assert len(edits) > 0, f"File {uri} should have at least one edit"
|
||||
|
||||
for edit in edits:
|
||||
assert "range" in edit, f"TextEdit in {uri} should have a range"
|
||||
assert "newText" in edit, f"TextEdit in {uri} should have newText"
|
||||
assert edit["newText"] == "useNumberFormatter", f"newText should be 'useNumberFormatter', got {edit['newText']}"
|
||||
assert "start" in edit["range"], f"Range in {uri} should have start position"
|
||||
assert "end" in edit["range"], f"Range in {uri} should have end position"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_rename_verifies_correct_file_paths_and_ranges(self, language_server: SolidLanguageServer) -> None:
|
||||
file_path = os.path.join("src", "App.vue")
|
||||
|
||||
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
|
||||
app_title_symbol = next((s for s in symbols[0] if s.get("name") == "appTitle"), None)
|
||||
|
||||
if not app_title_symbol or "selectionRange" not in app_title_symbol:
|
||||
pytest.skip("appTitle symbol not found - test fixture may need updating")
|
||||
|
||||
sel_start = app_title_symbol["selectionRange"]["start"]
|
||||
|
||||
workspace_edit = language_server.request_rename_symbol_edit(
|
||||
file_path, sel_start["line"], sel_start["character"], "applicationTitle"
|
||||
)
|
||||
|
||||
assert workspace_edit is not None, "Should return WorkspaceEdit for rename operation"
|
||||
assert isinstance(workspace_edit, dict), "WorkspaceEdit should be a dictionary"
|
||||
|
||||
has_changes = "changes" in workspace_edit and workspace_edit["changes"]
|
||||
has_document_changes = "documentChanges" in workspace_edit and workspace_edit["documentChanges"]
|
||||
|
||||
assert has_changes or has_document_changes, "WorkspaceEdit must have 'changes' or 'documentChanges'"
|
||||
|
||||
if has_changes:
|
||||
changes = workspace_edit["changes"]
|
||||
|
||||
assert isinstance(changes, dict), "changes should be a dict mapping URIs to TextEdit lists"
|
||||
|
||||
assert len(changes) > 0, "Should have edits for at least one file"
|
||||
|
||||
for uri, edits in changes.items():
|
||||
assert isinstance(uri, str), f"URI should be a string, got {type(uri)}"
|
||||
assert uri.startswith("file://"), f"URI should start with 'file://', got {uri}"
|
||||
|
||||
assert isinstance(edits, list), f"Edits for {uri} should be a list, got {type(edits)}"
|
||||
assert len(edits) > 0, f"Should have at least one edit for {uri}"
|
||||
|
||||
for idx, edit in enumerate(edits):
|
||||
assert isinstance(edit, dict), f"Edit {idx} in {uri} should be a dict, got {type(edit)}"
|
||||
|
||||
assert "range" in edit, f"Edit {idx} in {uri} missing 'range'"
|
||||
assert "newText" in edit, f"Edit {idx} in {uri} missing 'newText'"
|
||||
|
||||
range_obj = edit["range"]
|
||||
assert "start" in range_obj, f"Edit {idx} range in {uri} missing 'start'"
|
||||
assert "end" in range_obj, f"Edit {idx} range in {uri} missing 'end'"
|
||||
|
||||
for pos_name in ["start", "end"]:
|
||||
pos = range_obj[pos_name]
|
||||
assert "line" in pos, f"Edit {idx} range {pos_name} in {uri} missing 'line'"
|
||||
assert "character" in pos, f"Edit {idx} range {pos_name} in {uri} missing 'character'"
|
||||
assert isinstance(pos["line"], int), f"Line should be int, got {type(pos['line'])}"
|
||||
assert isinstance(pos["character"], int), f"Character should be int, got {type(pos['character'])}"
|
||||
assert pos["line"] >= 0, f"Line number should be >= 0, got {pos['line']}"
|
||||
assert pos["character"] >= 0, f"Character offset should be >= 0, got {pos['character']}"
|
||||
|
||||
assert isinstance(edit["newText"], str), f"newText should be string, got {type(edit['newText'])}"
|
||||
assert edit["newText"] == "applicationTitle", f"newText should be 'applicationTitle', got {edit['newText']}"
|
||||
|
||||
elif has_document_changes:
|
||||
document_changes = workspace_edit["documentChanges"]
|
||||
assert isinstance(document_changes, list), "documentChanges should be a list"
|
||||
assert len(document_changes) > 0, "Should have at least one document change"
|
||||
|
||||
for change in document_changes:
|
||||
assert isinstance(change, dict), "Each document change should be a dict"
|
||||
assert "textDocument" in change, "Document change should have textDocument"
|
||||
assert "edits" in change, "Document change should have edits"
|
||||
|
||||
text_doc = change["textDocument"]
|
||||
assert "uri" in text_doc, "textDocument should have uri"
|
||||
assert text_doc["uri"].startswith("file://"), f"URI should start with 'file://', got {text_doc['uri']}"
|
||||
|
||||
edits = change["edits"]
|
||||
assert isinstance(edits, list), "edits should be a list"
|
||||
assert len(edits) > 0, "Should have at least one edit"
|
||||
|
||||
for idx, edit in enumerate(edits):
|
||||
assert isinstance(edit, dict), f"Edit {idx} in {text_doc['uri']} should be a dict, got {type(edit)}"
|
||||
|
||||
assert "range" in edit, f"Edit {idx} in {text_doc['uri']} missing 'range'"
|
||||
assert "newText" in edit, f"Edit {idx} in {text_doc['uri']} missing 'newText'"
|
||||
|
||||
range_obj = edit["range"]
|
||||
assert "start" in range_obj, f"Edit {idx} range in {text_doc['uri']} missing 'start'"
|
||||
assert "end" in range_obj, f"Edit {idx} range in {text_doc['uri']} missing 'end'"
|
||||
|
||||
for pos_name in ["start", "end"]:
|
||||
pos = range_obj[pos_name]
|
||||
assert "line" in pos, f"Edit {idx} range {pos_name} in {text_doc['uri']} missing 'line'"
|
||||
assert "character" in pos, f"Edit {idx} range {pos_name} in {text_doc['uri']} missing 'character'"
|
||||
assert isinstance(pos["line"], int), f"Line should be int, got {type(pos['line'])}"
|
||||
assert isinstance(pos["character"], int), f"Character should be int, got {type(pos['character'])}"
|
||||
assert pos["line"] >= 0, f"Line number should be >= 0, got {pos['line']}"
|
||||
assert pos["character"] >= 0, f"Character offset should be >= 0, got {pos['character']}"
|
||||
|
||||
assert isinstance(edit["newText"], str), f"newText should be string, got {type(edit['newText'])}"
|
||||
assert edit["newText"] == "applicationTitle", f"newText should be 'applicationTitle', got {edit['newText']}"
|
||||
@@ -0,0 +1,249 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp import SolidLanguageServer
|
||||
from solidlsp.ls_config import Language
|
||||
from solidlsp.ls_types import SymbolKind
|
||||
|
||||
pytestmark = pytest.mark.vue
|
||||
|
||||
|
||||
class TestVueSymbolRetrieval:
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_request_containing_symbol_script_setup_function(self, language_server: SolidLanguageServer) -> None:
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
# First, get the document symbols to find the handleDigit function
|
||||
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
|
||||
handle_digit_symbol = next((s for s in symbols[0] if s.get("name") == "handleDigit"), None)
|
||||
|
||||
if not handle_digit_symbol or "range" not in handle_digit_symbol:
|
||||
pytest.skip("handleDigit symbol not found - test fixture may need updating")
|
||||
|
||||
# Get a position inside the handleDigit function body
|
||||
# We'll use a line a few lines after the function start
|
||||
func_start_line = handle_digit_symbol["range"]["start"]["line"]
|
||||
position_inside_func = func_start_line + 1
|
||||
position_character = 4
|
||||
|
||||
# Request the containing symbol for this position
|
||||
containing_symbol = language_server.request_containing_symbol(
|
||||
file_path, position_inside_func, position_character, include_body=True
|
||||
)
|
||||
|
||||
# Verify we found the correct containing symbol
|
||||
assert containing_symbol is not None, "Should find containing symbol inside handleDigit function"
|
||||
assert containing_symbol["name"] == "handleDigit", f"Expected handleDigit, got {containing_symbol.get('name')}"
|
||||
assert containing_symbol["kind"] in [
|
||||
SymbolKind.Function,
|
||||
SymbolKind.Method,
|
||||
SymbolKind.Variable,
|
||||
], f"Expected function-like kind, got {containing_symbol.get('kind')}"
|
||||
|
||||
# Verify the body is included if available
|
||||
if "body" in containing_symbol:
|
||||
assert "handleDigit" in containing_symbol["body"], "Function body should contain function name"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_request_containing_symbol_computed_property(self, language_server: SolidLanguageServer) -> None:
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
# Find the formattedDisplay computed property
|
||||
symbols = language_server.request_document_symbols(file_path).get_all_symbols_and_roots()
|
||||
formatted_display_symbol = next((s for s in symbols[0] if s.get("name") == "formattedDisplay"), None)
|
||||
|
||||
if not formatted_display_symbol or "range" not in formatted_display_symbol:
|
||||
pytest.skip("formattedDisplay computed property not found - test fixture may need updating")
|
||||
|
||||
# Get a position inside the computed property body
|
||||
computed_start_line = formatted_display_symbol["range"]["start"]["line"]
|
||||
position_inside_computed = computed_start_line + 1
|
||||
position_character = 4
|
||||
|
||||
# Request the containing symbol for this position
|
||||
containing_symbol = language_server.request_containing_symbol(
|
||||
file_path, position_inside_computed, position_character, include_body=True
|
||||
)
|
||||
|
||||
# Verify we found the correct containing symbol
|
||||
# The language server returns the arrow function inside computed() rather than
|
||||
# the variable name. This is technically correct from LSP's perspective.
|
||||
assert containing_symbol is not None, "Should find containing symbol inside computed property"
|
||||
assert containing_symbol["name"] in [
|
||||
"formattedDisplay",
|
||||
"computed() callback",
|
||||
], f"Expected formattedDisplay or computed() callback, got {containing_symbol.get('name')}"
|
||||
assert containing_symbol["kind"] in [
|
||||
SymbolKind.Property,
|
||||
SymbolKind.Variable,
|
||||
SymbolKind.Function,
|
||||
], f"Expected property/variable/function kind for computed, got {containing_symbol.get('kind')}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_request_containing_symbol_no_containing_symbol(self, language_server: SolidLanguageServer) -> None:
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
# Position in the import statements at the top of the script setup
|
||||
# Line 1-6 contain imports in CalculatorInput.vue
|
||||
import_line = 2
|
||||
import_character = 10
|
||||
|
||||
# Request containing symbol for a position in the imports
|
||||
containing_symbol = language_server.request_containing_symbol(file_path, import_line, import_character)
|
||||
|
||||
# Should return None or empty dictionary for positions without containing symbol
|
||||
assert (
|
||||
containing_symbol is None or containing_symbol == {}
|
||||
), f"Expected None or empty dict for import position, got {containing_symbol}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_request_referencing_symbols_store_function(self, language_server: SolidLanguageServer) -> None:
|
||||
store_file = os.path.join("src", "stores", "calculator.ts")
|
||||
|
||||
# Find the 'add' action in the calculator store
|
||||
symbols = language_server.request_document_symbols(store_file).get_all_symbols_and_roots()
|
||||
add_symbol = next((s for s in symbols[0] if s.get("name") == "add"), None)
|
||||
|
||||
if not add_symbol or "selectionRange" not in add_symbol:
|
||||
pytest.skip("add action not found in calculator store - test fixture may need updating")
|
||||
|
||||
# Request referencing symbols for the add action (include_self=True to get at least the definition)
|
||||
sel_start = add_symbol["selectionRange"]["start"]
|
||||
ref_symbols = [
|
||||
ref.symbol
|
||||
for ref in language_server.request_referencing_symbols(store_file, sel_start["line"], sel_start["character"], include_self=True)
|
||||
]
|
||||
|
||||
assert isinstance(ref_symbols, list), f"request_referencing_symbols should return a list, got {type(ref_symbols)}"
|
||||
|
||||
for symbol in ref_symbols:
|
||||
assert "name" in symbol, "Referencing symbol should have a name"
|
||||
assert "kind" in symbol, "Referencing symbol should have a kind"
|
||||
|
||||
vue_refs = [
|
||||
symbol for symbol in ref_symbols if "location" in symbol and "uri" in symbol["location"] and ".vue" in symbol["location"]["uri"]
|
||||
]
|
||||
|
||||
if len(vue_refs) > 0:
|
||||
calculator_input_refs = [
|
||||
ref
|
||||
for ref in vue_refs
|
||||
if "location" in ref and "uri" in ref["location"] and "CalculatorInput.vue" in ref["location"]["uri"]
|
||||
]
|
||||
for ref in calculator_input_refs:
|
||||
assert "name" in ref, "Reference should have name"
|
||||
assert "location" in ref, "Reference should have location"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_request_referencing_symbols_composable(self, language_server: SolidLanguageServer) -> None:
|
||||
composable_file = os.path.join("src", "composables", "useFormatter.ts")
|
||||
|
||||
# Find the useFormatter composable function
|
||||
symbols = language_server.request_document_symbols(composable_file).get_all_symbols_and_roots()
|
||||
use_formatter_symbol = next((s for s in symbols[0] if s.get("name") == "useFormatter"), None)
|
||||
|
||||
if not use_formatter_symbol or "selectionRange" not in use_formatter_symbol:
|
||||
pytest.skip("useFormatter composable not found - test fixture may need updating")
|
||||
|
||||
# Request referencing symbols for the composable
|
||||
sel_start = use_formatter_symbol["selectionRange"]["start"]
|
||||
ref_symbols = [
|
||||
ref.symbol for ref in language_server.request_referencing_symbols(composable_file, sel_start["line"], sel_start["character"])
|
||||
]
|
||||
|
||||
# Verify we found references - useFormatter is imported and used in CalculatorInput.vue
|
||||
assert (
|
||||
len(ref_symbols) >= 1
|
||||
), f"useFormatter should have at least 1 reference (used in CalculatorInput.vue), found {len(ref_symbols)} references"
|
||||
|
||||
# Check for references in Vue components
|
||||
vue_refs = [
|
||||
symbol for symbol in ref_symbols if "location" in symbol and "uri" in symbol["location"] and ".vue" in symbol["location"]["uri"]
|
||||
]
|
||||
|
||||
# CalculatorInput.vue imports and uses useFormatter
|
||||
assert len(vue_refs) >= 1, f"Should find at least 1 Vue component reference to useFormatter, found {len(vue_refs)}"
|
||||
|
||||
# Verify we found reference in CalculatorInput.vue specifically
|
||||
has_calculator_input_ref = any(
|
||||
"CalculatorInput.vue" in ref["location"]["uri"] for ref in vue_refs if "location" in ref and "uri" in ref["location"]
|
||||
)
|
||||
assert has_calculator_input_ref, (
|
||||
f"Should find reference to useFormatter in CalculatorInput.vue. "
|
||||
f"Found references in: {[ref['location']['uri'] for ref in vue_refs if 'location' in ref and 'uri' in ref['location']]}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_vue_component_cross_references(self, language_server: SolidLanguageServer) -> None:
|
||||
input_file = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
button_file = os.path.join("src", "components", "CalculatorButton.vue")
|
||||
|
||||
definitions = language_server.request_definition(input_file, 4, 10)
|
||||
|
||||
assert len(definitions) == 1, f"Should find exactly 1 definition for CalculatorButton import, got {len(definitions)}"
|
||||
assert (
|
||||
"CalculatorButton.vue" in definitions[0]["relativePath"]
|
||||
), f"Definition should point to CalculatorButton.vue, got {definitions[0]['relativePath']}"
|
||||
|
||||
refs = language_server.request_references(input_file, 4, 10)
|
||||
|
||||
assert len(refs) >= 2, (
|
||||
f"Should find at least 2 references to CalculatorButton (import + template usages). "
|
||||
f"In CalculatorInput.vue, CalculatorButton is imported and used ~7 times in template. Found {len(refs)} references"
|
||||
)
|
||||
|
||||
button_symbols = language_server.request_document_symbols(button_file).get_all_symbols_and_roots()
|
||||
symbol_names = [s.get("name") for s in button_symbols[0]]
|
||||
|
||||
assert "Props" in symbol_names, "CalculatorButton.vue should have Props interface"
|
||||
assert "handleClick" in symbol_names, "CalculatorButton.vue should have handleClick function"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_request_defining_symbol_import_resolution(self, language_server: SolidLanguageServer) -> None:
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
# Find the import position for useCalculatorStore
|
||||
# In CalculatorInput.vue (0-indexed lines):
|
||||
# Line 2: import { useCalculatorStore } from '@/stores/calculator'
|
||||
# Line 8: const store = useCalculatorStore()
|
||||
# We'll request definition at the position of "useCalculatorStore" in the usage line
|
||||
defining_symbol = language_server.request_defining_symbol(file_path, 8, 18)
|
||||
|
||||
if defining_symbol is None:
|
||||
# Some language servers may not support go-to-definition at usage sites
|
||||
# Try at line 2 (import statement) instead
|
||||
defining_symbol = language_server.request_defining_symbol(file_path, 2, 18)
|
||||
|
||||
# Verify we found a defining symbol
|
||||
assert defining_symbol is not None, "Should find defining symbol for useCalculatorStore"
|
||||
assert "name" in defining_symbol, "Defining symbol should have a name"
|
||||
assert defining_symbol.get("name") in [
|
||||
"useCalculatorStore",
|
||||
"calculator",
|
||||
], f"Expected useCalculatorStore or calculator, got {defining_symbol.get('name')}"
|
||||
|
||||
# Verify it points to the store file
|
||||
if "location" in defining_symbol and "uri" in defining_symbol["location"]:
|
||||
assert (
|
||||
"calculator.ts" in defining_symbol["location"]["uri"]
|
||||
), f"Should point to calculator.ts, got {defining_symbol['location']['uri']}"
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.VUE], indirect=True)
|
||||
def test_request_defining_symbol_component_import(self, language_server: SolidLanguageServer) -> None:
|
||||
file_path = os.path.join("src", "components", "CalculatorInput.vue")
|
||||
|
||||
definitions = language_server.request_definition(file_path, 4, 10)
|
||||
|
||||
assert len(definitions) > 0, "Should find definition for CalculatorButton import"
|
||||
|
||||
definition = definitions[0]
|
||||
assert definition["relativePath"] is not None, "Definition should have a relative path"
|
||||
assert (
|
||||
"CalculatorButton.vue" in definition["relativePath"]
|
||||
), f"Should point to CalculatorButton.vue, got {definition['relativePath']}"
|
||||
|
||||
assert definition["range"]["start"]["line"] == 0, "Definition should point to start of .vue file"
|
||||
|
||||
defining_symbol = language_server.request_defining_symbol(file_path, 4, 10)
|
||||
assert defining_symbol is None or "name" in defining_symbol, "If defining_symbol is found, it should have a name"
|
||||
Reference in new issue
Block a user