Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
2bbd9c1
refactor: generate workspace context
9romise Mar 7, 2026
d78aee9
remove virtual document
9romise Mar 7, 2026
d4ee1ff
remove compatible properties
9romise Mar 7, 2026
98b8f19
expose nameRange & specRange directly
9romise Mar 7, 2026
958d1d3
cleanup
9romise Mar 7, 2026
0d9f19b
move specific logic into extractor
9romise Mar 7, 2026
0e24f49
extract package-manager-detect
9romise Mar 7, 2026
12bb2d7
use vscode-find-up
9romise Mar 7, 2026
f2b2aba
reuse memoize
9romise Mar 7, 2026
e230feb
cleanup
9romise Mar 7, 2026
f3a8904
clean up
9romise Mar 7, 2026
b6d16ee
cleanup
9romise Mar 7, 2026
575ff00
cleanup
9romise Mar 7, 2026
207e423
cleanup
9romise Mar 7, 2026
c39d430
cleanup plan
9romise Mar 7, 2026
542bea8
cleanup
9romise Mar 7, 2026
a006016
cleanup
9romise Mar 7, 2026
2ada7ae
use more ResolvedDependencyInfo
9romise Mar 7, 2026
476536d
lazy init
9romise Mar 7, 2026
c748884
clean up
9romise Mar 7, 2026
9fd20a1
refactor: use single pattern
9romise Mar 8, 2026
85992ff
cleanup
9romise Mar 8, 2026
5a14242
cleaning up
9romise Mar 8, 2026
8caaa25
Merge branch 'main' into resolve
9romise Mar 8, 2026
570d838
fix: get workspace file correctly
9romise Mar 8, 2026
5831f54
fix: check resolvedProtocol instead of `isSupportedProtocol`
9romise Mar 8, 2026
0e77278
fix: watch files change, more logs
9romise Mar 8, 2026
5cb7784
test: fix
9romise Mar 8, 2026
ff50284
Merge branch 'main' into resolve
9romise Mar 8, 2026
8e3e540
test: fix merge
9romise Mar 8, 2026
aea54ad
refactor: class WorkspaceContext
9romise Mar 8, 2026
76d2294
rename
9romise Mar 8, 2026
9c8b129
update
9romise Mar 8, 2026
56c6b93
fix: correctly handle version spec
9romise Mar 8, 2026
938bba3
ci: fix
9romise Mar 8, 2026
53036de
Merge branch 'main' into resolve
9romise Mar 9, 2026
882e4a9
feat: get extractor by file extension
9romise Mar 9, 2026
daf8c6a
fix: only watch document text changes, no active editor changes liste…
9romise Mar 9, 2026
d9feee0
fix: ensure workspace context initial
9romise Mar 9, 2026
a8fe3e5
chore: improve log
9romise Mar 9, 2026
b5688eb
Merge branch 'main' into resolve
9romise Mar 9, 2026
97e9de1
fix: issues reported by coderabbit
9romise Mar 9, 2026
d93cf4e
Merge branch 'main' into resolve
9romise Mar 9, 2026
5b39533
fix: issues reported by coderabbit
9romise Mar 9, 2026
b886fc3
fix: lint
9romise Mar 9, 2026
5100e96
refactor: re-organize
9romise Mar 10, 2026
70de49e
reloadWorkspace when workspace-level files change
9romise Mar 10, 2026
e16622f
don't require name and version
9romise Mar 10, 2026
551d08e
fix: lint
9romise Mar 10, 2026
ca3a4df
fix: coderabbit
9romise Mar 10, 2026
8c31528
Merge branch 'main' into resolve
9romise Mar 10, 2026
81d1ecc
update
9romise Mar 10, 2026
0d19065
rename
9romise Mar 10, 2026
953317a
fix
9romise Mar 10, 2026
1db3de3
Merge branch 'main' into resolve
9romise Mar 10, 2026
8249e18
docs: update
9romise Mar 10, 2026
c8e79ca
Merge branch 'main' into resolve
9romise Mar 10, 2026
f4c7d9a
fix: lint
9romise Mar 10, 2026
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
15 changes: 13 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ playground/ # Playground for testing
res/ # Assets (e.g. marketplace icon)
src/ # Extension source code
├── commands/ # Command handlers (vscode API only, no reactive-vscode)
├── extractors/ # Extractors (package.json, pnpm-workspace.yaml)
├── composables/ # Composables (reactive-vscode hooks)
├── core/ # Core logic
│ ├── extractors/ # Extractors (JSON, YAML)
│ └── workspace.ts # Workspace context resolution
├── providers/ # Providers
│ ├── code-actions/ # Code action providers (quick fixes)
│ ├── completion-item/ # Completion providers (version autocomplete)
Expand All @@ -101,9 +104,17 @@ tests/ # Tests
├── __setup__/ # Test setup and utilities
├── code-actions/ # Code action tests
├── diagnostics/ # Diagnostic tests
├── fixtures/ # Test fixtures (workspace scenarios)
└── utils/ # Utility tests
```

### Key concepts

- **Extractor** – Parses a supported file (`package.json`, `pnpm-workspace.yaml`, `.yarnrc.yml`) and extracts dependency information with AST ranges. Each file format has its own extractor in `src/core/extractors/`.
- **WorkspaceContext** – Holds per-workspace-folder state: detected package manager, resolved catalogs, and memoized dependency info. Created lazily and invalidated when workspace-level files change.
- **ResolvedDependencyInfo** – A dependency with its protocol resolved (e.g., `catalog:` → actual version, `npm:alias@version` → underlying package). Providers consume resolved dependencies instead of raw AST data.
- **Provider** – VS Code language feature (hover, completion, diagnostics, etc.) that operates on resolved dependencies.

## Code style

When committing changes, try to keep an eye out for unintended formatting updates. These can make a pull request look noisier than it really is and slow down the review process. Sometimes IDEs automatically reformat files on save, which can unintentionally introduce extra changes.
Expand All @@ -122,7 +133,7 @@ If you want to get ahead of any formatting issues, you can also run `pnpm lint:f
> This will be fixed by eslint.

1. Type imports first (`import type { ... }`)
2. Internal aliases (`#constants`, `#utils/`, `#composables/`, etc.)
2. Internal aliases (`#constants`, `#state`, `#utils/`, `#core/`, `#composables/`, `#types/`, etc.)
3. External packages (including `node:`)
4. Relative imports (`./`, `../`)
5. No blank lines between groups
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +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.
- **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 eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default defineConfig(
{
pnpm: true,
typescript: true,
ignores: ['playground'],
ignores: ['playground', 'tests/fixtures'],
},
{
name: 'extensions/all',
Expand Down
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@
"vscode": "^1.101.0"
},
"activationEvents": [
"workspaceContains:package.json",
"workspaceContains:pnpm-workspace.yaml",
"workspaceContains:.yarnrc.yml"
"workspaceContains:package.json"
],
"contributes": {
"configuration": {
Expand Down Expand Up @@ -231,6 +229,7 @@
"msw": "catalog:test",
"nano-staged": "catalog:dev",
"ofetch": "catalog:inline",
"pathe": "catalog:inline",
"perfect-debounce": "catalog:inline",
"reactive-vscode": "catalog:inline",
"semver": "catalog:inline",
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ catalogs:
fast-npm-meta: ^1.3.0
jsonc-parser: ^3.3.1
ofetch: ^2.0.0-alpha.3
pathe: ^2.0.3
perfect-debounce: ^2.1.0
reactive-vscode: ^1.0.0-beta.2
semver: ^7.7.4
Expand Down
4 changes: 2 additions & 2 deletions src/commands/open-file-in-npmx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export async function openFileInNpmx(fileUri?: Uri) {
// Construct the npmx.dev URL and open it. VSCode uses 0-indexed lines, npmx uses 1-indexed.
const { selection } = textEditor ?? {}
const url = npmxFileUrl(
manifest.name,
manifest.version,
manifest.name!,
manifest.version!,
relativePath,
openingActiveFile && selection ? selection.start.line + 1 : undefined,
openingActiveFile && selection ? selection.end.line + 1 : undefined,
Expand Down
45 changes: 45 additions & 0 deletions src/composables/workspace-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Uri } from 'vscode'
import { SUPPORTED_DOCUMENT_PATTERN } from '#constants'
import { deleteWorkspaceContextCache, getWorkspaceContext } from '#core/workspace'
import { logger } from '#state'
import { isSupportedDependencyDocument, isWorkspaceLevelFile } from '#utils/file'
import { useDisposable, useFileSystemWatcher } from 'reactive-vscode'
import { window, workspace } from 'vscode'

export function useWorkspaceContext() {
useDisposable(workspace.onDidChangeWorkspaceFolders(({ removed }) => {
removed.forEach((folder) => {
deleteWorkspaceContextCache(folder)
logger.info(`[workspace-context] delete workspace folder cache: ${folder.uri.path}`)
})
}))

async function deleteCacheByUri(uri: Uri, reload = true) {
if (!isSupportedDependencyDocument(uri))
return

const ctx = await getWorkspaceContext(uri)
if (!ctx)
return

ctx.loadPackageManifestInfo.delete(uri)
ctx.loadWorkspaceCatalogInfo.delete(uri)
logger.info(`[workspace-context] delete dependencies cache: ${uri.path}`)
if (reload && isWorkspaceLevelFile(uri)) {
await ctx.loadWorkspace()
}
}

useDisposable(workspace.onDidChangeTextDocument(({ document }) => {
if (document !== window.activeTextEditor?.document)
return

deleteCacheByUri(document.uri, false)
}))

const { onDidCreate, onDidChange, onDidDelete } = useFileSystemWatcher(SUPPORTED_DOCUMENT_PATTERN)

onDidCreate(deleteCacheByUri)
onDidChange(deleteCacheByUri)
onDidDelete(deleteCacheByUri)
}
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export const PACKAGE_JSON_BASENAME = 'package.json'
export const PNPM_WORKSPACE_BASENAME = 'pnpm-workspace.yaml'
export const YARN_WORKSPACE_BASENAME = '.yarnrc.yml'

export const SUPPORTED_DOCUMENT_PATTERN = `**/{${PACKAGE_JSON_BASENAME},${PNPM_WORKSPACE_BASENAME},${YARN_WORKSPACE_BASENAME}}`

export const PRERELEASE_PATTERN = /-.+/

export const CACHE_TTL_ONE_DAY = 1000 * 60 * 60 * 24
Expand Down
21 changes: 21 additions & 0 deletions src/core/extractors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { extname } from 'pathe'
import { JsonExtractor } from './json'
import { YamlExtractor } from './yaml'

const jsonExtractor = new JsonExtractor()
const yamlExtractor = new YamlExtractor()

const extractorsByExtension = {
'.json': jsonExtractor,
'.yaml': yamlExtractor,
'.yml': yamlExtractor,
} as const satisfies Record<string, JsonExtractor | YamlExtractor>

type ExtractorByExt<T extends string>
= T extends `${string}.json` ? JsonExtractor
: T extends `${string}.yaml` | `${string}.yml` ? YamlExtractor
: JsonExtractor | YamlExtractor | undefined

export function getExtractor<T extends string>(filename: T): ExtractorByExt<T> {
return extractorsByExtension[extname(filename) as keyof typeof extractorsByExtension] as ExtractorByExt<T>
}
97 changes: 97 additions & 0 deletions src/core/extractors/json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { BaseExtractor, DependencyCategory, DependencyInfo, JsonNode, OffsetRange, PackageManifestExtractor, PackageManifestInfo } from '#types/extractor'
import type { Engines } from 'fast-npm-meta'
import { findNodeAtLocation, parseTree } from 'jsonc-parser'

const DEPENDENCY_SECTIONS: DependencyCategory[] = [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
]

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

#getStringValue(root: JsonNode, key: string): string | undefined {
const node = findNodeAtLocation(root, [key])
return typeof node?.value === 'string' ? node.value : undefined
}

#getStringNodeRange(node: JsonNode): OffsetRange {
return [node.offset + 1, node.offset + node.length - 1]
}

#parseDependencyNode(node: JsonNode, category: DependencyCategory): DependencyInfo | undefined {
if (!node.children?.length)
return

const [nameNode, specNode] = node.children

if (
typeof nameNode?.value !== 'string'
|| typeof specNode?.value !== 'string'
) {
return
}

return {
category,
rawName: nameNode.value,
rawSpec: specNode.value,
nameRange: this.#getStringNodeRange(nameNode),
specRange: this.#getStringNodeRange(specNode),
}
}

#getEngines(root: JsonNode): Engines | undefined {
const enginesNode = findNodeAtLocation(root, ['engines'])
if (enginesNode?.type !== 'object' || !enginesNode.children?.length)
return

let engines: Engines | undefined

for (const engineNode of enginesNode.children) {
const [nameNode, rangeNode] = engineNode.children ?? []
if (typeof nameNode?.value !== 'string' || typeof rangeNode?.value !== 'string')
continue

engines ??= {}
engines[nameNode.value] = rangeNode.value
}

return engines
}

getDependenciesInfo(root: JsonNode) {
const result: DependencyInfo[] = []

DEPENDENCY_SECTIONS.forEach((section) => {
const node = findNodeAtLocation(root, [section])
if (!node || !node.children)
return

for (const dep of node.children) {
const info = this.#parseDependencyNode(dep, section)

if (info)
result.push(info)
}
})

return result
}

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

return {
name: this.#getStringValue(root, 'name'),
version: this.#getStringValue(root, 'version'),
packageManager: this.#getStringValue(root, 'packageManager'),
engines: this.#getEngines(root),
dependencies: this.getDependenciesInfo(root),
}
}
}
105 changes: 105 additions & 0 deletions src/core/extractors/yaml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { BaseExtractor, DependencyInfo, OffsetRange, WorkspaceCatalogExtractor, WorkspaceCatalogInfo, YamlNode } from '#types/extractor'
import type { Pair, Scalar, YAMLMap } from 'yaml'
import { isMap, isPair, isScalar, parseDocument } from 'yaml'

const CATALOG_SECTION = 'catalog'
const CATALOGS_SECTION = 'catalogs'

type CatalogEntry = Pair<Scalar<string>, Scalar<string>>

type CatalogEntryVisitor = (
catalog: CatalogEntry,
meta: {
category: 'catalog' | 'catalogs'
categoryName?: string
},
) => boolean | void

