From 5a45c40d1f043f88b2a10b97c6f040fa1c7572b3 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Mon, 23 Mar 2026 21:55:15 +0800 Subject: [PATCH 1/9] feat: enable language server integration --- extensions/vscode/src/index.ts | 18 ++++++++---------- extensions/vscode/tsdown.config.ts | 5 ++--- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/extensions/vscode/src/index.ts b/extensions/vscode/src/index.ts index c3e2849..46df530 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' @@ -16,13 +15,12 @@ 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() 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'], From 70c82a15615769c44d9c5b786e1ca0f6d3aff152 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Tue, 24 Mar 2026 01:14:15 +0800 Subject: [PATCH 2/9] feat: migrate hover to language-service --- extensions/vscode/src/client.ts | 36 ++- extensions/vscode/src/index.ts | 2 - .../vscode/src/providers/hover/index.ts | 31 --- extensions/vscode/src/providers/hover/npmx.ts | 54 ----- .../src/providers/hover/resolve.test.ts | 223 ------------------ .../vscode/src/providers/hover/resolve.ts | 40 ---- .../language-core/src/utils/source-import.ts | 4 +- packages/language-core/src/workspace.ts | 21 +- packages/language-server/src/workspace.ts | 16 ++ packages/language-service/package.json | 3 +- packages/language-service/src/index.ts | 4 +- .../language-service/src/plugins/hover.ts | 105 +++++++++ packages/language-service/src/types.ts | 1 + .../src/utils/import-resolution.test.ts | 121 ++++++++++ .../src/utils/import-resolution.ts | 23 ++ packages/language-service/src/utils/range.ts | 15 ++ .../language-service/src/utils/text.test.ts | 46 ++++ packages/language-service/src/utils/text.ts | 20 ++ playground/index.ts | 12 +- pnpm-lock.yaml | 3 + 20 files changed, 419 insertions(+), 361 deletions(-) delete mode 100644 extensions/vscode/src/providers/hover/index.ts delete mode 100644 extensions/vscode/src/providers/hover/npmx.ts delete mode 100644 extensions/vscode/src/providers/hover/resolve.test.ts delete mode 100644 extensions/vscode/src/providers/hover/resolve.ts create mode 100644 packages/language-service/src/plugins/hover.ts create mode 100644 packages/language-service/src/utils/import-resolution.test.ts create mode 100644 packages/language-service/src/utils/import-resolution.ts create mode 100644 packages/language-service/src/utils/range.ts create mode 100644 packages/language-service/src/utils/text.test.ts create mode 100644 packages/language-service/src/utils/text.ts diff --git a/extensions/vscode/src/client.ts b/extensions/vscode/src/client.ts index 04b40e3..d532a6c 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,28 @@ 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 (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 +69,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 46df530..3ef4b97 100644 --- a/extensions/vscode/src/index.ts +++ b/extensions/vscode/src/index.ts @@ -12,7 +12,6 @@ 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) => { @@ -24,7 +23,6 @@ export const { activate, deactivate } = defineExtension((ctx) => { 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/packages/language-core/src/utils/source-import.ts b/packages/language-core/src/utils/source-import.ts index cd00888..9251881 100644 --- a/packages/language-core/src/utils/source-import.ts +++ b/packages/language-core/src/utils/source-import.ts @@ -10,8 +10,8 @@ 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 FROM_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)from\s*(?:\n|$)/ +const BARE_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)import\s*(?:\n|$)/ const DYNAMIC_IMPORT_PREFIX_PATTERN = /(?:\b|\{|\s+)import\s*\(\s*$/ const REQUIRE_PREFIX_PATTERN = /(?:\b|\s+)require\s*\(\s*$/ 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..d93b79a --- /dev/null +++ b/packages/language-service/src/plugins/hover.ts @@ -0,0 +1,105 @@ +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 { isDependencyFile } from 'npmx-language-core/utils' +import { URI } from 'vscode-uri' +import { getConfig } from '../config' +import { extractImportSpecifier } from '../utils/import-resolution' +import { getResolvedDependencyByOffset } 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 = getResolvedDependencyByOffset(dependencies, offset) + if (!dep) + return + + return renderHover(dep) + } else { + const text = document.getText() + const specifier = extractImportSpecifier(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/import-resolution.test.ts b/packages/language-service/src/utils/import-resolution.test.ts new file mode 100644 index 0000000..824c957 --- /dev/null +++ b/packages/language-service/src/utils/import-resolution.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { extractImportSpecifier } from './import-resolution' + +describe('extractImportSpecifier', () => { + it('should extract import from single-line import statement', () => { + const text = "import foo from 'lodash'" + const offset = text.indexOf('lodash') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) + }) + + it('should extract import from bare import statement', () => { + const text = "import 'lodash/fp'" + const offset = text.indexOf('lodash') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toEqual({ specifier: 'lodash/fp', packageName: 'lodash' }) + }) + + it('should extract import from named import', () => { + const text = "import { foo } from 'lodash'" + const offset = text.indexOf('lodash') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) + }) + + it('should extract import from require call with assignment', () => { + const text = "const lodash = require('lodash')" + const offset = text.lastIndexOf('lodash') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) + }) + + it('should extract import from multiline require call with assignment', () => { + const text = "const lodash =\n require('lodash')" + const offset = text.lastIndexOf('lodash') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) + }) + + it('should extract import from multiline import with specifier on separate line', () => { + const text = "import {\n foo,\n} from\n 'lodash'" + const offset = text.indexOf('lodash') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) + }) + + it('should extract import from dynamic import', () => { + const text = "const pkg = await import('lodash')" + const offset = text.indexOf('lodash') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) + }) + + it('should handle scoped package', () => { + const text = "import foo from '@babel/core'" + const offset = text.indexOf('@babel') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toEqual({ specifier: '@babel/core', packageName: '@babel/core' }) + }) + + it('should return undefined for relative import', () => { + const text = "import foo from './utils'" + const offset = text.indexOf('./utils') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toBeUndefined() + }) + + it('should return undefined for absolute import', () => { + const text = "import foo from '/utils'" + const offset = text.indexOf('/utils') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toBeUndefined() + }) + + it('should return undefined for protocol import', () => { + const text = "import foo from 'node:fs'" + const offset = text.indexOf('node:fs') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toBeUndefined() + }) + + it('should return undefined when not on a word', () => { + const text = "import foo from 'lodash'" + const offset = text.indexOf("'") + + const result = extractImportSpecifier(text, offset) + + expect(result).toBeUndefined() + }) + + it('should return undefined when not inside import string', () => { + const text = 'const lodash = someValue' + const offset = text.indexOf('lodash') + 1 + + const result = extractImportSpecifier(text, offset) + + expect(result).toBeUndefined() + }) +}) diff --git a/packages/language-service/src/utils/import-resolution.ts b/packages/language-service/src/utils/import-resolution.ts new file mode 100644 index 0000000..120a3af --- /dev/null +++ b/packages/language-service/src/utils/import-resolution.ts @@ -0,0 +1,23 @@ +import { getImportSpecifierInLine } from 'npmx-language-core/utils' +import { getWordRangeAtOffset } from '../utils/text' + +export interface ImportSpecifierContext { + specifier: string + packageName: string +} + +export function extractImportSpecifier(text: string, offset: number): ImportSpecifierContext | undefined { + const wordRange = getWordRangeAtOffset(text, offset) + if (!wordRange) + return + + const hit = getImportSpecifierInLine(text, wordRange) + + if (!hit) + return + + return { + specifier: hit.specifier, + packageName: hit.packageName, + } +} diff --git a/packages/language-service/src/utils/range.ts b/packages/language-service/src/utils/range.ts new file mode 100644 index 0000000..70def97 --- /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 getResolvedDependencyByOffset( + dependencies: DependencyInfo[], + offset: number, +): DependencyInfo | undefined { + return dependencies.find((dep) => + isOffsetInRange(offset, dep.nameRange) || isOffsetInRange(offset, dep.specRange), + ) +} diff --git a/packages/language-service/src/utils/text.test.ts b/packages/language-service/src/utils/text.test.ts new file mode 100644 index 0000000..739f382 --- /dev/null +++ b/packages/language-service/src/utils/text.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { getWordRangeAtOffset } from './text' + +describe('getWordRangeAtOffset', () => { + it('should return word range for valid word', () => { + const text = "import foo from 'lodash'" + const result = getWordRangeAtOffset(text, text.indexOf('foo')) + + expect(result).toEqual([7, 10]) + }) + + it('should return undefined for non-word character', () => { + const text = "import foo from 'lodash'" + const result = getWordRangeAtOffset(text, 6) + + expect(result).toBeUndefined() + }) + + it('should extend range to full word', () => { + const text = "const lodash = require('lodash')" + const result = getWordRangeAtOffset(text, text.indexOf('lodash') + 1) + + expect(result).toEqual([6, 12]) + }) + + it('should handle word at start of line', () => { + const text = 'lodash import' + const result = getWordRangeAtOffset(text, 0) + + expect(result).toEqual([0, 6]) + }) + + it('should handle word at end of line', () => { + const text = 'import lodash' + const result = getWordRangeAtOffset(text, text.length - 1) + + expect(result).toEqual([7, 13]) + }) + + it('should handle multiline import case', () => { + const text = "import {\n foo,\n} from\n 'lodash'" + const result = getWordRangeAtOffset(text, text.indexOf('lodash') + 1) + + expect(result).toEqual([26, 32]) + }) +}) \ No newline at end of file diff --git a/packages/language-service/src/utils/text.ts b/packages/language-service/src/utils/text.ts new file mode 100644 index 0000000..bcdd684 --- /dev/null +++ b/packages/language-service/src/utils/text.ts @@ -0,0 +1,20 @@ +import type { OffsetRange } from 'npmx-language-core/types' + +const WORD_CHAR = /[\w-]/ + +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] +} diff --git a/playground/index.ts b/playground/index.ts index 25ad0a8..0b1afca 100644 --- a/playground/index.ts +++ b/playground/index.ts @@ -1,7 +1,15 @@ import fetch 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: From dcc39fd37284bc77da418016a2db8e11f700e4f6 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Mar 2026 21:35:19 +0800 Subject: [PATCH 3/9] fix: ensure get the correct import name --- .../src/utils/source-import.test.ts | 108 ++++++++++++---- .../language-core/src/utils/source-import.ts | 68 +++++++++- .../language-service/src/plugins/hover.ts | 4 +- .../src/utils/import-resolution.test.ts | 121 ------------------ .../src/utils/import-resolution.ts | 23 ---- .../language-service/src/utils/text.test.ts | 46 ------- packages/language-service/src/utils/text.ts | 20 --- playground/index.ts | 2 +- 8 files changed, 148 insertions(+), 244 deletions(-) delete mode 100644 packages/language-service/src/utils/import-resolution.test.ts delete mode 100644 packages/language-service/src/utils/import-resolution.ts delete mode 100644 packages/language-service/src/utils/text.test.ts delete mode 100644 packages/language-service/src/utils/text.ts diff --git a/packages/language-core/src/utils/source-import.test.ts b/packages/language-core/src/utils/source-import.test.ts index 05456ec..9c9f8a0 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,101 @@ 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() + }) +}) + +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\')' - - expect(getImportSpecifierInLine(text, getLastRange(text, 'react'))).toEqual({ - specifier: 'react', - packageName: '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'], - ])('should ignore unsupported specifier in %s', (text, target) => { - expect(getImportSpecifierInLine(text, getRange(text, target))).toBeUndefined() + [`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 return undefined outside import syntax', () => { - const text = 'const lodash = someValue' + it('should extract import in full document', () => { + const text = `import fetch from \n 'ofetch'\n\nconst string = 'ofetch'` - expect(getImportSpecifierInLine(text, getRange(text, 'lodash'))).toBeUndefined() + expect(getImportSpecifier(text, getRange(text, 'ofetch'))).toEqual({ + specifier: 'ofetch', + packageName: 'ofetch', + }) }) - it('should return undefined when the current line does not contain the import context', () => { - const text = '\'lodash\'' + 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() + }) +}) + +describe('getImportSpecifierAtOffset', () => { + it.each([ + [`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.each([ + [`import foo from './utils'`, './utils'], + [`import foo from 'node:fs'`, 'node:fs'], + ['const lodash = someValue', 'lodash'], + [`import fetch from \n 'ofetch'\n\nconst string = 'ofetch'`, 'string'], + ])('should return undefined for %s', (text, target) => { + expect(getImportSpecifierAtOffset(text, text.lastIndexOf(target) + 1)).toBeUndefined() + }) - 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 9251881..0b54199 100644 --- a/packages/language-core/src/utils/source-import.ts +++ b/packages/language-core/src/utils/source-import.ts @@ -5,10 +5,11 @@ 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]*(?:;[^\n]*)?(?:\n|$)/ const CALL_SUFFIX_PATTERN = /^\s*\)/ const FROM_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)from\s*(?:\n|$)/ const BARE_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)import\s*(?:\n|$)/ @@ -46,7 +47,56 @@ 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 firstChar = text[lineStart + 1] + if (firstChar !== undefined && firstChar !== ' ' && firstChar !== '\t' && firstChar !== '\n' && firstChar !== '\r') + return lineStart + 1 + + 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 === ')') + 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 +111,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 +130,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-service/src/plugins/hover.ts b/packages/language-service/src/plugins/hover.ts index d93b79a..963ec18 100644 --- a/packages/language-service/src/plugins/hover.ts +++ b/packages/language-service/src/plugins/hover.ts @@ -5,7 +5,7 @@ import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from 'npmx-language-core/l import { isDependencyFile } from 'npmx-language-core/utils' import { URI } from 'vscode-uri' import { getConfig } from '../config' -import { extractImportSpecifier } from '../utils/import-resolution' +import { getImportSpecifierAtOffset } from 'npmx-language-core/utils' import { getResolvedDependencyByOffset } from '../utils/range' export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { @@ -85,7 +85,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { return renderHover(dep) } else { const text = document.getText() - const specifier = extractImportSpecifier(text, offset) + const specifier = getImportSpecifierAtOffset(text, offset) if (!specifier) return diff --git a/packages/language-service/src/utils/import-resolution.test.ts b/packages/language-service/src/utils/import-resolution.test.ts deleted file mode 100644 index 824c957..0000000 --- a/packages/language-service/src/utils/import-resolution.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { extractImportSpecifier } from './import-resolution' - -describe('extractImportSpecifier', () => { - it('should extract import from single-line import statement', () => { - const text = "import foo from 'lodash'" - const offset = text.indexOf('lodash') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) - }) - - it('should extract import from bare import statement', () => { - const text = "import 'lodash/fp'" - const offset = text.indexOf('lodash') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toEqual({ specifier: 'lodash/fp', packageName: 'lodash' }) - }) - - it('should extract import from named import', () => { - const text = "import { foo } from 'lodash'" - const offset = text.indexOf('lodash') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) - }) - - it('should extract import from require call with assignment', () => { - const text = "const lodash = require('lodash')" - const offset = text.lastIndexOf('lodash') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) - }) - - it('should extract import from multiline require call with assignment', () => { - const text = "const lodash =\n require('lodash')" - const offset = text.lastIndexOf('lodash') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) - }) - - it('should extract import from multiline import with specifier on separate line', () => { - const text = "import {\n foo,\n} from\n 'lodash'" - const offset = text.indexOf('lodash') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) - }) - - it('should extract import from dynamic import', () => { - const text = "const pkg = await import('lodash')" - const offset = text.indexOf('lodash') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toEqual({ specifier: 'lodash', packageName: 'lodash' }) - }) - - it('should handle scoped package', () => { - const text = "import foo from '@babel/core'" - const offset = text.indexOf('@babel') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toEqual({ specifier: '@babel/core', packageName: '@babel/core' }) - }) - - it('should return undefined for relative import', () => { - const text = "import foo from './utils'" - const offset = text.indexOf('./utils') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toBeUndefined() - }) - - it('should return undefined for absolute import', () => { - const text = "import foo from '/utils'" - const offset = text.indexOf('/utils') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toBeUndefined() - }) - - it('should return undefined for protocol import', () => { - const text = "import foo from 'node:fs'" - const offset = text.indexOf('node:fs') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toBeUndefined() - }) - - it('should return undefined when not on a word', () => { - const text = "import foo from 'lodash'" - const offset = text.indexOf("'") - - const result = extractImportSpecifier(text, offset) - - expect(result).toBeUndefined() - }) - - it('should return undefined when not inside import string', () => { - const text = 'const lodash = someValue' - const offset = text.indexOf('lodash') + 1 - - const result = extractImportSpecifier(text, offset) - - expect(result).toBeUndefined() - }) -}) diff --git a/packages/language-service/src/utils/import-resolution.ts b/packages/language-service/src/utils/import-resolution.ts deleted file mode 100644 index 120a3af..0000000 --- a/packages/language-service/src/utils/import-resolution.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { getImportSpecifierInLine } from 'npmx-language-core/utils' -import { getWordRangeAtOffset } from '../utils/text' - -export interface ImportSpecifierContext { - specifier: string - packageName: string -} - -export function extractImportSpecifier(text: string, offset: number): ImportSpecifierContext | undefined { - const wordRange = getWordRangeAtOffset(text, offset) - if (!wordRange) - return - - const hit = getImportSpecifierInLine(text, wordRange) - - if (!hit) - return - - return { - specifier: hit.specifier, - packageName: hit.packageName, - } -} diff --git a/packages/language-service/src/utils/text.test.ts b/packages/language-service/src/utils/text.test.ts deleted file mode 100644 index 739f382..0000000 --- a/packages/language-service/src/utils/text.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { getWordRangeAtOffset } from './text' - -describe('getWordRangeAtOffset', () => { - it('should return word range for valid word', () => { - const text = "import foo from 'lodash'" - const result = getWordRangeAtOffset(text, text.indexOf('foo')) - - expect(result).toEqual([7, 10]) - }) - - it('should return undefined for non-word character', () => { - const text = "import foo from 'lodash'" - const result = getWordRangeAtOffset(text, 6) - - expect(result).toBeUndefined() - }) - - it('should extend range to full word', () => { - const text = "const lodash = require('lodash')" - const result = getWordRangeAtOffset(text, text.indexOf('lodash') + 1) - - expect(result).toEqual([6, 12]) - }) - - it('should handle word at start of line', () => { - const text = 'lodash import' - const result = getWordRangeAtOffset(text, 0) - - expect(result).toEqual([0, 6]) - }) - - it('should handle word at end of line', () => { - const text = 'import lodash' - const result = getWordRangeAtOffset(text, text.length - 1) - - expect(result).toEqual([7, 13]) - }) - - it('should handle multiline import case', () => { - const text = "import {\n foo,\n} from\n 'lodash'" - const result = getWordRangeAtOffset(text, text.indexOf('lodash') + 1) - - expect(result).toEqual([26, 32]) - }) -}) \ No newline at end of file diff --git a/packages/language-service/src/utils/text.ts b/packages/language-service/src/utils/text.ts deleted file mode 100644 index bcdd684..0000000 --- a/packages/language-service/src/utils/text.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { OffsetRange } from 'npmx-language-core/types' - -const WORD_CHAR = /[\w-]/ - -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] -} diff --git a/playground/index.ts b/playground/index.ts index 0b1afca..889b584 100644 --- a/playground/index.ts +++ b/playground/index.ts @@ -1,4 +1,4 @@ -import fetch from +import ofetch from 'ofetch' import fetch2 from From a62fd1d5fed757fc086ed67e11a1e0c5b075ff8d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:03:21 +0000 Subject: [PATCH 4/9] [autofix.ci] apply automated fixes --- packages/language-service/src/plugins/hover.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/language-service/src/plugins/hover.ts b/packages/language-service/src/plugins/hover.ts index 963ec18..3348b70 100644 --- a/packages/language-service/src/plugins/hover.ts +++ b/packages/language-service/src/plugins/hover.ts @@ -2,10 +2,9 @@ import type { Hover, LanguageServicePlugin, LanguageServicePluginInstance } from import type { DependencyInfo } from 'npmx-language-core/workspace' import type { IWorkspaceState } from '../types' import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from 'npmx-language-core/links' -import { isDependencyFile } from 'npmx-language-core/utils' +import { getImportSpecifierAtOffset, isDependencyFile } from 'npmx-language-core/utils' import { URI } from 'vscode-uri' import { getConfig } from '../config' -import { getImportSpecifierAtOffset } from 'npmx-language-core/utils' import { getResolvedDependencyByOffset } from '../utils/range' export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { From 12187a4109a2be34a269bf1e8d2c8c1ef256e6c8 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Mar 2026 22:17:45 +0800 Subject: [PATCH 5/9] rename --- packages/language-service/src/plugins/hover.ts | 4 ++-- packages/language-service/src/utils/range.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/language-service/src/plugins/hover.ts b/packages/language-service/src/plugins/hover.ts index 3348b70..8a27de2 100644 --- a/packages/language-service/src/plugins/hover.ts +++ b/packages/language-service/src/plugins/hover.ts @@ -5,7 +5,7 @@ import { jsrPackageUrl, npmxDocsUrl, npmxPackageUrl } from 'npmx-language-core/l import { getImportSpecifierAtOffset, isDependencyFile } from 'npmx-language-core/utils' import { URI } from 'vscode-uri' import { getConfig } from '../config' -import { getResolvedDependencyByOffset } from '../utils/range' +import { getResolvedDependencyAtOffset } from '../utils/range' export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { const SPACER = ' ' @@ -77,7 +77,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { const dependencies = await workspaceState.getResolvedDependencies(document.uri) if (!dependencies) return - const dep = getResolvedDependencyByOffset(dependencies, offset) + const dep = getResolvedDependencyAtOffset(dependencies, offset) if (!dep) return diff --git a/packages/language-service/src/utils/range.ts b/packages/language-service/src/utils/range.ts index 70def97..feca297 100644 --- a/packages/language-service/src/utils/range.ts +++ b/packages/language-service/src/utils/range.ts @@ -5,7 +5,7 @@ function isOffsetInRange(offset: number, [start, end]: OffsetRange): boolean { return offset >= start && offset <= end } -export function getResolvedDependencyByOffset( +export function getResolvedDependencyAtOffset( dependencies: DependencyInfo[], offset: number, ): DependencyInfo | undefined { From 5efbc3a013f93161aff1303ad54206666c5df1c9 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Mar 2026 22:46:52 +0800 Subject: [PATCH 6/9] fix: handle CRLF line endings in import parsing --- .../src/utils/source-import.test.ts | 18 +++++++++++++++ .../language-core/src/utils/source-import.ts | 22 ++++++++++++++----- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/language-core/src/utils/source-import.test.ts b/packages/language-core/src/utils/source-import.test.ts index 9c9f8a0..f1f68d0 100644 --- a/packages/language-core/src/utils/source-import.test.ts +++ b/packages/language-core/src/utils/source-import.test.ts @@ -87,6 +87,24 @@ describe('getImportSpecifier', () => { 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, + }) + }) + + 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', () => { diff --git a/packages/language-core/src/utils/source-import.ts b/packages/language-core/src/utils/source-import.ts index 0b54199..e6cf926 100644 --- a/packages/language-core/src/utils/source-import.ts +++ b/packages/language-core/src/utils/source-import.ts @@ -9,10 +9,10 @@ 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 = /^[ \t]*(?:;[^\n]*)?(?:\n|$)/ +const STATEMENT_SUFFIX_PATTERN = /^[ \t]*(?:;[^\r\n]*)?(?:\r?\n|$)/ const CALL_SUFFIX_PATTERN = /^\s*\)/ -const FROM_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)from\s*(?:\n|$)/ -const BARE_IMPORT_PREFIX_PATTERN = /(?:\b|\s+)import\s*(?:\n|$)/ +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*$/ @@ -54,9 +54,14 @@ function findStatementStart(text: string, offset: number): number { if (lineStart === -1) return 0 - const firstChar = text[lineStart + 1] + 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 lineStart + 1 + return lineStartPos pos = lineStart } @@ -73,6 +78,13 @@ function findStatementEnd(text: string, offset: number): number { 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 } From 9af3355584f29870f1088129c47362622229ddf5 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Mar 2026 22:51:57 +0800 Subject: [PATCH 7/9] fix: include @ and / in word char for package name hover --- packages/language-core/src/utils/source-import.test.ts | 9 +++++++++ packages/language-core/src/utils/source-import.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/language-core/src/utils/source-import.test.ts b/packages/language-core/src/utils/source-import.test.ts index f1f68d0..56f6b43 100644 --- a/packages/language-core/src/utils/source-import.test.ts +++ b/packages/language-core/src/utils/source-import.test.ts @@ -31,6 +31,15 @@ describe('getWordRangeAtOffset', () => { 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', () => { diff --git a/packages/language-core/src/utils/source-import.ts b/packages/language-core/src/utils/source-import.ts index e6cf926..60a332b 100644 --- a/packages/language-core/src/utils/source-import.ts +++ b/packages/language-core/src/utils/source-import.ts @@ -5,7 +5,7 @@ export interface ImportSpecifierHit { packageName: string } -const WORD_CHAR = /[\w-]/ +const WORD_CHAR = /[@/\w.-]/ const RELATIVE_IMPORT_PATTERN = /^\.{1,2}(?:\/|$)/ const ABSOLUTE_IMPORT_PATTERN = /^\// const PROTOCOL_IMPORT_PATTERN = /^[a-z][a-z\d+.-]*:/i From d61f6f598b0c5ef7fb82b480b16d80276e59d5f4 Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Mar 2026 22:54:54 +0800 Subject: [PATCH 8/9] test: use actual string positions in negative offset cases --- packages/language-core/src/utils/source-import.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/language-core/src/utils/source-import.test.ts b/packages/language-core/src/utils/source-import.test.ts index 56f6b43..c5fd5ab 100644 --- a/packages/language-core/src/utils/source-import.test.ts +++ b/packages/language-core/src/utils/source-import.test.ts @@ -130,10 +130,13 @@ describe('getImportSpecifierAtOffset', () => { }) it.each([ - [`import foo from './utils'`, './utils'], + [`import foo from './utils'`, 'utils'], [`import foo from 'node:fs'`, 'node:fs'], ['const lodash = someValue', 'lodash'], - [`import fetch from \n 'ofetch'\n\nconst string = 'ofetch'`, 'string'], + [`import fetch from + 'ofetch' + +const string = 'ofetch'`, 'ofetch'], ])('should return undefined for %s', (text, target) => { expect(getImportSpecifierAtOffset(text, text.lastIndexOf(target) + 1)).toBeUndefined() }) From 07856f7d7bf0acd973744d7332356c47aa3ccfff Mon Sep 17 00:00:00 2001 From: Vida Xie Date: Wed, 25 Mar 2026 23:02:45 +0800 Subject: [PATCH 9/9] fix: preserve existing MarkdownString instances in hover middleware --- extensions/vscode/src/client.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/extensions/vscode/src/client.ts b/extensions/vscode/src/client.ts index d532a6c..dc07d5b 100644 --- a/extensions/vscode/src/client.ts +++ b/extensions/vscode/src/client.ts @@ -47,13 +47,12 @@ export function launch(serverPath: string) { return const contents = hover.contents.map((c) => { - if (typeof c === 'string') { + if (c instanceof MarkdownString) + return c + if (typeof c === 'string') return transformMarkdownString(c) - } - - if ('value' in c) { + if ('value' in c) return transformMarkdownString(c.value) - } return c })