@rcrsr/rill-cli 0.6.0

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.
Files changed (171) hide show
  1. package/LICENSE +21 -0
  2. package/dist/check/config.d.ts +20 -0
  3. package/dist/check/config.d.ts.map +1 -0
  4. package/dist/check/config.js +151 -0
  5. package/dist/check/config.js.map +1 -0
  6. package/dist/check/fixer.d.ts +39 -0
  7. package/dist/check/fixer.d.ts.map +1 -0
  8. package/dist/check/fixer.js +119 -0
  9. package/dist/check/fixer.js.map +1 -0
  10. package/dist/check/index.d.ts +10 -0
  11. package/dist/check/index.d.ts.map +1 -0
  12. package/dist/check/index.js +21 -0
  13. package/dist/check/index.js.map +1 -0
  14. package/dist/check/rules/anti-patterns.d.ts +65 -0
  15. package/dist/check/rules/anti-patterns.d.ts.map +1 -0
  16. package/dist/check/rules/anti-patterns.js +481 -0
  17. package/dist/check/rules/anti-patterns.js.map +1 -0
  18. package/dist/check/rules/closures.d.ts +66 -0
  19. package/dist/check/rules/closures.d.ts.map +1 -0
  20. package/dist/check/rules/closures.js +370 -0
  21. package/dist/check/rules/closures.js.map +1 -0
  22. package/dist/check/rules/collections.d.ts +90 -0
  23. package/dist/check/rules/collections.d.ts.map +1 -0
  24. package/dist/check/rules/collections.js +373 -0
  25. package/dist/check/rules/collections.js.map +1 -0
  26. package/dist/check/rules/conditionals.d.ts +41 -0
  27. package/dist/check/rules/conditionals.d.ts.map +1 -0
  28. package/dist/check/rules/conditionals.js +134 -0
  29. package/dist/check/rules/conditionals.js.map +1 -0
  30. package/dist/check/rules/flow.d.ts +46 -0
  31. package/dist/check/rules/flow.d.ts.map +1 -0
  32. package/dist/check/rules/flow.js +206 -0
  33. package/dist/check/rules/flow.js.map +1 -0
  34. package/dist/check/rules/formatting.d.ts +143 -0
  35. package/dist/check/rules/formatting.d.ts.map +1 -0
  36. package/dist/check/rules/formatting.js +656 -0
  37. package/dist/check/rules/formatting.js.map +1 -0
  38. package/dist/check/rules/helpers.d.ts +26 -0
  39. package/dist/check/rules/helpers.d.ts.map +1 -0
  40. package/dist/check/rules/helpers.js +66 -0
  41. package/dist/check/rules/helpers.js.map +1 -0
  42. package/dist/check/rules/index.d.ts +21 -0
  43. package/dist/check/rules/index.d.ts.map +1 -0
  44. package/dist/check/rules/index.js +78 -0
  45. package/dist/check/rules/index.js.map +1 -0
  46. package/dist/check/rules/loops.d.ts +77 -0
  47. package/dist/check/rules/loops.d.ts.map +1 -0
  48. package/dist/check/rules/loops.js +310 -0
  49. package/dist/check/rules/loops.js.map +1 -0
  50. package/dist/check/rules/naming.d.ts +21 -0
  51. package/dist/check/rules/naming.d.ts.map +1 -0
  52. package/dist/check/rules/naming.js +174 -0
  53. package/dist/check/rules/naming.js.map +1 -0
  54. package/dist/check/rules/strings.d.ts +28 -0
  55. package/dist/check/rules/strings.d.ts.map +1 -0
  56. package/dist/check/rules/strings.js +79 -0
  57. package/dist/check/rules/strings.js.map +1 -0
  58. package/dist/check/rules/types.d.ts +41 -0
  59. package/dist/check/rules/types.d.ts.map +1 -0
  60. package/dist/check/rules/types.js +167 -0
  61. package/dist/check/rules/types.js.map +1 -0
  62. package/dist/check/types.d.ts +112 -0
  63. package/dist/check/types.d.ts.map +1 -0
  64. package/dist/check/types.js +6 -0
  65. package/dist/check/types.js.map +1 -0
  66. package/dist/check/validator.d.ts +18 -0
  67. package/dist/check/validator.d.ts.map +1 -0
  68. package/dist/check/validator.js +110 -0
  69. package/dist/check/validator.js.map +1 -0
  70. package/dist/check/visitor.d.ts +33 -0
  71. package/dist/check/visitor.d.ts.map +1 -0
  72. package/dist/check/visitor.js +259 -0
  73. package/dist/check/visitor.js.map +1 -0
  74. package/dist/cli-check.d.ts +43 -0
  75. package/dist/cli-check.d.ts.map +1 -0
  76. package/dist/cli-check.js +366 -0
  77. package/dist/cli-check.js.map +1 -0
  78. package/dist/cli-error-enrichment.d.ts +73 -0
  79. package/dist/cli-error-enrichment.d.ts.map +1 -0
  80. package/dist/cli-error-enrichment.js +205 -0
  81. package/dist/cli-error-enrichment.js.map +1 -0
  82. package/dist/cli-error-formatter.d.ts +45 -0
  83. package/dist/cli-error-formatter.d.ts.map +1 -0
  84. package/dist/cli-error-formatter.js +218 -0
  85. package/dist/cli-error-formatter.js.map +1 -0
  86. package/dist/cli-eval.d.ts +15 -0
  87. package/dist/cli-eval.d.ts.map +1 -0
  88. package/dist/cli-eval.js +116 -0
  89. package/dist/cli-eval.js.map +1 -0
  90. package/dist/cli-exec.d.ts +58 -0
  91. package/dist/cli-exec.d.ts.map +1 -0
  92. package/dist/cli-exec.js +326 -0
  93. package/dist/cli-exec.js.map +1 -0
  94. package/dist/cli-explain.d.ts +24 -0
  95. package/dist/cli-explain.d.ts.map +1 -0
  96. package/dist/cli-explain.js +68 -0
  97. package/dist/cli-explain.js.map +1 -0
  98. package/dist/cli-lsp-diagnostic.d.ts +35 -0
  99. package/dist/cli-lsp-diagnostic.d.ts.map +1 -0
  100. package/dist/cli-lsp-diagnostic.js +98 -0
  101. package/dist/cli-lsp-diagnostic.js.map +1 -0
  102. package/dist/cli-module-loader.d.ts +19 -0
  103. package/dist/cli-module-loader.d.ts.map +1 -0
  104. package/dist/cli-module-loader.js +83 -0
  105. package/dist/cli-module-loader.js.map +1 -0
  106. package/dist/cli-shared.d.ts +62 -0
  107. package/dist/cli-shared.d.ts.map +1 -0
  108. package/dist/cli-shared.js +158 -0
  109. package/dist/cli-shared.js.map +1 -0
  110. package/dist/cli.d.ts +13 -0
  111. package/dist/cli.d.ts.map +1 -0
  112. package/dist/cli.js +62 -0
  113. package/dist/cli.js.map +1 -0
  114. package/dist/test-internal-import.d.ts +2 -0
  115. package/dist/test-internal-import.d.ts.map +1 -0
  116. package/dist/test-internal-import.js +7 -0
  117. package/dist/test-internal-import.js.map +1 -0
  118. package/package.json +24 -0
  119. package/src/check/config.ts +202 -0
  120. package/src/check/fixer.ts +174 -0
  121. package/src/check/index.ts +39 -0
  122. package/src/check/rules/anti-patterns.ts +585 -0
  123. package/src/check/rules/closures.ts +445 -0
  124. package/src/check/rules/collections.ts +437 -0
  125. package/src/check/rules/conditionals.ts +155 -0
  126. package/src/check/rules/flow.ts +262 -0
  127. package/src/check/rules/formatting.ts +811 -0
  128. package/src/check/rules/helpers.ts +89 -0
  129. package/src/check/rules/index.ts +140 -0
  130. package/src/check/rules/loops.ts +372 -0
  131. package/src/check/rules/naming.ts +242 -0
  132. package/src/check/rules/strings.ts +104 -0
  133. package/src/check/rules/types.ts +214 -0
  134. package/src/check/types.ts +163 -0
  135. package/src/check/validator.ts +136 -0
  136. package/src/check/visitor.ts +338 -0
  137. package/src/cli-check.ts +456 -0
  138. package/src/cli-error-enrichment.ts +274 -0
  139. package/src/cli-error-formatter.ts +313 -0
  140. package/src/cli-eval.ts +145 -0
  141. package/src/cli-exec.ts +408 -0
  142. package/src/cli-explain.ts +76 -0
  143. package/src/cli-lsp-diagnostic.ts +132 -0
  144. package/src/cli-module-loader.ts +101 -0
  145. package/src/cli-shared.ts +187 -0
  146. package/tests/check/cli-check.test.ts +189 -0
  147. package/tests/check/config.test.ts +350 -0
  148. package/tests/check/fixer.test.ts +373 -0
  149. package/tests/check/format-diagnostics.test.ts +327 -0
  150. package/tests/check/rules/anti-patterns.test.ts +467 -0
  151. package/tests/check/rules/closures.test.ts +192 -0
  152. package/tests/check/rules/collections.test.ts +380 -0
  153. package/tests/check/rules/conditionals.test.ts +185 -0
  154. package/tests/check/rules/flow.test.ts +250 -0
  155. package/tests/check/rules/formatting.test.ts +755 -0
  156. package/tests/check/rules/loops.test.ts +334 -0
  157. package/tests/check/rules/naming.test.ts +336 -0
  158. package/tests/check/rules/strings.test.ts +129 -0
  159. package/tests/check/rules/types.test.ts +257 -0
  160. package/tests/check/validator.test.ts +444 -0
  161. package/tests/check/visitor.test.ts +171 -0
  162. package/tests/cli/check.test.ts +801 -0
  163. package/tests/cli/error-enrichment.test.ts +510 -0
  164. package/tests/cli/error-formatter.test.ts +631 -0
  165. package/tests/cli/eval.test.ts +85 -0
  166. package/tests/cli/exec.test.ts +537 -0
  167. package/tests/cli-explain.test.ts +249 -0
  168. package/tests/cli-lsp-diagnostic.test.ts +202 -0
  169. package/tests/cli-shared.test.ts +439 -0
  170. package/tsconfig.json +9 -0
  171. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,274 @@
