Skip to content

Commit 074dfce

Browse files
authored
feat: add support for pnpm-workspace.yaml (#6)
* feat: use `yaml` * feat: yaml pattern * implement `getDependenciesInfo` * type safe * provide more specific info * implement `getDependencyInfoByOffset` * refactor: better name
1 parent fa51a69 commit 074dfce

12 files changed

Lines changed: 182 additions & 48 deletions

File tree

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
"reactive-vscode": "^0.4.1",
8888
"tsdown": "^0.20.1",
8989
"typescript": "^5.9.3",
90-
"vscode-ext-gen": "1.3.0"
90+
"vscode-ext-gen": "1.3.0",
91+
"yaml": "^2.8.2"
9192
}
9293
}

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export const PACKAGE_JSON_PATTERN = '**/package.json'
2+
export const PNPM_WORKSPACE_PATTERN = '**/pnpm-workspace.yaml'
3+
4+
export const VERSION_TRIGGER_CHARACTERS = ['.', '^', '~', ...Array.from({ length: 10 }).map((_, i) => `${i}`)]
25

36
export const NPM_REGISTRY = 'https://registry.npmjs.org'

src/extractors/json.ts

Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { DependencyInfo, Extractor } from '#types/extractor'
22
import type { Node } from 'jsonc-parser'
33
import type { TextDocument } from 'vscode'
4+
import { isInRange } from '#utils/ast'
45
import { createCachedParse } from '#utils/data'
56
import { findNodeAtLocation, findNodeAtOffset, parseTree } from 'jsonc-parser'
67
import { Range } from 'vscode'
78

