Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

- **Hover Information** – Quick links to package details and documentation on [npmx.dev](https://npmx.dev), with provenance verification status.
- **Version Completion** – Autocomplete package versions with provenance filtering and prerelease exclusion support.
- **Workspace-Aware Resolution** – Dependencies in `package.json`, `pnpm-workspace.yaml`, and `.yarnrc.yml` are resolved from a shared workspace context, including catalogs and workspace references.
- **Workspace-Aware Resolution** – Dependencies in `package.json`, `pnpm-workspace.yaml`, and `.yarnrc.yml` are resolved from a shared workspace context, including npm, pnpm, yarn, and bun package managers plus root `package.json` catalogs and workspace references.
- **Diagnostics**
- Deprecated package warnings with deprecation messages
- Package replacement suggestions (via [module-replacements](https://github.com/es-tooling/module-replacements))
Expand Down
2 changes: 1 addition & 1 deletion extensions/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

- **Hover Information** – Quick links to package details and documentation on [npmx.dev](https://npmx.dev), with provenance verification status.
- **Version Completion** – Autocomplete package versions with provenance filtering and prerelease exclusion support.
- **Workspace-Aware Resolution** – Dependencies in `package.json`, `pnpm-workspace.yaml`, and `.yarnrc.yml` are resolved from a shared workspace context, including catalogs and workspace references.
- **Workspace-Aware Resolution** – Dependencies in `package.json`, `pnpm-workspace.yaml`, and `.yarnrc.yml` are resolved from a shared workspace context, including npm, pnpm, yarn, and bun package managers plus root `package.json` catalogs and workspace references.
- **Diagnostics**
- Deprecated package warnings with deprecation messages
- Package replacement suggestions (via [module-replacements](https://github.com/es-tooling/module-replacements))
Expand Down
70 changes: 70 additions & 0 deletions packages/language-core/src/extractors/json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import { JsonExtractor } from './json'

describe('jsonExtractor', () => {
const extractor = new JsonExtractor()

it('extracts bun workspace catalogs from package.json', () => {
const info = extractor.getWorkspaceCatalogInfo(`{
"workspaces": ["packages/*"],
"catalog": {
"lodash": "^4.17.21"
},
"catalogs": {
"prod": {
"@deno/doc": "jsr:^0.189.1"
}
}
}`)

expect(info?.catalogs).toEqual({
default: {
lodash: '^4.17.21',
},
prod: {
'@deno/doc': 'jsr:^0.189.1',
},
})
expect(info?.dependencies.map(({ rawName, rawSpec, categoryName }) => ({
rawName,
rawSpec,
categoryName,
}))).toEqual([
{
rawName: 'lodash',
rawSpec: '^4.17.21',
categoryName: '',
},
{
rawName: '@deno/doc',
rawSpec: 'jsr:^0.189.1',
categoryName: 'prod',
},
])
})

it('extracts catalogs nested inside the workspaces object', () => {
const info = extractor.getWorkspaceCatalogInfo(`{
"workspaces": {
"packages": ["packages/*"],
"catalog": {
"react": "^19.0.0"
},
"catalogs": {
"test": {
"vitest": "^4.0.0"
}
}
}
}`)

expect(info?.catalogs).toEqual({
default: {
react: '^19.0.0',
},
test: {
vitest: '^4.0.0',
},
})
})
})
88 changes: 86 additions & 2 deletions packages/language-core/src/extractors/json.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import type { Node as JsonNode } from 'jsonc-parser'
import type { BaseExtractor, DependencyCategory, Engines, ExtractedDependencyInfo, OffsetRange, PackageManifestExtractor, PackageManifestInfo } from '../types'
import type {
BaseExtractor,
DependencyCategory,
Engines,
ExtractedDependencyInfo,
OffsetRange,
PackageManifestExtractor,
PackageManifestInfo,
WorkspaceCatalogExtractor,
WorkspaceCatalogInfo,
} from '../types'
import { findNodeAtLocation, parseTree } from 'jsonc-parser'
import { normalizeCatalogName } from '../utils'

const DEPENDENCY_SECTIONS: DependencyCategory[] = [
'dependencies',
Expand All @@ -9,7 +20,22 @@ const DEPENDENCY_SECTIONS: DependencyCategory[] = [
'optionalDependencies',
]

export class JsonExtractor implements PackageManifestExtractor, BaseExtractor<JsonNode> {
interface CatalogMeta {
category: 'catalog' | 'catalogs'
categoryName?: string
}

const CATALOG_NODE_PATHS: {
path: string[]
meta: CatalogMeta
}[] = [
{ path: ['catalog'], meta: { category: 'catalog', categoryName: '' } },
{ path: ['catalogs'], meta: { category: 'catalogs' } },
{ path: ['workspaces', 'catalog'], meta: { category: 'catalog', categoryName: '' } },
{ path: ['workspaces', 'catalogs'], meta: { category: 'catalogs' } },
]

export class JsonExtractor implements PackageManifestExtractor, WorkspaceCatalogExtractor, BaseExtractor<JsonNode> {
parse = (text: string) => parseTree(text) ?? null

#getStringValue(root: JsonNode, key: string): string | undefined {
Expand Down Expand Up @@ -43,6 +69,40 @@ export class JsonExtractor implements PackageManifestExtractor, BaseExtractor<Js
}
}

#parseCatalogEntries(node: JsonNode, meta: CatalogMeta): ExtractedDependencyInfo[] {
if (node.type !== 'object' || !node.children)
return []

if (meta.category === 'catalog') {
return node.children
.map((entry) => this.#parseDependencyNode(entry, meta.category))
.flatMap((dependency) => dependency
? [{ ...dependency, categoryName: meta.categoryName }]
: [])
}

const result: ExtractedDependencyInfo[] = []

for (const catalogNode of node.children) {
const [nameNode, valueNode] = catalogNode.children ?? []
if (typeof nameNode?.value !== 'string' || valueNode?.type !== 'object' || !valueNode.children)
continue

for (const entry of valueNode.children) {
const dependency = this.#parseDependencyNode(entry, meta.category)
if (!dependency)
continue

result.push({
...dependency,
categoryName: nameNode.value,
})
}
}

return result
}

#getEngines(root: JsonNode): Engines | undefined {
const enginesNode = findNodeAtLocation(root, ['engines'])
if (enginesNode?.type !== 'object' || !enginesNode.children?.length)
Expand Down Expand Up @@ -81,6 +141,30 @@ export class JsonExtractor implements PackageManifestExtractor, BaseExtractor<Js
return result
}

getWorkspaceCatalogInfo(text: string): WorkspaceCatalogInfo | undefined {
const root = this.parse(text)
if (!root)
return

const dependencies = CATALOG_NODE_PATHS.flatMap(({ path, meta }) => {
const node = findNodeAtLocation(root, path)
return node ? this.#parseCatalogEntries(node, meta) : []
})

const catalogs: Record<string, Record<string, string>> = {}

for (const dependency of dependencies) {
const categoryName = normalizeCatalogName(dependency.categoryName ?? '')
catalogs[categoryName] ??= {}
catalogs[categoryName][dependency.rawName] = dependency.rawSpec
}

return {
dependencies,
catalogs: Object.keys(catalogs).length > 0 ? catalogs : undefined,
}
}

getPackageManifestInfo(text: string): PackageManifestInfo | undefined {
const root = this.parse(text)
if (!root)
Expand Down
177 changes: 177 additions & 0 deletions packages/language-core/src/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { WorkspaceAdapter } from './workspace'
import { describe, expect, it } from 'vitest'
import { WorkspaceContext } from './workspace'

describe('workspaceContext', () => {
it('loads bun workspace catalogs from the root package.json', async () => {
const readPaths: string[] = []

const adapter: WorkspaceAdapter = {
async readFile(path) {
readPaths.push(path)
return `{
"workspaces": ["packages/*"],
"catalog": {
"lodash": "^4.17.21"
}
}`
},
async fileExists(path) {
return path === '/repo/package.json'
},
async detectPackageManager() {
return 'bun'
},
}

const ctx = await WorkspaceContext.create('/repo', adapter)

expect(ctx.packageManager).toBe('bun')
expect(ctx.workspaceFilePath).toBe('/repo/package.json')
expect(await ctx.getCatalogs()).toEqual({
default: {
lodash: '^4.17.21',
},
})
expect(readPaths).toEqual(['/repo/package.json'])
})

it('still loads workspace catalogs for pnpm workspaces', async () => {
const checkedPaths: string[] = []

const adapter: WorkspaceAdapter = {
async readFile() {
throw new Error('this test should not read a missing workspace file')
},
async fileExists(path) {
checkedPaths.push(path)
return false
},
async detectPackageManager() {
return 'pnpm'
},
}

const ctx = await WorkspaceContext.create('/repo', adapter)

expect(ctx.packageManager).toBe('pnpm')
expect(ctx.workspaceFilePath).toBe('/repo/pnpm-workspace.yaml')
expect(await ctx.getCatalogs()).toBeUndefined()
expect(checkedPaths).toEqual(['/repo/pnpm-workspace.yaml'])
})

it('ignores nested workspace files once the root workspace file path is known', async () => {
const readPaths: string[] = []
const files = new Map<string, string>([
['/repo/pnpm-workspace.yaml', `catalog:
lodash: ^4.17.21
`],
['/repo/packages/app/pnpm-workspace.yaml', `catalog:
semver: ^7.7.2
`],
])

const adapter: WorkspaceAdapter = {
async readFile(path) {
readPaths.push(path)
const content = files.get(path)
if (!content)
throw new Error(`Unexpected read: ${path}`)
return content
},
async fileExists(path) {
return files.has(path)
},
async detectPackageManager() {
return 'pnpm'
},
}

const ctx = await WorkspaceContext.create('/repo', adapter)
const info = await ctx.loadWorkspaceFileInfo('/repo/packages/app/pnpm-workspace.yaml')

expect(ctx.workspaceFilePath).toBe('/repo/pnpm-workspace.yaml')
expect(info).toBeUndefined()
expect(readPaths).toEqual(['/repo/pnpm-workspace.yaml'])
})

it('preserves the leading slash for windows-style uri paths', async () => {
const checkedPaths: string[] = []

const adapter: WorkspaceAdapter = {
async readFile() {
throw new Error('this test should not read a missing workspace file')
},
async fileExists(path) {
checkedPaths.push(path)
return false
},
async detectPackageManager() {
return 'bun'
},
}

const ctx = await WorkspaceContext.create('/d:/repo', adapter)

expect(ctx.workspaceFilePath).toBe('/d:/repo/package.json')
expect(checkedPaths).toEqual(['/d:/repo/package.json'])
})

it('resolves bun catalog dependencies for workspace packages', async () => {
const files = new Map<string, string>([
['/repo/package.json', `{
"workspaces": ["packages/*"],
"catalog": {
"lodash": "^4.17.21"
},
"catalogs": {
"prod": {
"@deno/doc": "jsr:^0.189.1"
}
}
}`],
['/repo/packages/app/package.json', `{
"name": "@playground/bun-app",
"dependencies": {
"lodash": "catalog:",
"@deno/doc": "catalog:prod"
}
}`],
])

const adapter: WorkspaceAdapter = {
async readFile(path) {
const content = files.get(path)
if (!content)
throw new Error(`Unexpected read: ${path}`)
return content
},
async fileExists(path) {
return files.has(path)
},
async detectPackageManager() {
return 'bun'
},
}

const ctx = await WorkspaceContext.create('/repo', adapter)
const info = await ctx.loadPackageManifestInfo('/repo/packages/app/package.json')

expect(info?.dependencies.map(({ rawName, resolvedSpec, resolvedProtocol }) => ({
rawName,
resolvedSpec,
resolvedProtocol,
}))).toEqual([
{
rawName: 'lodash',
resolvedSpec: '^4.17.21',
resolvedProtocol: 'npm',
},
{
rawName: '@deno/doc',
resolvedSpec: '^0.189.1',
resolvedProtocol: 'jsr',
},
])
})
})
Loading
Loading