@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.
@@ -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,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 routes;
18
+ private projects;
18
19
  private workspaceRoot;
19
20
  constructor(workspaceRoot: string);
20
21
  /**
21
- * Perform initial scan of the pages directory.
22
+ * Perform initial scan of all pages directories in the workspace.
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,43 +89,106 @@ function collectFiles(dir, test) {
89
89
  return results;
90
90
  }
91
91
  /**
92
- * Scans the workspace for Analog page files and builds a route map.
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.routes = [];
139
+ this.projects = [];
97
140
  this.workspaceRoot = workspaceRoot;
98
141
  }
99
142
  /**
100
- * Perform initial scan of the pages directory.
143
+ * Perform initial scan of all pages directories in the workspace.
101
144
  */
102
145
  scan() {
103
- const pagesDir = path.join(this.workspaceRoot, 'src', 'app', 'pages');
104
- const pageFiles = collectFiles(pagesDir, (name) => name.endsWith('.page.ts'));
105
- this.routes = pageFiles.map((filePath) => {
106
- const urlPath = fileToUrlPath(filePath);
107
- const serverFilePath = filePath.replace(/\.page\.ts$/, '.server.ts');
108
- const hasServer = fs.existsSync(serverFilePath);
109
- return {
110
- urlPath,
111
- filePath,
112
- serverFilePath: hasServer ? serverFilePath : undefined,
113
- params: extractParams(urlPath),
114
- isCatchAll: urlPath.includes('**'),
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
- * Get all discovered routes.
164
+ * Determine which project a document belongs to based on its file path.
120
165
  */
121
- getRoutes() {
122
- return this.routes;
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.routes.find((r) => r.urlPath === urlPath);
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
- * Matches both exact paths and parameterized patterns.
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 exact = this.routes.filter((r) => r.urlPath === normalized);
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 this.routes.filter((r) => {
218
+ return routes.filter((r) => {
155
219
  if (r.isCatchAll)
156
220
  return false;
157
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.1",
4
+ "version": "0.2.4",
5
5
  "main": "out/index.js",
6
6
  "license": "MIT",
7
7
  "scripts": {