@analogjs/language-server 0.2.1 → 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 +15 -9
- package/out/routeScanner.js +90 -26
- 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,31 +11,37 @@ 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
|
/**
|
|
21
|
-
* Perform initial scan of
|
|
22
|
+
* Perform initial scan of all pages directories in the workspace.
|
|
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,43 +89,106 @@ function collectFiles(dir, test) {
|
|
|
89
89
|
return results;
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
92
|
+
* Find all projects with `src/app/pages` directories.
|
|
93
|
+
* Returns an array of { projectRoot, pagesDir } pairs.
|
|
94
|
+
*/
|
|
95
|
+
function findProjects(root) {
|
|
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
|
+
}
|
|
105
|
+
// Direct project: root/src/app/pages
|
|
106
|
+
const direct = path.join(root, 'src', 'app', 'pages');
|
|
107
|
+
if (fs.existsSync(direct)) {
|
|
108
|
+
add(root, direct);
|
|
109
|
+
}
|
|
110
|
+
// Monorepo: scan common app directories
|
|
111
|
+
const monorepoParents = ['apps', 'projects', 'packages'];
|
|
112
|
+
for (const parent of monorepoParents) {
|
|
113
|
+
const parentDir = path.join(root, parent);
|
|
114
|
+
let entries;
|
|
115
|
+
try {
|
|
116
|
+
entries = fs.readdirSync(parentDir, { withFileTypes: true });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (!entry.isDirectory())
|
|
123
|
+
continue;
|
|
124
|
+
const projectRoot = path.join(parentDir, entry.name);
|
|
125
|
+
const candidate = path.join(projectRoot, 'src', 'app', 'pages');
|
|
126
|
+
if (fs.existsSync(candidate)) {
|
|
127
|
+
add(projectRoot, candidate);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return results;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Scans the workspace for Analog page files and builds a route map,
|
|
135
|
+
* grouped by project so monorepo routes are scoped correctly.
|
|
93
136
|
*/
|
|
94
137
|
class RouteScanner {
|
|
95
138
|
constructor(workspaceRoot) {
|
|
96
|
-
this.
|
|
139
|
+
this.projects = [];
|
|
97
140
|
this.workspaceRoot = workspaceRoot;
|
|
98
141
|
}
|
|
99
142
|
/**
|
|
100
|
-
* Perform initial scan of
|
|
143
|
+
* Perform initial scan of all pages directories in the workspace.
|
|
101
144
|
*/
|
|
102
145
|
scan() {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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 };
|
|
116
161
|
});
|
|
117
162
|
}
|
|
118
163
|
/**
|
|
119
|
-
*
|
|
164
|
+
* Determine which project a document belongs to based on its file path.
|
|
120
165
|
*/
|
|
121
|
-
|
|
122
|
-
|
|
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.
|
|
177
|
+
*/
|
|
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);
|
|
123
186
|
}
|
|
124
187
|
/**
|
|
125
|
-
* Find a route by its URL path.
|
|
188
|
+
* Find a route by its URL path, scoped to the document's project.
|
|
126
189
|
*/
|
|
127
|
-
findByUrlPath(urlPath) {
|
|
128
|
-
return this.
|
|
190
|
+
findByUrlPath(urlPath, documentPath) {
|
|
191
|
+
return this.getRoutes(documentPath).find((r) => r.urlPath === urlPath);
|
|
129
192
|
}
|
|
130
193
|
/**
|
|
131
194
|
* Get the paired .server.ts file for a .page.ts file, or vice versa.
|
|
@@ -143,15 +206,16 @@ class RouteScanner {
|
|
|
143
206
|
}
|
|
144
207
|
/**
|
|
145
208
|
* Find routes whose URL path matches a given string (for go-to-definition).
|
|
146
|
-
*
|
|
209
|
+
* Scoped to the project containing the given document.
|
|
147
210
|
*/
|
|
148
|
-
findByUrlString(urlString) {
|
|
211
|
+
findByUrlString(urlString, documentPath) {
|
|
149
212
|
const normalized = urlString.startsWith('/') ? urlString : '/' + urlString;
|
|
150
|
-
const
|
|
213
|
+
const routes = this.getRoutes(documentPath);
|
|
214
|
+
const exact = routes.filter((r) => r.urlPath === normalized);
|
|
151
215
|
if (exact.length > 0)
|
|
152
216
|
return exact;
|
|
153
217
|
// Try matching parameterized routes: "/products/123" against "/products/:productId"
|
|
154
|
-
return
|
|
218
|
+
return routes.filter((r) => {
|
|
155
219
|
if (r.isCatchAll)
|
|
156
220
|
return false;
|
|
157
221
|
const routeParts = r.urlPath.split('/');
|