Skip to content

Commit a572eeb

Browse files
authored
feat(nxls): add clickable links for {workspaceRoot} and {projectRoot} links (#2904)
1 parent 93ba07e commit a572eeb

File tree

4 files changed

+353
-0
lines changed

4 files changed

+353
-0
lines changed
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import {
2+
e2eCwd,
3+
modifyJsonFile,
4+
newWorkspace,
5+
uniq,
6+
} from '@nx-console/shared-e2e-utils';
7+
import { readFileSync, writeFileSync } from 'fs';
8+
import { join } from 'path';
9+
import { Position } from 'vscode-languageserver';
10+
import { URI } from 'vscode-uri';
11+
import { NxlsWrapper } from '../nxls-wrapper';
12+
13+
let nxlsWrapper: NxlsWrapper;
14+
const workspaceName = uniq('workspace');
15+
16+
const projectJsonPath = join(
17+
e2eCwd,
18+
workspaceName,
19+
'apps',
20+
workspaceName,
21+
'project.json',
22+
);
23+
24+
describe('interpolated path links', () => {
25+
beforeAll(async () => {
26+
newWorkspace({
27+
name: workspaceName,
28+
options: {
29+
preset: 'next',
30+
},
31+
});
32+
writeFileSync(
33+
projectJsonPath,
34+
JSON.stringify(
35+
{
36+
root: `apps/${workspaceName}`,
37+
targets: {
38+
build: {
39+
inputs: ['{workspaceRoot}/nx.json', '{projectRoot}/project.json'],
40+
},
41+
},
42+
},
43+
null,
44+
2,
45+
),
46+
);
47+
nxlsWrapper = new NxlsWrapper(true);
48+
await nxlsWrapper.startNxls(join(e2eCwd, workspaceName));
49+
50+
nxlsWrapper.sendNotification({
51+
method: 'textDocument/didOpen',
52+
params: {
53+
textDocument: {
54+
uri: URI.file(projectJsonPath).toString(),
55+
languageId: 'JSON',
56+
version: 1,
57+
text: readFileSync(projectJsonPath, 'utf-8'),
58+
},
59+
},
60+
});
61+
});
62+
afterAll(async () => {
63+
await nxlsWrapper.stopNxls();
64+
});
65+
66+
it('should return correct links for {workspaceRoot} and {projectRoot}', async () => {
67+
const text = readFileSync(projectJsonPath, 'utf-8');
68+
const lines = text.split('\n');
69+
70+
// Check workspace link
71+
const workspaceLine = lines.findIndex((line) =>
72+
line.includes('{workspaceRoot}/nx.json'),
73+
);
74+
const workspaceChar = lines[workspaceLine].indexOf(
75+
'{workspaceRoot}/nx.json',
76+
);
77+
78+
const workspaceLinkResponse = await nxlsWrapper.sendRequest({
79+
method: 'textDocument/documentLink',
80+
params: {
81+
textDocument: {
82+
uri: URI.file(projectJsonPath).toString(),
83+
},
84+
position: Position.create(workspaceLine, workspaceChar + 1),
85+
},
86+
});
87+
88+
const workspaceLinks = workspaceLinkResponse.result as any[];
89+
const workspaceLink = workspaceLinks.find(
90+
(l) => l.target && l.target.endsWith('nx.json'),
91+
);
92+
expect(workspaceLink).toBeDefined();
93+
expect(decodeURI(workspaceLink.target)).toContain(
94+
join(workspaceName, 'nx.json'),
95+
);
96+
97+
// Check project link
98+
const projectLine = lines.findIndex((line) =>
99+
line.includes('{projectRoot}/project.json'),
100+
);
101+
const projectChar = lines[projectLine].indexOf(
102+
'{projectRoot}/project.json',
103+
);
104+
105+
const projectLinkResponse = await nxlsWrapper.sendRequest({
106+
method: 'textDocument/documentLink',
107+
params: {
108+
textDocument: {
109+
uri: URI.file(projectJsonPath).toString(),
110+
},
111+
position: Position.create(projectLine, projectChar + 1),
112+
},
113+
});
114+
115+
const projectLinks = projectLinkResponse.result as any[];
116+
const projectLink = projectLinks.find(
117+
(l) => l.target && l.target.endsWith('project.json'),
118+
);
119+
expect(projectLink).toBeDefined();
120+
expect(decodeURI(projectLink.target)).toContain(
121+
join(workspaceName, 'apps', workspaceName, 'project.json'),
122+
);
123+
});
124+
125+
it('should return correct links for negated {workspaceRoot} and {projectRoot}', async () => {
126+
modifyJsonFile(projectJsonPath, (data) => ({
127+
...data,
128+
targets: {
129+
build: {
130+
inputs: ['!{workspaceRoot}/nx.json', '!{projectRoot}/project.json'],
131+
},
132+
},
133+
}));
134+
135+
nxlsWrapper.sendNotification({
136+
method: 'textDocument/didChange',
137+
params: {
138+
textDocument: {
139+
uri: URI.file(projectJsonPath).toString(),
140+
languageId: 'JSON',
141+
version: 2,
142+
},
143+
contentChanges: [
144+
{
145+
text: readFileSync(projectJsonPath, 'utf-8'),
146+
},
147+
],
148+
},
149+
});
150+
151+
const text = readFileSync(projectJsonPath, 'utf-8');
152+
const lines = text.split('\n');
153+
154+
// Check workspace link
155+
const workspaceLine = lines.findIndex((line) =>
156+
line.includes('!{workspaceRoot}/nx.json'),
157+
);
158+
const workspaceChar = lines[workspaceLine].indexOf(
159+
'!{workspaceRoot}/nx.json',
160+
);
161+
162+
const workspaceLinkResponse = await nxlsWrapper.sendRequest({
163+
method: 'textDocument/documentLink',
164+
params: {
165+
textDocument: {
166+
uri: URI.file(projectJsonPath).toString(),
167+
},
168+
position: Position.create(workspaceLine, workspaceChar + 1),
169+
},
170+
});
171+
172+
const workspaceLinks = workspaceLinkResponse.result as any[];
173+
const workspaceLink = workspaceLinks.find(
174+
(l) => l.target && l.target.endsWith('nx.json'),
175+
);
176+
expect(workspaceLink).toBeDefined();
177+
expect(decodeURI(workspaceLink.target)).toContain(
178+
join(workspaceName, 'nx.json'),
179+
);
180+
181+
// Check project link
182+
const projectLine = lines.findIndex((line) =>
183+
line.includes('!{projectRoot}/project.json'),
184+
);
185+
const projectChar = lines[projectLine].indexOf(
186+
'!{projectRoot}/project.json',
187+
);
188+
189+
const projectLinkResponse = await nxlsWrapper.sendRequest({
190+
method: 'textDocument/documentLink',
191+
params: {
192+
textDocument: {
193+
uri: URI.file(projectJsonPath).toString(),
194+
},
195+
position: Position.create(projectLine, projectChar + 1),
196+
},
197+
});
198+
199+
const projectLinks = projectLinkResponse.result as any[];
200+
const projectLink = projectLinks.find(
201+
(l) => l.target && l.target.endsWith('project.json'),
202+
);
203+
expect(projectLink).toBeDefined();
204+
expect(decodeURI(projectLink.target)).toContain(
205+
join(workspaceName, 'apps', workspaceName, 'project.json'),
206+
);
207+
});
208+
});

libs/language-server/capabilities/document-links/src/lib/get-document-links.spec.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,93 @@ it('should get all document links for properties that have a X_COMPLETION_TYPE (
7373
documentMapper.dispose();
7474
});
7575

