@analogjs/language-server 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/plugins/routingPlugin.d.ts +2 -1
- package/out/plugins/routingPlugin.js +121 -31
- package/out/routeScanner.d.ts +14 -8
- package/out/routeScanner.js +62 -36
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import type { LanguageServicePlugin } from '@volar/language-service';
|
|
|
2
2
|
import type { RouteScanner } from '../routeScanner';
|
|
3
3
|
/**
|
|
4
4
|
* Creates a Volar language service plugin that provides
|
|
5
|
-
* route path completions and go-to-definition
|
|
5
|
+
* route path completions, document links, and go-to-definition
|
|
6
|
+
* for Analog's file-based routing.
|
|
6
7
|
*/
|
|
7
8
|
export declare function createRoutingPlugin(scanner: RouteScanner): LanguageServicePlugin;
|
|
@@ -28,57 +28,107 @@ const vscode = __importStar(require("vscode-languageserver-protocol"));
|
|
|
28
28
|
const vscode_uri_1 = require("vscode-uri");
|
|
29
29
|
/**
|
|
30
30
|
* Check if a position is inside a string literal and return the string content.
|
|
31
|
-
* Handles
|
|
31
|
+
* Handles single quotes, double quotes, and backtick template literals.
|
|
32
32
|
*/
|
|
33
33
|
function getStringAtPosition(document, position) {
|
|
34
34
|
const line = document.getText(vscode.Range.create(position.line, 0, position.line + 1, 0));
|
|
35
35
|
const offset = position.character;
|
|
36
|
-
|
|
37
|
-
let
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
const quotes = ['"', "'", '`'];
|
|
37
|
+
let bestMatch;
|
|
38
|
+
for (const quote of quotes) {
|
|
39
|
+
let start = -1;
|
|
40
|
+
let end = -1;
|
|
41
|
+
for (let i = offset - 1; i >= 0; i--) {
|
|
42
|
+
if (line[i] === quote && (i === 0 || line[i - 1] !== '\\')) {
|
|
43
|
+
start = i;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
if (start === -1)
|
|
48
|
+
continue;
|
|
49
|
+
for (let i = offset; i < line.length; i++) {
|
|
50
|
+
if (line[i] === quote && line[i - 1] !== '\\') {
|
|
51
|
+
end = i;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (end === -1)
|
|
56
|
+
continue;
|
|
57
|
+
if (!bestMatch || start > bestMatch.start) {
|
|
58
|
+
const value = line.substring(start + 1, end);
|
|
59
|
+
const range = vscode.Range.create(position.line, start + 1, position.line, end);
|
|
60
|
+
bestMatch = { value, range, start };
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
|
-
|
|
56
|
-
return undefined;
|
|
57
|
-
const value = line.substring(start + 1, end);
|
|
58
|
-
const range = vscode.Range.create(position.line, start + 1, position.line, end);
|
|
59
|
-
return { value, range };
|
|
63
|
+
return bestMatch;
|
|
60
64
|
}
|
|
61
65
|
/**
|
|
62
66
|
* Check if the string at position is in a routing context.
|
|
63
|
-
* Looks for routerLink, navigate, navigateByUrl, route(), etc.
|
|
64
67
|
*/
|
|
65
68
|
function isRoutingContext(document, position) {
|
|
66
69
|
const line = document.getText(vscode.Range.create(position.line, 0, position.line + 1, 0));
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
return /routerLink|\.navigate\(|\.navigateByUrl\(|route\(|injectNavigate|injectNavigateByUrl/.test(line);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Extract a file system path from a document URI.
|
|
74
|
+
*/
|
|
75
|
+
function getDocumentFsPath(document) {
|
|
76
|
+
const uri = vscode_uri_1.URI.parse(document.uri);
|
|
77
|
+
if (uri.scheme === 'file') {
|
|
78
|
+
return uri.fsPath;
|
|
79
|
+
}
|
|
80
|
+
if (uri.scheme === 'angular-embedded-content') {
|
|
81
|
+
const decoded = decodeURIComponent(uri.path);
|
|
82
|
+
const match = decoded.match(/file:\/\/(.+?)(?:\.html|\.css)?$/);
|
|
83
|
+
if (match)
|
|
84
|
+
return match[1];
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Find all route-path strings in a document and return their ranges and values.
|
|
90
|
+
*/
|
|
91
|
+
function findRouteStrings(document) {
|
|
92
|
+
const text = document.getText();
|
|
93
|
+
const results = [];
|
|
94
|
+
// Match routerLink="..." routerLink='...' [routerLink]="'...'"
|
|
95
|
+
const patterns = [
|
|
96
|
+
/routerLink\s*=\s*"(\/[^"]*)"/g,
|
|
97
|
+
/routerLink\s*=\s*'(\/[^']*)'/g,
|
|
98
|
+
/\[routerLink\]\s*=\s*"'(\/[^']*)'"/g,
|
|
99
|
+
/\[routerLink\]\s*=\s*"\"(\/[^\"]*)\""/g,
|
|
100
|
+
/\.navigateByUrl\(\s*['"](\/[^'"]*)['"]/g,
|
|
101
|
+
/route\(\s*['"](\/[^'"]*)['"]/g,
|
|
102
|
+
];
|
|
103
|
+
for (const pattern of patterns) {
|
|
104
|
+
let match;
|
|
105
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
106
|
+
const value = match[1];
|
|
107
|
+
const valueStart = match.index + match[0].indexOf(value);
|
|
108
|
+
const startPos = document.positionAt(valueStart);
|
|
109
|
+
const endPos = document.positionAt(valueStart + value.length);
|
|
110
|
+
results.push({
|
|
111
|
+
value,
|
|
112
|
+
range: vscode.Range.create(startPos, endPos),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return results;
|
|
69
117
|
}
|
|
70
118
|
/**
|
|
71
119
|
* Creates a Volar language service plugin that provides
|
|
72
|
-
* route path completions and go-to-definition
|
|
120
|
+
* route path completions, document links, and go-to-definition
|
|
121
|
+
* for Analog's file-based routing.
|
|
73
122
|
*/
|
|
74
123
|
function createRoutingPlugin(scanner) {
|
|
75
124
|
return {
|
|
76
125
|
name: 'analog-routing',
|
|
77
126
|
capabilities: {
|
|
78
127
|
completionProvider: {
|
|
79
|
-
triggerCharacters: ["'", '"', '/'],
|
|
128
|
+
triggerCharacters: ["'", '"', '/', '`'],
|
|
80
129
|
},
|
|
81
130
|
definitionProvider: true,
|
|
131
|
+
documentLinkProvider: {},
|
|
82
132
|
},
|
|
83
133
|
create() {
|
|
84
134
|
return {
|
|
@@ -89,17 +139,33 @@ function createRoutingPlugin(scanner) {
|
|
|
89
139
|
const stringInfo = getStringAtPosition(document, position);
|
|
90
140
|
if (!stringInfo)
|
|
91
141
|
return undefined;
|
|
92
|
-
const
|
|
142
|
+
const documentPath = getDocumentFsPath(document);
|
|
143
|
+
const routes = scanner.getRoutes(documentPath);
|
|
93
144
|
if (routes.length === 0)
|
|
94
145
|
return undefined;
|
|
95
146
|
const items = routes
|
|
96
147
|
.filter((route) => !route.isCatchAll)
|
|
97
148
|
.map((route) => {
|
|
149
|
+
const hasParams = route.params.length > 0;
|
|
150
|
+
let insertText;
|
|
151
|
+
if (hasParams) {
|
|
152
|
+
// Convert :param to ${N:param} snippet tab stops
|
|
153
|
+
let tabIndex = 1;
|
|
154
|
+
insertText = route.urlPath.replace(/:([^/]+)/g, () => {
|
|
155
|
+
const param = route.params[tabIndex - 1] || 'value';
|
|
156
|
+
return `\${${tabIndex++}:${param}}`;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
insertText = route.urlPath;
|
|
161
|
+
}
|
|
98
162
|
const item = {
|
|
99
163
|
label: route.urlPath,
|
|
100
164
|
kind: vscode.CompletionItemKind.File,
|
|
101
165
|
detail: route.filePath.replace(/^.*\/src\//, 'src/'),
|
|
102
|
-
sortText:
|
|
166
|
+
sortText: hasParams ? '1' : '0',
|
|
167
|
+
insertTextFormat: hasParams ? 2 : 1,
|
|
168
|
+
textEdit: vscode.TextEdit.replace(stringInfo.range, insertText),
|
|
103
169
|
};
|
|
104
170
|
if (route.params.length > 0) {
|
|
105
171
|
item.documentation = {
|
|
@@ -127,6 +193,24 @@ function createRoutingPlugin(scanner) {
|
|
|
127
193
|
items,
|
|
128
194
|
};
|
|
129
195
|
},
|
|
196
|
+
provideDocumentLinks(document) {
|
|
197
|
+
const documentPath = getDocumentFsPath(document);
|
|
198
|
+
const routeStrings = findRouteStrings(document);
|
|
199
|
+
if (routeStrings.length === 0)
|
|
200
|
+
return undefined;
|
|
201
|
+
const links = [];
|
|
202
|
+
for (const { value, range } of routeStrings) {
|
|
203
|
+
const matches = scanner.findByUrlString(value, documentPath);
|
|
204
|
+
if (matches.length > 0) {
|
|
205
|
+
links.push({
|
|
206
|
+
range,
|
|
207
|
+
target: vscode_uri_1.URI.file(matches[0].filePath).toString(),
|
|
208
|
+
tooltip: `Go to ${matches[0].filePath.replace(/^.*\/src\//, 'src/')}`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return links;
|
|
213
|
+
},
|
|
130
214
|
provideDefinition(document, position) {
|
|
131
215
|
const stringInfo = getStringAtPosition(document, position);
|
|
132
216
|
if (!stringInfo)
|
|
@@ -134,10 +218,16 @@ function createRoutingPlugin(scanner) {
|
|
|
134
218
|
const value = stringInfo.value;
|
|
135
219
|
if (!value.startsWith('/'))
|
|
136
220
|
return undefined;
|
|
137
|
-
const
|
|
221
|
+
const documentPath = getDocumentFsPath(document);
|
|
222
|
+
const matches = scanner.findByUrlString(value, documentPath);
|
|
138
223
|
if (matches.length === 0)
|
|
139
224
|
return undefined;
|
|
140
|
-
return matches.map((route) =>
|
|
225
|
+
return matches.map((route) => ({
|
|
226
|
+
targetUri: vscode_uri_1.URI.file(route.filePath).toString(),
|
|
227
|
+
targetRange: vscode.Range.create(0, 0, 0, 0),
|
|
228
|
+
targetSelectionRange: vscode.Range.create(0, 0, 0, 0),
|
|
229
|
+
originSelectionRange: stringInfo.range,
|
|
230
|
+
}));
|
|
141
231
|
},
|
|
142
232
|
};
|
|
143
233
|
},
|
package/out/routeScanner.d.ts
CHANGED
|
@@ -11,10 +11,11 @@ export interface RouteInfo {
|
|
|
11
11
|
isCatchAll: boolean;
|
|
12
12
|
}
|
|
13
13
|
/**
|
|
14
|
-
* Scans the workspace for Analog page files and builds a route map
|
|
14
|
+
* Scans the workspace for Analog page files and builds a route map,
|
|
15
|
+
* grouped by project so monorepo routes are scoped correctly.
|
|
15
16
|
*/
|
|
16
17
|
export declare class RouteScanner {
|
|
17
|
-
private
|
|
18
|
+
private projects;
|
|
18
19
|
private workspaceRoot;
|
|
19
20
|
constructor(workspaceRoot: string);
|
|
20
21
|
/**
|
|
@@ -22,20 +23,25 @@ export declare class RouteScanner {
|
|
|
22
23
|
*/
|
|
23
24
|
scan(): void;
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
+
* Determine which project a document belongs to based on its file path.
|
|
26
27
|
*/
|
|
27
|
-
|
|
28
|
+
private getProjectForFile;
|
|
28
29
|
/**
|
|
29
|
-
*
|
|
30
|
+
* Get routes scoped to the project containing the given document.
|
|
31
|
+
* Falls back to all routes if no project match is found.
|
|
30
32
|
*/
|
|
31
|
-
|
|
33
|
+
getRoutes(documentPath?: string): RouteInfo[];
|
|
34
|
+
/**
|
|
35
|
+
* Find a route by its URL path, scoped to the document's project.
|
|
36
|
+
*/
|
|
37
|
+
findByUrlPath(urlPath: string, documentPath?: string): RouteInfo | undefined;
|
|
32
38
|
/**
|
|
33
39
|
* Get the paired .server.ts file for a .page.ts file, or vice versa.
|
|
34
40
|
*/
|
|
35
41
|
getPairedFile(filePath: string): string | undefined;
|
|
36
42
|
/**
|
|
37
43
|
* Find routes whose URL path matches a given string (for go-to-definition).
|
|
38
|
-
*
|
|
44
|
+
* Scoped to the project containing the given document.
|
|
39
45
|
*/
|
|
40
|
-
findByUrlString(urlString: string): RouteInfo[];
|
|
46
|
+
findByUrlString(urlString: string, documentPath?: string): RouteInfo[];
|
|
41
47
|
}
|
package/out/routeScanner.js
CHANGED
|
@@ -89,17 +89,23 @@ function collectFiles(dir, test) {
|
|
|
89
89
|
return results;
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
|
-
* Find all `pages` directories
|
|
93
|
-
*
|
|
94
|
-
* Handles both standalone projects (src/app/pages) and monorepos
|
|
95
|
-
* (apps/my-app/src/app/pages, projects/foo/src/app/pages).
|
|
92
|
+
* Find all projects with `src/app/pages` directories.
|
|
93
|
+
* Returns an array of { projectRoot, pagesDir } pairs.
|
|
96
94
|
*/
|
|
97
|
-
function
|
|
95
|
+
function findProjects(root) {
|
|
98
96
|
const results = [];
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
function add(projectRoot, pagesDir) {
|
|
99
|
+
const resolved = path.resolve(pagesDir);
|
|
100
|
+
if (seen.has(resolved))
|
|
101
|
+
return;
|
|
102
|
+
seen.add(resolved);
|
|
103
|
+
results.push({ projectRoot: path.resolve(projectRoot), pagesDir: resolved });
|
|
104
|
+
}
|
|
99
105
|
// Direct project: root/src/app/pages
|
|
100
106
|
const direct = path.join(root, 'src', 'app', 'pages');
|
|
101
107
|
if (fs.existsSync(direct)) {
|
|
102
|
-
|
|
108
|
+
add(root, direct);
|
|
103
109
|
}
|
|
104
110
|
// Monorepo: scan common app directories
|
|
105
111
|
const monorepoParents = ['apps', 'projects', 'packages'];
|
|
@@ -115,55 +121,74 @@ function findPagesDirs(root) {
|
|
|
115
121
|
for (const entry of entries) {
|
|
116
122
|
if (!entry.isDirectory())
|
|
117
123
|
continue;
|
|
118
|
-
const
|
|
124
|
+
const projectRoot = path.join(parentDir, entry.name);
|
|
125
|
+
const candidate = path.join(projectRoot, 'src', 'app', 'pages');
|
|
119
126
|
if (fs.existsSync(candidate)) {
|
|
120
|
-
|
|
127
|
+
add(projectRoot, candidate);
|
|
121
128
|
}
|
|
122
129
|
}
|
|
123
130
|
}
|
|
124
131
|
return results;
|
|
125
132
|
}
|
|
126
133
|
/**
|
|
127
|
-
* Scans the workspace for Analog page files and builds a route map
|
|
134
|
+
* Scans the workspace for Analog page files and builds a route map,
|
|
135
|
+
* grouped by project so monorepo routes are scoped correctly.
|
|
128
136
|
*/
|
|
129
137
|
class RouteScanner {
|
|
130
138
|
constructor(workspaceRoot) {
|
|
131
|
-
this.
|
|
139
|
+
this.projects = [];
|
|
132
140
|
this.workspaceRoot = workspaceRoot;
|
|
133
141
|
}
|
|
134
142
|
/**
|
|
135
143
|
* Perform initial scan of all pages directories in the workspace.
|
|
136
144
|
*/
|
|
137
145
|
scan() {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
};
|
|
146
|
+
this.projects = findProjects(this.workspaceRoot).map(({ projectRoot, pagesDir }) => {
|
|
147
|
+
const pageFiles = collectFiles(pagesDir, (name) => name.endsWith('.page.ts'));
|
|
148
|
+
const routes = pageFiles.map((filePath) => {
|
|
149
|
+
const urlPath = fileToUrlPath(filePath);
|
|
150
|
+
const serverFilePath = filePath.replace(/\.page\.ts$/, '.server.ts');
|
|
151
|
+
const hasServer = fs.existsSync(serverFilePath);
|
|
152
|
+
return {
|
|
153
|
+
urlPath,
|
|
154
|
+
filePath,
|
|
155
|
+
serverFilePath: hasServer ? serverFilePath : undefined,
|
|
156
|
+
params: extractParams(urlPath),
|
|
157
|
+
isCatchAll: urlPath.includes('**'),
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
return { projectRoot, routes };
|
|
154
161
|
});
|
|
155
162
|
}
|
|
156
163
|
/**
|
|
157
|
-
*
|
|
164
|
+
* Determine which project a document belongs to based on its file path.
|
|
165
|
+
*/
|
|
166
|
+
getProjectForFile(documentPath) {
|
|
167
|
+
const resolved = path.resolve(documentPath);
|
|
168
|
+
// Find the project whose root is an ancestor of the document.
|
|
169
|
+
// Sort by longest projectRoot first so nested projects match correctly.
|
|
170
|
+
return this.projects
|
|
171
|
+
.filter((p) => resolved.startsWith(p.projectRoot + path.sep) || resolved.startsWith(p.projectRoot + '/'))
|
|
172
|
+
.sort((a, b) => b.projectRoot.length - a.projectRoot.length)[0];
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Get routes scoped to the project containing the given document.
|
|
176
|
+
* Falls back to all routes if no project match is found.
|
|
158
177
|
*/
|
|
159
|
-
getRoutes() {
|
|
160
|
-
|
|
178
|
+
getRoutes(documentPath) {
|
|
179
|
+
if (documentPath) {
|
|
180
|
+
const project = this.getProjectForFile(documentPath);
|
|
181
|
+
if (project)
|
|
182
|
+
return project.routes;
|
|
183
|
+
}
|
|
184
|
+
// Fallback: return all routes
|
|
185
|
+
return this.projects.flatMap((p) => p.routes);
|
|
161
186
|
}
|
|
162
187
|
/**
|
|
163
|
-
* Find a route by its URL path.
|
|
188
|
+
* Find a route by its URL path, scoped to the document's project.
|
|
164
189
|
*/
|
|
165
|
-
findByUrlPath(urlPath) {
|
|
166
|
-
return this.
|
|
190
|
+
findByUrlPath(urlPath, documentPath) {
|
|
191
|
+
return this.getRoutes(documentPath).find((r) => r.urlPath === urlPath);
|
|
167
192
|
}
|
|
168
193
|
/**
|
|
169
194
|
* Get the paired .server.ts file for a .page.ts file, or vice versa.
|
|
@@ -181,15 +206,16 @@ class RouteScanner {
|
|
|
181
206
|
}
|
|
182
207
|
/**
|
|
183
208
|
* Find routes whose URL path matches a given string (for go-to-definition).
|
|
184
|
-
*
|
|
209
|
+
* Scoped to the project containing the given document.
|
|
185
210
|
*/
|
|
186
|
-
findByUrlString(urlString) {
|
|
211
|
+
findByUrlString(urlString, documentPath) {
|
|
187
212
|
const normalized = urlString.startsWith('/') ? urlString : '/' + urlString;
|
|
188
|
-
const
|
|
213
|
+
const routes = this.getRoutes(documentPath);
|
|
214
|
+
const exact = routes.filter((r) => r.urlPath === normalized);
|
|
189
215
|
if (exact.length > 0)
|
|
190
216
|
return exact;
|
|
191
217
|
// Try matching parameterized routes: "/products/123" against "/products/:productId"
|
|
192
|
-
return
|
|
218
|
+
return routes.filter((r) => {
|
|
193
219
|
if (r.isCatchAll)
|
|
194
220
|
return false;
|
|
195
221
|
const routeParts = r.urlPath.split('/');
|