diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 9ad8c4f0..0449ed25 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -68,7 +68,7 @@ | `npmx.diagnostics.vulnerability` | Show warnings for packages with known vulnerabilities | `boolean` | `true` | | `npmx.diagnostics.distTag` | Show warnings when a dependency uses a dist tag | `boolean` | `true` | | `npmx.diagnostics.engineMismatch` | Show warnings when dependency engines mismatch with the current package | `boolean` | `true` | -| `npmx.packageLinks` | Enable clickable links for package names | `string` | `"declared"` | +| `npmx.packageLinks` | Enable clickable links for package version specs | `string` | `"declared"` | | `npmx.ignore.upgrade` | Ignore list for upgrade diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` | | `npmx.ignore.deprecation` | Ignore list for deprecation diagnostics ("name" or "name@version"). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` | | `npmx.ignore.replacement` | Ignore list for replacement diagnostics ("name" only). See [Ignore Diagnostics](https://github.com/npmx-dev/vscode-npmx#ignore-diagnostics) | `array` | `[]` | diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index e930f16f..734d1a17 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -110,13 +110,13 @@ "resolved" ], "enumDescriptions": [ - "Disable clickable links", + "Disable clickable package version spec links", "Link to package page (latest version)", "Use raw version from package.json (preserves ^1.0.0, latest, etc.)", "Resolve to actual matching version via API (^18.0.0 → 18.2.0)" ], "default": "declared", - "description": "Enable clickable links for package names" + "description": "Enable clickable links for package version specs" }, "npmx.ignore.upgrade": { "scope": "resource", diff --git a/packages/language-core/src/constants.ts b/packages/language-core/src/constants.ts index 3fa89d43..730b2759 100644 --- a/packages/language-core/src/constants.ts +++ b/packages/language-core/src/constants.ts @@ -1,4 +1,5 @@ export const PACKAGE_JSON_BASENAME = 'package.json' +export const NODE_MODULES_BASENAME = 'node_modules' export const PNPM_WORKSPACE_BASENAME = 'pnpm-workspace.yaml' export const YARN_WORKSPACE_BASENAME = '.yarnrc.yml' diff --git a/packages/language-core/src/workspace.test.ts b/packages/language-core/src/workspace.test.ts index 8b548b70..4b8e3578 100644 --- a/packages/language-core/src/workspace.test.ts +++ b/packages/language-core/src/workspace.test.ts @@ -1,4 +1,4 @@ -import type { WorkspaceAdapter } from './workspace' +import type { PackageManager, WorkspaceAdapter } from './workspace' import { describe, expect, it } from 'vitest' import { WorkspaceContext } from './workspace' @@ -174,4 +174,132 @@ describe('workspaceContext', () => { }, ]) }) + + describe('findInstalledPackageManifestPath', () => { + interface InstallLookupFixtureOptions { + files: string[] + packageManager?: PackageManager + realpaths?: [path: string, realpath: string][] + } + + async function createInstallLookupFixture({ + files, + packageManager = 'npm', + realpaths = [], + }: InstallLookupFixtureOptions) { + const checkedPaths: string[] = [] + const fileSet = new Set(files) + const realpathMap = new Map(realpaths) + const adapter: WorkspaceAdapter = { + async readFile() { + throw new Error('this test should not read package manifests') + }, + async fileExists(path) { + checkedPaths.push(path) + return fileSet.has(path) + }, + async detectPackageManager() { + return packageManager + }, + } + + if (realpaths.length > 0) { + adapter.realpath = async (path) => realpathMap.get(path) ?? path + } + + return { + checkedPaths, + ctx: await WorkspaceContext.create('/repo', adapter), + } + } + + it('finds installed package manifests while walking toward the workspace root', async () => { + const { checkedPaths, ctx } = await createInstallLookupFixture({ + files: [ + '/repo/node_modules/lodash/package.json', + ], + }) + + await expect(ctx.findInstalledPackageManifestPath( + '/repo/packages/app/package.json', + 'lodash', + )).resolves.toBe('/repo/node_modules/lodash/package.json') + expect(checkedPaths).toEqual([ + '/repo/packages/app/node_modules/lodash/package.json', + '/repo/packages/node_modules/lodash/package.json', + '/repo/node_modules/lodash/package.json', + ]) + }) + + it('finds scoped installed package manifests', async () => { + const { checkedPaths, ctx } = await createInstallLookupFixture({ + files: [ + '/repo/packages/app/node_modules/@scope/pkg/package.json', + ], + }) + + await expect(ctx.findInstalledPackageManifestPath( + '/repo/packages/app/package.json', + '@scope/pkg', + )).resolves.toBe('/repo/packages/app/node_modules/@scope/pkg/package.json') + expect(checkedPaths).toEqual([ + '/repo/packages/app/node_modules/@scope/pkg/package.json', + ]) + }) + + it('uses the real path of node_modules packages when resolving transitive dependencies', async () => { + const { checkedPaths, ctx } = await createInstallLookupFixture({ + files: [ + '/repo/node_modules/.pnpm/foo@1.0.0/node_modules/bar/package.json', + ], + packageManager: 'pnpm', + realpaths: [[ + '/repo/node_modules/foo/package.json', + '/repo/node_modules/.pnpm/foo@1.0.0/node_modules/foo/package.json', + ]], + }) + + checkedPaths.length = 0 + await expect(ctx.findInstalledPackageManifestPath( + '/repo/node_modules/foo/package.json', + 'bar', + )).resolves.toBe('/repo/node_modules/.pnpm/foo@1.0.0/node_modules/bar/package.json') + expect(checkedPaths).toEqual([ + '/repo/node_modules/.pnpm/foo@1.0.0/node_modules/foo/node_modules/bar/package.json', + '/repo/node_modules/.pnpm/foo@1.0.0/node_modules/bar/package.json', + ]) + }) + + it('keeps external symlink lookups on workspace-visible paths', async () => { + const { checkedPaths, ctx } = await createInstallLookupFixture({ + files: [ + '/repo/node_modules/foo/node_modules/bar/package.json', + ], + realpaths: [[ + '/repo/node_modules/foo/package.json', + '/linked/foo/package.json', + ]], + }) + + await expect(ctx.findInstalledPackageManifestPath( + '/repo/node_modules/foo/package.json', + 'bar', + )).resolves.toBe('/repo/node_modules/foo/node_modules/bar/package.json') + expect(checkedPaths).toEqual([ + '/repo/node_modules/foo/node_modules/bar/package.json', + ]) + }) + + it('ignores dependency names that cannot be package names', async () => { + const { checkedPaths, ctx } = await createInstallLookupFixture({ files: [] }) + + for (const packageName of ['../outside', '@/pkg']) { + await expect(ctx.findInstalledPackageManifestPath( + '/repo/package.json', + packageName, + )).resolves.toBeUndefined() + } + expect(checkedPaths).toEqual([]) + }) + }) }) diff --git a/packages/language-core/src/workspace.ts b/packages/language-core/src/workspace.ts index 3379fca9..b0354434 100644 --- a/packages/language-core/src/workspace.ts +++ b/packages/language-core/src/workspace.ts @@ -8,9 +8,9 @@ import type { WorkspaceCatalogInfo, } from './types' import { defineCachedFunction } from 'ocache' -import { dirname, join } from 'path-browserify' +import { basename, dirname, join } from 'path-browserify' import { getPackageInfo } from './api/package' -import { PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from './constants' +import { NODE_MODULES_BASENAME, PACKAGE_JSON_BASENAME, PNPM_WORKSPACE_BASENAME, YARN_WORKSPACE_BASENAME } from './constants' import { getExtractor } from './extractors' import { isPackageManifest, lazyInit, resolveDependencySpec, resolveExactVersion } from './utils' @@ -28,6 +28,7 @@ export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn' export interface WorkspaceAdapter { readFile: (path: string) => Promise fileExists: (path: string) => Promise + realpath?: (path: string) => Promise detectPackageManager: (rootPath: string) => Promise } @@ -42,6 +43,28 @@ function getWorkspaceFileBasename(packageManager: PackageManager): string | unde } } +function isPathInsideOrEqual(path: string, parent: string): boolean { + return path === parent || path.startsWith(`${parent}/`) +} + +function isPackageNamePathSafe(name: string): boolean { + if (name === '' || name.includes('\\')) + return false + + const parts = name.split('/') + if (parts.some((part) => part === '' || part === '.' || part === '..')) + return false + + if (!name.startsWith('@')) + return parts.length === 1 + + const [scope, packageName] = parts + return parts.length === 2 + && scope !== undefined + && packageName !== undefined + && scope.length > 1 +} + function createResolvedDependencyInfo( dependency: ExtractedDependencyInfo, catalogs?: CatalogsInfo, @@ -177,7 +200,7 @@ export class WorkspaceContext { async findNearestPackageManifestPath(packageManifestPath: string): Promise { let dir = dirname(packageManifestPath) - while (dir === this.rootPath || dir.startsWith(`${this.rootPath}/`)) { + while (isPathInsideOrEqual(dir, this.rootPath)) { const manifestPath = join(dir, PACKAGE_JSON_BASENAME) if (await this.adapter.fileExists(manifestPath)) return manifestPath @@ -192,6 +215,40 @@ export class WorkspaceContext { } } + async findInstalledPackageManifestPath( + packageManifestPath: string, + packageName: string, + ): Promise { + if (!isPackageNamePathSafe(packageName)) + return + + let searchPath = packageManifestPath + if (this.adapter.realpath) { + const realPath = await this.adapter.realpath(packageManifestPath).catch(() => undefined) + // Keep external symlinks on the workspace-visible path so subsequent lookups still have a workspace context. + if (realPath && isPathInsideOrEqual(realPath, this.rootPath)) + searchPath = realPath + } + + let dir = dirname(searchPath) + + while (isPathInsideOrEqual(dir, this.rootPath)) { + if (basename(dir) !== NODE_MODULES_BASENAME) { + const manifestPath = join(dir, NODE_MODULES_BASENAME, packageName, 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 d871d127..204209bb 100644 --- a/packages/language-server/src/workspace.ts +++ b/packages/language-server/src/workspace.ts @@ -1,7 +1,7 @@ import type { Connection, LanguageServer } from '@volar/language-server' import type { DependencyInfo, PackageManager, WorkspaceAdapter } from 'npmx-language-core/workspace' import type { ClientFeatures, IWorkspaceState } from 'npmx-language-service/types' -import { access, readFile } from 'node:fs/promises' +import { access, realpath as fsRealpath, readFile } from 'node:fs/promises' import { DEPENDENCY_FILE_GLOB, PACKAGE_JSON_BASENAME } from 'npmx-language-core/constants' import { isDependencyFile, isPackageManifest } from 'npmx-language-core/utils' import { WorkspaceContext } from 'npmx-language-core/workspace' @@ -51,6 +51,10 @@ function createLanguageServerAdapter(folderUri: URI, server: LanguageServer): Wo } }, + async realpath(path: string): Promise { + return URI.file(await fsRealpath(folderUri.with({ path }).fsPath)).path + }, + detectPackageManager: detectPackageManagerFromProject, } } diff --git a/packages/language-service/src/index.ts b/packages/language-service/src/index.ts index a95dd9ca..30f585ba 100644 --- a/packages/language-service/src/index.ts +++ b/packages/language-service/src/index.ts @@ -4,6 +4,7 @@ import { create as createNpmxCatalogService } from './plugins/catalog' import { create as createNpmxDiagnosticsService } from './plugins/diagnostics' import { create as createNpmxDocumentLinkService } from './plugins/document-link' import { create as createNpmxHoverService } from './plugins/hover' +import { create as createNpmxInstalledPackageDefinitionService } from './plugins/installed-package-definition' import { create as createNpmxVersionCompletionService } from './plugins/version-completion' export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): LanguageServicePlugin[] { @@ -12,6 +13,7 @@ export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): La createNpmxDiagnosticsService(workspace), createNpmxDocumentLinkService(workspace), createNpmxHoverService(workspace), + createNpmxInstalledPackageDefinitionService(workspace), createNpmxVersionCompletionService(workspace), ] } diff --git a/packages/language-service/src/plugins/catalog.test.ts b/packages/language-service/src/plugins/catalog.test.ts new file mode 100644 index 00000000..7247efb1 --- /dev/null +++ b/packages/language-service/src/plugins/catalog.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { createDependencyInfo } from '../test-utils/dependency' +import { getCatalogDependencyAtOffset } from './catalog' + +describe('getCatalogDependencyAtOffset', () => { + const dependency = createDependencyInfo({ + rawSpec: 'catalog:', + nameRange: [10, 16], + specRange: [20, 28], + protocol: 'catalog', + categoryName: 'default', + }) + + it('matches catalog specs separately from package names', () => { + expect(getCatalogDependencyAtOffset([dependency], 10)).toBeUndefined() + expect(getCatalogDependencyAtOffset([dependency], 20)).toBe(dependency) + expect(getCatalogDependencyAtOffset([dependency], 28)).toBe(dependency) + }) + + it('ignores non-catalog specs', () => { + const npmDependency = createDependencyInfo({ + rawSpec: '^1.0.0', + protocol: 'npm', + specRange: [20, 28], + }) + + expect(getCatalogDependencyAtOffset([npmDependency], 20)).toBeUndefined() + }) +}) diff --git a/packages/language-service/src/plugins/catalog.ts b/packages/language-service/src/plugins/catalog.ts index ea519080..e0ec1289 100644 --- a/packages/language-service/src/plugins/catalog.ts +++ b/packages/language-service/src/plugins/catalog.ts @@ -3,7 +3,18 @@ import type { DependencyInfo } from 'npmx-language-core/workspace' import type { IWorkspaceState } from '../types' import { isPackageManifest, normalizeCatalogName } from 'npmx-language-core/utils' import { URI } from 'vscode-uri' -import { getDocumentByUri, getResolvedDependencyAtOffset } from '../utils/document' +import { getDocumentByUri, getResolvedDependencySpecAtOffset } from '../utils/document' + +export function getCatalogDependencyAtOffset( + dependencies: DependencyInfo[], + offset: number, +): DependencyInfo | undefined { + const dependency = getResolvedDependencySpecAtOffset(dependencies, offset) + if (!dependency?.rawSpec.startsWith('catalog:')) + return + + return dependency +} export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { function getDependencyFileUri(documentUri: string): URI | undefined { @@ -19,11 +30,7 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { if (!dependencies) return - const dependency = getResolvedDependencyAtOffset(dependencies, offset) - if (!dependency?.rawSpec.startsWith('catalog:')) - return - - return dependency + return getCatalogDependencyAtOffset(dependencies, offset) } function matchesCatalogDependency(candidate: DependencyInfo, dependency: DependencyInfo): boolean { diff --git a/packages/language-service/src/plugins/document-link.test.ts b/packages/language-service/src/plugins/document-link.test.ts new file mode 100644 index 00000000..7434795f --- /dev/null +++ b/packages/language-service/src/plugins/document-link.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { createDependencyInfo } from '../test-utils/dependency' +import { providePackageDocumentLinks } from './document-link' + +describe('providePackageDocumentLinks', () => { + it('links package version specs to npmx', async () => { + const sourceText = '{"dependencies":{"lodash":"^1.0.0"}}' + const document = TextDocument.create( + 'file:///repo/package.json', + 'json', + 0, + sourceText, + ) + const nameStart = sourceText.indexOf('lodash') + const specStart = sourceText.indexOf('^1.0.0') + const specEnd = specStart + '^1.0.0'.length + + await expect(providePackageDocumentLinks( + document, + [createDependencyInfo({ + nameRange: [nameStart, nameStart + 'lodash'.length], + specRange: [specStart, specEnd], + })], + 'declared', + )).resolves.toEqual([{ + range: { + start: document.positionAt(specStart), + end: document.positionAt(specEnd), + }, + target: 'https://npmx.dev/package/lodash/v/^1.0.0', + tooltip: 'Open lodash@^1.0.0 on npmx', + }]) + }) + + it('does not link catalog specs', async () => { + const sourceText = '{"dependencies":{"lodash":"catalog:"}}' + const document = TextDocument.create( + 'file:///repo/package.json', + 'json', + 0, + sourceText, + ) + const nameStart = sourceText.indexOf('lodash') + const specStart = sourceText.indexOf('catalog:') + + await expect(providePackageDocumentLinks( + document, + [createDependencyInfo({ + rawSpec: 'catalog:', + nameRange: [nameStart, nameStart + 'lodash'.length], + specRange: [specStart, specStart + 'catalog:'.length], + protocol: 'catalog', + resolvedSpec: '^1.0.0', + resolvedProtocol: 'npm', + })], + 'declared', + )).resolves.toEqual([]) + }) +}) diff --git a/packages/language-service/src/plugins/document-link.ts b/packages/language-service/src/plugins/document-link.ts index ac26e43b..adce85de 100644 --- a/packages/language-service/src/plugins/document-link.ts +++ b/packages/language-service/src/plugins/document-link.ts @@ -1,10 +1,55 @@ import type { DocumentLink, LanguageServicePlugin, LanguageServicePluginInstance } from '@volar/language-service' +import type { DependencyInfo } from 'npmx-language-core/workspace' +import type { ConfigKeyTypeMap } from 'npmx-shared/meta' +import type { TextDocument } from 'vscode-languageserver-textdocument' import type { IWorkspaceState } from '../types' import { npmxPackageUrl } from 'npmx-language-core/links' import { isDependencyFile } from 'npmx-language-core/utils' import { URI } from 'vscode-uri' import { getConfig } from '../config' +type PackageLinkMode = ConfigKeyTypeMap['npmx.packageLinks'] +type EnabledPackageLinkMode = Exclude + +export async function providePackageDocumentLinks( + document: TextDocument, + dependencies: DependencyInfo[], + linkMode: EnabledPackageLinkMode, +): Promise { + const links: DocumentLink[] = [] + + for (const dep of dependencies) { + if (dep.resolvedProtocol !== 'npm' || dep.protocol === 'catalog') + continue + + const { resolvedName, resolvedSpec, specRange } = dep + + let targetVersion: string | undefined + + if (linkMode === 'declared') { + targetVersion = resolvedSpec + } else if (linkMode === 'resolved') { + targetVersion = await dep.resolvedVersion() ?? resolvedSpec + } + + const url = targetVersion + ? npmxPackageUrl(resolvedName, targetVersion) + : npmxPackageUrl(resolvedName) + + const [start, end] = specRange + links.push({ + range: { + start: document.positionAt(start), + end: document.positionAt(end), + }, + target: url, + tooltip: `Open ${resolvedName}@${targetVersion ?? 'latest'} on npmx`, + }) + } + + return links +} + export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { return { name: 'npmx-document-link', @@ -14,50 +59,19 @@ export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { create(context): LanguageServicePluginInstance { return { async provideDocumentLinks(document): Promise { - const linkMode = await getConfig(context, 'npmx.packageLinks') - if (linkMode === 'off') - return - const uri = URI.parse(document.uri) if (uri.scheme !== 'file' || !isDependencyFile(uri.path)) return + const linkMode = await getConfig(context, 'npmx.packageLinks') + if (linkMode === 'off') + return + const dependencies = await workspaceState.getResolvedDependencies(document.uri) if (!dependencies) return - const links: DocumentLink[] = [] - - for (const dep of dependencies) { - if (dep.resolvedProtocol !== 'npm') - continue - - const { resolvedName, resolvedSpec, nameRange } = dep - - let targetVersion: string | undefined - - if (linkMode === 'declared') { - targetVersion = resolvedSpec - } else if (linkMode === 'resolved') { - targetVersion = await dep.resolvedVersion() ?? resolvedSpec - } - - const url = targetVersion - ? npmxPackageUrl(resolvedName, targetVersion) - : npmxPackageUrl(resolvedName) - - const [start, end] = nameRange - links.push({ - range: { - start: document.positionAt(start), - end: document.positionAt(end), - }, - target: url, - tooltip: `Open ${resolvedName}@${targetVersion ?? 'latest'} on npmx`, - }) - } - - return links + return providePackageDocumentLinks(document, dependencies, linkMode) }, } }, diff --git a/packages/language-service/src/plugins/installed-package-definition.test.ts b/packages/language-service/src/plugins/installed-package-definition.test.ts new file mode 100644 index 00000000..e8e33b5c --- /dev/null +++ b/packages/language-service/src/plugins/installed-package-definition.test.ts @@ -0,0 +1,114 @@ +import type { DependencyInfo, WorkspaceAdapter } from 'npmx-language-core/workspace' +import type { IWorkspaceState } from '../types' +import { WorkspaceContext } from 'npmx-language-core/workspace' +import { describe, expect, it } from 'vitest' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { createDependencyInfo } from '../test-utils/dependency' +import { DEFAULT_CLIENT_FEATURES } from '../types' +import { provideInstalledPackageDefinition } from './installed-package-definition' + +async function createWorkspaceState( + files: string[], + dependencies: DependencyInfo[], +): Promise { + const fileSet = new Set(files) + const adapter: WorkspaceAdapter = { + async readFile() { + throw new Error('this test should not read files through the adapter') + }, + async fileExists(path) { + return fileSet.has(path) + }, + async detectPackageManager() { + return 'npm' + }, + } + const workspaceContext = await WorkspaceContext.create('/repo', adapter) + + return { + getClientFeatures: () => DEFAULT_CLIENT_FEATURES, + async getWorkspaceContext() { + return workspaceContext + }, + async getResolvedDependencies() { + return dependencies + }, + async getResolvedDependenciesForContainingPackage() { + return undefined + }, + } +} + +describe('provideInstalledPackageDefinition', () => { + it('links dependency names to installed package manifests', async () => { + const sourceText = `{ + "dependencies": { + "lodash": "^1.0.0" + } +} +` + const sourceDocument = TextDocument.create( + 'file:///repo/package.json', + 'json', + 0, + sourceText, + ) + const nameStart = sourceText.indexOf('lodash') + const nameEnd = nameStart + 'lodash'.length + const specStart = sourceText.indexOf('^1.0.0') + const specEnd = specStart + '^1.0.0'.length + const workspaceState = await createWorkspaceState( + ['/repo/node_modules/lodash/package.json'], + [createDependencyInfo({ + nameRange: [nameStart, nameEnd], + specRange: [specStart, specEnd], + })], + ) + + await expect(provideInstalledPackageDefinition( + sourceDocument, + sourceDocument.positionAt(nameStart), + workspaceState, + )).resolves.toEqual([{ + targetUri: 'file:///repo/node_modules/lodash/package.json', + targetRange: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + targetSelectionRange: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + originSelectionRange: { + start: sourceDocument.positionAt(nameStart), + end: sourceDocument.positionAt(nameEnd), + }, + }]) + }) + + it('does not handle dependency specs', async () => { + const sourceText = '{"dependencies":{"lodash":"^1.0.0"}}' + const sourceDocument = TextDocument.create( + 'file:///repo/package.json', + 'json', + 0, + sourceText, + ) + const nameStart = sourceText.indexOf('lodash') + const nameEnd = nameStart + 'lodash'.length + const specStart = sourceText.indexOf('^1.0.0') + const workspaceState = await createWorkspaceState( + ['/repo/node_modules/lodash/package.json'], + [createDependencyInfo({ + nameRange: [nameStart, nameEnd], + specRange: [specStart, specStart + '^1.0.0'.length], + })], + ) + + await expect(provideInstalledPackageDefinition( + sourceDocument, + sourceDocument.positionAt(specStart), + workspaceState, + )).resolves.toBeUndefined() + }) +}) diff --git a/packages/language-service/src/plugins/installed-package-definition.ts b/packages/language-service/src/plugins/installed-package-definition.ts new file mode 100644 index 00000000..64b81a9a --- /dev/null +++ b/packages/language-service/src/plugins/installed-package-definition.ts @@ -0,0 +1,78 @@ +import type { LanguageServicePlugin, LanguageServicePluginInstance, LocationLink, Position } from '@volar/language-service' +import type { TextDocument } from 'vscode-languageserver-textdocument' +import type { IWorkspaceState } from '../types' +import { isPackageManifest } from 'npmx-language-core/utils' +import { URI } from 'vscode-uri' +import { getResolvedDependencyNameAtOffset } from '../utils/document' + +const ZERO_POSITION = { line: 0, character: 0 } + +export async function provideInstalledPackageDefinition( + document: TextDocument, + position: Position, + workspaceState: IWorkspaceState, +): Promise { + const packageManifestUri = URI.parse(document.uri) + if (packageManifestUri.scheme !== 'file' || !isPackageManifest(packageManifestUri.path)) + return + + const dependencies = await workspaceState.getResolvedDependencies(document.uri) + if (!dependencies) + return + + const offset = document.offsetAt(position) + const dependency = getResolvedDependencyNameAtOffset(dependencies, offset) + if (!dependency) + return + + const workspaceContext = await workspaceState.getWorkspaceContext(document.uri) + if (!workspaceContext) + return + + const targetPath = await workspaceContext.findInstalledPackageManifestPath( + packageManifestUri.path, + dependency.rawName, + ) + if (!targetPath) + return + + const targetUri = packageManifestUri.with({ path: targetPath }) + const originStart = document.positionAt(dependency.nameRange[0]) + const originEnd = document.positionAt(dependency.nameRange[1]) + + return [{ + targetUri: targetUri.toString(), + targetRange: { + start: ZERO_POSITION, + end: ZERO_POSITION, + }, + targetSelectionRange: { + start: ZERO_POSITION, + end: ZERO_POSITION, + }, + originSelectionRange: { + start: originStart, + end: originEnd, + }, + }] +} + +export function create(workspaceState: IWorkspaceState): LanguageServicePlugin { + return { + name: 'npmx-installed-package-definition', + capabilities: { + definitionProvider: true, + }, + create(): LanguageServicePluginInstance { + return { + async provideDefinition(document, position): Promise { + return provideInstalledPackageDefinition( + document, + position, + workspaceState, + ) + }, + } + }, + } +} diff --git a/packages/language-service/src/test-utils/dependency.ts b/packages/language-service/src/test-utils/dependency.ts new file mode 100644 index 00000000..d8e8ee81 --- /dev/null +++ b/packages/language-service/src/test-utils/dependency.ts @@ -0,0 +1,18 @@ +import type { DependencyInfo } from 'npmx-language-core/workspace' + +export function createDependencyInfo(overrides: Partial = {}): DependencyInfo { + return { + category: 'dependencies', + rawName: 'lodash', + rawSpec: '^1.0.0', + nameRange: [0, 0], + specRange: [0, 0], + protocol: 'npm', + resolvedName: 'lodash', + resolvedSpec: '^1.0.0', + resolvedProtocol: 'npm', + packageInfo: async () => null, + resolvedVersion: async () => null, + ...overrides, + } +} diff --git a/packages/language-service/src/utils/document.test.ts b/packages/language-service/src/utils/document.test.ts new file mode 100644 index 00000000..6869ea47 --- /dev/null +++ b/packages/language-service/src/utils/document.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { createDependencyInfo } from '../test-utils/dependency' +import { + getResolvedDependencyAtOffset, + getResolvedDependencyNameAtOffset, + getResolvedDependencySpecAtOffset, +} from './document' + +describe('dependency offset helpers', () => { + const dependency = createDependencyInfo({ + nameRange: [10, 16], + specRange: [20, 26], + }) + const dependencies = [dependency] + + it('matches dependency names separately from specs', () => { + expect(getResolvedDependencyNameAtOffset(dependencies, 10)).toBe(dependency) + expect(getResolvedDependencyNameAtOffset(dependencies, 16)).toBe(dependency) + expect(getResolvedDependencyNameAtOffset(dependencies, 20)).toBeUndefined() + }) + + it('matches dependency specs separately from names', () => { + expect(getResolvedDependencySpecAtOffset(dependencies, 20)).toBe(dependency) + expect(getResolvedDependencySpecAtOffset(dependencies, 26)).toBe(dependency) + expect(getResolvedDependencySpecAtOffset(dependencies, 10)).toBeUndefined() + }) + + it('keeps the combined dependency lookup behavior', () => { + expect(getResolvedDependencyAtOffset(dependencies, 10)).toBe(dependency) + expect(getResolvedDependencyAtOffset(dependencies, 20)).toBe(dependency) + expect(getResolvedDependencyAtOffset(dependencies, 30)).toBeUndefined() + }) +}) diff --git a/packages/language-service/src/utils/document.ts b/packages/language-service/src/utils/document.ts index 0a435ecb..0090e02d 100644 --- a/packages/language-service/src/utils/document.ts +++ b/packages/language-service/src/utils/document.ts @@ -20,11 +20,24 @@ function isOffsetInRange(offset: number, [start, end]: OffsetRange): boolean { return offset >= start && offset <= end } +export function getResolvedDependencyNameAtOffset( + dependencies: DependencyInfo[], + offset: number, +): DependencyInfo | undefined { + return dependencies.find((dep) => isOffsetInRange(offset, dep.nameRange)) +} + +export function getResolvedDependencySpecAtOffset( + dependencies: DependencyInfo[], + offset: number, +): DependencyInfo | undefined { + return dependencies.find((dep) => isOffsetInRange(offset, dep.specRange)) +} + export function getResolvedDependencyAtOffset( dependencies: DependencyInfo[], offset: number, ): DependencyInfo | undefined { - return dependencies.find((dep) => - isOffsetInRange(offset, dep.nameRange) || isOffsetInRange(offset, dep.specRange), - ) + return getResolvedDependencyNameAtOffset(dependencies, offset) + ?? getResolvedDependencySpecAtOffset(dependencies, offset) }