@checkstack/ui 0.2.4 → 0.3.1
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/CHANGELOG.md +38 -0
- package/package.json +10 -3
- package/src/components/CodeEditor/CodeEditor.tsx +420 -0
- package/src/components/CodeEditor/index.ts +10 -0
- package/src/components/CodeEditor/languageSupport/enterBehavior.test.ts +173 -0
- package/src/components/CodeEditor/languageSupport/enterBehavior.ts +35 -0
- package/src/components/CodeEditor/languageSupport/index.ts +22 -0
- package/src/components/CodeEditor/languageSupport/json-utils.ts +117 -0
- package/src/components/CodeEditor/languageSupport/json.test.ts +274 -0
- package/src/components/CodeEditor/languageSupport/json.ts +139 -0
- package/src/components/CodeEditor/languageSupport/markdown-utils.ts +65 -0
- package/src/components/CodeEditor/languageSupport/markdown.test.ts +245 -0
- package/src/components/CodeEditor/languageSupport/markdown.ts +134 -0
- package/src/components/CodeEditor/languageSupport/types.ts +48 -0
- package/src/components/CodeEditor/languageSupport/xml-utils.ts +94 -0
- package/src/components/CodeEditor/languageSupport/xml.test.ts +239 -0
- package/src/components/CodeEditor/languageSupport/xml.ts +116 -0
- package/src/components/CodeEditor/languageSupport/yaml-utils.ts +101 -0
- package/src/components/CodeEditor/languageSupport/yaml.test.ts +203 -0
- package/src/components/CodeEditor/languageSupport/yaml.ts +120 -0
- package/src/components/DynamicForm/DynamicForm.tsx +2 -24
- package/src/components/DynamicForm/DynamicOptionsField.tsx +48 -21
- package/src/components/DynamicForm/FormField.tsx +38 -70
- package/src/components/DynamicForm/JsonField.tsx +19 -25
- package/src/components/DynamicForm/KeyValueEditor.tsx +314 -0
- package/src/components/DynamicForm/MultiTypeEditorField.tsx +465 -0
- package/src/components/DynamicForm/index.ts +13 -0
- package/src/components/DynamicForm/types.ts +14 -8
- package/src/components/DynamicForm/utils.test.ts +390 -0
- package/src/components/DynamicForm/utils.ts +142 -3
- package/src/index.ts +1 -1
- package/src/components/TemplateEditor.test.ts +0 -156
- package/src/components/TemplateEditor.tsx +0 -435
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isValidXmlTemplatePosition,
|
|
5
|
+
calculateXmlIndentation,
|
|
6
|
+
} from "./xml-utils";
|
|
7
|
+
|
|
8
|
+
describe("isValidXmlTemplatePosition", () => {
|
|
9
|
+
describe("text content positions", () => {
|
|
10
|
+
it("returns true in text between tags", () => {
|
|
11
|
+
expect(isValidXmlTemplatePosition("<tag>hello")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns true after opening tag on new line", () => {
|
|
15
|
+
expect(isValidXmlTemplatePosition("<tag>\n ")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns true in text with existing template", () => {
|
|
19
|
+
expect(isValidXmlTemplatePosition("<tag>hello {{name}}")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns true in empty text position", () => {
|
|
23
|
+
expect(isValidXmlTemplatePosition("<tag>")).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("attribute value positions", () => {
|
|
28
|
+
it("returns true inside double-quoted attribute value", () => {
|
|
29
|
+
expect(isValidXmlTemplatePosition('<tag attr="')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns true inside single-quoted attribute value", () => {
|
|
33
|
+
expect(isValidXmlTemplatePosition("<tag attr='")).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns true in middle of attribute value", () => {
|
|
37
|
+
expect(isValidXmlTemplatePosition('<tag attr="hello')).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns true in attribute with template", () => {
|
|
41
|
+
expect(isValidXmlTemplatePosition('<tag attr="{{name}}')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("invalid positions", () => {
|
|
46
|
+
it("returns false inside tag name", () => {
|
|
47
|
+
expect(isValidXmlTemplatePosition("<ta")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns false for empty string", () => {
|
|
51
|
+
expect(isValidXmlTemplatePosition("")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns false in attribute name position", () => {
|
|
55
|
+
expect(isValidXmlTemplatePosition("<tag at")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns false right after opening bracket", () => {
|
|
59
|
+
expect(isValidXmlTemplatePosition("<")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("complex nested structures", () => {
|
|
64
|
+
it("returns true in nested tag text content", () => {
|
|
65
|
+
expect(isValidXmlTemplatePosition("<root><child>text")).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns true in deeply nested text", () => {
|
|
69
|
+
expect(isValidXmlTemplatePosition("<a><b><c>")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns true after self-closing tag", () => {
|
|
73
|
+
expect(isValidXmlTemplatePosition("<root><self/>text")).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("calculateXmlIndentation", () => {
|
|
79
|
+
const INDENT = 2;
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// Basic tag indentation
|
|
83
|
+
// ============================================================================
|
|
84
|
+
describe("basic tag indentation", () => {
|
|
85
|
+
it("returns 0 for empty document", () => {
|
|
86
|
+
expect(calculateXmlIndentation("", INDENT)).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("indents after opening tag (new line)", () => {
|
|
90
|
+
// User typed "<root>" and pressed Enter
|
|
91
|
+
expect(calculateXmlIndentation("<root>\n", INDENT)).toBe(INDENT);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("indents when inside an unclosed opening tag", () => {
|
|
95
|
+
// After an opening tag, depth is 1 even on same line
|
|
96
|
+
expect(calculateXmlIndentation("<root>", INDENT)).toBe(INDENT);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns 0 after self-closing tag", () => {
|
|
100
|
+
expect(calculateXmlIndentation("<self/>\n", INDENT)).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("returns 0 after closing tag", () => {
|
|
104
|
+
expect(calculateXmlIndentation("</root>\n", INDENT)).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns 0 after complete open-close on same line", () => {
|
|
108
|
+
expect(calculateXmlIndentation("<tag></tag>\n", INDENT)).toBe(0);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// Nested structure indentation
|
|
114
|
+
// ============================================================================
|
|
115
|
+
describe("nested structure indentation", () => {
|
|
116
|
+
it("indents to level 2 after nested opening tag", () => {
|
|
117
|
+
const text = `<root>
|
|
118
|
+
<child>\n`;
|
|
119
|
+
expect(calculateXmlIndentation(text, INDENT)).toBe(2 * INDENT);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("maintains level 1 after nested closing tag", () => {
|
|
123
|
+
const text = `<root>
|
|
124
|
+
<child></child>\n`;
|
|
125
|
+
expect(calculateXmlIndentation(text, INDENT)).toBe(INDENT);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("indents to level 3 for deep nesting", () => {
|
|
129
|
+
const text = `<root>
|
|
130
|
+
<a>
|
|
131
|
+
<b>\n`;
|
|
132
|
+
expect(calculateXmlIndentation(text, INDENT)).toBe(3 * INDENT);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("handles mixed self-closing and regular tags", () => {
|
|
136
|
+
const text = `<root>
|
|
137
|
+
<self/>
|
|
138
|
+
<child>\n`;
|
|
139
|
+
expect(calculateXmlIndentation(text, INDENT)).toBe(2 * INDENT);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ============================================================================
|
|
144
|
+
// Template handling (no interference with structure)
|
|
145
|
+
// ============================================================================
|
|
146
|
+
describe("template handling", () => {
|
|
147
|
+
it("counts depth correctly with template in text content", () => {
|
|
148
|
+
// Template doesn't affect tag depth counting
|
|
149
|
+
expect(calculateXmlIndentation("<tag>{{value}}", INDENT)).toBe(INDENT);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("indents after tag even with template content", () => {
|
|
153
|
+
expect(calculateXmlIndentation("<tag>\n {{value}}\n", INDENT)).toBe(
|
|
154
|
+
INDENT,
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("handles template in attribute value", () => {
|
|
159
|
+
expect(calculateXmlIndentation('<tag attr="{{value}}">\n', INDENT)).toBe(
|
|
160
|
+
INDENT,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("handles multiple templates", () => {
|
|
165
|
+
const text = `<root>
|
|
166
|
+
<a>{{val1}}</a>
|
|
167
|
+
<b>{{val2}}</b>\n`;
|
|
168
|
+
expect(calculateXmlIndentation(text, INDENT)).toBe(INDENT);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ============================================================================
|
|
173
|
+
// Edge cases
|
|
174
|
+
// ============================================================================
|
|
175
|
+
describe("edge cases", () => {
|
|
176
|
+
it("handles only newline", () => {
|
|
177
|
+
expect(calculateXmlIndentation("\n", INDENT)).toBe(0);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("handles multiple newlines", () => {
|
|
181
|
+
expect(calculateXmlIndentation("\n\n\n", INDENT)).toBe(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("handles XML declaration", () => {
|
|
185
|
+
expect(calculateXmlIndentation('<?xml version="1.0"?>\n', INDENT)).toBe(
|
|
186
|
+
0,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("handles tag with attributes", () => {
|
|
191
|
+
expect(calculateXmlIndentation('<tag attr="value">\n', INDENT)).toBe(
|
|
192
|
+
INDENT,
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("handles tag with multiple attributes", () => {
|
|
197
|
+
expect(calculateXmlIndentation('<tag a="1" b="2" c="3">\n', INDENT)).toBe(
|
|
198
|
+
INDENT,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("handles CDATA section (counts parent tag)", () => {
|
|
203
|
+
// CDATA is inside an unclosed tag
|
|
204
|
+
expect(calculateXmlIndentation("<tag><![CDATA[", INDENT)).toBe(INDENT);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("handles comments", () => {
|
|
208
|
+
expect(calculateXmlIndentation("<!-- comment -->\n", INDENT)).toBe(0);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// ============================================================================
|
|
213
|
+
// Real-world document structures
|
|
214
|
+
// ============================================================================
|
|
215
|
+
describe("real-world document structures", () => {
|
|
216
|
+
it("indents HTML-like structure", () => {
|
|
217
|
+
const text = `<html>
|
|
218
|
+
<head>
|
|
219
|
+
<title>Test</title>
|
|
220
|
+
</head>
|
|
221
|
+
<body>\n`;
|
|
222
|
+
expect(calculateXmlIndentation(text, INDENT)).toBe(2 * INDENT);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("handles configuration-style XML", () => {
|
|
226
|
+
const text = `<config>
|
|
227
|
+
<database>
|
|
228
|
+
<host>\n`;
|
|
229
|
+
expect(calculateXmlIndentation(text, INDENT)).toBe(3 * INDENT);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("handles SOAP-style envelope", () => {
|
|
233
|
+
const text = `<soap:Envelope>
|
|
234
|
+
<soap:Body>
|
|
235
|
+
<Request>\n`;
|
|
236
|
+
expect(calculateXmlIndentation(text, INDENT)).toBe(3 * INDENT);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { xml } from "@codemirror/lang-xml";
|
|
2
|
+
import { Decoration } from "@codemirror/view";
|
|
3
|
+
import type { LanguageSupport, DecorationRange } from "./types";
|
|
4
|
+
|
|
5
|
+
// Re-export pure utils for backwards compatibility
|
|
6
|
+
export {
|
|
7
|
+
isValidXmlTemplatePosition,
|
|
8
|
+
calculateXmlIndentation,
|
|
9
|
+
} from "./xml-utils";
|
|
10
|
+
|
|
11
|
+
// Decoration marks for XML syntax highlighting using inline styles
|
|
12
|
+
const xmlTagMark = Decoration.mark({
|
|
13
|
+
attributes: { style: "color: hsl(280, 65%, 60%)" },
|
|
14
|
+
});
|
|
15
|
+
const xmlAttrNameMark = Decoration.mark({
|
|
16
|
+
attributes: { style: "color: hsl(190, 70%, 50%)" },
|
|
17
|
+
});
|
|
18
|
+
const xmlAttrValueMark = Decoration.mark({
|
|
19
|
+
attributes: { style: "color: hsl(142.1, 76.2%, 36.3%)" },
|
|
20
|
+
});
|
|
21
|
+
const templateMark = Decoration.mark({
|
|
22
|
+
attributes: { style: "color: hsl(190, 70%, 50%); font-weight: 500" },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build XML + template decorations.
|
|
27
|
+
*/
|
|
28
|
+
function buildXmlDecorations(doc: string): DecorationRange[] {
|
|
29
|
+
const ranges: DecorationRange[] = [];
|
|
30
|
+
|
|
31
|
+
// Match templates first (highest priority)
|
|
32
|
+
const templateRegex = /\{\{[\w.[\]]*\}\}/g;
|
|
33
|
+
let match;
|
|
34
|
+
while ((match = templateRegex.exec(doc)) !== null) {
|
|
35
|
+
ranges.push({
|
|
36
|
+
from: match.index,
|
|
37
|
+
to: match.index + match[0].length,
|
|
38
|
+
decoration: templateMark,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Match XML tags (opening and closing)
|
|
43
|
+
const tagRegex = /<\/?[\w:-]+/g;
|
|
44
|
+
while ((match = tagRegex.exec(doc)) !== null) {
|
|
45
|
+
ranges.push({
|
|
46
|
+
from: match.index,
|
|
47
|
+
to: match.index + match[0].length,
|
|
48
|
+
decoration: xmlTagMark,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Match closing > and />
|
|
53
|
+
const closeTagRegex = /\/?>/g;
|
|
54
|
+
while ((match = closeTagRegex.exec(doc)) !== null) {
|
|
55
|
+
ranges.push({
|
|
56
|
+
from: match.index,
|
|
57
|
+
to: match.index + match[0].length,
|
|
58
|
+
decoration: xmlTagMark,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Match attribute names
|
|
63
|
+
const attrNameRegex = /\s([\w:-]+)=/g;
|
|
64
|
+
while ((match = attrNameRegex.exec(doc)) !== null) {
|
|
65
|
+
ranges.push({
|
|
66
|
+
from: match.index + 1, // Skip leading space
|
|
67
|
+
to: match.index + 1 + match[1].length,
|
|
68
|
+
decoration: xmlAttrNameMark,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Match attribute values (quoted strings after =)
|
|
73
|
+
const attrValueRegex = /=(["'])(?:(?!\1)[^\\]|\\.)*\1/g;
|
|
74
|
+
while ((match = attrValueRegex.exec(doc)) !== null) {
|
|
75
|
+
// Skip the = but include the quotes
|
|
76
|
+
ranges.push({
|
|
77
|
+
from: match.index + 1,
|
|
78
|
+
to: match.index + match[0].length,
|
|
79
|
+
decoration: xmlAttrValueMark,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Sort by position
|
|
84
|
+
ranges.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
85
|
+
|
|
86
|
+
// Remove overlaps (templates take priority)
|
|
87
|
+
const filtered: DecorationRange[] = [];
|
|
88
|
+
for (const range of ranges) {
|
|
89
|
+
const overlaps = filtered.some(
|
|
90
|
+
(existing) =>
|
|
91
|
+
(range.from >= existing.from && range.from < existing.to) ||
|
|
92
|
+
(range.to > existing.from && range.to <= existing.to),
|
|
93
|
+
);
|
|
94
|
+
if (!overlaps) {
|
|
95
|
+
filtered.push(range);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return filtered;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Import the pure utils for use in the language support object
|
|
103
|
+
import {
|
|
104
|
+
isValidXmlTemplatePosition,
|
|
105
|
+
calculateXmlIndentation,
|
|
106
|
+
} from "./xml-utils";
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* XML language support for CodeEditor with template expression handling.
|
|
110
|
+
*/
|
|
111
|
+
export const xmlLanguageSupport: LanguageSupport = {
|
|
112
|
+
extension: xml(),
|
|
113
|
+
buildDecorations: buildXmlDecorations,
|
|
114
|
+
isValidTemplatePosition: isValidXmlTemplatePosition,
|
|
115
|
+
calculateIndentation: calculateXmlIndentation,
|
|
116
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure utility functions for YAML parsing that don't depend on CodeMirror.
|
|
3
|
+
* These are extracted to allow testing without triggering CodeMirror's module loading.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if cursor is in a valid YAML template position.
|
|
8
|
+
* In YAML, templates can appear as values after "key: ".
|
|
9
|
+
* @internal Exported for testing
|
|
10
|
+
*/
|
|
11
|
+
export function isValidYamlTemplatePosition(text: string): boolean {
|
|
12
|
+
// Find the current line
|
|
13
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
14
|
+
const currentLine = text.slice(lastNewline + 1);
|
|
15
|
+
|
|
16
|
+
// Valid positions in YAML:
|
|
17
|
+
// 1. After a colon and space (value position): "key: |"
|
|
18
|
+
// 2. Inside a quoted string: 'key: "hello|'
|
|
19
|
+
// 3. In a list item position: "- |"
|
|
20
|
+
|
|
21
|
+
// Check if we're inside a quoted string
|
|
22
|
+
const singleQuotes = (currentLine.match(/'/g) || []).length;
|
|
23
|
+
const doubleQuotes = (currentLine.match(/"/g) || []).length;
|
|
24
|
+
if (singleQuotes % 2 === 1 || doubleQuotes % 2 === 1) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check if we're after "key: " (value position)
|
|
29
|
+
const colonMatch = currentLine.match(/^\s*[\w-]+:\s+/);
|
|
30
|
+
if (colonMatch) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check if we're in a list item position "- "
|
|
35
|
+
const listMatch = currentLine.match(/^\s*-\s+/);
|
|
36
|
+
if (listMatch) {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Check if we're after ": " anywhere in the line
|
|
41
|
+
if (currentLine.includes(": ")) {
|
|
42
|
+
const afterColon = currentLine.split(": ").slice(1).join(": ");
|
|
43
|
+
// If we're in the value part (after colon)
|
|
44
|
+
if (afterColon.length > 0 || currentLine.endsWith(": ")) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate indentation for YAML, properly handling template expressions.
|
|
54
|
+
* YAML uses indentation-based structure.
|
|
55
|
+
* @internal Exported for testing
|
|
56
|
+
*/
|
|
57
|
+
export function calculateYamlIndentation(
|
|
58
|
+
textBefore: string,
|
|
59
|
+
indentUnit: number,
|
|
60
|
+
): number {
|
|
61
|
+
const lines = textBefore.split("\n");
|
|
62
|
+
if (lines.length === 0) return 0;
|
|
63
|
+
|
|
64
|
+
// Get the last line (the one we're currently on after Enter)
|
|
65
|
+
const currentLine = lines.at(-1);
|
|
66
|
+
|
|
67
|
+
// If current line is empty or whitespace, look at previous line
|
|
68
|
+
if (currentLine?.trim().length === 0 && lines.length > 1) {
|
|
69
|
+
// Find the last non-empty line
|
|
70
|
+
for (let i = lines.length - 2; i >= 0; i--) {
|
|
71
|
+
const line = lines[i];
|
|
72
|
+
if (line.trim().length === 0) continue;
|
|
73
|
+
|
|
74
|
+
// Count leading spaces
|
|
75
|
+
const leadingSpaces = line.match(/^(\s*)/)?.[1].length ?? 0;
|
|
76
|
+
|
|
77
|
+
// If line ends with ":" it's a key that starts a new block
|
|
78
|
+
if (line.trimEnd().endsWith(":")) {
|
|
79
|
+
return leadingSpaces + indentUnit;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// If line starts with "- " it's a list item
|
|
83
|
+
if (line.trim().startsWith("-")) {
|
|
84
|
+
return leadingSpaces + indentUnit;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Otherwise, maintain current indentation
|
|
88
|
+
return leadingSpaces;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// For non-empty current line, find previous non-empty for context
|
|
93
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
94
|
+
const line = lines[i];
|
|
95
|
+
if (line.trim().length === 0) continue;
|
|
96
|
+
const leadingSpaces = line.match(/^(\s*)/)?.[1].length ?? 0;
|
|
97
|
+
return leadingSpaces;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
isValidYamlTemplatePosition,
|
|
5
|
+
calculateYamlIndentation,
|
|
6
|
+
} from "./yaml-utils";
|
|
7
|
+
|
|
8
|
+
describe("isValidYamlTemplatePosition", () => {
|
|
9
|
+
describe("value positions", () => {
|
|
10
|
+
it("returns true after 'key: '", () => {
|
|
11
|
+
expect(isValidYamlTemplatePosition("name: ")).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns true after 'key: ' with existing value", () => {
|
|
15
|
+
expect(isValidYamlTemplatePosition("name: hello")).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns true in list item position", () => {
|
|
19
|
+
expect(isValidYamlTemplatePosition("- ")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns true inside double-quoted string", () => {
|
|
23
|
+
expect(isValidYamlTemplatePosition('name: "hello')).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns true inside single-quoted string", () => {
|
|
27
|
+
expect(isValidYamlTemplatePosition("name: 'hello")).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("invalid positions", () => {
|
|
32
|
+
it("returns false for key position", () => {
|
|
33
|
+
expect(isValidYamlTemplatePosition("name")).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns false for empty string", () => {
|
|
37
|
+
expect(isValidYamlTemplatePosition("")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("calculateYamlIndentation", () => {
|
|
43
|
+
const INDENT = 2;
|
|
44
|
+
|
|
45
|
+
// ============================================================================
|
|
46
|
+
// Basic YAML key:value patterns
|
|
47
|
+
// ============================================================================
|
|
48
|
+
describe("basic key:value indentation", () => {
|
|
49
|
+
it("returns 0 for empty document", () => {
|
|
50
|
+
expect(calculateYamlIndentation("", INDENT)).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("indents after key ending with colon (new line)", () => {
|
|
54
|
+
// User typed "test:" and pressed Enter
|
|
55
|
+
// textBefore = "test:\n" (cursor at start of empty new line)
|
|
56
|
+
expect(calculateYamlIndentation("test:\n", INDENT)).toBe(INDENT);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("indents after key with trailing whitespace", () => {
|
|
60
|
+
expect(calculateYamlIndentation("test: \n", INDENT)).toBe(INDENT);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("maintains 0 indent for same-line content after key", () => {
|
|
64
|
+
// User is typing on the line "test:" without pressing Enter
|
|
65
|
+
expect(calculateYamlIndentation("test:", INDENT)).toBe(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("maintains 0 indent for key:value on same line", () => {
|
|
69
|
+
expect(calculateYamlIndentation("test: value", INDENT)).toBe(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ============================================================================
|
|
74
|
+
// Nested object indentation
|
|
75
|
+
// ============================================================================
|
|
76
|
+
describe("nested object indentation", () => {
|
|
77
|
+
it("indents to level 2 after nested key", () => {
|
|
78
|
+
const text = `parent:
|
|
79
|
+
child:\n`;
|
|
80
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(2 * INDENT);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("maintains level 1 after nested key:value", () => {
|
|
84
|
+
const text = `parent:
|
|
85
|
+
child: value\n`;
|
|
86
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(INDENT);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("indents to level 3 for deep nesting", () => {
|
|
90
|
+
const text = `level1:
|
|
91
|
+
level2:
|
|
92
|
+
level3:\n`;
|
|
93
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(3 * INDENT);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns to level 1 after deep nesting with value", () => {
|
|
97
|
+
const text = `level1:
|
|
98
|
+
level2:
|
|
99
|
+
level3: value\n`;
|
|
100
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(2 * INDENT);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// List item indentation
|
|
106
|
+
// ============================================================================
|
|
107
|
+
describe("list item indentation", () => {
|
|
108
|
+
it("indents after list item", () => {
|
|
109
|
+
expect(calculateYamlIndentation("- item\n", INDENT)).toBe(INDENT);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("indents after list item with nested key", () => {
|
|
113
|
+
const text = `items:
|
|
114
|
+
- name: test\n`;
|
|
115
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(2 * INDENT);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("indents after empty list marker", () => {
|
|
119
|
+
expect(calculateYamlIndentation("-\n", INDENT)).toBe(INDENT);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("handles list with object items", () => {
|
|
123
|
+
const text = `items:
|
|
124
|
+
- name: test
|
|
125
|
+
value: 123\n`;
|
|
126
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(2 * INDENT);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// Template handling (no interference with structure)
|
|
132
|
+
// ============================================================================
|
|
133
|
+
describe("template handling", () => {
|
|
134
|
+
it("maintains indentation with template value on same line", () => {
|
|
135
|
+
expect(calculateYamlIndentation("name: {{value}}", INDENT)).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("indents after key even when value contains template", () => {
|
|
139
|
+
expect(calculateYamlIndentation("name: {{value}}\n", INDENT)).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("maintains nesting with template in nested value", () => {
|
|
143
|
+
const text = `parent:
|
|
144
|
+
child: {{value}}\n`;
|
|
145
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(INDENT);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("indents after key with template but ending with colon", () => {
|
|
149
|
+
const text = `parent:
|
|
150
|
+
child:\n`;
|
|
151
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(2 * INDENT);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ============================================================================
|
|
156
|
+
// Multi-line string handling
|
|
157
|
+
// ============================================================================
|
|
158
|
+
describe("multi-line scenarios", () => {
|
|
159
|
+
it("maintains current indent after blank line", () => {
|
|
160
|
+
const text = `parent:
|
|
161
|
+
child: value
|
|
162
|
+
|
|
163
|
+
`;
|
|
164
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(INDENT);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("handles multiple blank lines", () => {
|
|
168
|
+
const text = `parent:
|
|
169
|
+
child: value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
`;
|
|
173
|
+
expect(calculateYamlIndentation(text, INDENT)).toBe(INDENT);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// Edge cases
|
|
179
|
+
// ============================================================================
|
|
180
|
+
describe("edge cases", () => {
|
|
181
|
+
it("handles only newline", () => {
|
|
182
|
+
expect(calculateYamlIndentation("\n", INDENT)).toBe(0);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("handles multiple newlines only", () => {
|
|
186
|
+
expect(calculateYamlIndentation("\n\n\n", INDENT)).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("handles colon in string value", () => {
|
|
190
|
+
expect(
|
|
191
|
+
calculateYamlIndentation('url: "http://example.com"\n', INDENT),
|
|
192
|
+
).toBe(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("handles key with numbers", () => {
|
|
196
|
+
expect(calculateYamlIndentation("key123:\n", INDENT)).toBe(INDENT);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("handles key with hyphens", () => {
|
|
200
|
+
expect(calculateYamlIndentation("my-key:\n", INDENT)).toBe(INDENT);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|