8-
const DEP_SECTIONS = [
9+
const DEPENDENCY_SECTIONS = [
910
'dependencies',
1011
'devDependencies',
1112
'peerDependencies',
@@ -17,58 +18,68 @@ export class JsonExtractor implements Extractor<Node> {
1718

1819
getNodeRange(doc: TextDocument, node: Node) {
1920
const start = doc.positionAt(node.offset + 1)
20-
const end = doc.positionAt(
21-
node.offset + node.length - 1,
22-
)
21+
const end = doc.positionAt(node.offset + node.length - 1)
2322

2423
return new Range(start, end)
2524
}
2625

27-
inDependencySection(root: Node, node: Node) {
28-
return DEP_SECTIONS.some((section) => {
26+
isInDependencySection(root: Node, node: Node) {
27+
return DEPENDENCY_SECTIONS.some((section) => {
2928
const dep = findNodeAtLocation(root, [section])
3029
if (!dep || !dep.parent)
3130
return false
3231

3332
const { offset, length } = dep.parent.children![1]
3433

35-
return node.offset > offset && node.offset < offset + length
34+
return isInRange(node.offset, [offset, offset + length])
3635
})
3736
}
3837

38+
private parseDependencyNode(node: Node): DependencyInfo<Node> | undefined {
39+
if (!node.children?.length)
40+
return
41+
42+
const [nameNode, versionNode] = node.children
43+
44+
if (
45+
typeof nameNode?.value !== 'string'
46+
|| typeof versionNode.value !== 'string'
47+
) {
48+
return
49+
}
50+
51+
return {
52+
nameNode,
53+
versionNode,
54+
name: nameNode.value,
55+
version: versionNode.value,
56+
}
57+
}
58+
3959
getDependenciesInfo(root: Node) {
40-
const info: DependencyInfo<Node>[] = []
60+
const result: DependencyInfo<Node>[] = []
4161

42-
DEP_SECTIONS.forEach((section) => {
62+
DEPENDENCY_SECTIONS.forEach((section) => {
4363
const node = findNodeAtLocation(root, [section])
4464
if (!node || !node.children)
4565
return
4666

4767
for (const dep of node.children) {
48-
const keyNode = dep.children?.[0]
49-
if (!keyNode || typeof keyNode.value !== 'string')
50-
continue
51-
52-
info.push({
53-
node: keyNode,
54-
name: keyNode.value,
55-
version: '',
56-
})
68+
const info = this.parseDependencyNode(dep)
69+
70+
if (info)
71+
result.push(info)
5772
}
5873
})
5974

60-
return info
75+
return result
6176
}
6277

6378
getDependencyInfoByOffset(root: Node, offset: number) {
6479
const node = findNodeAtOffset(root, offset)
65-
if (!node || node.type !== 'string' || !this.inDependencySection(root, node))
80+
if (!node || node.type !== 'string' || !this.isInDependencySection(root, node))
6681
return
6782

68-
return {
69-
node,
70-
name: node.parent!.children![0].value as string,
71-
version: node.value as string,
72-
}
83+
return this.parseDependencyNode(node.parent!)
7384
}
7485
}

src/extractors/yaml.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { DependencyInfo, Extractor } from '#types/extractor'
2+
import type { TextDocument } from 'vscode'
3+
import type { Node, Pair, Scalar, YAMLMap } from 'yaml'
4+
import { isInRange } from '#utils/ast'
5+
import { createCachedParse } from '#utils/data'
6+
import { Range } from 'vscode'
7+
import { isMap, isPair, isScalar, parseDocument } from 'yaml'
8+
9+
const CATALOG_SECTION = 'catalog'
10+
const CATALOGS_SECTION = 'catalogs'
11+
12+
type CatalogEntry = Pair<Scalar<string>, Scalar<string>>
13+
14+
type CatalogEntryVisitor = (catalog: CatalogEntry) => boolean | void
15+
16+
export class YamlExtractor implements Extractor<Node> {
17+
parse = createCachedParse((text) => parseDocument(text).contents)
18+
19+
getNodeRange(doc: TextDocument, node: Node) {
20+
const [start, end] = node.range!
21+
22+
return new Range(
23+
doc.positionAt(start),
24+
doc.positionAt(end),
25+
)
26+
}
27+
28+
getDependenciesInfo(root: Node): DependencyInfo<Node>[] {
29+
if (!isMap(root))
30+
return []
31+
32+
const result: DependencyInfo<Node>[] = []
33+
34+
this.traverseCatalogs(root, (item) => {
35+
result.push({
36+
nameNode: item.key,
37+
versionNode: item.value!,
38+
name: String(item.key.value),
39+
version: String(item.value!.value),
40+
})
41+
})
42+
43+
return result
44+
}
45+
46+
private traverseCatalogs(root: YAMLMap, callback: CatalogEntryVisitor): boolean {
47+
const catalog = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOG_SECTION)
48+
if (this.traverseCatalog(catalog, callback))
49+
return true
50+
51+
const catalogs = root.items.find((i) => isScalar(i.key) && i.key.value === CATALOGS_SECTION)
52+
if (isMap(catalogs?.value)) {
53+
for (const c of catalogs.value.items) {
54+
if (this.traverseCatalog(c, callback))
55+
return true
56+
}
57+
}
58+
59+
return false
60+
}
61+
62+
private traverseCatalog(catalog: unknown, callback: CatalogEntryVisitor): boolean {
63+
if (!isPair(catalog))
64+
return false
65+
if (!isMap(catalog.value))
66+
return false
67+
68+
for (const item of catalog.value.items) {
69+
if (isScalar(item.key) && isScalar(item.value)) {
70+
if (callback(item as CatalogEntry))
71+
return true
72+
}
73+
}
74+
75+
return false
76+
}
77+
78+
getDependencyInfoByOffset(root: Node, offset: number): DependencyInfo<Node> | undefined {
79+
if (!isMap(root))
80+
return
81+
82+
let result: DependencyInfo<Node> | undefined
83+
84+
this.traverseCatalogs(root, (item) => {
85+
if (isInRange(offset, item.value!.range!)) {
86+
result = {
87+
nameNode: item.key,
88+
versionNode: item.value!,
89+
name: String(item.key.value),
90+
version: String(item.value!.value),
91+
}
92+
return true
93+
}
94+
})
95+
96+
return result
97+
}
98+
}

src/index.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,41 @@
1-
import { PACKAGE_JSON_PATTERN } from '#constants'
1+
import { PACKAGE_JSON_PATTERN, PNPM_WORKSPACE_PATTERN, VERSION_TRIGGER_CHARACTERS } from '#constants'
22
import { defineExtension } from 'reactive-vscode'
33
import { languages } from 'vscode'
44
import { JsonExtractor } from './extractors/json'
5+
import { YamlExtractor } from './extractors/yaml'
56
import { displayName, version } from './generated-meta'
6-
import { triggerChars, VersionCompletionItemProvider } from './providers/completion-item/version'
7+
import { VersionCompletionItemProvider } from './providers/completion-item/version'
78
import { NpmxDocumentLinkProvider } from './providers/document-link/npmx'
89
import { config, logger } from './state'
910

1011
export const { activate, deactivate } = defineExtension((ctx) => {
1112
logger.info(`${displayName} Activated, v${version}`)
1213

1314
const jsonExtractor = new JsonExtractor()
15+
const yamlExtractor = new YamlExtractor()
1416

1517
ctx.subscriptions.push(
1618
languages.registerDocumentLinkProvider(
1719
{ pattern: PACKAGE_JSON_PATTERN },
1820
new NpmxDocumentLinkProvider(jsonExtractor),
1921
),
22+
languages.registerDocumentLinkProvider(
23+
{ pattern: PNPM_WORKSPACE_PATTERN },
24+
new NpmxDocumentLinkProvider(yamlExtractor),
25+
),
2026
)
2127

2228
if (config.versionCompletion !== 'off') {
2329
ctx.subscriptions.push(
2430
languages.registerCompletionItemProvider(
2531
{ pattern: PACKAGE_JSON_PATTERN },
2632
new VersionCompletionItemProvider(jsonExtractor),
27-
...triggerChars,
33+
...VERSION_TRIGGER_CHARACTERS,
34+
),
35+
languages.registerCompletionItemProvider(
36+
{ pattern: PNPM_WORKSPACE_PATTERN },
37+
new VersionCompletionItemProvider(yamlExtractor),
38+
...VERSION_TRIGGER_CHARACTERS,
2839
),
2940
)
3041
}

src/providers/completion-item/version.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@ function isVersionPrefix(c: string) {
88
return c === '^' || c === '~'
99
}
1010

11-
function getPrefix(v: string) {
11+
function extractVersionPrefix(v: string) {
1212
const firstChar = v[0]
1313
const valid = isVersionPrefix(firstChar)
1414

1515
return valid ? firstChar : ''
1616
}
1717

18-
export const triggerChars = ['.', '^', '~', ...Array.from({ length: 10 }).map((_, i) => `${i}`)]
19-
2018
export class VersionCompletionItemProvider<T extends Extractor<any>> implements CompletionItemProvider {
2119
extractor: T
2220

@@ -35,14 +33,14 @@ export class VersionCompletionItemProvider<T extends Extractor<any>> implements
3533
return
3634

3735
const {
38-
node,
36+
versionNode,
3937
name,
4038
version,
4139
} = info
4240

4341
const pkg = await getPackageInfo(name)
4442

45-
const prefix = getPrefix(version)
43+
const prefix = extractVersionPrefix(version)
4644

4745
let versionsKV = Object.values(pkg.versions)
4846

@@ -53,7 +51,7 @@ export class VersionCompletionItemProvider<T extends Extractor<any>> implements
5351
const text = `${prefix}${version}`
5452
const item = new CompletionItem(text, CompletionItemKind.Value)
5553

56-
item.range = this.extractor.getNodeRange(document, node)
54+
item.range = this.extractor.getNodeRange(document, versionNode)
5755
item.insertText = text
5856
if (tag)
5957
item.detail = tag

src/providers/document-link/npmx.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ export class NpmxDocumentLinkProvider<T extends Extractor<any>> implements Docum
1414
if (!root)
1515
return
1616

17-
return this.extractor.getDependenciesInfo(root).map(({ node, name }) => {
18-
const range = this.extractor.getNodeRange(document, node)
17+
return this.extractor.getDependenciesInfo(root).map(({ nameNode, name }) => {
18+
const range = this.extractor.getNodeRange(document, nameNode)
1919

2020
const uri = Uri.parse(`https://npmx.dev/package/${name}`)
2121

src/types/extractor.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import type { Range, TextDocument } from 'vscode'
22

33
export interface DependencyInfo<T> {
4-
node: T
4+
nameNode: T
5+
versionNode: T
56
name: string
67
version: string
78
}
89

910
export interface Extractor<T> {
10-
parse: (document: TextDocument) => T | undefined
11+
parse: (document: TextDocument) => T | null | undefined
1112

1213
getNodeRange: (document: TextDocument, node: T) => Range
1314

src/utils/ast.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function isInRange(offset: number, [start, end]: [number, number, ...any]): boolean {
2+
return offset >= start && offset <= end
3+
}

0 commit comments

Comments
 (0)