Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion extensions/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `[]` |
Expand Down
4 changes: 2 additions & 2 deletions extensions/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/language-core/src/constants.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
130 changes: 129 additions & 1 deletion packages/language-core/src/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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([])
})
})
})
63 changes: 60 additions & 3 deletions packages/language-core/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -28,6 +28,7 @@ export type PackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn'
export interface WorkspaceAdapter {
readFile: (path: string) => Promise<string>
fileExists: (path: string) => Promise<boolean>
realpath?: (path: string) => Promise<string>
detectPackageManager: (rootPath: string) => Promise<PackageManager>
}

Expand All @@ -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,
Expand Down Expand Up @@ -177,7 +200,7 @@ export class WorkspaceContext {
async findNearestPackageManifestPath(packageManifestPath: string): Promise<string | undefined> {
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
Expand All @@ -192,6 +215,40 @@ export class WorkspaceContext {
}
}

async findInstalledPackageManifestPath(
packageManifestPath: string,
packageName: string,
): Promise<string | undefined> {
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)
Expand Down
6 changes: 5 additions & 1 deletion packages/language-server/src/workspace.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -51,6 +51,10 @@ function createLanguageServerAdapter(folderUri: URI, server: LanguageServer): Wo
}
},

async realpath(path: string): Promise<string> {
return URI.file(await fsRealpath(folderUri.with({ path }).fsPath)).path
},

detectPackageManager: detectPackageManagerFromProject,
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/language-service/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] {
Expand All @@ -12,6 +13,7 @@ export function createNpmxLanguageServicePlugins(workspace: IWorkspaceState): La
createNpmxDiagnosticsService(workspace),
createNpmxDocumentLinkService(workspace),
createNpmxHoverService(workspace),
createNpmxInstalledPackageDefinitionService(workspace),
createNpmxVersionCompletionService(workspace),
]
}
29 changes: 29 additions & 0 deletions packages/language-service/src/plugins/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
19 changes: 13 additions & 6 deletions packages/language-service/src/plugins/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading