diff --git a/extensions/vscode/src/client.ts b/extensions/vscode/src/client.ts index 04b40e3..dc07d5b 100644 --- a/extensions/vscode/src/client.ts +++ b/extensions/vscode/src/client.ts @@ -2,11 +2,10 @@ import type { GetPackageManagerRequest } from '#shared/protocol' import type { DocumentFilter } from '@volar/vscode' import { displayName, extensionId } from '#shared/meta' import { GET_PACKAGE_MANAGER_METHOD } from '#shared/protocol' -import { logger } from '#state' import { SUPPORTED_DOCUMENT_PATTERN } from '#utils/constants' import { middleware } from '@volar/vscode' import { LanguageClient, TransportKind } from '@volar/vscode/node' -import { commands, Uri } from 'vscode' +import { commands, Hover, MarkdownString, Uri } from 'vscode' const SUPPORTED_LANGUAGES = [ 'javascript', @@ -20,6 +19,10 @@ const SUPPORTED_LANGUAGES = [ 'html', ] as const +function transformMarkdownString(md: string) { + return new MarkdownString(md, true) +} + export function launch(serverPath: string) { const client = new LanguageClient( extensionId, @@ -36,7 +39,27 @@ export function launch(serverPath: string) { }, }, { - middleware, + middleware: { + ...middleware, + provideHover: async (document, position, token, next) => { + const hover = await next(document, position, token) + if (!hover) + return + + const contents = hover.contents.map((c) => { + if (c instanceof MarkdownString) + return c + if (typeof c === 'string') + return transformMarkdownString(c) + if ('value' in c) + return transformMarkdownString(c.value) + + return c + }) + + return new Hover(contents, hover.range) + }, + }, documentSelector: [ { scheme: 'file', pattern: SUPPORTED_DOCUMENT_PATTERN }, ...SUPPORTED_LANGUAGES.map((language) => ({ scheme: 'file', language } satisfies DocumentFilter)), @@ -45,7 +68,11 @@ export function launch(serverPath: string) { isTrusted: true, supportHtml: true, }, - outputChannel: logger.logger.value!, + synchronize: { + configurationSection: [displayName], + }, + diagnosticCollectionName: displayName, + outputChannelName: `${displayName} Language Server`, }, ) diff --git a/extensions/vscode/src/index.ts b/extensions/vscode/src/index.ts index c3e2849..3ef4b97 100644 --- a/extensions/vscode/src/index.ts +++ b/extensions/vscode/src/index.ts @@ -1,10 +1,9 @@ import { useWorkspaceContext } from '#composables/workspace-context' import { commands, displayName, version } from '#shared/meta' -// TODO: Uncomment when language server integration is ready -// import { createLabsInfo } from '@volar/vscode' +import { createLabsInfo } from '@volar/vscode' import { defineExtension, useCommands } from 'reactive-vscode' -// import { Uri } from 'vscode' -// import { launch } from './client' +import { Uri } from 'vscode' +import { launch } from './client' import { openFileInNpmx } from './commands/open-file-in-npmx' import { openInBrowser } from './commands/open-in-browser' import { useCodeActions } from './providers/code-actions' @@ -13,20 +12,17 @@ import { useDecorators } from './providers/decorators' import { useDefinition } from './providers/definition' import { useDiagnostics } from './providers/diagnostics' import { useDocumentLink } from './providers/document-link' -import { useHover } from './providers/hover' import { logger } from './state' -export const { activate, deactivate } = defineExtension((_ctx) => { - // TODO: Uncomment when language server integration is ready - // const volarLabs = createLabsInfo() +export const { activate, deactivate } = defineExtension((ctx) => { + const volarLabs = createLabsInfo() - // const serverPath = Uri.joinPath(ctx.extensionUri, './dist/server/bin/index.js').fsPath - // const { client } = launch(serverPath) - // volarLabs.addLanguageClient(client) + const serverPath = Uri.joinPath(ctx.extensionUri, './dist/server/bin/index.js').fsPath + const { client } = launch(serverPath) + volarLabs.addLanguageClient(client) useWorkspaceContext() - useHover() useCompletionItem() useDiagnostics() useDecorators() diff --git a/extensions/vscode/src/providers/hover/index.ts b/extensions/vscode/src/providers/hover/index.ts deleted file mode 100644 index 12db863..0000000 --- a/extensions/vscode/src/providers/hover/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { config } from '#state' -import { SUPPORTED_DOCUMENT_PATTERN } from '#utils/constants' -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 }, - ...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 deleted file mode 100644 index 684c62c..0000000 --- a/extensions/vscode/src/providers/hover/npmx.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { HoverProvider, Position, TextDocument } from 'vscode' -import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from 'npmx-language-core/links' -import { Hover, MarkdownString } from 'vscode' -import { resolveHoverDependency } from './resolve' - -const SPACER = ' ' - -export class NpmxHoverProvider implements HoverProvider { - async provideHover(document: TextDocument, position: Position) { - const dep = await resolveHoverDependency(document, position) - if (!dep) - return - - const { resolvedName, resolvedSpec, resolvedProtocol, packageInfo } = dep - - switch (resolvedProtocol) { - case 'jsr': { - const jsrMd = new MarkdownString('', true) - jsrMd.isTrusted = true - - const jsrPackageLink = `[$(package)${SPACER}View on jsr.io](${jsrPackageUrl(resolvedName)})` - jsrMd.appendMarkdown(`${jsrPackageLink} | $(warning) Not on npmx`) - return new Hover(jsrMd) - } - case 'npm': { - const pkg = await packageInfo() - if (!pkg) { - const errorMd = new MarkdownString('', true) - - errorMd.isTrusted = true - errorMd.appendMarkdown('$(warning) Unable to fetch package information') - - return new Hover(errorMd) - } - - const md = new MarkdownString('', true) - md.isTrusted = true - - const resolvedVersion = await dep.resolvedVersion() - if (resolvedVersion && pkg.versionsMeta[resolvedVersion]?.provenance) - // npmx.dev can resolve ranges and tags version specifier - md.appendMarkdown(`[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance)\n\n`) - - const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(resolvedName)})` - // npmx.dev can resolve ranges and tags version specifier - const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(resolvedName, resolvedSpec)})` - - md.appendMarkdown(`${packageLink} | ${docsLink}`) - - return new Hover(md) - } - } - } -} diff --git a/extensions/vscode/src/providers/hover/resolve.test.ts b/extensions/vscode/src/providers/hover/resolve.test.ts deleted file mode 100644 index 0d27799..0000000 --- a/extensions/vscode/src/providers/hover/resolve.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { DependencyInfo } from 'npmx-language-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 deleted file mode 100644 index 0a25a7c..0000000 --- a/extensions/vscode/src/providers/hover/resolve.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { DependencyInfo } from 'npmx-language-core/workspace' -import type { Position, TextDocument } from 'vscode' -import { getResolvedDependencies, getResolvedDependencyByOffset } from '#core/workspace' -import { PACKAGE_JSON_BASENAME } from 'npmx-language-core/constants' -import { getImportSpecifierInLine, isDependencyFile } from 'npmx-language-core/utils' -import { findUp } from 'vscode-find-up' - -export async function resolveHoverDependency( - document: TextDocument, - position: Position, -): Promise { - if (document.uri.scheme !== 'file') - return - - const offset = document.offsetAt(position) - - if (isDependencyFile(document.uri.path)) - return await getResolvedDependencyByOffset(document.uri, offset) - - 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/extensions/vscode/tsdown.config.ts b/extensions/vscode/tsdown.config.ts index 9e33fe8..29f8129 100644 --- a/extensions/vscode/tsdown.config.ts +++ b/extensions/vscode/tsdown.config.ts @@ -5,9 +5,8 @@ import { umdToEsm } from '../../plugins/umd-to-esm.ts' export default defineConfig({ copy: [ '../../res', - // TODO: Enable when language server integration is ready - // { from: 'node_modules/npmx-language-server/bin/**', to: 'dist/server/bin' }, - // { from: 'node_modules/npmx-language-server/dist/**', to: 'dist/server/dist' }, + { from: 'node_modules/npmx-language-server/bin/**', to: 'dist/server/bin' }, + { from: 'node_modules/npmx-language-server/dist/**', to: 'dist/server/dist' }, ], deps: { neverBundle: ['vscode'], diff --git a/packages/language-core/src/utils/source-import.test.ts b/packages/language-core/src/utils/source-import.test.ts index 05456ec..c5fd5ab 100644 --- a/packages/language-core/src/utils/source-import.test.ts +++ b/packages/language-core/src/utils/source-import.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getImportSpecifierInLine } from './source-import' +import { getImportSpecifier, getImportSpecifierAtOffset, getWordRangeAtOffset } from './source-import' function getRange(text: string, target: string, fromIndex = 0): [number, number] { const index = text.indexOf(target, fromIndex) @@ -17,47 +17,131 @@ function getLastRange(text: string, target: string): [number, number] { return [index, index + target.length] } -describe('getImportSpecifierInLine', () => { +describe('getWordRangeAtOffset', () => { 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({ + [`import foo from 'lodash'`, 'foo', [7, 10]], + [`const lodash = require('lodash')`, 'lodash', [6, 12]], + ['lodash import', 'lodash', [0, 6]], + ['import lodash', 'lodash', [7, 13]], + [`import {\n foo,\n} from\n 'lodash'`, 'lodash', [26, 32]], + ])('should return range for %s targeting %s', (text, target, expected) => { + expect(getWordRangeAtOffset(text, text.indexOf(target) + 1)).toEqual(expected) + }) + + it('should return undefined for non-word character', () => { + expect(getWordRangeAtOffset(`import foo from 'lodash'`, 6)).toBeUndefined() + }) + + it.each([ + [`import foo from '@babel/core'`, '@', [17, 28]], + [`import foo from '@babel/core'`, 'babel', [17, 28]], + [`import 'lodash/fp'`, '/', [8, 17]], + [`import 'lodash/fp'`, 'lodash', [8, 17]], + ])('should include special chars in package name (%s at %s)', (text, target, expected) => { + expect(getWordRangeAtOffset(text, text.indexOf(target) + 1)).toEqual(expected) + }) +}) + +describe('getImportSpecifier', () => { + it.each([ + [`import foo from 'lodash'`, 'lodash', 'lodash'], + [`import 'vite/client'`, 'vite', 'vite/client'], + [`export * from '@scope/pkg/subpath'`, '@scope/pkg', '@scope/pkg/subpath'], + [`await import('zod')`, 'zod', 'zod'], + [`import {\n foo,\n bar,\n} from 'lodash'`, 'lodash', 'lodash'], + [`await import(\n 'zod'\n)`, 'zod', 'zod'], + [`import { foo }\nfrom 'lodash'`, 'lodash', 'lodash'], + [`import {\n foo,\n} from\n 'lodash'`, 'lodash', 'lodash'], + ])('should extract from %s', (text, packageName, specifier) => { + expect(getImportSpecifier(text, getRange(text, packageName))).toEqual({ specifier, packageName, }) }) - it('should extract import specifier from require call', () => { - const text = 'const react = require(\'react\')' + it.each([ + [`const react = require('react')`, 'react'], + [`const x = require(\n 'react'\n)`, 'react'], + [`const lodash =\n require('lodash')`, 'lodash'], + ])('should extract from require in %s', (text, packageName) => { + expect(getImportSpecifier(text, getLastRange(text, packageName))).toEqual({ + specifier: packageName, + packageName, + }) + }) + + 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'], + ['const lodash = someValue', 'lodash'], + [`'lodash'`, 'lodash'], + ])('should return undefined for %s', (text, target) => { + expect(getImportSpecifier(text, getRange(text, target))).toBeUndefined() + }) + + it('should extract import in full document', () => { + const text = `import fetch from \n 'ofetch'\n\nconst string = 'ofetch'` + + expect(getImportSpecifier(text, getRange(text, 'ofetch'))).toEqual({ + specifier: 'ofetch', + packageName: 'ofetch', + }) + }) + + it('should not match a plain string that follows a multi-line import', () => { + const text = `import fetch from \n 'ofetch'\n\nconst string = 'ofetch'` + + expect(getImportSpecifier(text, getLastRange(text, 'ofetch'))).toBeUndefined() + }) + + it.each([ + [`import foo from 'lodash'\r\n`, 'lodash', 'lodash'], + [`import 'lodash/fp'\r\n`, 'lodash', 'lodash/fp'], + ])('should extract from CRLF line endings (%s)', (text, packageName, specifier) => { + expect(getImportSpecifier(text, getRange(text, packageName))).toEqual({ + specifier, + packageName, + }) + }) - expect(getImportSpecifierInLine(text, getLastRange(text, 'react'))).toEqual({ + it('should extract from require with CRLF', () => { + const text = `const react = require('react')\r\n` + expect(getImportSpecifier(text, getLastRange(text, 'react'))).toEqual({ specifier: 'react', packageName: 'react', }) }) +}) +describe('getImportSpecifierAtOffset', () => { 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() + [`import foo from 'lodash'`, 'lodash', 'lodash', 'lodash'], + [`import 'lodash/fp'`, 'lodash', 'lodash/fp', 'lodash'], + [`import { foo } from 'lodash'`, 'lodash', 'lodash', 'lodash'], + [`const pkg = await import('lodash')`, 'lodash', 'lodash', 'lodash'], + [`import foo from '@babel/core'`, '@babel', '@babel/core', '@babel/core'], + [`import {\n foo,\n} from\n 'lodash'`, 'lodash', 'lodash', 'lodash'], + [`import fetch from \n 'ofetch'\n\nconst string = 'ofetch'`, 'ofetch', 'ofetch', 'ofetch'], + ])('should extract from %s', (text, target, specifier, packageName) => { + expect(getImportSpecifierAtOffset(text, text.indexOf(target) + 1)).toEqual({ specifier, packageName }) }) - it('should return undefined outside import syntax', () => { - const text = 'const lodash = someValue' + it.each([ + [`import foo from './utils'`, 'utils'], + [`import foo from 'node:fs'`, 'node:fs'], + ['const lodash = someValue', 'lodash'], + [`import fetch from + 'ofetch' - expect(getImportSpecifierInLine(text, getRange(text, 'lodash'))).toBeUndefined() +const string = 'ofetch'`, 'ofetch'], + ])('should return undefined for %s', (text, target) => { + expect(getImportSpecifierAtOffset(text, text.lastIndexOf(target) + 1)).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() + it('should return undefined when not on a word', () => { + expect(getImportSpecifierAtOffset(`import foo from 'lodash'`, 16)).toBeUndefined() }) }) diff --git a/packages/language-core/src/utils/source-import.ts b/packages/language-core/src/utils/source-import.ts index cd00888..60a332b 100644 --- a/packages/language-core/src/utils/source-import.ts +++ b/packages/language-core/src/utils/source-import.ts @@ -5,13 +5,14 @@ export interface ImportSpecifierHit { packageName: string } +const WORD_CHAR = /[@/\w.-]/ 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 STATEMENT_SUFFIX_PATTERN = /^[ \t]*(?:;[^\r\n]*)?(?:\r?\n|$)/ const CALL_SUFFIX_PATTERN = /^\s*\)/ -const FROM_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)from\s+$/ -const BARE_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)import\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*$/ @@ -46,7 +47,68 @@ function findQuote(text: string, start: number, step: -1 | 1): number { return -1 } -export function getImportSpecifierInLine(text: string, range: OffsetRange): ImportSpecifierHit | undefined { +function findStatementStart(text: string, offset: number): number { + let pos = offset + while (pos > 0) { + const lineStart = text.lastIndexOf('\n', pos - 1) + if (lineStart === -1) + return 0 + + const lineStartPos = lineStart + 1 + let firstChar = text[lineStartPos] + + if (firstChar === '\r' && text[lineStartPos + 1] === '\n') + firstChar = text[lineStartPos + 2] + + if (firstChar !== undefined && firstChar !== ' ' && firstChar !== '\t' && firstChar !== '\n' && firstChar !== '\r') + return lineStartPos + + pos = lineStart + } + return 0 +} + +function findStatementEnd(text: string, offset: number): number { + for (let i = offset; i < text.length; i++) { + const char = text[i] + if (char === ';' || char === '\n') { + const next = text[i + 1] + if (next === undefined || next === '\n' || next === '\r') + return i + 1 + if (char === ';') + return i + 1 + } + if (char === '\r') { + const next = text[i + 1] + if (next === '\n') + return i + 2 + if (next === undefined || next === '\r') + return i + 1 + } + if (char === ')') + return i + 1 + } + return text.length +} + +export function getWordRangeAtOffset(text: string, offset: number): OffsetRange | undefined { + const char = text[offset] + if (!char || !WORD_CHAR.test(char)) + return + + let start = offset + let end = offset + 1 + + while (start > 0 && WORD_CHAR.test(text[start - 1]!)) + start-- + + while (end < text.length && WORD_CHAR.test(text[end]!)) + end++ + + return [start, end] +} + +export function getImportSpecifier(text: string, range: OffsetRange): ImportSpecifierHit | undefined { const [start, end] = range const leftQuoteIndex = findQuote(text, start - 1, -1) if (leftQuoteIndex === -1) @@ -61,8 +123,10 @@ export function getImportSpecifierInLine(text: string, range: OffsetRange): Impo if (!packageName) return - const before = text.slice(0, leftQuoteIndex) - const after = text.slice(rightQuoteIndex + 1) + const statementStart = findStatementStart(text, leftQuoteIndex) + const statementEnd = findStatementEnd(text, rightQuoteIndex + 1) + const before = text.slice(statementStart, leftQuoteIndex) + const after = text.slice(rightQuoteIndex + 1, statementEnd) const isModule = (FROM_IMPORT_PREFIX_PATTERN.test(before) && STATEMENT_SUFFIX_PATTERN.test(after)) @@ -78,3 +142,11 @@ export function getImportSpecifierInLine(text: string, range: OffsetRange): Impo packageName, } } + +export function getImportSpecifierAtOffset(text: string, offset: number): ImportSpecifierHit | undefined { + const wordRange = getWordRangeAtOffset(text, offset) + if (!wordRange) + return + + return getImportSpecifier(text, wordRange) +} diff --git a/packages/language-core/src/workspace.ts b/packages/language-core/src/workspace.ts index 7def41f..7a0d569 100644 --- a/packages/language-core/src/workspace.ts +++ b/packages/language-core/src/workspace.ts @@ -8,8 +8,9 @@ import type { WorkspaceCatalogInfo, } from './types' import { defineCachedFunction } from 'ocache' +import { dirname, join } from 'pathe' import { getPackageInfo } from './api/package' -import { PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from './constants' +import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from './constants' import { getExtractor } from './extractors' import { isPackageManifest, isWorkspaceFile, lazyInit, resolveDependencySpec, resolveExactVersion } from './utils' @@ -162,6 +163,24 @@ export class WorkspaceContext { } }, this.#cacheOptions) + async findNearestPackageManifestPath(path: string): Promise { + let dir = dirname(path) + + while (dir === this.rootPath || dir.startsWith(`${this.rootPath}/`)) { + const manifestPath = join(dir, PACKAGE_JSON_BASENAME) + if (await this.adapter.fileExists(manifestPath)) + return manifestPath + + if (dir === this.rootPath) + break + + const parent = dirname(dir) + if (parent === dir) + break + dir = parent + } + } + async invalidateDependencyInfo(path: string) { if (isPackageManifest(path)) await this.loadPackageManifestInfo.invalidate(path) diff --git a/packages/language-server/src/workspace.ts b/packages/language-server/src/workspace.ts index 29c386b..2b0a54a 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -149,6 +149,22 @@ export class WorkspaceState implements IWorkspaceState { : await ctx.loadWorkspaceFileInfo(uri.path) )?.dependencies } + + async getResolvedDependenciesForContainingPackage(uriString: string): Promise { + const ctx = await this.getWorkspaceContext(uriString) + if (!ctx) + return + + const uri = URI.parse(uriString) + if (uri.scheme !== 'file') + return + + const manifestPath = await ctx.findNearestPackageManifestPath(uri.path) + if (!manifestPath) + return + + return (await ctx.loadPackageManifestInfo(manifestPath))?.dependencies + } } export function createWorkspaceState(connection: Connection, server: LanguageServer) { diff --git a/packages/language-service/package.json b/packages/language-service/package.json index 634a82f..6de50c5 100644 --- a/packages/language-service/package.json +++ b/packages/language-service/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@volar/language-service": "catalog:lsp", - "npmx-language-core": "workspace:*" + "npmx-language-core": "workspace:*", + "vscode-uri": "catalog:lsp" } } diff --git a/packages/language-service/src/index.ts b/packages/language-service/src/index.ts index d625511..ee872d9 100644 --- a/packages/language-service/src/index.ts +++ b/packages/language-service/src/index.ts @@ -1,7 +1,9 @@ import type { LanguageServicePlugin } from '@volar/language-service' import type { IWorkspaceState } from './types' +import { create as createNpmxHoverService } from './plugins/hover' -export function createNpmxLanguageServicePlugins(_workspace: IWorkspaceState): LanguageServicePlugin[] { +export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): LanguageServicePlugin[] { return [ + createNpmxHoverService(workspace), ] } diff --git a/packages/language-service/src/plugins/hover.ts b/packages/language-service/src/plugins/hover.ts new file mode 100644 index 0000000..8a27de2 --- /dev/null +++ b/packages/language-service/src/plugins/hover.ts @@ -0,0 +1,104 @@ +import type { Hover, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service' +import type { DependencyInfo } from 'npmx-language-core/workspace' +import type { IWorkspaceState } from '../types' +import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from 'npmx-language-core/links' +import { getImportSpecifierAtOffset, isDependencyFile } from 'npmx-language-core/utils' +import { URI } from 'vscode-uri' +import { getConfig } from '../config' +import { getResolvedDependencyAtOffset } from '../utils/range' + +export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { + const SPACER = ' ' + + async function renderHover(dep: DependencyInfo): Promise { + const { resolvedName, resolvedSpec, resolvedProtocol, packageInfo } = dep + + switch (resolvedProtocol) { + case 'jsr': { + const jsrPackageLink = `[$(package)${SPACER}View on jsr.io](${jsrPackageUrl(resolvedName)})` + + return { + contents: { + kind: 'markdown', + value: `${jsrPackageLink} | $(warning) Not on npmx`, + }, + } satisfies Hover + } + case 'npm': { + const pkg = await packageInfo() + if (!pkg) { + return { + contents: { + kind: 'markdown', + value: '$(warning) Unable to fetch package information', + }, + } satisfies Hover + } + + const resolvedVersion = await dep.resolvedVersion() + let content = '' + if (resolvedVersion && pkg.versionsMeta[resolvedVersion]?.provenance) { + content += `[$(verified)${SPACER}Verified provenance](${npmxPackageUrl(resolvedName, resolvedSpec)}#provenance)\n\n` + } + + const packageLink = `[$(package)${SPACER}View on npmx.dev](${npmxPackageUrl(resolvedName)})` + const docsLink = `[$(book)${SPACER}View docs on npmx.dev](${npmxDocsUrl(resolvedName, resolvedSpec)})` + + content += `${packageLink} | ${docsLink}` + + return { + contents: { + kind: 'markdown', + value: content, + }, + } + } + } + } + + return { + name: 'npmx-hover', + capabilities: { + hoverProvider: true, + }, + create(context): LanguageServicePluginInstance { + return { + async provideHover(document, position): Promise { + if (!await getConfig(context, 'npmx.hover.enabled')) + return + + const uri = URI.parse(document.uri) + if (uri.scheme !== 'file') + return + + const offset = document.offsetAt(position) + + if (isDependencyFile(uri.path)) { + const dependencies = await workspaceState.getResolvedDependencies(document.uri) + if (!dependencies) + return + const dep = getResolvedDependencyAtOffset(dependencies, offset) + if (!dep) + return + + return renderHover(dep) + } else { + const text = document.getText() + const specifier = getImportSpecifierAtOffset(text, offset) + if (!specifier) + return + + const { packageName } = specifier + + const dependencies = await workspaceState.getResolvedDependenciesForContainingPackage(document.uri) + const dep = dependencies?.find((d) => d.rawName === packageName) + if (!dep) + return + + return renderHover(dep) + } + }, + } + }, + } +} diff --git a/packages/language-service/src/types.ts b/packages/language-service/src/types.ts index 31ba9dd..4a086cd 100644 --- a/packages/language-service/src/types.ts +++ b/packages/language-service/src/types.ts @@ -3,4 +3,5 @@ import type { DependencyInfo, WorkspaceContext } from 'npmx-language-core/worksp export interface IWorkspaceState { getWorkspaceContext: (uri: string) => Promise getResolvedDependencies: (uri: string) => Promise + getResolvedDependenciesForContainingPackage: (uri: string) => Promise } diff --git a/packages/language-service/src/utils/range.ts b/packages/language-service/src/utils/range.ts new file mode 100644 index 0000000..feca297 --- /dev/null +++ b/packages/language-service/src/utils/range.ts @@ -0,0 +1,15 @@ +import type { OffsetRange } from 'npmx-language-core/types' +import type { DependencyInfo } from 'npmx-language-core/workspace' + +function isOffsetInRange(offset: number, [start, end]: OffsetRange): boolean { + return offset >= start && offset <= end +} + +export function getResolvedDependencyAtOffset( + dependencies: DependencyInfo[], + offset: number, +): DependencyInfo | undefined { + return dependencies.find((dep) => + isOffsetInRange(offset, dep.nameRange) || isOffsetInRange(offset, dep.specRange), + ) +} diff --git a/playground/index.ts b/playground/index.ts index 25ad0a8..889b584 100644 --- a/playground/index.ts +++ b/playground/index.ts @@ -1,7 +1,15 @@ -import fetch from +import ofetch from 'ofetch' +import fetch2 + from + 'ofetch' +import fetch1 from 'ofetch' import './package.json' -const axios = require('axios') -const nuxt = await import('nuxt') +const axios = + require('axios') +const axios1 = require('axios') +const nuxt = +await import('nuxt') +const nuxt1 = await import('nuxt') const string = 'ofetch' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c798655..8d91079 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -231,6 +231,9 @@ importers: npmx-language-core: specifier: workspace:* version: link:../language-core + vscode-uri: + specifier: catalog:lsp + version: 3.1.0 packages: