diff --git a/demo-output.yaml b/demo-output.yaml index a0b38c62..c8897b34 100644 --- a/demo-output.yaml +++ b/demo-output.yaml @@ -597,44 +597,9 @@ description: Testing that the node provider works - type category: potential incidents: - - uri: file:///examples/nodejs/test_a.ts - message: nodejs sample rule 001 - codeSnip: " 1 export interface Greeter {\n 2 name: string;\n 3 hello(): string;\n 4 }\n 5 \n 6 export const greeter: Greeter = {\n 7 name: \"Person1\",\n 8 hello() {\n 9 return `Hello, I'm ${this.name}`;\n10 },\n11 };\n" - lineNumber: 5 - variables: - file: file:///examples/nodejs/test_a.ts - uri: file:///examples/nodejs/test_b.ts message: nodejs sample rule 001 codeSnip: " 1 import { greeter } from './test_a';\n 2 \n 3 console.log(greeter.hello());\n" - lineNumber: 0 - variables: - file: file:///examples/nodejs/test_b.ts - - uri: file:///examples/nodejs/test_b.ts - message: nodejs sample rule 001 - codeSnip: " 1 import { greeter } from './test_a';\n 2 \n 3 console.log(greeter.hello());\n" - lineNumber: 2 - variables: - file: file:///examples/nodejs/test_b.ts - effort: 1 - node-sample-rule-002: - description: Testing that the node provider works - function - category: potential - incidents: - - uri: file:///examples/nodejs/test_a.ts - message: nodejs sample rule 002 - codeSnip: " 1 export interface Greeter {\n 2 name: string;\n 3 hello(): string;\n 4 }\n 5 \n 6 export const greeter: Greeter = {\n 7 name: \"Person1\",\n 8 hello() {\n 9 return `Hello, I'm ${this.name}`;\n10 },\n11 };\n" - lineNumber: 2 - variables: - file: file:///examples/nodejs/test_a.ts - - uri: file:///examples/nodejs/test_a.ts - message: nodejs sample rule 002 - codeSnip: " 1 export interface Greeter {\n 2 name: string;\n 3 hello(): string;\n 4 }\n 5 \n 6 export const greeter: Greeter = {\n 7 name: \"Person1\",\n 8 hello() {\n 9 return `Hello, I'm ${this.name}`;\n10 },\n11 };\n" - lineNumber: 7 - variables: - file: file:///examples/nodejs/test_a.ts - - uri: file:///examples/nodejs/test_b.ts - message: nodejs sample rule 002 - codeSnip: " 1 import { greeter } from './test_a';\n 2 \n 3 console.log(greeter.hello());\n" lineNumber: 2 variables: file: file:///examples/nodejs/test_b.ts @@ -1353,5 +1318,6 @@ unmatched: - file-002 - lang-ref-002 + - node-sample-rule-002 - node-sample-rule-003 - python-sample-rule-003 diff --git a/external-providers/generic-external-provider/pkg/server_configurations/nodejs/import_search_test.go b/external-providers/generic-external-provider/pkg/server_configurations/nodejs/import_search_test.go new file mode 100644 index 00000000..01d663eb --- /dev/null +++ b/external-providers/generic-external-provider/pkg/server_configurations/nodejs/import_search_test.go @@ -0,0 +1,669 @@ +package nodejs + +import ( + "os" + "path/filepath" + "testing" +) + +// Test normalizeMultilineImports +func TestNormalizeMultilineImports(t *testing.T) { + sc := &NodeServiceClient{} + + tests := []struct { + name string + input string + expected string + }{ + { + name: "Single line import unchanged", + input: `import { Button } from '@patternfly/react-core'; +console.log('test');`, + expected: `import { Button } from '@patternfly/react-core'; +console.log('test');`, + }, + { + name: "Multiline import normalized", + input: `import { + Button, + Card +} from '@patternfly/react-core';`, + expected: `import { Button, Card } from '@patternfly/react-core';`, + }, + { + name: "Nested braces in import preserved", + input: `import { + type Foo, + Button +} from '@patternfly/react-core';`, + expected: `import { type Foo, Button } from '@patternfly/react-core';`, + }, + { + name: "String literals with newlines preserved", + input: `const x = "line1\nline2"; +import { Button } from 'pkg';`, + expected: `const x = "line1\nline2"; +import { Button } from 'pkg';`, + }, + { + name: "Comment handling", + input: `import { + Button, // Main button + Card // Card component +} from '@patternfly/react-core';`, + expected: `import { Button, // Main button Card // Card component } from '@patternfly/react-core';`, + }, + { + name: "Windows line endings (CRLF) normalized", + input: "import {\r\n Button,\r\n Card\r\n} from 'pkg';", + expected: `import { Button, Card } from 'pkg';`, + }, + { + name: "Mixed line endings normalized", + input: "import {\r\n Button,\n Card\r\n} from 'pkg';", + expected: `import { Button, Card } from 'pkg';`, + }, + { + name: "Escaped quotes in import string", + input: `import { Button } from "my\"quoted\"package";`, + expected: `import { Button } from "my\"quoted\"package";`, + }, + { + name: "Double backslash before quote", + input: `import { Button } from "my\\\"path";`, + expected: `import { Button } from "my\\\"path";`, + }, + { + name: "Template literals with backticks", + input: "import { Button } from `@patternfly/react-core`;", + expected: "import { Button } from `@patternfly/react-core`;", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sc.normalizeMultilineImports(tt.input) + if result != tt.expected { + t.Errorf("normalizeMultilineImports() = %q, want %q", result, tt.expected) + } + }) + } +} + +// Test findImportStatements with various import patterns +func TestFindImportStatements(t *testing.T) { + // Create temporary test files + tmpDir := t.TempDir() + + testCases := []struct { + name string + fileContent string + pattern string + expectedCount int + expectedLine uint32 + expectedColumn uint32 + }{ + { + name: "Named import - simple", + fileContent: `import { Button } from '@patternfly/react-core'; +export const MyButton = Button;`, + pattern: "Button", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 9, + }, + { + name: "Named import - multiple", + fileContent: `import { Button, Card, Chip } from '@patternfly/react-core'; +export const MyButton = Button;`, + pattern: "Card", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 17, + }, + { + name: "Multiline named import", + fileContent: `import { + Button, + Card, + Chip +} from '@patternfly/react-core';`, + pattern: "Card", + expectedCount: 1, + expectedLine: 2, + expectedColumn: 2, + }, + { + name: "Default import", + fileContent: `import React from 'react'; +export const Component = () =>
;`, + pattern: "React", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 7, + }, + { + name: "Pattern not found", + fileContent: `import { Button } from '@patternfly/react-core'; +export const MyButton = Button;`, + pattern: "Card", + expectedCount: 0, + }, + { + name: "Word boundary - avoid partial match", + fileContent: `import { Card, CardBody } from '@patternfly/react-core'; +export const MyCard = Card;`, + pattern: "Card", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 9, + }, + { + name: "TypeScript type import - named", + fileContent: `import type { Button } from '@patternfly/react-core'; +export const MyButton = Button;`, + pattern: "Button", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 14, + }, + { + name: "Mixed imports", + fileContent: `import React from 'react'; +import { Button, Card } from '@patternfly/react-core'; +import { useState } from 'react';`, + pattern: "Button", + expectedCount: 1, + expectedLine: 1, + expectedColumn: 9, + }, + { + name: "Namespace import", + fileContent: `import * as PatternFly from '@patternfly/react-core'; +export const MyCard = PatternFly.Card;`, + pattern: "PatternFly", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 12, + }, + { + name: "Namespace import - multiline", + fileContent: `import * as +PatternFly +from '@patternfly/react-core';`, + pattern: "PatternFly", + expectedCount: 1, + expectedLine: 1, + expectedColumn: 0, + }, + { + name: "Default + named import (mixed)", + fileContent: `import React, { useState, useEffect } from 'react'; +export const Component = () => {};`, + pattern: "React", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 7, + }, + { + name: "Default + named import - search named", + fileContent: `import React, { useState, useEffect } from 'react'; +export const Component = () => {};`, + pattern: "useState", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 16, + }, + { + name: "Default + namespace import (rare)", + fileContent: `import React, * as ReactAll from 'react'; +export const Component = () => {};`, + pattern: "ReactAll", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 19, + }, + { + name: "Side-effect import - no symbols", + fileContent: `import '@patternfly/react-core/dist/styles/base.css'; +export const Component = () => {};`, + pattern: "Card", + expectedCount: 0, + }, + { + name: "Namespace import - pattern not found", + fileContent: `import * as PatternFly from '@patternfly/react-core'; +export const MyCard = PatternFly.Card;`, + pattern: "Card", + expectedCount: 0, // "Card" is not the namespace identifier + }, + { + name: "Default + named - search default part", + fileContent: `import React, { useState } from 'react'; +const x = useState();`, + pattern: "React", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 7, + }, + { + name: "Default + namespace - search default part", + fileContent: `import React, * as ReactAll from 'react'; +const x = ReactAll.useState();`, + pattern: "React", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 7, + }, + { + name: "Multiline default + named", + fileContent: `import React, { + useState, + useEffect +} from 'react';`, + pattern: "useEffect", + expectedCount: 1, + expectedLine: 2, + expectedColumn: 2, + }, + { + name: "Multiple namespace imports in file", + fileContent: `import * as PF from '@patternfly/react-core'; +import * as Icons from '@patternfly/react-icons'; +import * as Hooks from '@patternfly/react-hooks';`, + pattern: "Icons", + expectedCount: 1, + expectedLine: 1, + expectedColumn: 12, + }, + { + name: "Namespace with special chars in package name", + fileContent: `import * as Util from '@company/util-package'; +export const test = Util.helper();`, + pattern: "Util", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 12, + }, + { + name: "Word boundary in namespace import", + fileContent: `import * as Card from '@patternfly/react-core'; +import * as CardHelper from './helpers';`, + pattern: "Card", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 12, // Should only match first, not CardHelper + }, + { + name: "Stress test - very long multiline import", + fileContent: `import { + Button, + Card, + CardBody, + CardHeader, + CardTitle, + Chip, + ChipGroup, + Label, + Badge, + Alert, + AlertGroup, + Modal, + ModalVariant, + Toolbar, + ToolbarContent, + ToolbarItem, + Select, + SelectOption, + SelectVariant +} from '@patternfly/react-core';`, + pattern: "ModalVariant", + expectedCount: 1, + expectedLine: 13, + expectedColumn: 2, + }, + { + name: "TypeScript type import - default", + fileContent: `import type React from 'react'; +type Props = { children: React.ReactNode };`, + pattern: "React", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 12, + }, + { + name: "TypeScript type import - namespace", + fileContent: `import type * as Types from './types'; +type MyType = Types.User;`, + pattern: "Types", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 17, + }, + { + name: "TypeScript type import - multiline", + fileContent: `import type { + ButtonProps, + CardProps +} from '@patternfly/react-core';`, + pattern: "CardProps", + expectedCount: 1, + expectedLine: 2, + expectedColumn: 2, + }, + { + name: "Pattern as prefix of another symbol on same line", + fileContent: `import { CardFooter, Card } from '@patternfly/react-core'; +export const MyCard = Card;`, + pattern: "Card", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 21, // Should find standalone "Card", not "Card" in "CardFooter" + }, + { + name: "Pattern in middle of other symbols", + fileContent: `import { Button, Card, CardBody } from '@patternfly/react-core';`, + pattern: "Card", + expectedCount: 1, + expectedLine: 0, + expectedColumn: 17, // Should find standalone "Card", not "Card" in "CardBody" + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create test file + testFile := filepath.Join(tmpDir, tc.name+".tsx") + err := os.WriteFile(testFile, []byte(tc.fileContent), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Create NodeServiceClient with test file + sc := &NodeServiceClient{} + files := []fileInfo{ + { + path: "file://" + testFile, + langID: "typescriptreact", + }, + } + + // Call findImportStatements + locations := sc.findImportStatements(tc.pattern, files) + + // Verify count + if len(locations) != tc.expectedCount { + t.Errorf("Expected %d locations, got %d", tc.expectedCount, len(locations)) + return + } + + // If we expect results, verify position + if tc.expectedCount > 0 { + if locations[0].Position.Line != tc.expectedLine { + t.Errorf("Expected line %d, got %d", tc.expectedLine, locations[0].Position.Line) + } + if locations[0].Position.Character != tc.expectedColumn { + t.Errorf("Expected column %d, got %d", tc.expectedColumn, locations[0].Position.Character) + } + } + }) + } +} + +// Test isIdentifierChar helper +func TestIsIdentifierChar(t *testing.T) { + tests := []struct { + char rune + expected bool + }{ + // Valid identifier characters + {'a', true}, + {'Z', true}, + {'0', true}, + {'_', true}, + {'$', true}, + + // Invalid identifier characters + {' ', false}, + {'{', false}, + {'}', false}, + {',', false}, + {';', false}, + {'\n', false}, + {'\t', false}, + } + + for _, tt := range tests { + t.Run(string(tt.char), func(t *testing.T) { + result := isIdentifierChar(tt.char) + if result != tt.expected { + t.Errorf("isIdentifierChar(%q) = %v, want %v", tt.char, result, tt.expected) + } + }) + } +} + +// Test word boundary detection in import matching +func TestImportWordBoundaries(t *testing.T) { + tmpDir := t.TempDir() + + testCases := []struct { + name string + fileContent string + pattern string + shouldMatch bool + description string + }{ + { + name: "Exact match", + fileContent: `import { Card } from '@patternfly/react-core';`, + pattern: "Card", + shouldMatch: true, + description: "Should match exact symbol name", + }, + { + name: "Avoid substring match - prefix", + fileContent: `import { CardBody } from '@patternfly/react-core';`, + pattern: "Card", + shouldMatch: false, + description: "Should NOT match when pattern is prefix of symbol", + }, + { + name: "Avoid substring match - suffix", + fileContent: `import { MyCard } from '@patternfly/react-core';`, + pattern: "Card", + shouldMatch: false, + description: "Should NOT match when pattern is suffix of symbol", + }, + { + name: "Multiple symbols - match first", + fileContent: `import { Button, Card, Chip } from '@patternfly/react-core';`, + pattern: "Button", + shouldMatch: true, + description: "Should match first symbol in list", + }, + { + name: "Multiple symbols - match middle", + fileContent: `import { Button, Card, Chip } from '@patternfly/react-core';`, + pattern: "Card", + shouldMatch: true, + description: "Should match middle symbol in list", + }, + { + name: "Multiple symbols - match last", + fileContent: `import { Button, Card, Chip } from '@patternfly/react-core';`, + pattern: "Chip", + shouldMatch: true, + description: "Should match last symbol in list", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create test file + testFile := filepath.Join(tmpDir, tc.name+".tsx") + err := os.WriteFile(testFile, []byte(tc.fileContent), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Create NodeServiceClient with test file + sc := &NodeServiceClient{} + files := []fileInfo{ + { + path: "file://" + testFile, + langID: "typescriptreact", + }, + } + + // Call findImportStatements + locations := sc.findImportStatements(tc.pattern, files) + + matched := len(locations) > 0 + if matched != tc.shouldMatch { + t.Errorf("%s: Expected match=%v, got match=%v (found %d locations)", + tc.description, tc.shouldMatch, matched, len(locations)) + } + }) + } +} + +// Test edge cases for import pattern matching +func TestImportEdgeCases(t *testing.T) { + tmpDir := t.TempDir() + + testCases := []struct { + name string + fileContent string + pattern string + expectedLen int + }{ + { + name: "Empty file", + fileContent: ``, + pattern: "Button", + expectedLen: 0, + }, + { + name: "No imports", + fileContent: `const Button = () =>
; +export default Button;`, + pattern: "Button", + expectedLen: 0, + }, + { + name: "Import in comment", + fileContent: `// import { Button } from '@patternfly/react-core'; +const x = 1;`, + pattern: "Button", + expectedLen: 1, // Note: Current implementation doesn't filter comments - acceptable limitation + }, + { + name: "Import in string", + fileContent: `const code = "import { Button } from '@patternfly/react-core';";`, + pattern: "Button", + expectedLen: 1, // Note: Current implementation doesn't filter strings - acceptable limitation + }, + { + name: "Whitespace variations", + fileContent: `import { Button } from '@patternfly/react-core' ;`, + pattern: "Button", + expectedLen: 1, + }, + { + name: "Single quotes", + fileContent: `import { Button } from '@patternfly/react-core';`, + pattern: "Button", + expectedLen: 1, + }, + { + name: "Double quotes", + fileContent: `import { Button } from "@patternfly/react-core";`, + pattern: "Button", + expectedLen: 1, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Create test file + testFile := filepath.Join(tmpDir, tc.name+".tsx") + err := os.WriteFile(testFile, []byte(tc.fileContent), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Create NodeServiceClient with test file + sc := &NodeServiceClient{} + files := []fileInfo{ + { + path: "file://" + testFile, + langID: "typescriptreact", + }, + } + + // Call findImportStatements + locations := sc.findImportStatements(tc.pattern, files) + + if len(locations) != tc.expectedLen { + t.Errorf("Expected %d locations, got %d", tc.expectedLen, len(locations)) + } + }) + } +} + +// Benchmark findImportStatements performance +func BenchmarkFindImportStatements(b *testing.B) { + tmpDir := b.TempDir() + + // Create a realistic test file + testContent := `import React from 'react'; +import { + Button, + Card, + CardBody, + CardHeader, + Chip, + ChipGroup, + Label, + LabelGroup +} from '@patternfly/react-core'; +import { useState, useEffect } from 'react'; + +export const MyComponent = () => { + const [open, setOpen] = useState(false); + + return ( + + + + + + + Tag 1 + Tag 2 + + + + ); +};` + + testFile := filepath.Join(tmpDir, "test.tsx") + err := os.WriteFile(testFile, []byte(testContent), 0644) + if err != nil { + b.Fatalf("Failed to create test file: %v", err) + } + + sc := &NodeServiceClient{} + files := []fileInfo{ + { + path: "file://" + testFile, + langID: "typescriptreact", + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = sc.findImportStatements("Button", files) + } +} diff --git a/external-providers/generic-external-provider/pkg/server_configurations/nodejs/service_client.go b/external-providers/generic-external-provider/pkg/server_configurations/nodejs/service_client.go index aa87820a..e73cfc48 100644 --- a/external-providers/generic-external-provider/pkg/server_configurations/nodejs/service_client.go +++ b/external-providers/generic-external-provider/pkg/server_configurations/nodejs/service_client.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "time" @@ -18,6 +19,11 @@ import ( "gopkg.in/yaml.v2" ) +var ( + // fromKeywordRegex matches "from" as a standalone keyword followed by a quote + fromKeywordRegex = regexp.MustCompile(`\bfrom\s+["']`) +) + type NodeServiceClientConfig struct { base.LSPServiceClientConfig `yaml:",inline"` @@ -31,7 +37,8 @@ type NodeServiceClient struct { *base.LSPServiceClientBase *base.LSPServiceClientEvaluator[*NodeServiceClient] - Config NodeServiceClientConfig + Config NodeServiceClientConfig + AnalysisMode provider.AnalysisMode } type NodeServiceClientBuilder struct{} @@ -88,6 +95,21 @@ func (n *NodeServiceClientBuilder) Init(ctx context.Context, log logr.Logger, c } sc.LSPServiceClientBase = scBase + // Synchronize BaseConfig.WorkspaceFolders with Config.WorkspaceFolders + // This ensures consistency between the two configurations + sc.BaseConfig.WorkspaceFolders = sc.Config.WorkspaceFolders + + // Store analysis mode for filtering behavior + sc.AnalysisMode = c.AnalysisMode + + // DEBUG: Log LSP initialization details + log.Info("NodeJS LSP initialized", + "rootURI", params.RootURI, + "workspaceFolders", sc.Config.WorkspaceFolders, + "analysisMode", sc.AnalysisMode, + "lspServerPath", sc.Config.LspServerPath, + "lspServerName", sc.Config.LspServerName) + // Initialize the fancy evaluator (dynamic dispatch ftw) eval, err := base.NewLspServiceClientEvaluator[*NodeServiceClient](sc, n.GetGenericServiceClientCapabilities(log)) if err != nil { @@ -122,7 +144,43 @@ type referencedCondition struct { } `yaml:"referenced"` } -// Example evaluate +// ImportLocation tracks where a symbol is imported +type ImportLocation struct { + FileURI string + LangID string + Position protocol.Position + Line string +} + +// fileInfo tracks a source file +type fileInfo struct { + path string + langID string +} + +// EvaluateReferenced implements nodejs.referenced capability using import-based search. +// +// Algorithm: +// 1. Scans all TypeScript/JavaScript files for import statements containing the pattern +// 2. For each import found, uses LSP textDocument/definition to get the symbol's definition location +// 3. For each definition, uses LSP textDocument/references to find all usage locations +// 4. Returns deduplicated incidents for all references within the workspace +// +// This approach is much faster than workspace/symbol search and correctly handles: +// - Multiline import statements +// - Named imports: import { Card } from "package" +// - Default imports: import Card from "package" +// - Multiple symbols in one import: import { Card, CardBody } from "package" +// +// Parameters: +// - ctx: Context for cancellation and timeouts +// - cap: Capability name ("referenced") +// - info: YAML-encoded referencedCondition with pattern to search +// +// Returns: +// - ProviderEvaluateResponse with Matched=true and incidents if symbol is found +// - ProviderEvaluateResponse with Matched=false if symbol is not imported anywhere +// - Error if processing fails func (sc *NodeServiceClient) EvaluateReferenced(ctx context.Context, cap string, info []byte) (provider.ProviderEvaluateResponse, error) { var cond referencedCondition err := yaml.Unmarshal(info, &cond) @@ -135,40 +193,49 @@ func (sc *NodeServiceClient) EvaluateReferenced(ctx context.Context, cap string, return resp{}, fmt.Errorf("unable to get query info") } - // get all ts files - folder := strings.TrimPrefix(sc.Config.WorkspaceFolders[0], "file://") - type fileInfo struct { - path string - langID string + // Find all import statements containing the pattern during file walk + if len(sc.Config.WorkspaceFolders) == 0 { + return resp{}, fmt.Errorf("no workspace folders configured") } - var nodeFiles []fileInfo + folder := strings.TrimPrefix(sc.Config.WorkspaceFolders[0], "file://") + + var importLocations []ImportLocation + filesProcessed := 0 + err = filepath.Walk(folder, func(path string, info os.FileInfo, err error) error { if err != nil { return err } - // TODO source-only mode + // Skip node_modules if info.IsDir() && info.Name() == "node_modules" { return filepath.SkipDir } + if !info.IsDir() { ext := filepath.Ext(path) - if ext == ".ts" || ext == ".tsx" { - langID := "typescript" - if ext == ".tsx" { - langID = "typescriptreact" - } - path = "file://" + path - nodeFiles = append(nodeFiles, fileInfo{path: path, langID: langID}) - } - if ext == ".js" || ext == ".jsx" { - langID := "javascript" - if ext == ".jsx" { - langID = "javascriptreact" - } - path = "file://" + path - nodeFiles = append(nodeFiles, fileInfo{path: path, langID: langID}) + var langID string + + // Determine language ID based on extension + switch ext { + case ".ts": + langID = "typescript" + case ".tsx": + langID = "typescriptreact" + case ".js": + langID = "javascript" + case ".jsx": + langID = "javascriptreact" + default: + return nil // Not a TypeScript/JavaScript file } + + filesProcessed++ + fileURI := "file://" + path + + // Search this file immediately for import statements + locations := sc.findImportStatementsInFile(query, fileURI, langID) + importLocations = append(importLocations, locations...) } return nil @@ -177,6 +244,21 @@ func (sc *NodeServiceClient) EvaluateReferenced(ctx context.Context, cap string, return provider.ProviderEvaluateResponse{}, err } + sc.Log.Info("Scanning for import statements", + "query", query, + "filesProcessed", filesProcessed, + "importsFound", len(importLocations)) + + if len(importLocations) == 0 { + sc.Log.Info("No imports found for symbol", + "query", query) + return resp{Matched: false}, nil + } + + sc.Log.Info("Found imports for symbol", + "query", query, + "importCount", len(importLocations)) + didOpen := func(uri string, langID string, text []byte) error { params := protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ @@ -186,8 +268,6 @@ func (sc *NodeServiceClient) EvaluateReferenced(ctx context.Context, cap string, Text: string(text), }, } - // typescript server seems to throw "No project" error without notification - // perhaps there's a better way to do this return sc.Conn.Notify(ctx, "textDocument/didOpen", params) } @@ -200,48 +280,166 @@ func (sc *NodeServiceClient) EvaluateReferenced(ctx context.Context, cap string, return sc.Conn.Notify(ctx, "textDocument/didClose", params) } - // Open all files first - for _, fileInfo := range nodeFiles { - trimmedURI := strings.TrimPrefix(fileInfo.path, "file://") - text, err := os.ReadFile(trimmedURI) - if err != nil { - return provider.ProviderEvaluateResponse{}, err + var allReferences []protocol.Location + processedDefinitions := make(map[string]bool) // Avoid processing same definition multiple times + openedFiles := make(map[string]bool) + + // Process each import location + for _, importLoc := range importLocations { + // Open the file if not already open + if !openedFiles[importLoc.FileURI] { + trimmedURI := strings.TrimPrefix(importLoc.FileURI, "file://") + text, err := os.ReadFile(trimmedURI) + if err != nil { + sc.Log.V(1).Info("Failed to read file", "file", importLoc.FileURI, "error", err) + continue + } + + err = didOpen(importLoc.FileURI, importLoc.LangID, text) + if err != nil { + sc.Log.V(1).Info("Failed to open file in LSP", "file", importLoc.FileURI, "error", err) + continue + } + openedFiles[importLoc.FileURI] = true + } + + // Get definition of the imported symbol using textDocument/definition + // Use retry logic with exponential backoff to handle LSP indexing delays + params := protocol.DefinitionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{ + URI: importLoc.FileURI, + }, + Position: importLoc.Position, + }, + } + + var definitions []protocol.Location + var err error + + // Retry up to 3 times with exponential backoff (50ms, 100ms, 200ms) + // This handles cases where the LSP server needs time to index newly opened files + maxRetries := 3 + for attempt := 0; attempt < maxRetries; attempt++ { + err = sc.Conn.Call(ctx, "textDocument/definition", params).Await(ctx, &definitions) + if err == nil && len(definitions) > 0 { + break + } + + if attempt < maxRetries-1 { + delay := time.Duration(50*(1< importStart { + // Search for the pattern in this and subsequent lines + found := false + for searchLine := lineNum; searchLine < len(lines) && searchLine < lineNum+20 && !found; searchLine++ { + searchContent := lines[searchLine] + + // Look for the pattern as a complete word + for searchStart := 0; ; { + patternPos := strings.Index(searchContent[searchStart:], pattern) + if patternPos == -1 { + break + } + patternPos += searchStart + + // Verify it's a complete word (not part of a larger identifier) + isWordStart := patternPos == 0 || !isIdentifierChar(rune(searchContent[patternPos-1])) + isWordEnd := patternPos+len(pattern) >= len(searchContent) || !isIdentifierChar(rune(searchContent[patternPos+len(pattern)])) + + if isWordStart && isWordEnd { + locations = append(locations, ImportLocation{ + FileURI: fileURI, + LangID: langID, + Position: protocol.Position{ + Line: uint32(searchLine), + Character: uint32(patternPos), + }, + Line: searchContent, + }) + found = true + break + } + + searchStart = patternPos + len(pattern) + } + } + break + } + charCount += len(line) + 1 // +1 for newline + } + } + + return locations +} + +// findImportStatements searches for import statements containing the given pattern. +// +// This method is used by tests and provides backward compatibility. +// It delegates to findImportStatementsInFile for each file. +// +// Parameters: +// - pattern: The symbol name to search for (e.g., "Button", "Card") +// - files: List of TypeScript/JavaScript files to search +// +// Returns: +// - Slice of ImportLocation structs containing file URI, language ID, and position +func (sc *NodeServiceClient) findImportStatements(pattern string, files []fileInfo) []ImportLocation { + var locations []ImportLocation + + for _, file := range files { + fileLocations := sc.findImportStatementsInFile(pattern, file.path, file.langID) + locations = append(locations, fileLocations...) + } + + return locations +} + +// normalizeMultilineImports converts multiline import statements to single lines. +// +// This preprocessing step allows the import regex to match imports that span multiple +// lines, which is common in formatted TypeScript/JavaScript code. +// +// Example transformation: +// Before: import {\n Card,\n CardBody\n} from "..." +// After: import { Card, CardBody } from "..." +// +// The method preserves: +// - String literals (quoted strings are not modified) +// - Brace depth tracking (handles nested structures) +// - Semicolons and statement boundaries +// +// Edge cases handled: +// - The word "import" within larger identifiers (e.g., "myimport") is not matched +// - Escape sequences in strings are preserved +// - Both single and double quoted strings are supported +// +// Parameters: +// - content: Source file content as a string +// +// Returns: +// - Normalized content with multiline imports converted to single lines +func (sc *NodeServiceClient) normalizeMultilineImports(content string) string { + // Normalize Windows line endings to Unix for consistent processing + content = strings.ReplaceAll(content, "\r\n", "\n") + + var result strings.Builder + result.Grow(len(content)) + + i := 0 + for i < len(content) { + // Look for "import" keyword + if i+6 <= len(content) && content[i:i+6] == "import" { + // Check if this is actually the start of an import statement + // (not part of a larger word like "myimport") + if i > 0 && isIdentifierChar(rune(content[i-1])) { + result.WriteByte(content[i]) + i++ + continue + } + + importStart := i + result.WriteString("import") + i += 6 + + // Process the import statement, normalizing multiline to single line + i = sc.normalizeImportStatement(&result, content, i, importStart) + } else { + result.WriteByte(content[i]) + i++ + } + } + + return result.String() +} + +// normalizeImportStatement processes a single import statement, normalizing +// multiline imports to a single line while preserving string literals and structure. +// +// It handles: +// - String literals (quoted strings are not modified) +// - Brace depth tracking (handles destructuring) +// - Line breaks within import statements +// - Import statement termination (semicolon or "from" clause completion) +// +// Parameters: +// - result: StringBuilder to write the normalized output +// - content: Full source file content +// - pos: Current position in content (after "import" keyword) +// - importStart: Position where "import" keyword started +// +// Returns: +// - New position after the import statement ends +func (sc *NodeServiceClient) normalizeImportStatement(result *strings.Builder, content string, pos, importStart int) int { + braceDepth := 0 + inString := false + stringChar := byte(0) + i := pos + + for i < len(content) { + ch := content[i] + + // Handle string entry/exit + if !inString && isStringDelimiter(ch) { + inString = true + stringChar = ch + result.WriteByte(ch) + i++ + continue + } else if inString && ch == stringChar && !isEscapedQuote(content, i) { + inString = false + result.WriteByte(ch) + i++ + continue + } else if inString { + // Inside string - copy as-is + result.WriteByte(ch) + i++ + continue + } + + // Handle import statement structure (not in strings) + switch ch { + case '{': + braceDepth++ + result.WriteByte(ch) + i++ + case '}': + braceDepth-- + result.WriteByte(ch) + i++ + case '\n': + // Handle line breaks - check if import is complete + // JavaScript/TypeScript files typically use \n (normalized above) + if braceDepth == 0 && sc.hasCompletedImportStatement(content, importStart, i) { + // Import statement is complete, preserve the newline + result.WriteByte('\n') + i++ + return i + } + // Within import statement, replace newline with space + result.WriteByte(' ') + i++ + case ';': + // Semicolon always ends the import statement + result.WriteByte(ch) + i++ + return i + default: + result.WriteByte(ch) + i++ + } + } + + return i +} + +// isIdentifierChar checks if a character can be part of a JavaScript identifier +func isIdentifierChar(ch rune) bool { + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '$' +} + +// isStringDelimiter checks if a character is a string delimiter (quote or backtick) +func isStringDelimiter(ch byte) bool { + return ch == '"' || ch == '\'' || ch == '`' +} + +// isEscapedQuote determines if a quote at the given position is escaped by backslashes. +// It counts preceding backslashes - if there's an odd number, the quote is escaped. +func isEscapedQuote(content string, pos int) bool { + if pos == 0 { + return false + } + escapeCount := 0 + for j := pos - 1; j >= 0 && content[j] == '\\'; j-- { + escapeCount++ + } + // Odd number of backslashes means the quote is escaped + return escapeCount%2 == 1 +} + +// hasCompletedImportStatement checks if we've seen a complete import statement +// by looking for the "from" keyword followed by a quote within the current import scope. +// +// This prevents treating incomplete imports as complete when encountering newlines. +func (sc *NodeServiceClient) hasCompletedImportStatement(content string, importStart, currentPos int) bool { + if currentPos <= importStart+6 { + return false + } + + // Look at just the current import statement (from "import" to current position) + snippet := content[importStart:min(currentPos+1, len(content))] + if len(snippet) <= 10 { + return false + } + + // Check the last ~50 chars for "from" keyword followed by a quote + // This avoids false matches from "from" in other parts of the file + start := len(snippet) - min(50, len(snippet)) + last50 := snippet[start:] + + // Use the fromKeywordRegex which matches "from" as a standalone word followed by a quote + return fromKeywordRegex.MatchString(last50) +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// GetDependencies returns an empty dependency map as the nodejs provider +// does not use external dependency providers. This overrides the base +// implementation which would return "dependency provider path not set" error. +func (sc *NodeServiceClient) GetDependencies(ctx context.Context) (map[uri.URI][]*provider.Dep, error) { + return map[uri.URI][]*provider.Dep{}, nil +} + +// GetDependenciesDAG returns an empty dependency DAG as the nodejs provider +// does not use external dependency providers. +func (sc *NodeServiceClient) GetDependenciesDAG(ctx context.Context) (map[uri.URI][]provider.DepDAGItem, error) { + return map[uri.URI][]provider.DepDAGItem{}, nil +} diff --git a/lsp/base_service_client/base_service_client.go b/lsp/base_service_client/base_service_client.go index fcdfbded..e38773da 100644 --- a/lsp/base_service_client/base_service_client.go +++ b/lsp/base_service_client/base_service_client.go @@ -382,8 +382,17 @@ func (sc *LSPServiceClientBase) GetAllDeclarations(ctx context.Context, workspac err := sc.Conn.Call(ctx, "workspace/symbol", params).Await(ctx, &symbols) if err != nil { - fmt.Printf("error: %v\n", err) + sc.Log.Error(err, "workspace/symbol call failed", "query", query) + } else { + sc.Log.Info("workspace/symbol results", + "query", query, + "symbolsFound", len(symbols)) + if len(symbols) == 0 { + sc.Log.Info("No symbols found via workspace/symbol, will try fallback method if available") + } } + } else { + sc.Log.Info("workspace/symbol not supported by LSP server, using fallback") } if regexErr != nil { diff --git a/lsp/base_service_client/cmd_dialer.go b/lsp/base_service_client/cmd_dialer.go index 9b974e9a..4e4b6a8c 100644 --- a/lsp/base_service_client/cmd_dialer.go +++ b/lsp/base_service_client/cmd_dialer.go @@ -69,6 +69,12 @@ func (rwc *CmdDialer) Write(p []byte) (int, error) { } func (rwc *CmdDialer) Close() error { + // Check if process was started before trying to kill it + if rwc.Cmd.Process == nil { + // Process was never started or already exited + return nil + } + err := rwc.Cmd.Process.Kill() if err != nil { return err