1
+ /**
2
+ * CLI Error Enrichment
3
+ * Functions for extracting source snippets and suggesting similar names
4
+ */
5
+
6
+ import type { SourceSpan, RillError, CallFrame } from '@rcrsr/rill';
7
+
8
+ // ============================================================
9
+ // PUBLIC TYPES
10
+ // ============================================================
11
+
12
+ export interface SourceSnippet {
13
+ readonly lines: SnippetLine[];
14
+ readonly highlightSpan: SourceSpan;
15
+ }
16
+
17
+ export interface SnippetLine {
18
+ readonly lineNumber: number;
19
+ readonly content: string;
20
+ readonly isErrorLine: boolean;
21
+ }
22
+
23
+ export interface ScopeInfo {
24
+ readonly variableNames: string[];
25
+ readonly functionNames: string[];
26
+ }
27
+
28
+ export interface EnrichedError {
29
+ readonly errorId: string;
30
+ readonly message: string;
31
+ readonly span?: SourceSpan | undefined;
32
+ readonly context?: Record<string, unknown> | undefined;
33
+ readonly callStack?: CallFrame[] | undefined;
34
+ readonly sourceSnippet?: SourceSnippet | undefined;
35
+ readonly suggestions?: string[] | undefined;
36
+ readonly helpUrl?: string | undefined;
37
+ }
38
+
39
+ // ============================================================
40
+ // SOURCE SNIPPET EXTRACTION
41
+ // ============================================================
42
+
43
+ /**
44
+ * Extract source lines around error location.
45
+ *
46
+ * Constraints:
47
+ * - Context lines: 2 before, 2 after (configurable)
48
+ * - Line numbers: 1-based
49
+ * - Handles edge cases: line 1, last line
50
+ *
51
+ * @param source - Full source text
52
+ * @param span - Error location span
53
+ * @param contextLines - Number of context lines before/after (default: 2)
54
+ * @returns Snippet with context lines
55
+ * @throws {RangeError} When span exceeds source bounds
56
+ */
57
+ export function extractSnippet(
58
+ source: string,
59
+ span: SourceSpan,
60
+ contextLines: number = 2
61
+ ): SourceSnippet {
62
+ // EC-7: Empty source returns empty snippet
63
+ if (source === '') {
64
+ return { lines: [], highlightSpan: span };
65
+ }
66
+
67
+ const lines = source.split('\n');
68
+ const totalLines = lines.length;
69
+
70
+ // EC-6: Validate span is within bounds (1-based line numbers)
71
+ if (span.start.line < 1 || span.start.line > totalLines) {
72
+ throw new RangeError('Span exceeds source bounds');
73
+ }
74
+ if (span.end.line < 1 || span.end.line > totalLines) {
75
+ throw new RangeError('Span exceeds source bounds');
76
+ }
77
+
78
+ // Calculate context range
79
+ const errorStartLine = span.start.line;
80
+ const errorEndLine = span.end.line;
81
+ const firstLine = Math.max(1, errorStartLine - contextLines);
82
+ const lastLine = Math.min(totalLines, errorEndLine + contextLines);
83
+
84
+ // Build snippet lines
85
+ const snippetLines: SnippetLine[] = [];
86
+ for (let lineNum = firstLine; lineNum <= lastLine; lineNum++) {
87
+ const isErrorLine = lineNum >= errorStartLine && lineNum <= errorEndLine;
88
+ snippetLines.push({
89
+ lineNumber: lineNum,
90
+ content: lines[lineNum - 1] ?? '', // Convert 1-based to 0-based index
91
+ isErrorLine,
92
+ });
93
+ }
94
+
95
+ return {
96
+ lines: snippetLines,
97
+ highlightSpan: span,
98
+ };
99
+ }
100
+
101
+ // ============================================================
102
+ // NAME SUGGESTION
103
+ // ============================================================
104
+
105
+ /**
106
+ * Find similar names using fuzzy matching.
107
+ *
108
+ * Constraints:
109
+ * - Edit distance threshold: <= 2
110
+ * - Max suggestions: 3
111
+ * - Sort: ascending by distance, then alphabetically
112
+ *
113
+ * @param target - Name to match against
114
+ * @param candidates - Available names
115
+ * @returns Up to 3 similar names, sorted by distance then alphabetically
116
+ */
117
+ export function suggestSimilarNames(
118
+ target: string,
119
+ candidates: string[]
120
+ ): string[] {
121
+ // EC-9: Empty target returns []
122
+ if (target === '') {
123
+ return [];
124
+ }
125
+
126
+ // EC-10: Empty candidates returns []
127
+ if (candidates.length === 0) {
128
+ return [];
129
+ }
130
+
131
+ // Calculate edit distance for each candidate
132
+ const candidatesWithDistance = candidates
133
+ .map((candidate) => ({
134
+ name: candidate,
135
+ distance: levenshteinDistance(target, candidate),
136
+ }))
137
+ .filter((item) => item.distance <= 2); // IR-8: Edit distance threshold
138
+
139
+ // Sort: ascending by distance, then alphabetically
140
+ candidatesWithDistance.sort((a, b) => {
141
+ if (a.distance !== b.distance) {
142
+ return a.distance - b.distance;
143
+ }
144
+ return a.name.localeCompare(b.name);
145
+ });
146
+
147
+ // IR-8: Max 3 suggestions
148
+ return candidatesWithDistance.slice(0, 3).map((item) => item.name);
149
+ }
150
+
151
+ /**
152
+ * Calculate Levenshtein distance between two strings.
153
+ * Uses dynamic programming with O(m*n) time and O(min(m,n)) space.
154
+ *
155
+ * @param a - First string
156
+ * @param b - Second string
157
+ * @returns Edit distance (number of operations to transform a into b)
158
+ */
159
+ function levenshteinDistance(a: string, b: string): number {
160
+ // Ensure a is the shorter string for space optimization
161
+ if (a.length > b.length) {
162
+ [a, b] = [b, a];
163
+ }
164
+
165
+ const m = a.length;
166
+ const n = b.length;
167
+
168
+ // Early exit for empty strings
169
+ if (m === 0) return n;
170
+ if (n === 0) return m;
171
+
172
+ // Use rolling array optimization (only need previous row)
173
+ let prevRow = Array.from({ length: m + 1 }, (_, i) => i);
174
+ let currRow = new Array<number>(m + 1);
175
+
176
+ for (let j = 1; j <= n; j++) {
177
+ currRow[0] = j;
178
+
179
+ for (let i = 1; i <= m; i++) {
180
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
181
+ currRow[i] = Math.min(
182
+ prevRow[i]! + 1, // deletion
183
+ currRow[i - 1]! + 1, // insertion
184
+ prevRow[i - 1]! + cost // substitution
185
+ );
186
+ }
187
+
188
+ // Swap rows for next iteration
189
+ [prevRow, currRow] = [currRow, prevRow];
190
+ }
191
+
192
+ return prevRow[m] ?? 0;
193
+ }
194
+
195
+ // ============================================================
196
+ // ERROR ENRICHMENT
197
+ // ============================================================
198
+
199
+ /**
200
+ * Enrich RillError with source snippets and suggestions.
201
+ *
202
+ * Constraints:
203
+ * - Source must be valid UTF-8
204
+ * - Snippet extraction: 2 lines before, error line, 2 after
205
+ * - Fuzzy matching: edit distance <= 2
206
+ * - Max 3 suggestions
207
+ *
208
+ * @param error - RillError to enrich
209
+ * @param source - Full source text
210
+ * @param scope - Optional scope information for suggestions
211
+ * @returns Enriched error with snippet and suggestions
212
+ * @throws {TypeError} When source is not a string or error is null
213
+ */
214
+ export function enrichError(
215
+ error: RillError,
216
+ source: string,
217
+ scope?: ScopeInfo
218
+ ): EnrichedError {
219
+ // EC-4: Null error
220
+ if (!error) {
221
+ throw new TypeError('Error is required');
222
+ }
223
+
224
+ // EC-3: Invalid source encoding (JavaScript strings are always valid UTF-16)
225
+ if (typeof source !== 'string') {
226
+ throw new TypeError('Source must be valid UTF-8');
227
+ }
228
+
229
+ // Extract span from error location
230
+ let span: SourceSpan | undefined;
231
+ if (error.location) {
232
+ // Create a minimal span from the location (single character)
233
+ span = {
234
+ start: error.location,
235
+ end: error.location,
236
+ };
237
+ }
238
+
239
+ // Extract source snippet if we have a span
240
+ let sourceSnippet: SourceSnippet | undefined;
241
+ if (span && source !== '') {
242
+ try {
243
+ sourceSnippet = extractSnippet(source, span);
244
+ } catch {
245
+ // If snippet extraction fails (e.g., invalid span), skip it
246
+ sourceSnippet = undefined;
247
+ }
248
+ }
249
+
250
+ // Generate suggestions if scope info is provided
251
+ let suggestions: string[] | undefined;
252
+ if (scope && error.context) {
253
+ // Look for undefined variable names in context
254
+ const undefinedName = error.context['name'] as string | undefined;
255
+ if (undefinedName && typeof undefinedName === 'string') {
256
+ const candidates = [...scope.variableNames, ...scope.functionNames];
257
+ const similarNames = suggestSimilarNames(undefinedName, candidates);
258
+ if (similarNames.length > 0) {
259
+ suggestions = similarNames;
260
+ }
261
+ }
262
+ }
263
+
264
+ return {
265
+ errorId: error.errorId,
266
+ message: error.message.replace(/ at \d+:\d+$/, ''), // Strip location suffix
267
+ span,
268
+ context: error.context,
269
+ callStack: undefined, // Call stack not part of base RillError
270
+ sourceSnippet,
271
+ suggestions,
272
+ helpUrl: error.helpUrl,
273
+ };
274
+ }
@@ -0,0 +1,313 @@
1
+ /**
2
+ * CLI Error Formatter
3
+ * Format enriched errors for human-readable, JSON, or compact output
4
+ */
5
+
6
+ import type { SourceSpan, CallFrame } from '@rcrsr/rill';
7
+ import type { EnrichedError } from './cli-error-enrichment.js';
8
+
9
+ // ============================================================
10
+ // PUBLIC TYPES
11
+ // ============================================================
12
+
13
+ export type { CallFrame, EnrichedError };
14
+
15
+ /**
16
+ * Format options for error output.
17
+ */
18
+ export interface FormatOptions {
19
+ readonly format: 'human' | 'json' | 'compact';
20
+ readonly verbose: boolean;
21
+ readonly includeCallStack: boolean;
22
+ readonly maxCallStackDepth: number;
23
+ }
24
+
25
+ // ============================================================
26
+ // ERROR FORMATTING
27
+ // ============================================================
28
+
29
+ /**
30
+ * Format enriched error for output.
31
+ *
32
+ * Constraints:
33
+ * - Human format: multi-line with snippet and caret underline
34
+ * - JSON format: LSP Diagnostic compatible
35
+ * - Compact format: single line for CI output
36
+ *
37
+ * @param error - Enriched error with context
38
+ * @param options - Format options
39
+ * @returns Formatted error string
40
+ * @throws {TypeError} Unknown format
41
+ */
42
+ export function formatError(
43
+ error: EnrichedError,
44
+ options: FormatOptions
45
+ ): string {
46
+ // EC-5: Unknown format throws TypeError
47
+ if (
48
+ options.format !== 'human' &&
49
+ options.format !== 'json' &&
50
+ options.format !== 'compact'
51
+ ) {
52
+ throw new TypeError(`Unknown format: ${options.format}`);
53
+ }
54
+
55
+ if (options.format === 'json') {
56
+ return formatErrorJson(error, options);
57
+ }
58
+
59
+ if (options.format === 'compact') {
60
+ return formatErrorCompact(error);
61
+ }
62
+
63
+ return formatErrorHuman(error, options);
64
+ }
65
+
66
+ /**
67
+ * Format error in human-readable format.
68
+ *
69
+ * Output format:
70
+ * ```
71
+ * error[RILL-R005]: Variable foo is not defined
72
+ * --> script.rill:5:10
73
+ * |
74
+ * 3 | "start" => $begin
75
+ * 4 | $begin -> .upper => $upper
76
+ * 5 | $foo -> .len
77
+ * | ^^^^ undefined variable
78
+ * |
79
+ * = help: Did you mean `$begin`?
80
+ * ```
81
+ */
82
+ function formatErrorHuman(
83
+ error: EnrichedError,
84
+ options: FormatOptions
85
+ ): string {
86
+ const lines: string[] = [];
87
+
88
+ // Header: error[RILL-XXXX]: message
89
+ lines.push(`error[${error.errorId}]: ${error.message}`);
90
+
91
+ // Location: --> script.rill:5:10
92
+ if (error.span) {
93
+ const location = `${error.span.start.line}:${error.span.start.column}`;
94
+ lines.push(` --> ${location}`);
95
+ }
96
+
97
+ // Source snippet with caret underline
98
+ if (error.sourceSnippet && error.sourceSnippet.lines.length > 0) {
99
+ lines.push(' |');
100
+
101
+ // Calculate padding width for line numbers
102
+ const maxLineNumber = Math.max(
103
+ ...error.sourceSnippet.lines.map((l) => l.lineNumber)
104
+ );
105
+ const lineNumberWidth = String(maxLineNumber).length;
106
+
107
+ for (const line of error.sourceSnippet.lines) {
108
+ const lineNumStr = String(line.lineNumber).padStart(lineNumberWidth, ' ');
109
+ lines.push(` ${lineNumStr} | ${line.content}`);
110
+
111
+ // Add caret underline for error lines
112
+ if (line.isErrorLine && error.span) {
113
+ const caret = renderCaretUnderline(error.span, line.content);
114
+ const padding = ' '.repeat(lineNumberWidth);
115
+ lines.push(` ${padding} | ${caret}`);
116
+ }
117
+ }
118
+ lines.push(' |');
119
+ }
120
+
121
+ // Suggestions: = help: Did you mean `$begin`?
122
+ if (error.suggestions && error.suggestions.length > 0) {
123
+ for (const suggestion of error.suggestions) {
124
+ lines.push(` = help: ${suggestion}`);
125
+ }
126
+ }
127
+
128
+ // Help URL
129
+ if (options.verbose && error.helpUrl) {
130
+ lines.push(` = see: ${error.helpUrl}`);
131
+ }
132
+
133
+ // Call stack
134
+ if (
135
+ options.includeCallStack &&
136
+ error.callStack &&
137
+ error.callStack.length > 0
138
+ ) {
139
+ lines.push('');
140
+ lines.push('Call stack:');
141
+ const depth = Math.min(error.callStack.length, options.maxCallStackDepth);
142
+ for (let i = 0; i < depth; i++) {
143
+ const frame = error.callStack[i]!;
144
+ const location = `${frame.location.start.line}:${frame.location.start.column}`;
145
+ const name = frame.functionName ?? '<anonymous>';
146
+ const context = frame.context ? ` (${frame.context})` : '';
147
+ lines.push(` ${i + 1}. ${name}${context} at ${location}`);
148
+ }
149
+ if (error.callStack.length > depth) {
150
+ lines.push(` ... ${error.callStack.length - depth} more frames`);
151
+ }
152
+ }
153
+
154
+ return lines.join('\n');
155
+ }
156
+
157
+ /**
158
+ * Format error in JSON format (LSP Diagnostic compatible).
159
+ */
160
+ function formatErrorJson(error: EnrichedError, options: FormatOptions): string {
161
+ const diagnostic: {
162
+ errorId: string;
163
+ severity: number;
164
+ message: string;
165
+ range?: {
166
+ start: { line: number; character: number };
167
+ end: { line: number; character: number };
168
+ };
169
+ source: string;
170
+ code: string;
171
+ suggestions?: string[];
172
+ callStack?: Array<{
173
+ location: {
174
+ start: { line: number; character: number };
175
+ end: { line: number; character: number };
176
+ };
177
+ functionName?: string | undefined;
178
+ context?: string | undefined;
179
+ }>;
180
+ helpUrl?: string;
181
+ } = {
182
+ errorId: error.errorId,
183
+ severity: 1, // Error severity in LSP (1 = Error, 2 = Warning, 3 = Information, 4 = Hint)
184
+ message: error.message,
185
+ source: 'rill',
186
+ code: error.errorId,
187
+ };
188
+
189
+ if (error.span) {
190
+ diagnostic.range = {
191
+ start: {
192
+ line: error.span.start.line - 1, // LSP uses 0-based line numbers
193
+ character: error.span.start.column,
194
+ },
195
+ end: {
196
+ line: error.span.end.line - 1, // LSP uses 0-based line numbers
197
+ character: error.span.end.column,
198
+ },
199
+ };
200
+ }
201
+
202
+ if (error.suggestions && error.suggestions.length > 0) {
203
+ diagnostic.suggestions = error.suggestions;
204
+ }
205
+
206
+ if (
207
+ options.includeCallStack &&
208
+ error.callStack &&
209
+ error.callStack.length > 0
210
+ ) {
211
+ const depth = Math.min(error.callStack.length, options.maxCallStackDepth);
212
+ diagnostic.callStack = error.callStack.slice(0, depth).map((frame) => {
213
+ const callFrame: {
214
+ location: {
215
+ start: { line: number; character: number };
216
+ end: { line: number; character: number };
217
+ };
218
+ functionName?: string | undefined;
219
+ context?: string | undefined;
220
+ } = {
221
+ location: {
222
+ start: {
223
+ line: frame.location.start.line - 1,
224
+ character: frame.location.start.column,
225
+ },
226
+ end: {
227
+ line: frame.location.end.line - 1,
228
+ character: frame.location.end.column,
229
+ },
230
+ },
231
+ };
232
+ if (frame.functionName !== undefined) {
233
+ callFrame.functionName = frame.functionName;
234
+ }
235
+ if (frame.context !== undefined) {
236
+ callFrame.context = frame.context;
237
+ }
238
+ return callFrame;
239
+ });
240
+ }
241
+
242
+ if (options.verbose && error.helpUrl) {
243
+ diagnostic.helpUrl = error.helpUrl;
244
+ }
245
+
246
+ return JSON.stringify(diagnostic, null, 2);
247
+ }
248
+
249
+ /**
250
+ * Format error in compact format (single line for CI).
251
+ */
252
+ function formatErrorCompact(error: EnrichedError): string {
253
+ const parts: string[] = [`[${error.errorId}]`, error.message];
254
+
255
+ if (error.span) {
256
+ parts.push(`at ${error.span.start.line}:${error.span.start.column}`);
257
+ }
258
+
259
+ if (error.suggestions && error.suggestions.length > 0) {
260
+ parts.push(`(hint: ${error.suggestions[0]})`);
261
+ }
262
+
263
+ return parts.join(' ');
264
+ }
265
+
266
+ // ============================================================
267
+ // CARET UNDERLINE
268
+ // ============================================================
269
+
270
+ /**
271
+ * Render caret underline for error span.
272
+ *
273
+ * Constraints:
274
+ * - Single-char: single ^
275
+ * - Multi-char same line: ^^^^^ (length = span width)
276
+ * - Multi-line: ^^^^^ (continues) on first line only
277
+ *
278
+ * @param span - Error span
279
+ * @param lineContent - Content of the line
280
+ * @returns Caret underline string
281
+ * @throws {RangeError} Invalid span (start after end)
282
+ */
283
+ export function renderCaretUnderline(
284
+ span: SourceSpan,
285
+ lineContent: string
286
+ ): string {
287
+ // EC-8: Invalid span throws RangeError
288
+ if (
289
+ span.start.line > span.end.line ||
290
+ (span.start.line === span.end.line && span.start.column > span.end.column)
291
+ ) {
292
+ throw new RangeError('Span start must precede end');
293
+ }
294
+
295
+ // Calculate the width of the underline
296
+ const startColumn = span.start.column;
297
+ let endColumn: number;
298
+
299
+ if (span.start.line === span.end.line) {
300
+ // Single-line span: underline from start to end
301
+ endColumn = span.end.column;
302
+ } else {
303
+ // Multi-line span: underline to end of first line
304
+ endColumn = lineContent.length;
305
+ }
306
+
307
+ // Build underline: spaces before, carets for the span
308
+ const padding = ' '.repeat(startColumn);
309
+ const caretCount = Math.max(1, endColumn - startColumn);
310
+ const carets = '^'.repeat(caretCount);
311
+
312
+ return padding + carets;
313
+ }