Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion extensions/vscode/src/providers/hover/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
Expand Down
5 changes: 2 additions & 3 deletions extensions/vscode/src/providers/hover/npmx.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down
223 changes: 223 additions & 0 deletions extensions/vscode/src/providers/hover/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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()
})
})
41 changes: 41 additions & 0 deletions extensions/vscode/src/providers/hover/resolve.ts
Original file line number Diff line number Diff line change
@@ -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 { getImportSpecifierInLine } from '#utils/import-specifier'
import { PACKAGE_JSON_BASENAME } from 'npmx-language-core/constants'
import { findUp } from 'vscode-find-up'

export async function resolveHoverDependency(
document: TextDocument,
position: Position,
): Promise<DependencyInfo | undefined> {
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)
}
63 changes: 63 additions & 0 deletions extensions/vscode/src/utils/import-specifier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest'
import { getImportSpecifierInLine } from './import-specifier'

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()
})
})
Loading
Loading