diff --git a/extensions/vscode/src/providers/hover/index.ts b/extensions/vscode/src/providers/hover/index.ts index 7e5f9925..12db8633 100644 --- a/extensions/vscode/src/providers/hover/index.ts +++ b/extensions/vscode/src/providers/hover/index.ts @@ -4,12 +4,27 @@ import { watchEffect } from 'reactive-vscode' import { languages } from 'vscode' import { NpmxHoverProvider } from './npmx' +const HOVER_LANGUAGE_SELECTORS = [ + 'javascript', + 'typescript', + 'javascriptreact', + 'typescriptreact', + 'vue', + 'astro', + 'svelte', + 'mdx', + 'html', +].map((language) => ({ scheme: 'file' as const, language })) + export function useHover() { watchEffect((onCleanup) => { if (!config.hover.enabled) return - const disposable = languages.registerHoverProvider({ pattern: SUPPORTED_DOCUMENT_PATTERN }, new NpmxHoverProvider()) + const disposable = languages.registerHoverProvider([ + { pattern: SUPPORTED_DOCUMENT_PATTERN }, + ...HOVER_LANGUAGE_SELECTORS, + ], new NpmxHoverProvider()) onCleanup(() => disposable.dispose()) }) diff --git a/extensions/vscode/src/providers/hover/npmx.ts b/extensions/vscode/src/providers/hover/npmx.ts index ac63d64b..86935ab8 100644 --- a/extensions/vscode/src/providers/hover/npmx.ts +++ b/extensions/vscode/src/providers/hover/npmx.ts @@ -1,14 +1,13 @@ import type { HoverProvider, Position, TextDocument } from 'vscode' -import { getResolvedDependencyByOffset } from '#core/workspace' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from '#utils/links' import { Hover, MarkdownString } from 'vscode' +import { resolveHoverDependency } from './resolve' const SPACER = ' ' export class NpmxHoverProvider implements HoverProvider { async provideHover(document: TextDocument, position: Position) { - const offset = document.offsetAt(position) - const dep = await getResolvedDependencyByOffset(document.uri, offset) + const dep = await resolveHoverDependency(document, position) if (!dep) return diff --git a/extensions/vscode/src/providers/hover/resolve.test.ts b/extensions/vscode/src/providers/hover/resolve.test.ts new file mode 100644 index 00000000..0867c463 --- /dev/null +++ b/extensions/vscode/src/providers/hover/resolve.test.ts @@ -0,0 +1,223 @@ +import type { DependencyInfo } from '#core/workspace' +import type { Position, TextDocument } from 'vscode' +import { getResolvedDependencies, getResolvedDependencyByOffset } from '#core/workspace' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { Uri } from 'vscode' +import { findUp } from 'vscode-find-up' +import { resolveHoverDependency } from './resolve' + +vi.mock('#core/workspace', () => ({ + getResolvedDependencies: vi.fn(), + getResolvedDependencyByOffset: vi.fn(), +})) + +vi.mock('vscode-find-up', () => ({ + findUp: vi.fn(), +})) + +const mockedGetResolvedDependencies = vi.mocked(getResolvedDependencies) +const mockedGetResolvedDependencyByOffset = vi.mocked(getResolvedDependencyByOffset) +const mockedFindUp = vi.mocked(findUp) + +function getOffset(text: string, target: string): number { + const index = text.indexOf(target) + if (index === -1) + throw new Error(`Missing target "${target}" in test input`) + + return index + 1 +} + +function getPosition(text: string, target: string): Position { + const offset = getOffset(text, target) + const lines = text.slice(0, offset).split('\n') + + return { + line: lines.length - 1, + character: (lines.at(-1)?.length ?? 1) - 1, + } as Position +} + +function createDependencyInfo(overrides: Partial = {}): DependencyInfo { + return { + category: 'dependencies', + rawName: 'lodash', + rawSpec: '^1.0.0', + nameRange: [0, 0], + specRange: [0, 0], + protocol: null, + resolvedName: 'lodash', + resolvedSpec: '^1.0.0', + resolvedProtocol: 'npm', + packageInfo: async () => null, + resolvedVersion: async () => null, + ...overrides, + } +} + +function createDocument(path: string, text: string): TextDocument { + const lines = text.split('\n') + + function getLineStartOffset(line: number): number { + return lines + .slice(0, line) + .reduce((total, current) => total + current.length + 1, 0) + } + + function getWordRangeAtPosition(position: Position) { + const lineText = lines[position.line] ?? '' + const char = lineText[position.character] + if (!char || !/[\w-]/.test(char)) + return + + let start = position.character + let end = position.character + 1 + + while (start > 0 && /[\w-]/.test(lineText[start - 1]!)) + start-- + + while (end < lineText.length && /[\w-]/.test(lineText[end]!)) + end++ + + return { + start: { line: position.line, character: start }, + end: { line: position.line, character: end }, + } + } + + return { + uri: Uri.file(path), + getText: () => text, + getWordRangeAtPosition, + lineAt: (line: number) => ({ + text: lines[line] ?? '', + lineNumber: line, + range: { + start: { line, character: 0 }, + end: { line, character: (lines[line] ?? '').length }, + }, + rangeIncludingLineBreak: { + start: { line, character: 0 }, + end: { line, character: (lines[line] ?? '').length + 1 }, + }, + firstNonWhitespaceCharacterIndex: (lines[line] ?? '').search(/\S|$/), + isEmptyOrWhitespace: !(lines[line] ?? '').trim(), + }), + offsetAt: (position: Position) => getLineStartOffset(position.line) + position.character, + } as unknown as TextDocument +} + +describe('resolveHoverDependency', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should resolve source imports from the nearest package.json', async () => { + const text = 'import foo from \'lodash\'' + const document = createDocument('/workspace/src/index.ts', text) + const pkgJsonUri = Uri.file('/workspace/package.json') + const dependency = createDependencyInfo() + + mockedFindUp.mockResolvedValue(pkgJsonUri) + mockedGetResolvedDependencies.mockResolvedValue([dependency]) + + const resolved = await resolveHoverDependency(document, getPosition(text, 'lodash')) + + expect(resolved).toBe(dependency) + expect(mockedFindUp).toHaveBeenCalledWith('package.json', { cwd: document.uri }) + expect(mockedGetResolvedDependencies).toHaveBeenCalledWith(pkgJsonUri) + }) + + it('should match package roots for import subpaths', async () => { + const text = 'import \'lodash/fp\'' + const document = createDocument('/workspace/src/index.ts', text) + const dependency = createDependencyInfo() + + mockedFindUp.mockResolvedValue(Uri.file('/workspace/package.json')) + mockedGetResolvedDependencies.mockResolvedValue([dependency]) + + const resolved = await resolveHoverDependency(document, getPosition(text, 'lodash')) + + expect(resolved).toBe(dependency) + }) + + it('should reuse aliased dependency metadata', async () => { + const text = 'import \'foo/subpath\'' + const document = createDocument('/workspace/src/index.ts', text) + const dependency = createDependencyInfo({ + rawName: 'foo', + rawSpec: 'npm:bar@^2.0.0', + protocol: 'npm', + resolvedName: 'bar', + resolvedSpec: '^2.0.0', + }) + + mockedFindUp.mockResolvedValue(Uri.file('/workspace/package.json')) + mockedGetResolvedDependencies.mockResolvedValue([dependency]) + + const resolved = await resolveHoverDependency(document, getPosition(text, 'foo')) + + expect(resolved).toBe(dependency) + expect(resolved?.resolvedName).toBe('bar') + }) + + it('should return undefined for undeclared imports', async () => { + const text = 'import \'react\'' + const document = createDocument('/workspace/src/index.ts', text) + + mockedFindUp.mockResolvedValue(Uri.file('/workspace/package.json')) + mockedGetResolvedDependencies.mockResolvedValue([ + createDependencyInfo({ rawName: 'lodash' }), + ]) + + await expect(resolveHoverDependency(document, getPosition(text, 'react'))).resolves.toBeUndefined() + }) + + it('should keep package manifest hover on the existing path', async () => { + const text = '"dependencies": { "lodash": "^1.0.0" }' + const document = createDocument('/workspace/package.json', text) + const dependency = createDependencyInfo() + const position = getPosition(text, 'lodash') + + mockedGetResolvedDependencyByOffset.mockResolvedValue(dependency) + + const resolved = await resolveHoverDependency(document, position) + + expect(resolved).toBe(dependency) + expect(mockedGetResolvedDependencyByOffset).toHaveBeenCalledWith(document.uri, document.offsetAt(position)) + expect(mockedFindUp).not.toHaveBeenCalled() + expect(mockedGetResolvedDependencies).not.toHaveBeenCalled() + }) + + it('should return early when the hover position is not on a word', async () => { + const text = 'import foo from \'lodash\'' + const document = createDocument('/workspace/src/index.ts', text) + + await expect(resolveHoverDependency(document, { line: 0, character: 6 } as Position)).resolves.toBeUndefined() + expect(mockedFindUp).not.toHaveBeenCalled() + expect(mockedGetResolvedDependencies).not.toHaveBeenCalled() + }) + + it('should return early when the hover word is not inside a string', async () => { + const text = 'const lodash = someValue' + const document = createDocument('/workspace/src/index.ts', text) + + await expect(resolveHoverDependency(document, getPosition(text, 'lodash'))).resolves.toBeUndefined() + expect(mockedFindUp).not.toHaveBeenCalled() + expect(mockedGetResolvedDependencies).not.toHaveBeenCalled() + }) + + it('should return undefined when import context is not on the current line', async () => { + const text = [ + 'import {', + ' foo,', + '} from', + ' \'lodash\'', + ].join('\n') + const document = createDocument('/workspace/src/index.ts', text) + + await expect(resolveHoverDependency(document, getPosition(text, 'lodash'))).resolves.toBeUndefined() + expect(mockedFindUp).not.toHaveBeenCalled() + expect(mockedGetResolvedDependencies).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/vscode/src/providers/hover/resolve.ts b/extensions/vscode/src/providers/hover/resolve.ts new file mode 100644 index 00000000..bab0bfd4 --- /dev/null +++ b/extensions/vscode/src/providers/hover/resolve.ts @@ -0,0 +1,41 @@ +import type { DependencyInfo } from '#core/workspace' +import type { Position, TextDocument } from 'vscode' +import { getResolvedDependencies, getResolvedDependencyByOffset } from '#core/workspace' +import { isSupportedDependencyDocument } from '#utils/file' +import { PACKAGE_JSON_BASENAME } from 'npmx-language-core/constants' +import { getImportSpecifierInLine } from 'npmx-language-core/utils' +import { findUp } from 'vscode-find-up' + +export async function resolveHoverDependency( + document: TextDocument, + position: Position, +): Promise { + const offset = document.offsetAt(position) + + if (isSupportedDependencyDocument(document)) + return await getResolvedDependencyByOffset(document.uri, offset) + + if (document.uri.scheme !== 'file') + return + + const wordRange = document.getWordRangeAtPosition(position) + if (!wordRange) + return + + const line = document.lineAt(position.line) + const hit = getImportSpecifierInLine(line.text, [ + wordRange.start.character, + wordRange.end.character, + ]) + if (!hit) + return + + const pkgJsonUri = await findUp(PACKAGE_JSON_BASENAME, { + cwd: document.uri, + }) + if (!pkgJsonUri) + return + + const dependencies = await getResolvedDependencies(pkgJsonUri) + return dependencies?.find((dependency) => dependency.rawName === hit.packageName) +} diff --git a/packages/language-core/src/utils/index.ts b/packages/language-core/src/utils/index.ts index e6058e0d..a8096736 100644 --- a/packages/language-core/src/utils/index.ts +++ b/packages/language-core/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './catalog' export * from './dependency' export * from './package' +export * from './source-import' diff --git a/packages/language-core/src/utils/source-import.test.ts b/packages/language-core/src/utils/source-import.test.ts new file mode 100644 index 00000000..05456ecb --- /dev/null +++ b/packages/language-core/src/utils/source-import.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { getImportSpecifierInLine } from './source-import' + +function getRange(text: string, target: string, fromIndex = 0): [number, number] { + const index = text.indexOf(target, fromIndex) + if (index === -1) + throw new Error(`Missing target "${target}" in test input`) + + return [index, index + target.length] +} + +function getLastRange(text: string, target: string): [number, number] { + const index = text.lastIndexOf(target) + if (index === -1) + throw new Error(`Missing target "${target}" in test input`) + + return [index, index + target.length] +} + +describe('getImportSpecifierInLine', () => { + it.each([ + ['import foo from \'lodash\'', 'lodash', 'lodash', 0], + ['import \'vite/client\'', 'vite', 'vite/client', 0], + ['export * from \'@scope/pkg/subpath\'', '@scope/pkg', '@scope/pkg/subpath', 0], + ['await import(\'zod\')', 'zod', 'zod', 0], + ])('should extract import specifier from %s', (text, packageName, specifier, fromIndex) => { + expect(getImportSpecifierInLine(text, getRange(text, packageName, fromIndex))).toEqual({ + specifier, + packageName, + }) + }) + + it('should extract import specifier from require call', () => { + const text = 'const react = require(\'react\')' + + expect(getImportSpecifierInLine(text, getLastRange(text, 'react'))).toEqual({ + specifier: 'react', + packageName: 'react', + }) + }) + + it.each([ + ['import foo from \'./local\'', 'local'], + ['import foo from \'../local\'', 'local'], + ['import foo from \'/abs\'', 'abs'], + ['import foo from \'node:fs\'', 'fs'], + ['import foo from \'https://example.com/mod.ts\'', 'example'], + ])('should ignore unsupported specifier in %s', (text, target) => { + expect(getImportSpecifierInLine(text, getRange(text, target))).toBeUndefined() + }) + + it('should return undefined outside import syntax', () => { + const text = 'const lodash = someValue' + + expect(getImportSpecifierInLine(text, getRange(text, 'lodash'))).toBeUndefined() + }) + + it('should return undefined when the current line does not contain the import context', () => { + const text = '\'lodash\'' + + expect(getImportSpecifierInLine(text, getRange(text, 'lodash'))).toBeUndefined() + }) +}) diff --git a/packages/language-core/src/utils/source-import.ts b/packages/language-core/src/utils/source-import.ts new file mode 100644 index 00000000..cd008889 --- /dev/null +++ b/packages/language-core/src/utils/source-import.ts @@ -0,0 +1,80 @@ +import type { OffsetRange } from '../types' + +export interface ImportSpecifierHit { + specifier: string + packageName: string +} + +const RELATIVE_IMPORT_PATTERN = /^\.{1,2}(?:\/|$)/ +const ABSOLUTE_IMPORT_PATTERN = /^\// +const PROTOCOL_IMPORT_PATTERN = /^[a-z][a-z\d+.-]*:/i +const STATEMENT_SUFFIX_PATTERN = /^\s*(?:;.*)?$/ +const CALL_SUFFIX_PATTERN = /^\s*\)/ +const FROM_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)from\s+$/ +const BARE_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)import\s+$/ +const DYNAMIC_IMPORT_PREFIX_PATTERN = /(?:\b|\{|\s+)import\s*\(\s*$/ +const REQUIRE_PREFIX_PATTERN = /(?:\b|\s+)require\s*\(\s*$/ + +function parsePackageName(specifier: string): string | undefined { + if ( + RELATIVE_IMPORT_PATTERN.test(specifier) + || ABSOLUTE_IMPORT_PATTERN.test(specifier) + || PROTOCOL_IMPORT_PATTERN.test(specifier) + ) { + return + } + + if (specifier.startsWith('@')) { + const segments = specifier.split('/') + if (segments.length < 2) + return + + return `${segments[0]}/${segments[1]}` + } + + const [packageName] = specifier.split('/') + return packageName || undefined +} + +function findQuote(text: string, start: number, step: -1 | 1): number { + for (let index = start; index >= 0 && index < text.length; index += step) { + const char = text[index] + if (char === '\'' || char === '"') + return index + } + + return -1 +} + +export function getImportSpecifierInLine(text: string, range: OffsetRange): ImportSpecifierHit | undefined { + const [start, end] = range + const leftQuoteIndex = findQuote(text, start - 1, -1) + if (leftQuoteIndex === -1) + return + + const rightQuoteIndex = findQuote(text, end, 1) + if (rightQuoteIndex === -1 || text[leftQuoteIndex] !== text[rightQuoteIndex]) + return + + const specifier = text.slice(leftQuoteIndex + 1, rightQuoteIndex) + const packageName = parsePackageName(specifier) + if (!packageName) + return + + const before = text.slice(0, leftQuoteIndex) + const after = text.slice(rightQuoteIndex + 1) + + const isModule + = (FROM_IMPORT_PREFIX_PATTERN.test(before) && STATEMENT_SUFFIX_PATTERN.test(after)) + || (BARE_IMPORT_PREFIX_PATTERN.test(before) && STATEMENT_SUFFIX_PATTERN.test(after)) + || (DYNAMIC_IMPORT_PREFIX_PATTERN.test(before) && CALL_SUFFIX_PATTERN.test(after)) + || (REQUIRE_PREFIX_PATTERN.test(before) && CALL_SUFFIX_PATTERN.test(after)) + + if (!isModule) + return + + return { + specifier, + packageName, + } +} diff --git a/playground/index.ts b/playground/index.ts new file mode 100644 index 00000000..25ad0a80 --- /dev/null +++ b/playground/index.ts @@ -0,0 +1,7 @@ +import fetch from + 'ofetch' +import './package.json' +const axios = require('axios') +const nuxt = await import('nuxt') + +const string = 'ofetch'