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:
authored and GitHub committed 2025-12-09 19:03:06 +01:00
1 parent e9826d8882
commit 9f5b8e792e
34 files changed
+6754 -31

No files matched your search

+4 -1
View File
@@ -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/
+3 -2
View File
@@ -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)
+1
View File
@@ -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#",
+8 -5
View File
@@ -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]
+7 -7
View File
@@ -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
View File
@@ -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()
+29
View File
@@ -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>
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
+10
View File
@@ -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
+139
View File
@@ -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)
+15
View File
@@ -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)
+362
View File
@@ -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())}"
)
+422
View File
@@ -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)
+241
View File
@@ -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"