From 3e010422c18b6ac898fb333961ddf1307d2a2a16 Mon Sep 17 00:00:00 2001 From: "sutong.527608" Date: Tue, 30 Sep 2025 16:52:15 +0800 Subject: [PATCH 1/8] Implement organize imports core logic Add import sorting and grouping functionality: - Compare imports by module specifiers (absolute vs relative) - Sort by import kind (side-effect, type-only, namespace, default, named) - Group imports separated by blank lines - Binary search for efficient insertion point calculation --- internal/ls/organizeimports.go | 162 ++++++++++++++++++++++- internal/ls/organizeimports_service.go | 172 +++++++++++++++++++++++++ 2 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 internal/ls/organizeimports_service.go diff --git a/internal/ls/organizeimports.go b/internal/ls/organizeimports.go index 8cdeddab5d..f38d508a96 100644 --- a/internal/ls/organizeimports.go +++ b/internal/ls/organizeimports.go @@ -11,10 +11,166 @@ import ( "github.com/microsoft/typescript-go/internal/tspath" ) +// GetImportDeclarationInsertIndex determines where to insert a new import in a sorted list +// It uses binary search to find the appropriate insertion index // statement = anyImportOrRequireStatement -func getImportDeclarationInsertIndex(sortedImports []*ast.Statement, newImport *ast.Statement, comparer func(a, b *ast.Statement) int) int { - // !!! - return len(sortedImports) +func GetImportDeclarationInsertIndex(sortedImports []*ast.Statement, newImport *ast.Statement, comparer func(a, b *ast.Statement) int) int { + n := len(sortedImports) + if n == 0 { + return 0 + } + + low, high := 0, n + for low < high { + mid := low + (high-low)/2 + if comparer(sortedImports[mid], newImport) < 0 { + low = mid + 1 + } else { + high = mid + } + } + return low +} + +// CompareImportsOrRequireStatements compares two import statements for sorting purposes +// Returns: +// - negative if s1 should come before s2 +// - 0 if they are equivalent +// - positive if s1 should come after s2 +func CompareImportsOrRequireStatements(s1, s2 *ast.Statement, comparer func(a, b string) int) int { + // First compare module specifiers + comparison := compareModuleSpecifiersWorker( + GetModuleSpecifierExpression(s1), + GetModuleSpecifierExpression(s2), + comparer, + ) + if comparison != 0 { + return comparison + } + // If module specifiers are equal, compare by import kind + return compareImportKind(s1, s2) +} + +// GetModuleSpecifierExpression extracts the module specifier expression from an import/export statement +func GetModuleSpecifierExpression(declaration *ast.Statement) *ast.Expression { + switch declaration.Kind { + case ast.KindImportDeclaration, ast.KindJSImportDeclaration: + return declaration.AsImportDeclaration().ModuleSpecifier + case ast.KindImportEqualsDeclaration: + moduleRef := declaration.AsImportEqualsDeclaration().ModuleReference + if moduleRef != nil && moduleRef.Kind == ast.KindExternalModuleReference { + return moduleRef.AsExternalModuleReference().Expression + } + return nil + case ast.KindVariableStatement: + // Handle require statements: const x = require("...") + declList := declaration.AsVariableStatement().DeclarationList + if declList != nil && declList.Kind == ast.KindVariableDeclarationList { + decls := declList.AsVariableDeclarationList().Declarations.Nodes + if len(decls) > 0 { + varDecl := decls[0].AsVariableDeclaration() + if varDecl.Initializer != nil && ast.IsCallExpression(varDecl.Initializer) { + call := varDecl.Initializer.AsCallExpression() + if len(call.Arguments.Nodes) > 0 { + return call.Arguments.Nodes[0] + } + } + } + } + return nil + } + return nil +} + +// compareModuleSpecifiersWorker compares two module specifier expressions. +// Ordering: +// 1. undefined module specifiers come last +// 2. Relative imports come after absolute imports +// 3. Otherwise, compare by the comparer function +func compareModuleSpecifiersWorker(m1, m2 *ast.Expression, comparer func(a, b string) int) int { + name1 := getExternalModuleNameText(m1) + name2 := getExternalModuleNameText(m2) + + // undefined names come last + if comparison := compareBooleans(name1 == "", name2 == ""); comparison != 0 { + return comparison + } + + // If both are defined, absolute imports come before relative imports + if name1 != "" && name2 != "" { + isRelative1 := tspath.IsExternalModuleNameRelative(name1) + isRelative2 := tspath.IsExternalModuleNameRelative(name2) + // compareBooleans returns -1 if first is true and second is false + // We want relative (true) to come after non-relative (false), so reverse the comparison + if comparison := compareBooleans(isRelative2, isRelative1); comparison != 0 { + return comparison + } + + // Finally, compare by the provided comparer + return comparer(name1, name2) + } + + return 0 +} + +// getExternalModuleNameText extracts the text of a module name from an expression +func getExternalModuleNameText(expr *ast.Expression) string { + if expr == nil { + return "" + } + + if ast.IsStringLiteral(expr) { + return expr.AsStringLiteral().Text + } + + return "" +} + +// compareImportKind compares imports by their kind/type for sorting +// Import order: +// 1. Side-effect imports (import "foo") +// 2. Type-only imports (import type { Foo } from "foo") +// 3. Namespace imports (import * as foo from "foo") +// 4. Default imports (import foo from "foo") +// 5. Named imports (import { foo } from "foo") +// 6. ImportEquals declarations (import foo = require("foo")) +// 7. Require variable statements (const foo = require("foo")) +func compareImportKind(s1, s2 *ast.Statement) int { + order1 := getImportKindOrder(s1) + order2 := getImportKindOrder(s2) + return cmp.Compare(order1, order2) +} + +// getImportKindOrder returns the sorting order for different import kinds +func getImportKindOrder(statement *ast.Statement) int { + switch statement.Kind { + case ast.KindImportDeclaration, ast.KindJSImportDeclaration: + importDecl := statement.AsImportDeclaration() + // Side-effect import (no import clause) + if importDecl.ImportClause == nil { + return 0 + } + importClause := importDecl.ImportClause.AsImportClause() + // Type-only import + if importClause.IsTypeOnly { + return 1 + } + // Namespace import (import * as foo) + if importClause.NamedBindings != nil && importClause.NamedBindings.Kind == ast.KindNamespaceImport { + return 2 + } + // Default import (import foo) + if importClause.Name() != nil { + return 3 + } + // Named import (import { foo }) + return 4 + case ast.KindImportEqualsDeclaration: + return 5 + case ast.KindVariableStatement: + return 6 + } + return 999 } // returns `-1` if `a` is better than `b` diff --git a/internal/ls/organizeimports_service.go b/internal/ls/organizeimports_service.go new file mode 100644 index 0000000000..31908f29cb --- /dev/null +++ b/internal/ls/organizeimports_service.go @@ -0,0 +1,172 @@ +package ls + +import ( + "context" + "slices" + "strings" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/compiler" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/scanner" +) + +// OrganizeImportsMode defines different modes for organizing imports +type OrganizeImportsMode int + +const ( + // OrganizeImportsModeAll removes unused imports, combines, and sorts imports + OrganizeImportsModeAll OrganizeImportsMode = iota + // OrganizeImportsModeSortAndCombine only sorts and combines imports without removing unused ones + OrganizeImportsModeSortAndCombine + // OrganizeImportsModeRemoveUnused only removes unused imports + OrganizeImportsModeRemoveUnused +) + +// OrganizeImports organizes the imports in a TypeScript/JavaScript file +// Currently supports sorting imports while preserving blank line groupings +func (l *LanguageService) OrganizeImports(ctx context.Context, documentURI lsproto.DocumentUri, mode OrganizeImportsMode) ([]lsproto.TextEdit, error) { + program, file := l.getProgramAndFile(documentURI) + if file == nil { + return nil, nil + } + + edits := l.organizeImportsCore(file, program, mode) + return edits, nil +} + +// organizeImportsCore sorts imports within groups separated by blank lines +func (l *LanguageService) organizeImportsCore(file *ast.SourceFile, program *compiler.Program, mode OrganizeImportsMode) []lsproto.TextEdit { + _ = program + _ = mode + + var imports []*ast.Statement + for _, stmt := range file.Statements.Nodes { + if stmt.Kind == ast.KindImportDeclaration || + stmt.Kind == ast.KindJSImportDeclaration || + stmt.Kind == ast.KindImportEqualsDeclaration { + imports = append(imports, stmt) + } + } + + if len(imports) == 0 { + return nil + } + + // Group imports by blank lines to preserve intentional grouping + importGroups := l.groupImportsByNewline(file, imports) + + comparer := func(a, b string) int { + return strings.Compare(a, b) + } + + var allEdits []lsproto.TextEdit + + // Process each group independently + for _, group := range importGroups { + if len(group) == 0 { + continue + } + + sortedGroup := make([]*ast.Statement, len(group)) + copy(sortedGroup, group) + + slices.SortFunc(sortedGroup, func(a, b *ast.Statement) int { + return CompareImportsOrRequireStatements(a, b, comparer) + }) + + // Check if already sorted + isSorted := true + for i := range group { + if group[i] != sortedGroup[i] { + isSorted = false + break + } + } + + if isSorted { + continue + } + + // Create text edit for this group + firstImport := group[0] + lastImport := group[len(group)-1] + + startPos := scanner.SkipTrivia(file.Text(), firstImport.Pos()) + endPos := lastImport.End() + + var newText strings.Builder + for i, sortedImp := range sortedGroup { + if i > 0 { + newText.WriteString("\n") + } + importText := scanner.GetTextOfNodeFromSourceText(file.Text(), sortedImp.AsNode(), false) + newText.WriteString(importText) + } + + startPosition := l.converters.PositionToLineAndCharacter(file, core.TextPos(startPos)) + endPosition := l.converters.PositionToLineAndCharacter(file, core.TextPos(endPos)) + + edit := lsproto.TextEdit{ + Range: lsproto.Range{ + Start: startPosition, + End: endPosition, + }, + NewText: newText.String(), + } + + allEdits = append(allEdits, edit) + } + + return allEdits +} + +// groupImportsByNewline groups consecutive imports separated by blank lines +func (l *LanguageService) groupImportsByNewline(file *ast.SourceFile, imports []*ast.Statement) [][]*ast.Statement { + if len(imports) == 0 { + return nil + } + + var groups [][]*ast.Statement + currentGroup := []*ast.Statement{imports[0]} + + for i := 1; i < len(imports); i++ { + if l.hasBlankLineBeforeStatement(file, imports[i]) { + groups = append(groups, currentGroup) + currentGroup = []*ast.Statement{imports[i]} + } else { + currentGroup = append(currentGroup, imports[i]) + } + } + + if len(currentGroup) > 0 { + groups = append(groups, currentGroup) + } + + return groups +} + +// hasBlankLineBeforeStatement checks if a statement has a blank line before it +// A blank line is indicated by 2+ consecutive newlines in the leading trivia +func (l *LanguageService) hasBlankLineBeforeStatement(file *ast.SourceFile, stmt *ast.Statement) bool { + text := file.Text() + pos := stmt.Pos() + newlineCount := 0 + + for i := pos; i < stmt.End() && i < len(text); i++ { + ch := text[i] + if ch == '\n' { + newlineCount++ + } else if ch == '\r' { + if i+1 < len(text) && text[i+1] == '\n' { + i++ + } + newlineCount++ + } else if ch != ' ' && ch != '\t' { + break + } + } + + return newlineCount >= 2 +} \ No newline at end of file From 7d6879c73ac57501ae1d0073d58372a282b2b282 Mon Sep 17 00:00:00 2001 From: "sutong.527608" Date: Tue, 30 Sep 2025 16:52:19 +0800 Subject: [PATCH 2/8] Add comprehensive tests for organize imports Test coverage includes: - Module specifier comparison (absolute vs relative, alphabetical) - Import kind ordering (side-effect, type-only, namespace, default, named) - Binary search insertion index calculation - Module specifier expression extraction - Mixed import type sorting Achieves 82%+ coverage for core sorting functions --- internal/ls/organizeimports_test.go | 381 ++++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 internal/ls/organizeimports_test.go diff --git a/internal/ls/organizeimports_test.go b/internal/ls/organizeimports_test.go new file mode 100644 index 0000000000..a8a4749744 --- /dev/null +++ b/internal/ls/organizeimports_test.go @@ -0,0 +1,381 @@ +package ls_test + +import ( + "strings" + "testing" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/ls" + "github.com/microsoft/typescript-go/internal/parser" + "gotest.tools/v3/assert" +) + +func parseImports(t *testing.T, source string) []*ast.Statement { + t.Helper() + + opts := ast.SourceFileParseOptions{ + FileName: "/test.ts", + } + sourceFile := parser.ParseSourceFile(opts, source, core.ScriptKindTS) + assert.Assert(t, sourceFile != nil, "Failed to parse source") + + var imports []*ast.Statement + for _, stmt := range sourceFile.Statements.Nodes { + if stmt.Kind == ast.KindImportDeclaration || stmt.Kind == ast.KindJSImportDeclaration || stmt.Kind == ast.KindImportEqualsDeclaration { + imports = append(imports, stmt) + } + } + return imports +} + +func TestCompareImportsOrRequireStatements_ModuleSpecifiers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + import1 string + import2 string + expected int + }{ + { + name: "absolute imports come before relative imports", + import1: `import "lodash";`, + import2: `import "./local";`, + expected: -1, + }, + { + name: "relative imports come after absolute imports", + import1: `import "./local";`, + import2: `import "react";`, + expected: 1, + }, + { + name: "alphabetical order for absolute imports", + import1: `import "react";`, + import2: `import "lodash";`, + expected: 1, + }, + { + name: "alphabetical order for relative imports", + import1: `import "./utils";`, + import2: `import "./api";`, + expected: 1, + }, + { + name: "same module specifier", + import1: `import { foo } from "react";`, + import2: `import { bar } from "react";`, + expected: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + imports1 := parseImports(t, tt.import1) + imports2 := parseImports(t, tt.import2) + + assert.Assert(t, len(imports1) == 1, "Expected 1 import in import1") + assert.Assert(t, len(imports2) == 1, "Expected 1 import in import2") + + comparer := strings.Compare + result := ls.CompareImportsOrRequireStatements(imports1[0], imports2[0], comparer) + + if tt.expected == 0 { + assert.Equal(t, 0, result, "Expected imports to be equal") + } else if tt.expected < 0 { + assert.Assert(t, result < 0, "Expected import1 < import2, got %d", result) + } else { + assert.Assert(t, result > 0, "Expected import1 > import2, got %d", result) + } + }) + } +} + +func TestCompareImportsOrRequireStatements_ImportKind(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + import1 string + import2 string + expected int + }{ + { + name: "side-effect import comes before type-only import", + import1: `import "react";`, + import2: `import type { FC } from "react";`, + expected: -1, + }, + { + name: "type-only import comes before namespace import", + import1: `import type { FC } from "react";`, + import2: `import * as React from "react";`, + expected: -1, + }, + { + name: "namespace import comes before default import", + import1: `import * as React from "react";`, + import2: `import React from "react";`, + expected: -1, + }, + { + name: "default import comes before named import", + import1: `import React from "react";`, + import2: `import { useState } from "react";`, + expected: -1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + imports1 := parseImports(t, tt.import1) + imports2 := parseImports(t, tt.import2) + + assert.Assert(t, len(imports1) == 1, "Expected 1 import in import1") + assert.Assert(t, len(imports2) == 1, "Expected 1 import in import2") + + comparer := strings.Compare + result := ls.CompareImportsOrRequireStatements(imports1[0], imports2[0], comparer) + + if tt.expected < 0 { + assert.Assert(t, result < 0, "Expected import1 < import2, got %d", result) + } else if tt.expected > 0 { + assert.Assert(t, result > 0, "Expected import1 > import2, got %d", result) + } else { + assert.Equal(t, 0, result, "Expected imports to be equal") + } + }) + } +} + +func TestGetImportDeclarationInsertIndex(t *testing.T) { + t.Parallel() + + source := `import "side-effect"; +import type { Type } from "library"; +import { namedImport } from "another"; +import defaultImport from "yet-another"; +import * as namespace from "namespace-lib"; +` + + imports := parseImports(t, source) + assert.Assert(t, len(imports) > 0, "Expected to parse imports") + + newImportSource := `import { newImport } from "library";` + newImports := parseImports(t, newImportSource) + assert.Assert(t, len(newImports) == 1, "Expected 1 new import") + + comparer := func(a, b *ast.Statement) int { + return ls.CompareImportsOrRequireStatements(a, b, strings.Compare) + } + + index := ls.GetImportDeclarationInsertIndex(imports, newImports[0], comparer) + assert.Assert(t, index >= 0 && index <= len(imports), + "Insert index %d out of range [0, %d]", index, len(imports)) +} + +func TestGetImportDeclarationInsertIndex_EmptyList(t *testing.T) { + t.Parallel() + + newImportSource := `import { foo } from "bar";` + newImports := parseImports(t, newImportSource) + assert.Assert(t, len(newImports) == 1, "Expected 1 new import") + + comparer := func(a, b *ast.Statement) int { + return ls.CompareImportsOrRequireStatements(a, b, strings.Compare) + } + + index := ls.GetImportDeclarationInsertIndex([]*ast.Statement{}, newImports[0], comparer) + assert.Equal(t, 0, index, "Expected index 0 for empty list") +} + +func TestCompareImports_RelativeVsAbsolute(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + imports []string + want []string + }{ + { + name: "mix of relative and absolute", + imports: []string{ + `import "./utils";`, + `import "react";`, + `import "../parent";`, + `import "lodash";`, + }, + want: []string{ + `import "lodash";`, + `import "react";`, + `import "../parent";`, + `import "./utils";`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var imports []*ast.Statement + for _, imp := range tt.imports { + parsed := parseImports(t, imp) + assert.Assert(t, len(parsed) == 1, "Expected 1 import") + imports = append(imports, parsed[0]) + } + + comparer := func(a, b *ast.Statement) int { + return ls.CompareImportsOrRequireStatements(a, b, strings.Compare) + } + + for i := 0; i < len(imports); i++ { + for j := i + 1; j < len(imports); j++ { + if comparer(imports[i], imports[j]) > 0 { + imports[i], imports[j] = imports[j], imports[i] + } + } + } + + for i, want := range tt.want { + wantImports := parseImports(t, want) + assert.Assert(t, len(wantImports) == 1, "Expected 1 wanted import") + + gotSpec := ls.GetModuleSpecifierExpression(imports[i]) + wantSpec := ls.GetModuleSpecifierExpression(wantImports[0]) + + if gotSpec != nil && wantSpec != nil { + assert.Assert(t, ast.IsStringLiteral(gotSpec), "Expected string literal in got") + assert.Assert(t, ast.IsStringLiteral(wantSpec), "Expected string literal in want") + + gotText := gotSpec.AsStringLiteral().Text + wantText := wantSpec.AsStringLiteral().Text + assert.Equal(t, wantText, gotText, "Import at position %d doesn't match", i) + } + } + }) + } +} + +func TestGetModuleSpecifierExpression(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + source string + expected string + }{ + { + name: "import declaration", + source: `import { foo } from "react";`, + expected: "react", + }, + { + name: "import equals declaration with external module", + source: `import foo = require("lodash");`, + expected: "lodash", + }, + { + name: "variable statement with require", + source: `const foo = require("express");`, + expected: "express", + }, + { + name: "side-effect import", + source: `import "./styles.css";`, + expected: "./styles.css", + }, + { + name: "namespace import", + source: `import * as React from "react";`, + expected: "react", + }, + { + name: "type-only import", + source: `import type { FC } from "react";`, + expected: "react", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + opts := ast.SourceFileParseOptions{ + FileName: "/test.ts", + } + sourceFile := parser.ParseSourceFile(opts, tt.source, core.ScriptKindTS) + assert.Assert(t, sourceFile != nil, "Failed to parse source") + assert.Assert(t, len(sourceFile.Statements.Nodes) > 0, "Expected at least one statement") + + stmt := sourceFile.Statements.Nodes[0] + expr := ls.GetModuleSpecifierExpression(stmt) + + assert.Assert(t, expr != nil, "Expected non-nil module specifier") + assert.Assert(t, ast.IsStringLiteral(expr), "Expected string literal") + + gotText := expr.AsStringLiteral().Text + assert.Equal(t, tt.expected, gotText) + }) + } +} + +func TestCompareImportsWithDifferentTypes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + import1 string + import2 string + expected string + }{ + { + name: "import equals vs import declaration", + import1: `import foo = require("library");`, + import2: `import { bar } from "library";`, + expected: "import2 first", + }, + { + name: "namespace import with named import", + import1: `import * as NS from "library";`, + import2: `import { foo } from "library";`, + expected: "import1 first", + }, + { + name: "default and named combined vs named only", + import1: `import React, { useState } from "react";`, + import2: `import { useEffect } from "react";`, + expected: "import1 first", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + imports1 := parseImports(t, tt.import1) + imports2 := parseImports(t, tt.import2) + + if len(imports1) == 0 || len(imports2) == 0 { + t.Skip("Failed to parse one of the imports") + } + + comparer := strings.Compare + result := ls.CompareImportsOrRequireStatements(imports1[0], imports2[0], comparer) + + switch tt.expected { + case "import1 first": + assert.Assert(t, result < 0, "Expected import1 < import2, got %d", result) + case "import2 first": + assert.Assert(t, result > 0, "Expected import1 > import2, got %d", result) + case "equal": + assert.Equal(t, 0, result) + } + }) + } +} \ No newline at end of file From 01f192a77b1be7b2f7e4175a3b26994796f7d09f Mon Sep 17 00:00:00 2001 From: "sutong.527608" Date: Tue, 30 Sep 2025 16:52:22 +0800 Subject: [PATCH 3/8] Add LSP server support for organize imports command Register workspace/executeCommand handler for typescript-go.organizeImports: - Declare command in ExecuteCommandProvider capabilities - Handle command execution and dispatch to language service - Apply text edits via workspace/applyEdit request --- internal/lsp/server.go | 65 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 62d3baa28c..14079e385d 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -463,6 +463,7 @@ var handlers = sync.OnceValue(func() handlerMap { registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentDocumentHighlightInfo, (*Server).handleDocumentHighlight) registerRequestHandler(handlers, lsproto.WorkspaceSymbolInfo, (*Server).handleWorkspaceSymbol) registerRequestHandler(handlers, lsproto.CompletionItemResolveInfo, (*Server).handleCompletionItemResolve) + registerRequestHandler(handlers, lsproto.WorkspaceExecuteCommandInfo, (*Server).handleExecuteCommand) return handlers }) @@ -640,6 +641,11 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ DocumentHighlightProvider: &lsproto.BooleanOrDocumentHighlightOptions{ Boolean: ptrTo(true), }, + ExecuteCommandProvider: &lsproto.ExecuteCommandOptions{ + Commands: []string{ + "typescript-go.organizeImports", + }, + }, }, } @@ -844,6 +850,65 @@ func (s *Server) handleDocumentHighlight(ctx context.Context, ls *ls.LanguageSer return ls.ProvideDocumentHighlights(ctx, params.TextDocument.Uri, params.Position) } +func (s *Server) handleExecuteCommand(ctx context.Context, params *lsproto.ExecuteCommandParams, _ *lsproto.RequestMessage) (lsproto.ExecuteCommandResponse, error) { + switch params.Command { + case "typescript-go.organizeImports": + return s.handleOrganizeImportsCommand(ctx, params) + default: + return lsproto.LSPAnyOrNull{}, fmt.Errorf("unknown command: %s", params.Command) + } +} + +func (s *Server) handleOrganizeImportsCommand(ctx context.Context, params *lsproto.ExecuteCommandParams) (lsproto.ExecuteCommandResponse, error) { + if params.Arguments == nil || len(*params.Arguments) == 0 { + return lsproto.LSPAnyOrNull{}, fmt.Errorf("organizeImports command requires a document URI argument") + } + + var documentURI lsproto.DocumentUri + argBytes, err := json.Marshal((*params.Arguments)[0]) + if err != nil { + return lsproto.LSPAnyOrNull{}, fmt.Errorf("failed to marshal argument: %w", err) + } + if err := json.Unmarshal(argBytes, &documentURI); err != nil { + return lsproto.LSPAnyOrNull{}, fmt.Errorf("invalid document URI: %w", err) + } + + languageService, err := s.session.GetLanguageService(ctx, documentURI) + if err != nil { + return lsproto.LSPAnyOrNull{}, err + } + + edits, err := languageService.OrganizeImports(ctx, documentURI, ls.OrganizeImportsModeAll) + if err != nil { + return lsproto.LSPAnyOrNull{}, err + } + + if len(edits) == 0 { + return lsproto.LSPAnyOrNull{}, nil + } + + editPtrs := make([]*lsproto.TextEdit, len(edits)) + for i := range edits { + editPtrs[i] = &edits[i] + } + + workspaceEdit := &lsproto.WorkspaceEdit{ + Changes: &map[lsproto.DocumentUri][]*lsproto.TextEdit{ + documentURI: editPtrs, + }, + } + + _, err = s.sendRequest(ctx, lsproto.MethodWorkspaceApplyEdit, &lsproto.ApplyWorkspaceEditParams{ + Label: ptrTo("Organize Imports"), + Edit: workspaceEdit, + }) + if err != nil { + return lsproto.LSPAnyOrNull{}, fmt.Errorf("failed to apply workspace edit: %w", err) + } + + return lsproto.LSPAnyOrNull{}, nil +} + func (s *Server) Log(msg ...any) { fmt.Fprintln(s.stderr, msg...) } From 965bd1609f54dc14850deba9d045d231ae80b4da Mon Sep 17 00:00:00 2001 From: "sutong.527608" Date: Tue, 30 Sep 2025 16:52:29 +0800 Subject: [PATCH 4/8] Add Sort Imports command to VS Code extension Integrate organize imports feature: - Add executeCommand method to LSP client - Register typescript.native-preview.sortImports command - Add Sort Imports to command palette and quick pick menu - Support for TypeScript and JavaScript files --- _extension/package.json | 6 ++++++ _extension/src/client.ts | 10 ++++++++++ _extension/src/commands.ts | 40 +++++++++++++++++++++++++++++++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/_extension/package.json b/_extension/package.json index 8aca1f4c3d..f26ec7a7fd 100644 --- a/_extension/package.json +++ b/_extension/package.json @@ -85,6 +85,12 @@ "title": "Show LSP Trace", "enablement": "typescript.native-preview.serverRunning", "category": "TypeScript Native Preview" + }, + { + "command": "typescript.native-preview.sortImports", + "title": "Sort Imports", + "enablement": "typescript.native-preview.serverRunning && editorLangId =~ /^(javascript|typescript|javascriptreact|typescriptreact)$/", + "category": "TypeScript Native Preview" } ] }, diff --git a/_extension/src/client.ts b/_extension/src/client.ts index b759501e97..35fc6423c6 100644 --- a/_extension/src/client.ts +++ b/_extension/src/client.ts @@ -158,4 +158,14 @@ export class Client { this.client.restart(); return new vscode.Disposable(() => {}); } + + async executeCommand(command: string, ...args: any[]): Promise { + if (!this.client) { + throw new Error("Language client is not initialized"); + } + return this.client.sendRequest("workspace/executeCommand", { + command, + arguments: args, + }); + } } diff --git a/_extension/src/commands.ts b/_extension/src/commands.ts index ef44a12b76..d5615d2dbd 100644 --- a/_extension/src/commands.ts +++ b/_extension/src/commands.ts @@ -33,6 +33,10 @@ export function registerLanguageCommands(context: vscode.ExtensionContext, clien disposables.push(vscode.commands.registerCommand("typescript.native-preview.showMenu", showCommands)); + disposables.push(vscode.commands.registerCommand("typescript.native-preview.sortImports", async () => { + return sortImports(client); + })); + return disposables; } @@ -53,11 +57,41 @@ async function updateUseTsgoSetting(enable: boolean): Promise { await vscode.commands.executeCommand("workbench.action.restartExtensionHost"); } -/** - * Shows the quick pick menu for TypeScript Native Preview commands - */ +async function sortImports(client: Client): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showErrorMessage("No active editor"); + return; + } + + const document = editor.document; + const languageId = document.languageId; + + // Check if the file is TypeScript or JavaScript + if (!["typescript", "javascript", "typescriptreact", "javascriptreact"].includes(languageId)) { + vscode.window.showErrorMessage("Sort Imports is only available for TypeScript and JavaScript files"); + return; + } + + try { + // Execute the sort imports command on the server via LSP + await client.executeCommand( + "typescript-go.organizeImports", + document.uri.toString() + ); + vscode.window.showInformationMessage("Imports sorted successfully"); + } catch (error) { + vscode.window.showErrorMessage(`Failed to sort imports: ${error}`); + } +} + async function showCommands(): Promise { const commands: readonly { label: string; description: string; command: string; }[] = [ + { + label: "$(symbol-namespace) Sort Imports", + description: "Sort imports in the current file", + command: "typescript.native-preview.sortImports", + }, { label: "$(refresh) Restart Server", description: "Restart the TypeScript Native Preview language server", From cd5f6fce182409ad7826449755c9fed8401c2899 Mon Sep 17 00:00:00 2001 From: "sutong.527608" Date: Tue, 30 Sep 2025 17:14:46 +0800 Subject: [PATCH 5/8] Fix hasBlankLineBeforeStatement to scan backwards The loop condition was incorrectly scanning forward from pos to stmt.End(). Fixed to scan backwards from pos-1 to find preceding newlines before the statement, which correctly identifies blank lines in the leading trivia. --- internal/ls/organizeimports_service.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/internal/ls/organizeimports_service.go b/internal/ls/organizeimports_service.go index 31908f29cb..a0c82d7b79 100644 --- a/internal/ls/organizeimports_service.go +++ b/internal/ls/organizeimports_service.go @@ -154,16 +154,18 @@ func (l *LanguageService) hasBlankLineBeforeStatement(file *ast.SourceFile, stmt pos := stmt.Pos() newlineCount := 0 - for i := pos; i < stmt.End() && i < len(text); i++ { + i := pos - 1 + for i >= 0 { ch := text[i] if ch == '\n' { newlineCount++ - } else if ch == '\r' { - if i+1 < len(text) && text[i+1] == '\n' { - i++ + i-- + if i >= 0 && text[i] == '\r' { + i-- } - newlineCount++ - } else if ch != ' ' && ch != '\t' { + } else if ch == ' ' || ch == '\t' || ch == '\r' { + i-- + } else { break } } From 04d537f43d0e29d5dc55c31301e8f39e27ac969b Mon Sep 17 00:00:00 2001 From: "sutong.527608" Date: Tue, 30 Sep 2025 17:23:20 +0800 Subject: [PATCH 6/8] Improve comment clarity in compareModuleSpecifiersWorker Rewrite the comment to be more direct and specific about why parameters are reversed: we want absolute imports (isRelative=false) to come before relative imports (isRelative=true). --- internal/ls/organizeimports.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/ls/organizeimports.go b/internal/ls/organizeimports.go index f38d508a96..2c8137d7db 100644 --- a/internal/ls/organizeimports.go +++ b/internal/ls/organizeimports.go @@ -100,8 +100,7 @@ func compareModuleSpecifiersWorker(m1, m2 *ast.Expression, comparer func(a, b st if name1 != "" && name2 != "" { isRelative1 := tspath.IsExternalModuleNameRelative(name1) isRelative2 := tspath.IsExternalModuleNameRelative(name2) - // compareBooleans returns -1 if first is true and second is false - // We want relative (true) to come after non-relative (false), so reverse the comparison + // Reverse parameter order because we want absolute imports (isRelative=false) before relative imports (isRelative=true) if comparison := compareBooleans(isRelative2, isRelative1); comparison != 0 { return comparison } From 14b3a37434b4cef3b31e61609105d95c2375a441 Mon Sep 17 00:00:00 2001 From: "sutong.527608" Date: Tue, 30 Sep 2025 17:30:40 +0800 Subject: [PATCH 7/8] Fix linter errors in handleOrganizeImportsCommand - Replace fmt.Errorf with errors.New for non-formatted error (perfsprint) - Fix variable shadowing by reusing err instead of declaring new one (customlint) --- internal/lsp/server.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 14079e385d..a5d7c0a2df 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -861,7 +861,7 @@ func (s *Server) handleExecuteCommand(ctx context.Context, params *lsproto.Execu func (s *Server) handleOrganizeImportsCommand(ctx context.Context, params *lsproto.ExecuteCommandParams) (lsproto.ExecuteCommandResponse, error) { if params.Arguments == nil || len(*params.Arguments) == 0 { - return lsproto.LSPAnyOrNull{}, fmt.Errorf("organizeImports command requires a document URI argument") + return lsproto.LSPAnyOrNull{}, errors.New("organizeImports command requires a document URI argument") } var documentURI lsproto.DocumentUri @@ -869,7 +869,8 @@ func (s *Server) handleOrganizeImportsCommand(ctx context.Context, params *lspro if err != nil { return lsproto.LSPAnyOrNull{}, fmt.Errorf("failed to marshal argument: %w", err) } - if err := json.Unmarshal(argBytes, &documentURI); err != nil { + err = json.Unmarshal(argBytes, &documentURI) + if err != nil { return lsproto.LSPAnyOrNull{}, fmt.Errorf("invalid document URI: %w", err) } From 137ea4370c0d6d11eece594dd3392af82660bba8 Mon Sep 17 00:00:00 2001 From: "sutong.527608" Date: Tue, 30 Sep 2025 17:54:32 +0800 Subject: [PATCH 8/8] Fix code formatting with dprint Run dprint fmt to fix formatting issues: - Add trailing comma in TypeScript function arguments - Move catch keyword to new line - Remove extra blank lines in Go test file - Add newline at end of files --- _extension/src/commands.ts | 5 ++-- internal/ls/organizeimports_service.go | 2 +- internal/ls/organizeimports_test.go | 34 +++++++++++++------------- 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/_extension/src/commands.ts b/_extension/src/commands.ts index d5615d2dbd..07bbd14b8e 100644 --- a/_extension/src/commands.ts +++ b/_extension/src/commands.ts @@ -77,10 +77,11 @@ async function sortImports(client: Client): Promise { // Execute the sort imports command on the server via LSP await client.executeCommand( "typescript-go.organizeImports", - document.uri.toString() + document.uri.toString(), ); vscode.window.showInformationMessage("Imports sorted successfully"); - } catch (error) { + } + catch (error) { vscode.window.showErrorMessage(`Failed to sort imports: ${error}`); } } diff --git a/internal/ls/organizeimports_service.go b/internal/ls/organizeimports_service.go index a0c82d7b79..bec96c1190 100644 --- a/internal/ls/organizeimports_service.go +++ b/internal/ls/organizeimports_service.go @@ -171,4 +171,4 @@ func (l *LanguageService) hasBlankLineBeforeStatement(file *ast.SourceFile, stmt } return newlineCount >= 2 -} \ No newline at end of file +} diff --git a/internal/ls/organizeimports_test.go b/internal/ls/organizeimports_test.go index a8a4749744..b2e59430ae 100644 --- a/internal/ls/organizeimports_test.go +++ b/internal/ls/organizeimports_test.go @@ -73,16 +73,16 @@ func TestCompareImportsOrRequireStatements_ModuleSpecifiers(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - + imports1 := parseImports(t, tt.import1) imports2 := parseImports(t, tt.import2) - + assert.Assert(t, len(imports1) == 1, "Expected 1 import in import1") assert.Assert(t, len(imports2) == 1, "Expected 1 import in import2") - + comparer := strings.Compare result := ls.CompareImportsOrRequireStatements(imports1[0], imports2[0], comparer) - + if tt.expected == 0 { assert.Equal(t, 0, result, "Expected imports to be equal") } else if tt.expected < 0 { @@ -132,16 +132,16 @@ func TestCompareImportsOrRequireStatements_ImportKind(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - + imports1 := parseImports(t, tt.import1) imports2 := parseImports(t, tt.import2) - + assert.Assert(t, len(imports1) == 1, "Expected 1 import in import1") assert.Assert(t, len(imports2) == 1, "Expected 1 import in import2") - + comparer := strings.Compare result := ls.CompareImportsOrRequireStatements(imports1[0], imports2[0], comparer) - + if tt.expected < 0 { assert.Assert(t, result < 0, "Expected import1 < import2, got %d", result) } else if tt.expected > 0 { @@ -175,7 +175,7 @@ import * as namespace from "namespace-lib"; } index := ls.GetImportDeclarationInsertIndex(imports, newImports[0], comparer) - assert.Assert(t, index >= 0 && index <= len(imports), + assert.Assert(t, index >= 0 && index <= len(imports), "Insert index %d out of range [0, %d]", index, len(imports)) } @@ -222,7 +222,7 @@ func TestCompareImports_RelativeVsAbsolute(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - + var imports []*ast.Statement for _, imp := range tt.imports { parsed := parseImports(t, imp) @@ -233,7 +233,7 @@ func TestCompareImports_RelativeVsAbsolute(t *testing.T) { comparer := func(a, b *ast.Statement) int { return ls.CompareImportsOrRequireStatements(a, b, strings.Compare) } - + for i := 0; i < len(imports); i++ { for j := i + 1; j < len(imports); j++ { if comparer(imports[i], imports[j]) > 0 { @@ -245,14 +245,14 @@ func TestCompareImports_RelativeVsAbsolute(t *testing.T) { for i, want := range tt.want { wantImports := parseImports(t, want) assert.Assert(t, len(wantImports) == 1, "Expected 1 wanted import") - + gotSpec := ls.GetModuleSpecifierExpression(imports[i]) wantSpec := ls.GetModuleSpecifierExpression(wantImports[0]) - + if gotSpec != nil && wantSpec != nil { assert.Assert(t, ast.IsStringLiteral(gotSpec), "Expected string literal in got") assert.Assert(t, ast.IsStringLiteral(wantSpec), "Expected string literal in want") - + gotText := gotSpec.AsStringLiteral().Text wantText := wantSpec.AsStringLiteral().Text assert.Equal(t, wantText, gotText, "Import at position %d doesn't match", i) @@ -315,10 +315,10 @@ func TestGetModuleSpecifierExpression(t *testing.T) { stmt := sourceFile.Statements.Nodes[0] expr := ls.GetModuleSpecifierExpression(stmt) - + assert.Assert(t, expr != nil, "Expected non-nil module specifier") assert.Assert(t, ast.IsStringLiteral(expr), "Expected string literal") - + gotText := expr.AsStringLiteral().Text assert.Equal(t, tt.expected, gotText) }) @@ -378,4 +378,4 @@ func TestCompareImportsWithDifferentTypes(t *testing.T) { } }) } -} \ No newline at end of file +}