@condorcet.vote/cef-writer 1.2.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 (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +419 -0
  3. package/dist/Cef.d.ts +188 -0
  4. package/dist/Cef.d.ts.map +1 -0
  5. package/dist/Cef.js +291 -0
  6. package/dist/CefFormat.d.ts +57 -0
  7. package/dist/CefFormat.d.ts.map +1 -0
  8. package/dist/CefFormat.js +133 -0
  9. package/dist/CommentLine.d.ts +18 -0
  10. package/dist/CommentLine.d.ts.map +1 -0
  11. package/dist/CommentLine.js +27 -0
  12. package/dist/Exception/CefFormatException.d.ts +16 -0
  13. package/dist/Exception/CefFormatException.d.ts.map +1 -0
  14. package/dist/Exception/CefFormatException.js +19 -0
  15. package/dist/Exception/CefWriteException.d.ts +12 -0
  16. package/dist/Exception/CefWriteException.d.ts.map +1 -0
  17. package/dist/Exception/CefWriteException.js +13 -0
  18. package/dist/Exception/DuplicateCandidateException.d.ts +9 -0
  19. package/dist/Exception/DuplicateCandidateException.d.ts.map +1 -0
  20. package/dist/Exception/DuplicateCandidateException.js +8 -0
  21. package/dist/Exception/InvalidUtf8Exception.d.ts +13 -0
  22. package/dist/Exception/InvalidUtf8Exception.d.ts.map +1 -0
  23. package/dist/Exception/InvalidUtf8Exception.js +12 -0
  24. package/dist/Exception/InvalidValueException.d.ts +15 -0
  25. package/dist/Exception/InvalidValueException.d.ts.map +1 -0
  26. package/dist/Exception/InvalidValueException.js +14 -0
  27. package/dist/Exception/InvalidWriterStateException.d.ts +14 -0
  28. package/dist/Exception/InvalidWriterStateException.d.ts.map +1 -0
  29. package/dist/Exception/InvalidWriterStateException.js +13 -0
  30. package/dist/Exception/ReservedCharacterException.d.ts +12 -0
  31. package/dist/Exception/ReservedCharacterException.d.ts.map +1 -0
  32. package/dist/Exception/ReservedCharacterException.js +11 -0
  33. package/dist/Exception/index.d.ts +8 -0
  34. package/dist/Exception/index.d.ts.map +1 -0
  35. package/dist/Exception/index.js +7 -0
  36. package/dist/FileWriteTarget.d.ts +27 -0
  37. package/dist/FileWriteTarget.d.ts.map +1 -0
  38. package/dist/FileWriteTarget.js +35 -0
  39. package/dist/Parameter/CandidatesParameter.d.ts +20 -0
  40. package/dist/Parameter/CandidatesParameter.d.ts.map +1 -0
  41. package/dist/Parameter/CandidatesParameter.js +39 -0
  42. package/dist/Parameter/CustomParameter.d.ts +19 -0
  43. package/dist/Parameter/CustomParameter.d.ts.map +1 -0
  44. package/dist/Parameter/CustomParameter.js +35 -0
  45. package/dist/Parameter/ImplicitRankingParameter.d.ts +11 -0
  46. package/dist/Parameter/ImplicitRankingParameter.d.ts.map +1 -0
  47. package/dist/Parameter/ImplicitRankingParameter.js +16 -0
  48. package/dist/Parameter/NumberOfSeatsParameter.d.ts +14 -0
  49. package/dist/Parameter/NumberOfSeatsParameter.d.ts.map +1 -0
  50. package/dist/Parameter/NumberOfSeatsParameter.js +23 -0
  51. package/dist/Parameter/ParameterInterface.d.ts +20 -0
  52. package/dist/Parameter/ParameterInterface.d.ts.map +1 -0
  53. package/dist/Parameter/ParameterInterface.js +1 -0
  54. package/dist/Parameter/StandardParameter.d.ts +14 -0
  55. package/dist/Parameter/StandardParameter.d.ts.map +1 -0
  56. package/dist/Parameter/StandardParameter.js +14 -0
  57. package/dist/Parameter/VotingMethodsParameter.d.ts +16 -0
  58. package/dist/Parameter/VotingMethodsParameter.d.ts.map +1 -0
  59. package/dist/Parameter/VotingMethodsParameter.js +29 -0
  60. package/dist/Parameter/WeightAllowedParameter.d.ts +11 -0
  61. package/dist/Parameter/WeightAllowedParameter.d.ts.map +1 -0
  62. package/dist/Parameter/WeightAllowedParameter.js +16 -0
  63. package/dist/Parameter/index.d.ts +9 -0
  64. package/dist/Parameter/index.d.ts.map +1 -0
  65. package/dist/Parameter/index.js +7 -0
  66. package/dist/Ranking.d.ts +91 -0
  67. package/dist/Ranking.d.ts.map +1 -0
  68. package/dist/Ranking.js +162 -0
  69. package/dist/VoteLine.d.ts +156 -0
  70. package/dist/VoteLine.d.ts.map +1 -0
  71. package/dist/VoteLine.js +289 -0
  72. package/dist/index.browser.d.ts +17 -0
  73. package/dist/index.browser.d.ts.map +1 -0
  74. package/dist/index.browser.js +16 -0
  75. package/dist/index.d.ts +8 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +7 -0
  78. package/dist/index.node.d.ts +19 -0
  79. package/dist/index.node.d.ts.map +1 -0
  80. package/dist/index.node.js +25 -0
  81. package/package.json +79 -0
  82. package/src/Cef.ts +405 -0
  83. package/src/CefFormat.ts +152 -0
  84. package/src/CommentLine.ts +32 -0
  85. package/src/Exception/CefFormatException.ts +19 -0
  86. package/src/Exception/CefWriteException.ts +13 -0
  87. package/src/Exception/DuplicateCandidateException.ts +8 -0
  88. package/src/Exception/InvalidUtf8Exception.ts +12 -0
  89. package/src/Exception/InvalidValueException.ts +14 -0
  90. package/src/Exception/InvalidWriterStateException.ts +13 -0
  91. package/src/Exception/ReservedCharacterException.ts +11 -0
  92. package/src/Exception/index.ts +7 -0
  93. package/src/FileWriteTarget.ts +42 -0
  94. package/src/Parameter/CandidatesParameter.ts +49 -0
  95. package/src/Parameter/CustomParameter.ts +45 -0
  96. package/src/Parameter/ImplicitRankingParameter.ts +17 -0
  97. package/src/Parameter/NumberOfSeatsParameter.ts +25 -0
  98. package/src/Parameter/ParameterInterface.ts +20 -0
  99. package/src/Parameter/StandardParameter.ts +13 -0
  100. package/src/Parameter/VotingMethodsParameter.ts +36 -0
  101. package/src/Parameter/WeightAllowedParameter.ts +17 -0
  102. package/src/Parameter/index.ts +8 -0
  103. package/src/Ranking.ts +197 -0
  104. package/src/VoteLine.ts +398 -0
  105. package/src/index.browser.ts +36 -0
  106. package/src/index.node.ts +47 -0
  107. package/src/index.ts +8 -0
package/src/Cef.ts ADDED
@@ -0,0 +1,405 @@
1
+ import { CommentLine } from './CommentLine';
2
+ import {
3
+ CefWriteException,
4
+ InvalidValueException,
5
+ InvalidWriterStateException,
6
+ ReservedCharacterException,
7
+ } from './Exception';
8
+ import type { ParameterInterface } from './Parameter';
9
+ import { VoteLine } from './VoteLine';
10
+
11
+ /**
12
+ * A sink the writer can stream lines into, mirroring the contract of PHP's
13
+ * `\SplFileObject::fwrite()`: `write()` returns the number of bytes actually
14
+ * written. Returning fewer bytes than supplied (or throwing) signals an I/O
15
+ * failure and triggers a {@link CefWriteException}.
16
+ */
17
+ export interface WriteTarget {
18
+ write(chunk: string): number;
19
+ }
20
+
21
+ /**
22
+ * A mutable string sink, the idiomatic TypeScript stand-in for PHP's
23
+ * "string passed by reference" target. The writer appends every line to it;
24
+ * read the accumulated document back with {@link toString} (or {@link value}).
25
+ */
26
+ export class StringBuffer {
27
+ private content = '';
28
+
29
+ public append(chunk: string): void {
30
+ this.content += chunk;
31
+ }
32
+
33
+ public get value(): string {
34
+ return this.content;
35
+ }
36
+
37
+ public toString(): string {
38
+ return this.content;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Options accepted by the {@link Cef} constructor. Exactly one of `file` or
44
+ * `string` must be provided.
45
+ */
46
+ export interface CefOptions {
47
+ /**
48
+ * A filesystem path (opened in truncating write mode) or any
49
+ * {@link WriteTarget} (e.g. an already-open {@link FileWriteTarget}).
50
+ */
51
+ file?: string | WriteTarget;
52
+
53
+ /**
54
+ * A {@link StringBuffer} the writer appends every line to.
55
+ */
56
+ string?: StringBuffer;
57
+ }
58
+
59
+ /**
60
+ * Streaming writer for a single Condorcet Election Format document.
61
+ *
62
+ * Each `add*()` call emits one line to the underlying target *immediately* —
63
+ * the library never buffers more than a single line in memory and previously
64
+ * written content cannot be edited.
65
+ *
66
+ * The target is chosen at construction time:
67
+ * - a {@link WriteTarget} (passed through);
68
+ * - a filesystem path (opened with mode `w`);
69
+ * - a {@link StringBuffer} that the writer will append to.
70
+ *
71
+ * # Phases
72
+ *
73
+ * Parameters must be emitted before votes. Comments and empty lines may be
74
+ * emitted at any time. Once the first {@link VoteLine} is written, calling
75
+ * {@link addParameter} throws an {@link InvalidWriterStateException}.
76
+ *
77
+ * # autoFormat
78
+ *
79
+ * When `true` (default), the writer follows the visually relaxed flavor of the
80
+ * spec — spaces around `>`, `=`, `;`, `,`; one blank line automatically
81
+ * inserted between the parameter block and the first vote. When `false`, the
82
+ * most compact form is emitted.
83
+ */
84
+ export class Cef {
85
+ /**
86
+ * Factory that turns a filesystem path into a {@link WriteTarget}.
87
+ *
88
+ * The browser entry point leaves this `null`, so the browser bundle never
89
+ * references {@link FileWriteTarget} (and therefore never pulls in `node:fs`).
90
+ * The Node entry point wires it to {@link FileWriteTarget}, enabling the
91
+ * `new Cef({ file: '/some/path' })` convenience.
92
+ *
93
+ * @internal
94
+ */
95
+ public static fileWriteTargetFactory: ((path: string) => WriteTarget) | null = null;
96
+
97
+ public autoFormat = true;
98
+
99
+ /**
100
+ * The active file target, or `null` when writing to a string.
101
+ */
102
+ public readonly file: WriteTarget | null;
103
+
104
+ /**
105
+ * Reference to the caller's string buffer in string mode, `null` in file
106
+ * mode.
107
+ */
108
+ private readonly stringTarget: StringBuffer | null;
109
+
110
+ private parameterEmitted = false;
111
+
112
+ private voteEmitted = false;
113
+
114
+ private autoSeparatorWritten = false;
115
+
116
+ /**
117
+ * Exactly one of `options.file` or `options.string` must be provided.
118
+ *
119
+ * @throws {InvalidWriterStateException}
120
+ */
121
+ public constructor(options: CefOptions = {}) {
122
+ const hasFile = options.file !== undefined;
123
+ const hasString = options.string !== undefined;
124
+
125
+ if (hasFile === hasString) {
126
+ throw new InvalidWriterStateException(
127
+ 'Exactly one of file or string must be provided to the Cef constructor.'
128
+ );
129
+ }
130
+
131
+ if (hasString) {
132
+ this.file = null;
133
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
134
+ this.stringTarget = options.string!;
135
+
136
+ return;
137
+ }
138
+
139
+ this.stringTarget = null;
140
+
141
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
142
+ const file = options.file!;
143
+
144
+ if (typeof file === 'string') {
145
+ this.file = this.createFileWriteTarget(file);
146
+ } else if (typeof file === 'object' && typeof file.write === 'function') {
147
+ this.file = file;
148
+ } else {
149
+ throw new InvalidWriterStateException(
150
+ 'The file option must be a string path or a WriteTarget.'
151
+ );
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Create a file-backed {@link WriteTarget} from a path, via the factory the
157
+ * Node entry point registers in {@link Cef.fileWriteTargetFactory}.
158
+ *
159
+ * @throws {InvalidWriterStateException} when no factory is registered — i.e.
160
+ * in the browser bundle, where filesystem access is unavailable.
161
+ */
162
+ private createFileWriteTarget(path: string): WriteTarget {
163
+ const factory = Cef.fileWriteTargetFactory;
164
+
165
+ if (factory === null) {
166
+ throw new InvalidWriterStateException(
167
+ 'Writing to a file path is not available in this environment (browser?). ' +
168
+ 'Use StringBuffer instead, or pass a custom WriteTarget. In Node.js, ' +
169
+ "import from '@condorcet.vote/cef-writer' (not '@condorcet.vote/cef-writer/browser')."
170
+ );
171
+ }
172
+
173
+ return factory(path);
174
+ }
175
+
176
+ /**
177
+ * Emit a parameter line `#/Name: value`.
178
+ *
179
+ * @throws {CefFormatException} if a vote has already been written
180
+ */
181
+ public addParameter(parameter: ParameterInterface): this {
182
+ if (this.voteEmitted) {
183
+ throw new InvalidWriterStateException('Parameters must be written before any vote line.');
184
+ }
185
+
186
+ const separator = this.autoFormat ? ': ' : ':';
187
+ const line =
188
+ '#/' + parameter.getName() + separator + parameter.getFormattedValue(this.autoFormat);
189
+
190
+ this.writeLine(line);
191
+ this.parameterEmitted = true;
192
+
193
+ return this;
194
+ }
195
+
196
+ /**
197
+ * Emit a vote line. Locks parameter mode permanently.
198
+ */
199
+ public addVote(vote: VoteLine): this {
200
+ this.writeAutoSeparatorIfNeeded();
201
+
202
+ let line = vote.format(this.autoFormat);
203
+
204
+ if (vote.inlineComment !== null) {
205
+ line += this.renderInlineComment(vote.inlineComment);
206
+ }
207
+
208
+ this.writeLine(line);
209
+ this.voteEmitted = true;
210
+
211
+ return this;
212
+ }
213
+
214
+ /**
215
+ * Emit a vote line directly from a pre-built string, skipping the allocation
216
+ * of a {@link VoteLine} instance. Use this when you already have ballots as
217
+ * text and want the fastest path to the output.
218
+ *
219
+ * The full CEF vote-line format is enforced — the same validation rules that
220
+ * {@link VoteLine.fromString} applies are run via
221
+ * {@link VoteLine.assertValidString}. In particular:
222
+ * - structural checks first: a single trailing line terminator
223
+ * (`\r\n`, `\n`, `\r`) is stripped, surrounding whitespace is trimmed,
224
+ * the result must be non-empty, must not contain any remaining
225
+ * `\r`/`\n`, and must not start with `#` (which would be a comment or a
226
+ * parameter line, not a vote);
227
+ * - format checks then: tags, ranking, weight, quantifier and inline
228
+ * comment are parsed and validated against every CEF rule.
229
+ *
230
+ * The `autoFormat` flag has no effect on a raw line: what you pass is what
231
+ * gets written (after structural cleaning).
232
+ *
233
+ * @throws {CefFormatException}
234
+ */
235
+ public addRawVoteLine(line: string): this {
236
+ let cleaned = line.replace(/\r\n$|[\r\n]$/, '');
237
+ cleaned = cleaned.trim();
238
+
239
+ if (cleaned === '') {
240
+ throw new InvalidValueException('Raw vote line cannot be empty.');
241
+ }
242
+
243
+ if (/[\r\n]/.test(cleaned)) {
244
+ throw new InvalidValueException(
245
+ 'Raw vote line must be a single line; embedded newlines are not allowed.'
246
+ );
247
+ }
248
+
249
+ if (cleaned.startsWith('#')) {
250
+ throw new ReservedCharacterException(
251
+ 'Raw vote line cannot start with "#"; that would be a comment or parameter line, not a vote.'
252
+ );
253
+ }
254
+
255
+ VoteLine.assertValidString(cleaned);
256
+
257
+ this.writeAutoSeparatorIfNeeded();
258
+ this.writeLine(cleaned);
259
+ this.voteEmitted = true;
260
+
261
+ return this;
262
+ }
263
+
264
+ /**
265
+ * Emit a vote line from a **ranking-only** string plus strictly-typed
266
+ * companions — the secure, paranoid sibling of {@link addRawVoteLine}.
267
+ *
268
+ * Whereas {@link addRawVoteLine} accepts a full vote line (and therefore lets
269
+ * the caller embed tags, a weight, a quantifier or an inline comment inside
270
+ * the text), `addRawVote()` guarantees that `vote` carries *only* a ranking.
271
+ * Any line break, the `||` tag separator, and every reserved character
272
+ * (`^`, `*`, `#`, `;`, `,`, `/`) are rejected, so the string can never
273
+ * smuggle a weight, quantifier, tag, inline comment or second vote into the
274
+ * output. Use this when the ranking comes from an untrusted source.
275
+ *
276
+ * Weight, quantifier and tags are supplied exclusively through the typed
277
+ * options. `weight` and `quantifier` are nullable and default to `null`, in
278
+ * which case they are omitted from the output; when provided they must be
279
+ * strictly positive. Just like {@link addRawVoteLine}, the ranking string is
280
+ * written verbatim — its original spacing is preserved and `autoFormat` does
281
+ * not reformat it. The `autoFormat` flag still governs the layout of the
282
+ * library-built companions (the `||` tag separator, `^weight`, `*quantifier`).
283
+ *
284
+ * @throws {CefFormatException}
285
+ */
286
+ public addRawVote(
287
+ vote: string,
288
+ options: {
289
+ quantifier?: number | null;
290
+ weight?: number | null;
291
+ tags?: readonly string[] | null;
292
+ } = {}
293
+ ): this {
294
+ const voteLine = VoteLine.fromRawRankingString(vote, {
295
+ tags: options.tags ?? [],
296
+ weight: options.weight ?? null,
297
+ quantifier: options.quantifier ?? null,
298
+ });
299
+
300
+ this.writeAutoSeparatorIfNeeded();
301
+ this.writeLine(voteLine.format(this.autoFormat));
302
+ this.voteEmitted = true;
303
+
304
+ return this;
305
+ }
306
+
307
+ /**
308
+ * Emit a standalone comment line.
309
+ */
310
+ public addComment(comment: CommentLine): this {
311
+ this.writeLine(comment.format(this.autoFormat));
312
+
313
+ return this;
314
+ }
315
+
316
+ /**
317
+ * Convenience helper: build a {@link CommentLine} from raw text and emit it
318
+ * in a single call.
319
+ */
320
+ public addCommentLine(text: string): this {
321
+ return this.addComment(new CommentLine(text));
322
+ }
323
+
324
+ /**
325
+ * Emit an empty line.
326
+ */
327
+ public addEmptyLine(): this {
328
+ this.writeLine('');
329
+
330
+ return this;
331
+ }
332
+
333
+ /**
334
+ * Close the underlying file target, if any. No-op in string mode or when the
335
+ * target does not expose a `close()` method.
336
+ */
337
+ public close(): void {
338
+ if (this.file !== null && 'close' in this.file && typeof this.file.close === 'function') {
339
+ (this.file as { close: () => void }).close();
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Insert one blank line between the parameter block and the first vote when
345
+ * `autoFormat` is on. Idempotent.
346
+ */
347
+ private writeAutoSeparatorIfNeeded(): void {
348
+ if (
349
+ this.autoFormat &&
350
+ this.parameterEmitted &&
351
+ !this.voteEmitted &&
352
+ !this.autoSeparatorWritten
353
+ ) {
354
+ this.writeLine('');
355
+ this.autoSeparatorWritten = true;
356
+ }
357
+ }
358
+
359
+ private renderInlineComment(comment: string): string {
360
+ if (!this.autoFormat) {
361
+ return '#' + comment;
362
+ }
363
+
364
+ const needsLeadingSpace = comment === '' || !comment.startsWith(' ');
365
+
366
+ return ' #' + (needsLeadingSpace ? ' ' : '') + comment;
367
+ }
368
+
369
+ private writeLine(content: string): void {
370
+ const line = content + '\n';
371
+
372
+ if (this.file !== null) {
373
+ const expected = Buffer.byteLength(line, 'utf-8');
374
+ let written: number;
375
+
376
+ try {
377
+ written = this.file.write(line);
378
+ } catch (error) {
379
+ throw new CefWriteException(
380
+ `Failed to write ${String(expected)} bytes to the file target. ` +
381
+ 'The underlying handle may be closed, read-only, or out of space.',
382
+ { cause: error }
383
+ );
384
+ }
385
+
386
+ if (written < expected) {
387
+ throw new CefWriteException(
388
+ `Failed to write ${String(expected)} bytes to the file target (write returned ${String(written)}). ` +
389
+ 'The underlying handle may be closed, read-only, or out of space.'
390
+ );
391
+ }
392
+
393
+ return;
394
+ }
395
+
396
+ if (this.stringTarget === null) {
397
+ throw new InvalidWriterStateException(
398
+ 'Cef writer has no target: neither file nor string is set. ' +
399
+ 'This indicates a corrupted internal state that the constructor should have prevented.'
400
+ );
401
+ }
402
+
403
+ this.stringTarget.append(line);
404
+ }
405
+ }
@@ -0,0 +1,152 @@
1
+ import {
2
+ InvalidUtf8Exception,
3
+ InvalidValueException,
4
+ ReservedCharacterException,
5
+ } from './Exception';
6
+
7
+ /**
8
+ * Internal helpers shared across the writer and the value objects.
9
+ *
10
+ * A namespace of static utilities that encode rules from the CEF specification
11
+ * (reserved characters, blank-ballot sentinel, etc.).
12
+ *
13
+ * @internal
14
+ */
15
+ // eslint-disable-next-line @typescript-eslint/no-namespace
16
+ export namespace CefFormat {
17
+ /**
18
+ * Characters that the specification reserves for syntactic use and that
19
+ * therefore must never appear inside any user-supplied value.
20
+ */
21
+ export const RESERVED_CHARACTERS: readonly string[] = [
22
+ '>',
23
+ '=',
24
+ ';',
25
+ ',',
26
+ '#',
27
+ '/',
28
+ '*',
29
+ '^',
30
+ ];
31
+
32
+ /**
33
+ * Sentinel value emitted as the whole ranking of a blank ballot.
34
+ */
35
+ export const EMPTY_RANKING = '/EMPTY_RANKING/';
36
+
37
+ /**
38
+ * Tag/ranking separator on a vote line.
39
+ */
40
+ export const TAGS_SEPARATOR = '||';
41
+
42
+ /**
43
+ * Reject any value that contains a reserved character or a line break. Empty
44
+ * strings are rejected too — this helper is meant for *required* structural
45
+ * values (names, tags, candidate labels, …).
46
+ *
47
+ * @throws {CefFormatException}
48
+ */
49
+ export function assertValueIsClean(value: string, context: string): void {
50
+ if (value === '') {
51
+ throw new InvalidValueException(`${context} cannot be empty.`);
52
+ }
53
+
54
+ assertNoReservedNorLineBreak(value, context);
55
+ }
56
+
57
+ /**
58
+ * Reject any value that contains a reserved character, a line break, a null
59
+ * byte, or an invalid UTF-8 byte sequence. Accept the empty string. Use for
60
+ * optionally-empty value strings (e.g. a custom parameter's free-form value).
61
+ *
62
+ * @throws {CefFormatException}
63
+ */
64
+ export function assertNoReservedNorLineBreak(value: string, context: string): void {
65
+ assertSafeText(value, context);
66
+
67
+ for (const reserved of RESERVED_CHARACTERS) {
68
+ if (value.includes(reserved)) {
69
+ throw new ReservedCharacterException(
70
+ `${context} cannot contain the reserved character "${reserved}".`
71
+ );
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Inline comments are free-form text but must stay on a single line and
78
+ * contain only valid UTF-8 (with no null byte).
79
+ *
80
+ * @throws {CefFormatException}
81
+ */
82
+ export function assertSingleLine(value: string, context: string): void {
83
+ assertSafeText(value, context);
84
+ }
85
+
86
+ /**
87
+ * Reject any value that contains the `||` tag separator.
88
+ *
89
+ * The separator is the only forbidden pattern that per-character validation
90
+ * cannot catch on its own, because `|` is not itself a reserved character.
91
+ * Both ranking strings and tag values rely on this check.
92
+ *
93
+ * @throws {CefFormatException}
94
+ */
95
+ export function assertNoTagSeparator(value: string, context: string): void {
96
+ if (value.includes(TAGS_SEPARATOR)) {
97
+ throw new ReservedCharacterException(
98
+ `${context} cannot contain the "${TAGS_SEPARATOR}" tag separator.`
99
+ );
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Verify that `value` is valid UTF-8, single-line, and contains no null byte.
105
+ * Shared base for every value-bound assertion above.
106
+ *
107
+ * UTF-8 validity is checked by rejecting ill-formed strings — those carrying
108
+ * an unpaired UTF-16 surrogate, which cannot be encoded to well-formed
109
+ * UTF-8. This is the TypeScript analog of PHP's `mb_check_encoding()`.
110
+ *
111
+ * @throws {CefFormatException}
112
+ */
113
+ function assertSafeText(value: string, context: string): void {
114
+ if (!isWellFormedUtf16(value)) {
115
+ throw new InvalidUtf8Exception(`${context} contains an invalid UTF-8 byte sequence.`);
116
+ }
117
+
118
+ if (/[\r\n]/.test(value)) {
119
+ throw new InvalidValueException(`${context} cannot contain a line break.`);
120
+ }
121
+
122
+ if (value.includes('\0')) {
123
+ throw new InvalidValueException(`${context} cannot contain a null byte.`);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Return `true` when every UTF-16 surrogate in `value` is correctly paired,
129
+ * i.e. the string can be encoded to well-formed UTF-8.
130
+ */
131
+ function isWellFormedUtf16(value: string): boolean {
132
+ for (let i = 0; i < value.length; i++) {
133
+ const code = value.charCodeAt(i);
134
+
135
+ if (code >= 0xd800 && code <= 0xdbff) {
136
+ // High surrogate: must be immediately followed by a low surrogate.
137
+ const next = value.charCodeAt(i + 1);
138
+
139
+ if (Number.isNaN(next) || next < 0xdc00 || next > 0xdfff) {
140
+ return false;
141
+ }
142
+
143
+ i++;
144
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
145
+ // Lone low surrogate.
146
+ return false;
147
+ }
148
+ }
149
+
150
+ return true;
151
+ }
152
+ }
@@ -0,0 +1,32 @@
1
+ import { CefFormat } from './CefFormat';
2
+
3
+ /**
4
+ * A standalone comment line (`# text`).
5
+ *
6
+ * Inline comments attached to vote lines are not represented by this class —
7
+ * they live on `VoteLine.inlineComment` instead.
8
+ */
9
+ export class CommentLine {
10
+ public readonly text: string;
11
+
12
+ /**
13
+ * @throws {CefFormatException}
14
+ */
15
+ public constructor(text: string) {
16
+ CefFormat.assertSingleLine(text, 'Comment');
17
+ this.text = text;
18
+ }
19
+
20
+ /**
21
+ * Render the line *without* trailing newline.
22
+ */
23
+ public format(autoFormat = true): string {
24
+ if (this.text === '') {
25
+ return '#';
26
+ }
27
+
28
+ const needsLeadingSpace = autoFormat && !this.text.startsWith(' ');
29
+
30
+ return '#' + (needsLeadingSpace ? ' ' : '') + this.text;
31
+ }
32
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Base class for every input/format violation thrown by the library.
3
+ *
4
+ * Catch this class to handle any format-related failure uniformly; catch one
5
+ * of the dedicated subclasses ({@link InvalidUtf8Exception},
6
+ * {@link ReservedCharacterException}, {@link InvalidValueException},
7
+ * {@link DuplicateCandidateException}, {@link InvalidWriterStateException}) to
8
+ * branch on a specific kind of violation.
9
+ *
10
+ * Non-final on purpose so the library and downstream callers can refine the
11
+ * hierarchy further if needed.
12
+ */
13
+ export class CefFormatException extends Error {
14
+ public constructor(message: string) {
15
+ super(message);
16
+ this.name = new.target.name;
17
+ Object.setPrototypeOf(this, new.target.prototype);
18
+ }
19
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Thrown when writing to the underlying target (a file or string buffer) fails.
3
+ * Distinct from {@link CefFormatException} because the cause is the I/O layer —
4
+ * disk full, broken pipe, closed handle, read-only file — not an invalid input
5
+ * from the caller.
6
+ */
7
+ export class CefWriteException extends Error {
8
+ public constructor(message: string, options?: { cause?: unknown }) {
9
+ super(message, options);
10
+ this.name = new.target.name;
11
+ Object.setPrototypeOf(this, new.target.prototype);
12
+ }
13
+ }
@@ -0,0 +1,8 @@
1
+ import { CefFormatException } from './CefFormatException';
2
+
3
+ /**
4
+ * Thrown when the same candidate label appears more than once where the CEF
5
+ * specification forbids it — either in `#/Candidates:` or in a single vote's
6
+ * ranking (across tied groups included).
7
+ */
8
+ export class DuplicateCandidateException extends CefFormatException {}
@@ -0,0 +1,12 @@
1
+ import { CefFormatException } from './CefFormatException';
2
+
3
+ /**
4
+ * Thrown when a value contains a byte sequence that does not decode as valid
5
+ * UTF-8. The CEF specification mandates UTF-8, so any non-UTF-8 input is
6
+ * rejected before it can land in the output stream.
7
+ *
8
+ * In this TypeScript port — where strings are sequences of UTF-16 code units —
9
+ * "non-UTF-8" means an ill-formed string carrying an unpaired surrogate, which
10
+ * cannot be encoded to well-formed UTF-8.
11
+ */
12
+ export class InvalidUtf8Exception extends CefFormatException {}
@@ -0,0 +1,14 @@
1
+ import { CefFormatException } from './CefFormatException';
2
+
3
+ /**
4
+ * Thrown when a value is structurally impossible at the CEF level — regardless
5
+ * of which reserved/UTF-8 rule it would otherwise hit.
6
+ *
7
+ * Typical triggers:
8
+ * - empty string where one is required (candidate name, tag, parameter name);
9
+ * - embedded line break or null byte;
10
+ * - non-positive `weight` or `quantifier`;
11
+ * - empty list when one or more entries are required (candidates, methods);
12
+ * - empty rank inside a ranking.
13
+ */
14
+ export class InvalidValueException extends CefFormatException {}
@@ -0,0 +1,13 @@
1
+ import { CefFormatException } from './CefFormatException';
2
+
3
+ /**
4
+ * Thrown when the streaming writer is asked to perform an operation that does
5
+ * not fit its current internal state.
6
+ *
7
+ * Typical triggers:
8
+ * - adding a parameter after the first vote has been emitted;
9
+ * - constructing a `Cef` with neither a file nor a string target, or with
10
+ * both at once;
11
+ * - parsing a vote-line string that ends up without a ranking.
12
+ */
13
+ export class InvalidWriterStateException extends CefFormatException {}
@@ -0,0 +1,11 @@
1
+ import { CefFormatException } from './CefFormatException';
2
+
3
+ /**
4
+ * Thrown when a value contains a character that the CEF format reserves for
5
+ * structural use and therefore forbids inside any value.
6
+ *
7
+ * Covers the eight spec-listed reserved characters (`> = ; , # / * ^`) as well
8
+ * as the secondary syntactic separators the library enforces: `:` in a custom
9
+ * parameter name, `||` in a tag, and a leading `#` on a raw vote line.
10
+ */
11
+ export class ReservedCharacterException extends CefFormatException {}
@@ -0,0 +1,7 @@
1
+ export { CefFormatException } from './CefFormatException';
2
+ export { CefWriteException } from './CefWriteException';
3
+ export { DuplicateCandidateException } from './DuplicateCandidateException';
4
+ export { InvalidUtf8Exception } from './InvalidUtf8Exception';
5
+ export { InvalidValueException } from './InvalidValueException';
6
+ export { InvalidWriterStateException } from './InvalidWriterStateException';
7
+ export { ReservedCharacterException } from './ReservedCharacterException';