@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.
@@ -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 for Analog's file-based routing.
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 both single and double quoted strings.
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
- // Find the string boundaries around the cursor
37
- let start = -1;
38
- let end = -1;
39
- let quote = '';
40
- for (let i = offset - 1; i >= 0; i--) {
41
- if ((line[i] === '"' || line[i] === "'") && (i === 0 || line[i - 1] !== '\\')) {
42
- start = i;
43
- quote = line[i];
44
- break;
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
- if (start === -1 || !quote)
48
- return undefined;
49
- for (let i = offset; i < line.length; i++) {
50
- if (line[i] === quote && line[i - 1] !== '\\') {
51
- end = i;
52
- break;
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
- if (end === -1)
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
- // Check for common routing patterns in the line
68
- return /routerLink|\.navigate\(|\.navigateByUrl\(|route\(|injectNavigate|injectNavigateByUrl|routerLink\s*=/.test(line);
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 for Analog's file-based routing.
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 routes = scanner.getRoutes();
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: route.params.length > 0 ? '1' : '0',
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 matches = scanner.findByUrlString(value);
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) => vscode.LocationLink.create(vscode_uri_1.URI.file(route.filePath).toString(), vscode.Range.create(0, 0, 0, 0), vscode.Range.create(0, 0, 0, 0), stringInfo.range));
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
  },
@@ -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 routes;
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
- * Get all discovered routes.
26
+ * Determine which project a document belongs to based on its file path.
26
27
  */
27
- getRoutes(): RouteInfo[];
28
+ private getProjectForFile;
28
29
  /**
29
- * Find a route by its URL path.
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
- findByUrlPath(urlPath: string): RouteInfo | undefined;
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
- * Matches both exact paths and parameterized patterns.
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
  }
@@ -89,17 +89,23 @@ function collectFiles(dir, test) {
89
89
  return results;
90
90
  }
91
91
  /**
92
- * Find all `pages` directories under a root by looking for the
93
- * `src/app/pages` convention at any nesting depth.
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 findPagesDirs(root) {
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
- results.push(direct);
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 candidate = path.join(parentDir, entry.name, 'src', 'app', 'pages');
124
+ const projectRoot = path.join(parentDir, entry.name);
125
+ const candidate = path.join(projectRoot, 'src', 'app', 'pages');
119
126
  if (fs.existsSync(candidate)) {
120
- results.push(candidate);
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.routes = [];
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
- const pagesDirs = findPagesDirs(this.workspaceRoot);
139
- const pageFiles = [];
140
- for (const dir of pagesDirs) {
141
- pageFiles.push(...collectFiles(dir, (name) => name.endsWith('.page.ts')));
142
- }
143
- this.routes = pageFiles.map((filePath) => {
144
- const urlPath = fileToUrlPath(filePath);
145
- const serverFilePath = filePath.replace(/\.page\.ts$/, '.server.ts');
146
- const hasServer = fs.existsSync(serverFilePath);
147
- return {
148
- urlPath,
149
- filePath,
150
- serverFilePath: hasServer ? serverFilePath : undefined,
151
- params: extractParams(urlPath),
152
- isCatchAll: urlPath.includes('**'),
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
- * Get all discovered routes.
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
- return this.routes;
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.routes.find((r) => r.urlPath === urlPath);
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
- * Matches both exact paths and parameterized patterns.
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 exact = this.routes.filter((r) => r.urlPath === normalized);
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 this.routes.filter((r) => {
218
+ return routes.filter((r) => {
193
219
  if (r.isCatchAll)
194
220
  return false;
195
221
  const routeParts = r.urlPath.split('/');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@analogjs/language-server",
3
3
  "description": "LSP server for AnalogJS Language Service",
4
- "version": "0.2.2",
4
+ "version": "0.2.4",
5
5
  "main": "out/index.js",
6
6
  "license": "MIT",
7
7
  "scripts": {