export class YamlExtractor implements WorkspaceCatalogExtractor, BaseExtractor<YamlNode> {
parse = (text: string) => parseDocument(text).contents

#getScalarRange(node: YamlNode): OffsetRange {
const [start, end] = node.range!
return [start, end]
}

#traverseCatalog(
catalog: unknown,
meta: {
category: 'catalog' | 'catalogs'
categoryName?: string
},
callback: CatalogEntryVisitor,
): boolean {
if (!isPair(catalog))
return false
if (!isMap(catalog.value))
return false

for (const item of catalog.value.items) {
if (isScalar(item.key) && isScalar(item.value)) {
if (callback(item as CatalogEntry, meta))
return true
}
}

return false
}

#traverseCatalogs(root: YAMLMap, callback: CatalogEntryVisitor): boolean {
const catalog = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOG_SECTION)
if (this.#traverseCatalog(catalog, { category: 'catalog' }, callback))
return true

const catalogs = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOGS_SECTION)
if (isMap(catalogs?.value)) {
for (const c of catalogs.value.items) {
const categoryName = isScalar(c.key) ? String(c.key.value) : undefined
if (this.#traverseCatalog(c, { category: 'catalogs', categoryName }, callback))
return true
}
}

return false
}

getDependenciesInfo(root: YamlNode): DependencyInfo[] {
if (!isMap(root))
return []

const result: DependencyInfo[] = []

this.#traverseCatalogs(root, (item, meta) => {
result.push({
category: meta.category,
rawName: String(item.key.value),
rawSpec: String(item.value!.value),
nameRange: this.#getScalarRange(item.key),
specRange: this.#getScalarRange(item.value!),
categoryName: meta.categoryName,
})
})

return result
}

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

const dependencies = this.getDependenciesInfo(root)
const catalogs: Record<string, Record<string, string>> = {}

for (const dependency of dependencies) {
const categoryName = dependency.category === 'catalog' ? 'default' : dependency.categoryName || 'default'
catalogs[categoryName] ??= {}
catalogs[categoryName][dependency.rawName] = dependency.rawSpec
}

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