links-notation 0.30.2 → 0.31.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.
package/README.md CHANGED
@@ -660,3 +660,48 @@ fail the format check.
660
660
 
661
661
  Exact Unicode identifiers and values round-trip with the [reference literal contract](../docs/protocol/reference-literals.md)
662
662
  introduced in 0.24.0, including mixed quotes, empty strings, controls and multiline text.
663
+
664
+ ### Recursive nested output
665
+
666
+ `formatNested` writes a link's leading atoms on its line and its trailing link
667
+ values on indented lines, recursively:
668
+
669
+ ```javascript
670
+ import { Link, Parser, formatNested } from 'links-notation';
671
+ const parser = new Parser();
672
+ const links = parser.parse(
673
+ '(form: stack (direction vertical) (save: button Save (on press (emit save))))'
674
+ );
675
+ const text = formatNested(links);
676
+ // form: stack
677
+ // direction vertical
678
+ // save: button Save
679
+ // on press
680
+ // emit save
681
+
682
+ const unindent = ({ element, children }) =>
683
+ children.length
684
+ ? new Link(element.id, [...element.values, ...children.map(unindent)])
685
+ : element;
686
+ const readBack = parser.parseGroups(text).map(unindent);
687
+ console.log(readBack[0].equals(links[0])); // true
688
+ ```
689
+
690
+ Run the complete [form example](../examples/js_nested_format.mjs) with Node.
691
+
692
+ Use `parseGroups` plus this fold to read nested output. Canonical `parse` keeps
693
+ its path expansion for ordinary indented heads. `formatIndentedDocument` remains
694
+ the separate two-level plain-text document helper.
695
+
696
+ The default indentation is two spaces; `formatNested(links, ' ')` uses four.
697
+ The result has no final newline. References use normal escaping. Parentheses
698
+ stay where needed for `()`, `((a b))`, or a lone reference under a bare `name:`
699
+ ancestor. An atom after a link stays in place, so `(root: (a b) tail (c d))`
700
+ becomes `root: (a b) tail\n c d` without changing value order. The existing
701
+ inline formatter's exclusions for empty links and named empty-value links used
702
+ as values also apply to this writer.
703
+
704
+ Threshold options (`maxInlineRefs`, `indentLongLines`, `preferInline`) retain
705
+ anonymous links inline when indentation would lose their container. Use
706
+ `formatNested` for recursive output. The same writer is available in all ten
707
+ bindings; see the [cross-language API table](../parity/README.md#recursive-nested-formatting).
package/dist/index.js CHANGED
@@ -439,9 +439,7 @@ class Link {
439
439
  }
440
440
  _formatIndented(options) {
441
441
  if (this.id === null) {
442
- const lines = this.values.map((v) => options.indentString + this.formatValue(v));
443
- return lines.join(`
444
- `);
442
+ return this.format(options.lessParentheses);
445
443
  }
446
444
  const idStr = Link.escapeReference(this.id);
447
445
  const lines = [`${idStr}:`];
@@ -4071,6 +4069,34 @@ class BinaryLinoCodec {
4071
4069
  return this.formatDocument(this.decode(bytes));
4072
4070
  }
4073
4071
  }
4072
+ // src/NestedFormat.js
4073
+ var isAtom = (link) => link.id !== null && link.values.length === 0;
4074
+ var canNest = (link) => !isAtom(link) && !(link.id === null && link.values.length === 1 && !isAtom(link.values[0]));
4075
+ var lineHolds = (link, count) => link.id !== null || count >= 2 || count === 1 && isAtom(link.values[0]);
4076
+ function nestedLines(link, pad, indent, underName = false) {
4077
+ if (!lineHolds(link, link.values.length) || underName && link.id === null && link.values.length === 1) {
4078
+ return [pad + link.format()];
4079
+ }
4080
+ let count = link.values.length;
4081
+ while (count > 0 && canNest(link.values[count - 1]))
4082
+ count--;
4083
+ while (count < link.values.length && !lineHolds(link, count))
4084
+ count++;
4085
+ const values = link.values.slice(0, count).map((value) => isAtom(value) ? Link.escapeReference(value.id) : value.format());
4086
+ if (link.id !== null)
4087
+ values.unshift(`${Link.escapeReference(link.id)}:`);
4088
+ return [
4089
+ pad + values.join(" "),
4090
+ ...link.values.slice(count).flatMap((value) => nestedLines(value, pad + indent, indent, underName || link.id !== null && count === 0))
4091
+ ];
4092
+ }
4093
+ function formatNested(links, indent = " ") {
4094
+ if (typeof indent !== "string" || !/^ +$/.test(indent)) {
4095
+ throw new TypeError("indent must contain only spaces and be nonempty");
4096
+ }
4097
+ return links.flatMap((link) => nestedLines(link, "", indent)).join(`
4098
+ `);
4099
+ }
4074
4100
  export {
4075
4101
  ArityRange,
4076
4102
  BinaryLinoCodec,
@@ -4098,6 +4124,7 @@ export {
4098
4124
  formatBinaryReference,
4099
4125
  formatIndentedDocument,
4100
4126
  formatLinks,
4127
+ formatNested,
4101
4128
  parseIndentedDocument,
4102
4129
  referenceQuote,
4103
4130
  stripComments,
package/index.d.ts CHANGED
@@ -724,3 +724,6 @@ export function formatBinaryReference(reference: string): string;
724
724
  export function encodeReferenceLiteral(text: string): string;
725
725
  /** Decode a complete literal. Throws on malformed hex, UTF-8, or unsupported versions. */
726
726
  export function decodeReferenceLiteral(literal: string): string;
727
+
728
+ /** Write recursive nested lines, read by folding Parser.parseGroups(). No final newline. */
729
+ export function formatNested(links: Link[], indent?: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "links-notation",
3
- "version": "0.30.2",
3
+ "version": "0.31.0",
4
4
  "description": "Links Notation parser for JavaScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "index.d.ts",
package/src/Link.js CHANGED
@@ -383,11 +383,8 @@ export class Link {
383
383
  */
384
384
  _formatIndented(options) {
385
385
  if (this.id === null) {
386
- // Values only - format each on separate line
387
- const lines = this.values.map(
388
- (v) => options.indentString + this.formatValue(v)
389
- );
390
- return lines.join('\n');
386
+ // A headless list reads as separate links. Keep the inline container.
387
+ return this.format(options.lessParentheses);
391
388
  }
392
389
 
393
390
  // Link with ID - format as id:\n value1\n value2
@@ -0,0 +1,55 @@
1
+ import { Link } from './Link.js';
2
+
3
+ const isAtom = (link) => link.id !== null && link.values.length === 0;
4
+ const canNest = (link) =>
5
+ !isAtom(link) &&
6
+ !(link.id === null && link.values.length === 1 && !isAtom(link.values[0]));
7
+ const lineHolds = (link, count) =>
8
+ link.id !== null || count >= 2 || (count === 1 && isAtom(link.values[0]));
9
+
10
+ function nestedLines(link, pad, indent, underName = false) {
11
+ if (
12
+ !lineHolds(link, link.values.length) ||
13
+ (underName && link.id === null && link.values.length === 1)
14
+ ) {
15
+ return [pad + link.format()];
16
+ }
17
+ // Only move a trailing run: moving a link past a later atom changes order.
18
+ let count = link.values.length;
19
+ while (count > 0 && canNest(link.values[count - 1])) count--;
20
+ while (count < link.values.length && !lineHolds(link, count)) count++;
21
+ const values = link.values
22
+ .slice(0, count)
23
+ .map((value) =>
24
+ isAtom(value) ? Link.escapeReference(value.id) : value.format()
25
+ );
26
+ if (link.id !== null) values.unshift(`${Link.escapeReference(link.id)}:`);
27
+ return [
28
+ pad + values.join(' '),
29
+ ...link.values
30
+ .slice(count)
31
+ .flatMap((value) =>
32
+ nestedLines(
33
+ value,
34
+ pad + indent,
35
+ indent,
36
+ underName || (link.id !== null && count === 0)
37
+ )
38
+ ),
39
+ ];
40
+ }
41
+
42
+ /**
43
+ * Write recursively nested lines. Read with Parser.parseGroups(), appending
44
+ * each group's children to its element's values. Ordinary parse() retains
45
+ * its path-expansion semantics. No final newline is added.
46
+ * @param {Link[]} links - Links in document order
47
+ * @param {string} [indent=' '] - Nonempty spaces for one indentation level
48
+ * @returns {string} Nested Links Notation
49
+ */
50
+ export function formatNested(links, indent = ' ') {
51
+ if (typeof indent !== 'string' || !/^ +$/.test(indent)) {
52
+ throw new TypeError('indent must contain only spaces and be nonempty');
53
+ }
54
+ return links.flatMap((link) => nestedLines(link, '', indent)).join('\n');
55
+ }
package/src/index.js CHANGED
@@ -32,3 +32,5 @@ export {
32
32
  formatBinaryDocument,
33
33
  formatBinaryReference,
34
34
  } from './Binary.js';
35
+
36
+ export { formatNested } from './NestedFormat.js';
@@ -0,0 +1,82 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { URL } from 'node:url';
3
+ import { expect, test } from 'bun:test';
4
+ import * as lino from '../src/index.js';
5
+
6
+ const { Parser, Link, FormatOptions, formatLinks } = lino;
7
+ const parser = new Parser();
8
+ const fold = ({ element, children }) =>
9
+ children.length
10
+ ? new Link(element.id, [...element.values, ...children.map(fold)])
11
+ : element;
12
+ const tree = (links) =>
13
+ links.map(({ id, values }) => ({ id, values: tree(values) }));
14
+ const form =
15
+ "(form: stack (direction vertical) (gap 8px) (save: button 'Save' (on press (emit save) (navigate home))))";
16
+
17
+ test('recursive nested writer formats the issue form and reads back through groups', () => {
18
+ const links = parser.parse(form);
19
+ const text = lino.formatNested(links);
20
+ expect(text).toBe(
21
+ 'form: stack\n direction vertical\n gap 8px\n save: button Save\n on press\n emit save\n navigate home'
22
+ );
23
+ expect(tree(parser.parseGroups(text).map(fold))).toEqual(tree(links));
24
+ });
25
+
26
+ test('anonymous threshold output retains the anonymous container', () => {
27
+ for (const input of ['a b c', '((b c))', '(a (b c))']) {
28
+ const links = parser.parse(input);
29
+ for (const threshold of [
30
+ { maxInlineRefs: 1 },
31
+ { indentLongLines: true, maxLineLength: 1 },
32
+ ]) {
33
+ const text = formatLinks(
34
+ links,
35
+ new FormatOptions({
36
+ lessParentheses: true,
37
+ preferInline: false,
38
+ ...threshold,
39
+ })
40
+ );
41
+ expect(tree(parser.parse(text)), input).toEqual(tree(links));
42
+ }
43
+ }
44
+ });
45
+
46
+ test('nested writer folds every existing shared text fixture without changing values', () => {
47
+ const fixtures = JSON.parse(
48
+ readFileSync(
49
+ new URL('../../parity/text-fixtures.json', import.meta.url),
50
+ 'utf8'
51
+ )
52
+ );
53
+ for (const fixture of fixtures) {
54
+ const links = parser.parse(fixture.input);
55
+ const text = lino.formatNested(links);
56
+ expect(tree(parser.parseGroups(text).map(fold)), fixture.id).toEqual(
57
+ tree(links)
58
+ );
59
+ }
60
+ });
61
+
62
+ test('shared nested golden corpus and custom indentation read back exactly', () => {
63
+ const cases = JSON.parse(
64
+ readFileSync(
65
+ new URL('../../parity/nested-fixtures.json', import.meta.url),
66
+ 'utf8'
67
+ )
68
+ );
69
+ for (const fixture of cases) {
70
+ const links = parser.parse(fixture.input);
71
+ expect(lino.formatNested(links), fixture.id).toBe(fixture.nested);
72
+ for (const indent of [' ', ' ']) {
73
+ const text = lino.formatNested(links, indent);
74
+ expect(tree(parser.parseGroups(text).map(fold)), fixture.id).toEqual(
75
+ tree(links)
76
+ );
77
+ }
78
+ }
79
+ expect(() => lino.formatNested([], '')).toThrow();
80
+ expect(() => lino.formatNested([], 'x')).toThrow();
81
+ expect(() => lino.formatNested([], '\t')).toThrow();
82
+ });
@@ -75,5 +75,5 @@ test('line length is measured on the parentheses a lone nested value keeps', ()
75
75
  preferInline: false,
76
76
  });
77
77
  expect(link.format(options(7))).toBe('((b c))');
78
- expect(link.format(options(6))).toBe(' (b c)');
78
+ expect(link.format(options(6))).toBe('((b c))');
79
79
  });