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