diff --git a/src/solidlsp/language_servers/typescript_language_server.py b/src/solidlsp/language_servers/typescript_language_server.py index 5b7ec4fe..ddf3f605 100644 --- a/src/solidlsp/language_servers/typescript_language_server.py +++ b/src/solidlsp/language_servers/typescript_language_server.py @@ -217,6 +217,15 @@ class TypeScriptLanguageServer(SolidLanguageServer): def _create_launch_command(self, core_path: str) -> list[str]: return [core_path, "--stdio"] + def _get_language_id_for_file(self, relative_file_path: str) -> str: + # JSX is parsed as TS without this, which silently truncates symbol + # ranges at the first multi-line JSX expression. + if relative_file_path.endswith(".tsx"): + return "typescriptreact" + if relative_file_path.endswith(".jsx"): + return "javascriptreact" + return self.language_id + def _get_initialize_params(self, repository_absolute_path: str) -> InitializeParams: """ Returns the initialize params for the TypeScript Language Server. diff --git a/test/resources/repos/typescript/test_repo/jsx_component.tsx b/test/resources/repos/typescript/test_repo/jsx_component.tsx new file mode 100644 index 00000000..c8dea77d --- /dev/null +++ b/test/resources/repos/typescript/test_repo/jsx_component.tsx @@ -0,0 +1,44 @@ +// Regression fixture: mirrors the real-world pattern that caused +// languageId=typescript (instead of typescriptreact) to truncate symbol +// ranges at the first multi-line JSX expression. + +import * as React from "react" + +type SectionProps = { title: string; emphasised?: boolean } + +function Section({ title, emphasised }: SectionProps) { + return emphasised ? ( +

{title}

+ ) : ( +

{title}

+ ) +} + +export function JsxComponent({ heading, items }: { heading: string; items: string[] }) { + const renderHeader = (title: string) => + title.length > 10 ? ( +
+ ) : ( +
+ ) + + // The truncation bug used to cut JsxComponent's range right around here, + // hiding everything below from find_symbol. Keep this comment so the + // regression test can assert the function body extends past it. + const renderItem = (item: string, idx: number) => ( +
  • + {item} +
  • + ) + + return ( +
    + {renderHeader(heading)} +
      {items.map(renderItem)}
    +
    + ) +} + +export function trailingHelper(): string { + return "this symbol is invisible to find_symbol when JsxComponent's range is truncated" +} diff --git a/test/resources/repos/typescript/test_repo/tsconfig.json b/test/resources/repos/typescript/test_repo/tsconfig.json index ba0514f5..7267a686 100644 --- a/test/resources/repos/typescript/test_repo/tsconfig.json +++ b/test/resources/repos/typescript/test_repo/tsconfig.json @@ -5,7 +5,8 @@ "strict": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, - "skipLibCheck": true + "skipLibCheck": true, + "jsx": "react" }, - "include": ["**/*.ts"] + "include": ["**/*.ts", "**/*.tsx"] } diff --git a/test/solidlsp/typescript/test_typescript_basic.py b/test/solidlsp/typescript/test_typescript_basic.py index ba86ba04..2caf6b11 100644 --- a/test/solidlsp/typescript/test_typescript_basic.py +++ b/test/solidlsp/typescript/test_typescript_basic.py @@ -33,6 +33,37 @@ class TestTypescriptLanguageServer: "index.ts should reference helperFunction (tried all positions in selectionRange)" ) + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) + def test_tsx_symbol_range_not_truncated_by_jsx(self, language_server: SolidLanguageServer) -> None: + # Regression: when the language id is sent as "typescript" instead of + # "typescriptreact" for .tsx files, tsserver parses JSX as syntax + # errors and recovers by truncating the enclosing symbol's range at + # the first multi-line JSX expression. find_symbol then returns a + # body that ends mid-component and hides everything below. + file_path = "jsx_component.tsx" + roots = language_server.request_document_symbols(file_path).root_symbols + + jsx_component = next((s for s in roots if s.get("name") == "JsxComponent"), None) + assert jsx_component is not None, "JsxComponent not found at root level of jsx_component.tsx" + + end_line = jsx_component["location"]["range"]["end"]["line"] + # JsxComponent's body extends to line 38 (0-based 37) in the fixture; + # the truncation bug cut it at the first multi-line JSX (~line 21). + # Use a generous lower bound so the test survives small fixture edits + # that don't affect the regression behaviour we care about. + assert end_line >= 30, ( + f"JsxComponent symbol range truncated at line {end_line + 1} (1-based); " + f"expected end at or past line 31 (1-based). " + f"This indicates the .tsx file was opened with the wrong languageId." + ) + + # The trailing helper must be visible as a top-level symbol — it lives + # past the truncation point and disappears entirely when the bug is + # active because tsserver stops emitting symbols after the parse error. + assert any(s.get("name") == "trailingHelper" for s in roots), ( + "trailingHelper missing from jsx_component.tsx root symbols; tsserver likely stopped parsing at the first JSX expression." + ) + @pytest.mark.parametrize("language_server", [Language.TYPESCRIPT], indirect=True) def test_bare_symbol_names(self, language_server) -> None: all_symbols = request_all_symbols(language_server)