@intentius/chant 0.0.11 → 0.0.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.0.11",
3
+ "version": "0.0.12",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "files": ["src/", "bin/"],
@@ -108,6 +108,7 @@ export function lexiconCompletions(
108
108
  }
109
109
 
110
110
  // Inside constructor props — look for `new ClassName({` pattern
111
+ if (!ctx.content) return [];
111
112
  const constructorMatch = ctx.content.slice(0, ctx.content.split("\n").slice(0, ctx.position.line + 1).join("\n").length)
112
113
  .match(/\bnew\s+(\w+)\s*\(\s*(?:["'][^"']*["']\s*,\s*)?{[^}]*$/s);
113
114
 
@@ -0,0 +1,192 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { emitYAML, parseYAML, parseScalar } from "./yaml";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // emitYAML
6
+ // ---------------------------------------------------------------------------
7
+ describe("emitYAML", () => {
8
+ test("null", () => {
9
+ expect(emitYAML(null, 0)).toBe("null");
10
+ expect(emitYAML(undefined, 0)).toBe("null");
11
+ });
12
+
13
+ test("booleans", () => {
14
+ expect(emitYAML(true, 0)).toBe("true");
15
+ expect(emitYAML(false, 0)).toBe("false");
16
+ });
17
+
18
+ test("numbers", () => {
19
+ expect(emitYAML(42, 0)).toBe("42");
20
+ expect(emitYAML(3.14, 0)).toBe("3.14");
21
+ });
22
+
23
+ test("plain strings", () => {
24
+ expect(emitYAML("hello", 0)).toBe("hello");
25
+ });
26
+
27
+ test("strings requiring quoting", () => {
28
+ // boolean-like
29
+ expect(emitYAML("true", 0)).toBe("'true'");
30
+ expect(emitYAML("yes", 0)).toBe("'yes'");
31
+ // colon-space
32
+ expect(emitYAML("key: value", 0)).toBe("'key: value'");
33
+ // hash
34
+ expect(emitYAML("a # comment", 0)).toBe("'a # comment'");
35
+ // leading special chars
36
+ expect(emitYAML("$VAR", 0)).toBe("'$VAR'");
37
+ expect(emitYAML("!ref", 0)).toBe("'!ref'");
38
+ expect(emitYAML("*alias", 0)).toBe("'*alias'");
39
+ expect(emitYAML("{obj}", 0)).toBe("'{obj}'");
40
+ expect(emitYAML("[arr]", 0)).toBe("'[arr]'");
41
+ // leading digit
42
+ expect(emitYAML("123abc", 0)).toBe("'123abc'");
43
+ // empty string
44
+ expect(emitYAML("", 0)).toBe("''");
45
+ });
46
+
47
+ test("single-quote escaping", () => {
48
+ // A string that needs quoting (leading digit) AND contains a single quote
49
+ expect(emitYAML("1's", 0)).toBe("'1''s'");
50
+ });
51
+
52
+ test("empty array", () => {
53
+ expect(emitYAML([], 0)).toBe("[]");
54
+ });
55
+
56
+ test("simple array", () => {
57
+ const result = emitYAML(["a", "b"], 0);
58
+ expect(result).toBe("\n- a\n- b");
59
+ });
60
+
61
+ test("array of objects inlines first key on dash line", () => {
62
+ const result = emitYAML([{ name: "x", value: 1 }], 0);
63
+ expect(result).toContain("- name: x");
64
+ expect(result).toContain(" value: 1");
65
+ });
66
+
67
+ test("empty object", () => {
68
+ expect(emitYAML({}, 0)).toBe("{}");
69
+ });
70
+
71
+ test("nested object", () => {
72
+ const result = emitYAML({ a: { b: 1 } }, 0);
73
+ expect(result).toContain("a:");
74
+ expect(result).toContain(" b: 1");
75
+ });
76
+
77
+ test("tagged value with array", () => {
78
+ const result = emitYAML({ tag: "!reference", value: [".base", "script"] }, 0);
79
+ expect(result).toBe("!reference [.base, script]");
80
+ });
81
+
82
+ test("tagged value with scalar", () => {
83
+ const result = emitYAML({ tag: "!include", value: "file.yml" }, 0);
84
+ expect(result).toBe("!include file.yml");
85
+ });
86
+
87
+ test("indentation at depth", () => {
88
+ const result = emitYAML({ key: "val" }, 1);
89
+ expect(result).toBe("\n key: val");
90
+ });
91
+ });
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // parseYAML
95
+ // ---------------------------------------------------------------------------
96
+ describe("parseYAML", () => {
97
+ test("JSON passthrough", () => {
98
+ const result = parseYAML('{"a": 1}');
99
+ expect(result).toEqual({ a: 1 });
100
+ });
101
+
102
+ test("simple key-value", () => {
103
+ const result = parseYAML("name: hello\ncount: 42");
104
+ expect(result).toEqual({ name: "hello", count: 42 });
105
+ });
106
+
107
+ test("nested object", () => {
108
+ const result = parseYAML("parent:\n child: value");
109
+ expect(result).toEqual({ parent: { child: "value" } });
110
+ });
111
+
112
+ test("block array", () => {
113
+ const result = parseYAML("items:\n - a\n - b\n - c");
114
+ expect(result).toEqual({ items: ["a", "b", "c"] });
115
+ });
116
+
117
+ test("inline array", () => {
118
+ const result = parseYAML('items: ["a", "b"]');
119
+ expect(result).toEqual({ items: ["a", "b"] });
120
+ });
121
+
122
+ test("inline object", () => {
123
+ const result = parseYAML('data: {"x": 1}');
124
+ expect(result).toEqual({ data: { x: 1 } });
125
+ });
126
+
127
+ test("comments and blank lines are skipped", () => {
128
+ const result = parseYAML("# comment\na: 1\n\n# another\nb: 2");
129
+ expect(result).toEqual({ a: 1, b: 2 });
130
+ });
131
+
132
+ test("scalar coercion", () => {
133
+ const result = parseYAML("a: true\nb: false\nc: null\nd: yes\ne: no\nf: ~");
134
+ expect(result).toEqual({ a: true, b: false, c: null, d: true, e: false, f: null });
135
+ });
136
+
137
+ test("quoted strings preserve value", () => {
138
+ const result = parseYAML("a: 'true'\nb: \"42\"");
139
+ expect(result).toEqual({ a: "true", b: "42" });
140
+ });
141
+
142
+ test("handles CRLF line endings", () => {
143
+ const result = parseYAML("apiVersion: v1\r\nkind: Pod\r\nmetadata:\r\n name: test\r\n");
144
+ expect(result).toEqual({
145
+ apiVersion: "v1",
146
+ kind: "Pod",
147
+ metadata: { name: "test" },
148
+ });
149
+ });
150
+
151
+ test("handles bare CR line endings", () => {
152
+ const result = parseYAML("a: 1\rb: 2\r");
153
+ expect(result).toEqual({ a: 1, b: 2 });
154
+ });
155
+
156
+ test("array of objects", () => {
157
+ const result = parseYAML("items:\n - name: x\n value: 1\n - name: y\n value: 2");
158
+ expect(result).toEqual({
159
+ items: [
160
+ { name: "x", value: 1 },
161
+ { name: "y", value: 2 },
162
+ ],
163
+ });
164
+ });
165
+ });
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // parseScalar
169
+ // ---------------------------------------------------------------------------
170
+ describe("parseScalar", () => {
171
+ test("null variants", () => {
172
+ expect(parseScalar("")).toBe(null);
173
+ expect(parseScalar("~")).toBe(null);
174
+ expect(parseScalar("null")).toBe(null);
175
+ });
176
+
177
+ test("boolean variants", () => {
178
+ expect(parseScalar("true")).toBe(true);
179
+ expect(parseScalar("yes")).toBe(true);
180
+ expect(parseScalar("false")).toBe(false);
181
+ expect(parseScalar("no")).toBe(false);
182
+ });
183
+
184
+ test("numbers", () => {
185
+ expect(parseScalar("42")).toBe(42);
186
+ expect(parseScalar("3.14")).toBe(3.14);
187
+ });
188
+
189
+ test("plain strings", () => {
190
+ expect(parseScalar("hello")).toBe("hello");
191
+ });
192
+ });
package/src/yaml.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Lightweight YAML emitter and parser.
3
+ *
4
+ * Covers the subset of YAML used by Chant lexicons (scalars, block arrays,
5
+ * nested objects, tagged values). Not a full YAML implementation — use a
6
+ * dedicated library if you need anchors, multi-document streams, or
7
+ * block scalars.
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Emitter
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Emit a YAML value with proper indentation.
16
+ *
17
+ * - Primitives render inline.
18
+ * - Arrays and objects render as block YAML, returning a string that starts
19
+ * with `\n` so the caller can append it after a key.
20
+ * - Tagged values `{ tag, value }` emit `!tag [...]` or `!tag scalar`.
21
+ */
22
+ export function emitYAML(value: unknown, indent: number): string {
23
+ const prefix = " ".repeat(indent);
24
+
25
+ if (value === null || value === undefined) {
26
+ return "null";
27
+ }
28
+
29
+ if (typeof value === "boolean") {
30
+ return value ? "true" : "false";
31
+ }
32
+
33
+ if (typeof value === "number") {
34
+ return String(value);
35
+ }
36
+
37
+ if (typeof value === "string") {
38
+ // Quote strings that could be misinterpreted
39
+ if (
40
+ value === "" ||
41
+ value === "true" ||
42
+ value === "false" ||
43
+ value === "null" ||
44
+ value === "yes" ||
45
+ value === "no" ||
46
+ value.includes(": ") ||
47
+ value.includes("#") ||
48
+ value.startsWith("*") ||
49
+ value.startsWith("&") ||
50
+ value.startsWith("!") ||
51
+ value.startsWith("{") ||
52
+ value.startsWith("[") ||
53
+ value.startsWith("'") ||
54
+ value.startsWith('"') ||
55
+ value.startsWith("$") ||
56
+ /^\d/.test(value)
57
+ ) {
58
+ // Use single quotes, escaping internal single quotes
59
+ return `'${value.replace(/'/g, "''")}'`;
60
+ }
61
+ return value;
62
+ }
63
+
64
+ if (Array.isArray(value)) {
65
+ if (value.length === 0) return "[]";
66
+ const lines: string[] = [];
67
+ for (const item of value) {
68
+ if (typeof item === "object" && item !== null && !Array.isArray(item)) {
69
+ // Object items in arrays
70
+ const entries = Object.entries(item as Record<string, unknown>);
71
+ if (entries.length > 0) {
72
+ const [firstKey, firstVal] = entries[0];
73
+ const firstEmitted = emitYAML(firstVal, indent + 2);
74
+ if (firstEmitted.startsWith("\n")) {
75
+ lines.push(`${prefix}- ${firstKey}:${firstEmitted}`);
76
+ } else {
77
+ lines.push(`${prefix}- ${firstKey}: ${firstEmitted}`);
78
+ }
79
+ for (let i = 1; i < entries.length; i++) {
80
+ const [key, val] = entries[i];
81
+ const emitted = emitYAML(val, indent + 2);
82
+ if (emitted.startsWith("\n")) {
83
+ lines.push(`${prefix} ${key}:${emitted}`);
84
+ } else {
85
+ lines.push(`${prefix} ${key}: ${emitted}`);
86
+ }
87
+ }
88
+ }
89
+ } else {
90
+ lines.push(`${prefix}- ${emitYAML(item, indent + 1).trimStart()}`);
91
+ }
92
+ }
93
+ return "\n" + lines.join("\n");
94
+ }
95
+
96
+ if (typeof value === "object") {
97
+ const obj = value as Record<string, unknown>;
98
+
99
+ // Handle tagged values (e.g. { tag: "!reference", value: [...] })
100
+ if ("tag" in obj && "value" in obj && typeof obj.tag === "string") {
101
+ if (Array.isArray(obj.value)) {
102
+ return `${obj.tag} [${(obj.value as unknown[]).map(String).join(", ")}]`;
103
+ }
104
+ return `${obj.tag} ${emitYAML(obj.value, indent)}`;
105
+ }
106
+
107
+ const entries = Object.entries(obj);
108
+ if (entries.length === 0) return "{}";
109
+ const lines: string[] = [];
110
+ for (const [key, val] of entries) {
111
+ const emitted = emitYAML(val, indent + 1);
112
+ if (emitted.startsWith("\n")) {
113
+ lines.push(`${prefix}${key}:${emitted}`);
114
+ } else {
115
+ lines.push(`${prefix}${key}: ${emitted}`);
116
+ }
117
+ }
118
+ return "\n" + lines.join("\n");
119
+ }
120
+
121
+ return String(value);
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Parser
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /** Result of parsing a YAML block. */
129
+ export interface ParseResult {
130
+ value: unknown;
131
+ endIndex: number;
132
+ }
133
+
134
+ /**
135
+ * Parse a YAML document (or JSON document) into a plain object.
136
+ *
137
+ * Tries `JSON.parse` first; falls back to a line-based YAML parser that
138
+ * handles the subset of YAML commonly found in CI configuration files.
139
+ */
140
+ export function parseYAML(content: string): Record<string, unknown> {
141
+ try {
142
+ return JSON.parse(content);
143
+ } catch {
144
+ // Fall through to YAML parsing
145
+ }
146
+
147
+ const lines = content.replace(/\r\n?/g, "\n").split("\n");
148
+ return parseYAMLLines(lines, 0, 0).value as Record<string, unknown>;
149
+ }
150
+
151
+ /**
152
+ * Parse indentation-based YAML lines into a key-value object.
153
+ */
154
+ export function parseYAMLLines(
155
+ lines: string[],
156
+ startIndex: number,
157
+ baseIndent: number,
158
+ ): ParseResult {
159
+ const result: Record<string, unknown> = {};
160
+ let i = startIndex;
161
+
162
+ while (i < lines.length) {
163
+ const line = lines[i];
164
+ // Skip empty lines and comments
165
+ if (line.trim() === "" || line.trim().startsWith("#")) {
166
+ i++;
167
+ continue;
168
+ }
169
+
170
+ const indent = line.search(/\S/);
171
+ if (indent < baseIndent) break; // Dedented — done with this block
172
+ if (indent > baseIndent && startIndex > 0) break; // Unexpected indent
173
+
174
+ const keyMatch = line.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
175
+ if (keyMatch) {
176
+ const key = keyMatch[2].trim();
177
+ const inlineValue = keyMatch[3].trim();
178
+
179
+ if (inlineValue === "" || inlineValue.startsWith("#")) {
180
+ // Check next line for array or nested object
181
+ if (i + 1 < lines.length) {
182
+ const nextLine = lines[i + 1];
183
+ const nextIndent = nextLine.search(/\S/);
184
+ if (nextIndent > indent && nextLine.trimStart().startsWith("- ")) {
185
+ const arr = parseYAMLArray(lines, i + 1, nextIndent);
186
+ result[key] = arr.value;
187
+ i = arr.endIndex;
188
+ continue;
189
+ } else if (nextIndent > indent) {
190
+ const nested = parseYAMLLines(lines, i + 1, nextIndent);
191
+ result[key] = nested.value;
192
+ i = nested.endIndex;
193
+ continue;
194
+ }
195
+ }
196
+ result[key] = null;
197
+ i++;
198
+ } else if (inlineValue.startsWith("[")) {
199
+ // Inline array
200
+ try {
201
+ result[key] = JSON.parse(inlineValue);
202
+ } catch {
203
+ result[key] = inlineValue;
204
+ }
205
+ i++;
206
+ } else if (inlineValue.startsWith("{")) {
207
+ // Inline object
208
+ try {
209
+ result[key] = JSON.parse(inlineValue);
210
+ } catch {
211
+ result[key] = inlineValue;
212
+ }
213
+ i++;
214
+ } else {
215
+ result[key] = parseScalar(inlineValue);
216
+ i++;
217
+ }
218
+ } else if (line.trimStart().startsWith("- ")) {
219
+ break;
220
+ } else {
221
+ i++;
222
+ }
223
+ }
224
+
225
+ return { value: result, endIndex: i };
226
+ }
227
+
228
+ /**
229
+ * Parse a block array (lines starting with `- `).
230
+ */
231
+ export function parseYAMLArray(
232
+ lines: string[],
233
+ startIndex: number,
234
+ baseIndent: number,
235
+ ): ParseResult {
236
+ const result: unknown[] = [];
237
+ let i = startIndex;
238
+
239
+ while (i < lines.length) {
240
+ const line = lines[i];
241
+ if (line.trim() === "" || line.trim().startsWith("#")) {
242
+ i++;
243
+ continue;
244
+ }
245
+
246
+ const indent = line.search(/\S/);
247
+ if (indent < baseIndent) break;
248
+
249
+ const itemMatch = line.match(/^(\s*)- (.*)$/);
250
+ if (itemMatch && indent === baseIndent) {
251
+ const itemValue = itemMatch[2].trim();
252
+ // Check if it's a key-value pair (object item in array)
253
+ const kvMatch = itemValue.match(/^([^\s:][^:]*?):\s*(.*)$/);
254
+ if (kvMatch) {
255
+ const obj: Record<string, unknown> = {};
256
+ obj[kvMatch[1].trim()] = parseScalar(kvMatch[2].trim());
257
+ // Check for more keys at indent+2
258
+ const nextIndent = indent + 2;
259
+ let j = i + 1;
260
+ while (j < lines.length) {
261
+ const nextLine = lines[j];
262
+ if (nextLine.trim() === "" || nextLine.trim().startsWith("#")) {
263
+ j++;
264
+ continue;
265
+ }
266
+ const ni = nextLine.search(/\S/);
267
+ if (ni !== nextIndent) break;
268
+ const nextKV = nextLine.match(/^(\s*)([^\s:][^:]*?):\s*(.*)$/);
269
+ if (nextKV) {
270
+ obj[nextKV[2].trim()] = parseScalar(nextKV[3].trim());
271
+ j++;
272
+ } else {
273
+ break;
274
+ }
275
+ }
276
+ result.push(obj);
277
+ i = j;
278
+ } else {
279
+ result.push(parseScalar(itemValue));
280
+ i++;
281
+ }
282
+ } else {
283
+ break;
284
+ }
285
+ }
286
+
287
+ return { value: result, endIndex: i };
288
+ }
289
+
290
+ /**
291
+ * Coerce a scalar string to a typed value.
292
+ */
293
+ export function parseScalar(value: string): unknown {
294
+ if (value === "" || value === "~" || value === "null") return null;
295
+ if (value === "true" || value === "yes") return true;
296
+ if (value === "false" || value === "no") return false;
297
+ // Strip quotes
298
+ if (
299
+ (value.startsWith("'") && value.endsWith("'")) ||
300
+ (value.startsWith('"') && value.endsWith('"'))
301
+ ) {
302
+ return value.slice(1, -1);
303
+ }
304
+ // Number
305
+ const num = Number(value);
306
+ if (!isNaN(num) && value !== "") return num;
307
+ return value;
308
+ }