76+
it('should get document links for interpolated paths', async () => {
77+
const { document, jsonAst } = documentMapper.retrieve(
78+
TextDocument.create(
79+
'file:///project.json',
80+
'json',
81+
0,
82+
`
83+
{
84+
"root": "apps/my-app",
85+
"interpolatedWorkspace": "{workspaceRoot}/libs/my-lib/src/index.ts",
86+
"interpolatedProject": "{projectRoot}/src/main.ts",
87+
"interpolatedGlob": "{projectRoot}/**/*.ts"
88+
}
89+
`,
90+
),
91+
);
92+
93+
const matchingSchemas = await languageService.getMatchingSchemas(
94+
document,
95+
jsonAst,
96+
{
97+
type: 'object',
98+
properties: {
99+
interpolatedWorkspace: { type: 'string' },
100+
interpolatedProject: { type: 'string' },
101+
interpolatedGlob: { type: 'string' },
102+
},
103+
},
104+
);
105+
106+
const documentLinks = await getDocumentLinks(
107+
'/workspace',
108+
jsonAst,
109+
document,
110+
matchingSchemas,
111+
);
112+
113+
expect(documentLinks.map((link) => link.target)).toEqual([
114+
'file:///workspace/libs/my-lib/src/index.ts',
115+
'file:///workspace/apps/my-app/src/main.ts',
116+
]);
117+
118+
documentMapper.dispose();
119+
});
120+
121+
it('should get document links for negated interpolated paths', async () => {
122+
const { document, jsonAst } = documentMapper.retrieve(
123+
TextDocument.create(
124+
'file:///project.json',
125+
'json',
126+
0,
127+
`
128+
{
129+
"interpolatedWorkspace": "!{workspaceRoot}/libs/my-lib/src/index.ts",
130+
"interpolatedProject": "!{projectRoot}/src/main.ts"
131+
}
132+
`,
133+
),
134+
);
135+
136+
const matchingSchemas = await languageService.getMatchingSchemas(
137+
document,
138+
jsonAst,
139+
{
140+
type: 'object',
141+
properties: {
142+
interpolatedWorkspace: { type: 'string' },
143+
interpolatedProject: { type: 'string' },
144+
},
145+
},
146+
);
147+
148+
const documentLinks = await getDocumentLinks(
149+
'/workspace',
150+
jsonAst,
151+
document,
152+
matchingSchemas,
153+
);
154+
155+
expect(documentLinks.map((link) => link.target)).toEqual([
156+
'file:///workspace/libs/my-lib/src/index.ts',
157+
'file:///workspace/apps/my-app/src/main.ts',
158+
]);
159+
160+
documentMapper.dispose();
161+
});
162+
76163
describe('project links', () => {
77164
const mockProjectGraph = {
78165
nodes: {

libs/language-server/capabilities/document-links/src/lib/get-document-links.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { createRange } from './create-range';
2121
import { targetLink } from './target-link';
2222
import { namedInputLink } from './named-input-link';
2323
import { projectLink } from './project-link';
24+
import { interpolatedPathLink } from './interpolated-path-link';
2425

2526
export async function getDocumentLinks(
2627
workingPath: string | undefined,
@@ -50,6 +51,21 @@ export async function getDocumentLinks(
5051
}
5152

5253
if (!linkType) {
54+
if (isStringNode(node)) {
55+
const value = node.value;
56+
if (
57+
(value.startsWith('{workspaceRoot}') ||
58+
value.startsWith('{projectRoot}') ||
59+
value.startsWith('!{workspaceRoot}') ||
60+
value.startsWith('!{projectRoot}')) &&
61+
!value.includes('*')
62+
) {
63+
const link = await interpolatedPathLink(workingPath, node);
64+
if (link) {
65+
links.push(DocumentLink.create(createRange(document, node), link));
66+
}
67+
}
68+
}
5369
continue;
5470
}
5571

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {
2+
findProjectRoot,
3+
isStringNode,
4+
lspLogger,
5+
} from '@nx-console/language-server-utils';
6+
import { join } from 'path';
7+
import { ASTNode } from 'vscode-json-languageservice';
8+
import { URI } from 'vscode-uri';
9+
10+
export async function interpolatedPathLink(
11+
workingPath: string,
12+
node: ASTNode,
13+
): Promise<string | undefined> {
14+
if (!isStringNode(node)) {
15+
return;
16+
}
17+
18+
let value = node.value;
19+
if (value.startsWith('!')) {
20+
value = value.substring(1);
21+
}
22+
23+
let path: string | undefined;
24+
25+
if (value.startsWith('{workspaceRoot}')) {
26+
path = value.replace('{workspaceRoot}', workingPath);
27+
} else if (value.startsWith('{projectRoot}')) {
28+
const projectRoot = findProjectRoot(node);
29+
if (projectRoot) {
30+
path = value.replace('{projectRoot}', join(workingPath, projectRoot));
31+
}
32+
}
33+
34+
if (path) {
35+
return URI.from({
36+
scheme: 'file',
37+
path: path,
38+
}).toString();
39+
}
40+
41+
return;
42+
}

0 commit comments

Comments
 (0)