From c9cb98c45748f85e3517cdd5f3abd47894b52fad Mon Sep 17 00:00:00 2001 From: Matt Kantor Date: Sat, 20 Dec 2025 11:39:30 -0500 Subject: [PATCH] Improve sanity of trailing newline handling in command-line output I had confused myself about why a trailing newline was necessary to see complete command-line output. It's due to an [npm bug][0], not any behavior of Node itself, and as such ought to be worked around in the npm scripts. However, whether this bug is present or not it's still good practice for most command outputs to end with a complete line (including `\n`), so all `pretty-*` notations have been updated to include a trailing newline. In the future I may decide to add additional `--output-format`s which use non-`pretty-*` notations so that users can get non-`\n`-terminated outputs from the runtime, but for now there's nothing new here. [0]: https://github.com/npm/cli/issues/8583 --- package.json | 7 ++- src/language/cli/output.ts | 14 ----- src/language/compiling/unparsing.test.ts | 57 ++++++++++--------- src/language/unparsing.ts | 10 +++- src/language/unparsing/inline-plz.ts | 1 + src/language/unparsing/pretty-json.ts | 1 + src/language/unparsing/pretty-plz.ts | 1 + .../unparsing/sugar-free-pretty-plz.ts | 1 + src/language/unparsing/unparsing-utilities.ts | 1 + 9 files changed, 45 insertions(+), 48 deletions(-) diff --git a/package.json b/package.json index 94b6e7c..17b6b96 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "bin": { "please": "please" }, + "//": "The ugly `bash -c` contortions in the scripts below are to work around .", "scripts": { - "0": "node ./dist/language/cli/0.js", - "1": "node ./dist/language/cli/1.js", - "2": "node ./dist/language/cli/2.js", + "0": "bash -c 'node ./dist/language/cli/0.js \"$@\"; echo' bash", + "1": "bash -c 'node ./dist/language/cli/1.js \"$@\"; echo' bash", + "2": "bash -c 'node ./dist/language/cli/2.js \"$@\"; echo' bash", "build": "tsc --build tsconfig.app.json --force", "build:tests": "tsc --project tsconfig.app.json --outDir dist-test --declarationDir dist && tsc --build tsconfig.test.json", "clean": "rm -rf dist* *.tsbuildinfo", diff --git a/src/language/cli/output.ts b/src/language/cli/output.ts index 7605d32..8b04f31 100644 --- a/src/language/cli/output.ts +++ b/src/language/cli/output.ts @@ -69,19 +69,5 @@ export const writeOutput = ( throw new Error(outputAsString.value.message) } else { writeStream.write(outputAsString.value) - - // Writing a newline ensures that output is flushed before terminating, - // otherwise nothing may be printed to the console. See: - // - - // - - // - - // - …and many other near-duplicate issues - // - // I've tried other workarounds such as explicitly terminating via - // `process.exit`, passing a callback to `writeStream.write` (ensuring the - // returned `Promise` is not resolved until it is called), and explicitly - // calling `writeStream.end`/`writeStream.uncork` and so far this is the - // only workaround which reliably results in the desired behavior. - writeStream.write('\n') } } diff --git a/src/language/compiling/unparsing.test.ts b/src/language/compiling/unparsing.test.ts index a69ccb2..a3599f1 100644 --- a/src/language/compiling/unparsing.test.ts +++ b/src/language/compiling/unparsing.test.ts @@ -57,55 +57,55 @@ const outputs = ( testCases(unparsers, input => `unparsing \`${JSON.stringify(input)}\``)( 'unparsing', [ - [{}, outputs({ inlinePlz: '{}', prettyPlz: '{}', prettyJson: '{}' })], - ['a', outputs({ inlinePlz: 'a', prettyPlz: 'a', prettyJson: '"a"' })], + [{}, outputs({ inlinePlz: '{}', prettyPlz: '{}\n', prettyJson: '{}\n' })], + ['a', outputs({ inlinePlz: 'a', prettyPlz: 'a\n', prettyJson: '"a"\n' })], [ 'Hello, world!', outputs({ inlinePlz: '"Hello, world!"', - prettyPlz: '"Hello, world!"', - prettyJson: '"Hello, world!"', + prettyPlz: '"Hello, world!"\n', + prettyJson: '"Hello, world!"\n', }), ], [ '@test', outputs({ inlinePlz: '"@test"', - prettyPlz: '"@test"', - prettyJson: '"@test"', + prettyPlz: '"@test"\n', + prettyJson: '"@test"\n', }), ], [ { 0: 'a' }, outputs({ inlinePlz: '{ a }', - prettyPlz: '{\n a\n}', - prettyJson: '{\n "0": "a"\n}', + prettyPlz: '{\n a\n}\n', + prettyJson: '{\n "0": "a"\n}\n', }), ], [ { 1: 'a' }, outputs({ inlinePlz: '{ 1: a }', - prettyPlz: '{\n 1: a\n}', - prettyJson: '{\n "1": "a"\n}', + prettyPlz: '{\n 1: a\n}\n', + prettyJson: '{\n "1": "a"\n}\n', }), ], [ { 0: 'a', 1: 'b', 3: 'c', somethingElse: 'd' }, outputs({ inlinePlz: '{ a, b, 3: c, somethingElse: d }', - prettyPlz: '{\n a\n b\n 3: c\n somethingElse: d\n}', + prettyPlz: '{\n a\n b\n 3: c\n somethingElse: d\n}\n', prettyJson: - '{\n "0": "a",\n "1": "b",\n "3": "c",\n "somethingElse": "d"\n}', + '{\n "0": "a",\n "1": "b",\n "3": "c",\n "somethingElse": "d"\n}\n', }), ], [ { a: { b: { c: 'd' } } }, outputs({ inlinePlz: '{ a: { b: { c: d } } }', - prettyPlz: '{\n a: {\n b: {\n c: d\n }\n }\n}', - prettyJson: '{\n "a": {\n "b": {\n "c": "d"\n }\n }\n}', + prettyPlz: '{\n a: {\n b: {\n c: d\n }\n }\n}\n', + prettyJson: '{\n "a": {\n "b": {\n "c": "d"\n }\n }\n}\n', }), ], [ @@ -127,9 +127,10 @@ testCases(unparsers, input => `unparsing \`${JSON.stringify(input)}\``)( }, outputs({ inlinePlz: '{ identity: a => :a, test: :identity("it works!") }', - prettyPlz: '{\n identity: a => :a\n test: :identity("it works!")\n}', + prettyPlz: + '{\n identity: a => :a\n test: :identity("it works!")\n}\n', prettyJson: - '{\n "identity": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@lookup",\n "1": {\n "0": "a"\n }\n }\n }\n },\n "test": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "0": "identity"\n }\n },\n "argument": "it works!"\n }\n }\n}', + '{\n "identity": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@lookup",\n "1": {\n "0": "a"\n }\n }\n }\n },\n "test": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "0": "identity"\n }\n },\n "argument": "it works!"\n }\n }\n}\n', }), ], [ @@ -148,9 +149,9 @@ testCases(unparsers, input => `unparsing \`${JSON.stringify(input)}\``)( }, outputs({ inlinePlz: '(a => :a)("it works!")', - prettyPlz: '(a => :a)("it works!")', + prettyPlz: '(a => :a)("it works!")\n', prettyJson: - '{\n "0": "@apply",\n "1": {\n "function": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@lookup",\n "1": {\n "0": "a"\n }\n }\n }\n },\n "argument": "it works!"\n }\n}', + '{\n "0": "@apply",\n "1": {\n "function": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@lookup",\n "1": {\n "0": "a"\n }\n }\n }\n },\n "argument": "it works!"\n }\n}\n', }), ], [ @@ -174,9 +175,9 @@ testCases(unparsers, input => `unparsing \`${JSON.stringify(input)}\``)( }, outputs({ inlinePlz: '@runtime { context => :context.program.start_time }', - prettyPlz: '@runtime {\n context => :context.program.start_time\n}', + prettyPlz: '@runtime {\n context => :context.program.start_time\n}\n', prettyJson: - '{\n "0": "@runtime",\n "1": {\n "0": {\n "0": "@function",\n "1": {\n "parameter": "context",\n "body": {\n "0": "@index",\n "1": {\n "object": {\n "0": "@lookup",\n "1": {\n "key": "context"\n }\n },\n "query": {\n "0": "program",\n "1": "start_time"\n }\n }\n }\n }\n }\n }\n}', + '{\n "0": "@runtime",\n "1": {\n "0": {\n "0": "@function",\n "1": {\n "parameter": "context",\n "body": {\n "0": "@index",\n "1": {\n "object": {\n "0": "@lookup",\n "1": {\n "key": "context"\n }\n },\n "query": {\n "0": "program",\n "1": "start_time"\n }\n }\n }\n }\n }\n }\n}\n', }), ], [ @@ -198,9 +199,9 @@ testCases(unparsers, input => `unparsing \`${JSON.stringify(input)}\``)( inlinePlz: '{ a.b: { "c \\"d\\"": { e.f: g } }, test: :"a.b"."c \\"d\\""."e.f" }', prettyPlz: - '{\n a.b: {\n "c \\"d\\"": {\n e.f: g\n }\n }\n test: :"a.b"."c \\"d\\""."e.f"\n}', + '{\n a.b: {\n "c \\"d\\"": {\n e.f: g\n }\n }\n test: :"a.b"."c \\"d\\""."e.f"\n}\n', prettyJson: - '{\n "a.b": {\n "c \\"d\\"": {\n "e.f": "g"\n }\n },\n "test": {\n "0": "@index",\n "1": {\n "object": {\n "0": "@lookup",\n "1": {\n "0": "a.b"\n }\n },\n "query": {\n "0": "c \\"d\\"",\n "1": "e.f"\n }\n }\n }\n}', + '{\n "a.b": {\n "c \\"d\\"": {\n "e.f": "g"\n }\n },\n "test": {\n "0": "@index",\n "1": {\n "object": {\n "0": "@lookup",\n "1": {\n "0": "a.b"\n }\n },\n "query": {\n "0": "c \\"d\\"",\n "1": "e.f"\n }\n }\n }\n}\n', }), ], [ @@ -224,9 +225,9 @@ testCases(unparsers, input => `unparsing \`${JSON.stringify(input)}\``)( }, outputs({ inlinePlz: '1 + 2', - prettyPlz: '1 + 2', + prettyPlz: '1 + 2\n', prettyJson: - '{\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "+"\n }\n },\n "argument": "2"\n }\n },\n "argument": "1"\n }\n}', + '{\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "+"\n }\n },\n "argument": "2"\n }\n },\n "argument": "1"\n }\n}\n', }), ], [ @@ -347,9 +348,9 @@ testCases(unparsers, input => `unparsing \`${JSON.stringify(input)}\``)( inlinePlz: '{ five: 5, answer: 1 + 2 - (3 + 4) < :five && :boolean.not(true) }', prettyPlz: - '{\n five: 5\n answer: 1 + 2 - (3 + 4) < :five && :boolean.not(true)\n}', + '{\n five: 5\n answer: 1 + 2 - (3 + 4) < :five && :boolean.not(true)\n}\n', prettyJson: - '{\n "five": "5",\n "answer": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "&&"\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@index",\n "1": {\n "object": {\n "0": "@lookup",\n "1": {\n "key": "boolean"\n }\n },\n "query": {\n "0": "not"\n }\n }\n },\n "argument": "true"\n }\n }\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "<"\n }\n },\n "argument": {\n "0": "@lookup",\n "1": {\n "key": "five"\n }\n }\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "-"\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "+"\n }\n },\n "argument": "4"\n }\n },\n "argument": "3"\n }\n }\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "+"\n }\n },\n "argument": "2"\n }\n },\n "argument": "1"\n }\n }\n }\n }\n }\n }\n }\n }\n}', + '{\n "five": "5",\n "answer": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "&&"\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@index",\n "1": {\n "object": {\n "0": "@lookup",\n "1": {\n "key": "boolean"\n }\n },\n "query": {\n "0": "not"\n }\n }\n },\n "argument": "true"\n }\n }\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "<"\n }\n },\n "argument": {\n "0": "@lookup",\n "1": {\n "key": "five"\n }\n }\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "-"\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "+"\n }\n },\n "argument": "4"\n }\n },\n "argument": "3"\n }\n }\n }\n },\n "argument": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "+"\n }\n },\n "argument": "2"\n }\n },\n "argument": "1"\n }\n }\n }\n }\n }\n }\n }\n }\n}\n', }), ], [ @@ -435,9 +436,9 @@ testCases(unparsers, input => `unparsing \`${JSON.stringify(input)}\``)( }, outputs({ inlinePlz: '((a => :a + 1) >> (a => :a - 1))(1)', - prettyPlz: '((a => :a + 1) >> (a => :a - 1))(1)', + prettyPlz: '((a => :a + 1) >> (a => :a - 1))(1)\n', prettyJson: - '{\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": ">>"\n }\n },\n "argument": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "-"\n }\n },\n "argument": "1"\n }\n },\n "argument": {\n "0": "@lookup",\n "1": {\n "key": "a"\n }\n }\n }\n }\n }\n }\n }\n },\n "argument": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "+"\n }\n },\n "argument": "1"\n }\n },\n "argument": {\n "0": "@lookup",\n "1": {\n "key": "a"\n }\n }\n }\n }\n }\n }\n }\n },\n "argument": "1"\n }\n}', + '{\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": ">>"\n }\n },\n "argument": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "-"\n }\n },\n "argument": "1"\n }\n },\n "argument": {\n "0": "@lookup",\n "1": {\n "key": "a"\n }\n }\n }\n }\n }\n }\n }\n },\n "argument": {\n "0": "@function",\n "1": {\n "parameter": "a",\n "body": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@apply",\n "1": {\n "function": {\n "0": "@lookup",\n "1": {\n "key": "+"\n }\n },\n "argument": "1"\n }\n },\n "argument": {\n "0": "@lookup",\n "1": {\n "key": "a"\n }\n }\n }\n }\n }\n }\n }\n },\n "argument": "1"\n }\n}\n', }), ], ], diff --git a/src/language/unparsing.ts b/src/language/unparsing.ts index 0750bce..729d1ea 100644 --- a/src/language/unparsing.ts +++ b/src/language/unparsing.ts @@ -1,4 +1,5 @@ import type { Either } from '@matt.kantor/either' +import either from '@matt.kantor/either' import type { UnserializableValueError } from './errors.js' import type { Atom, Molecule } from './parsing.js' import type { Notation } from './unparsing/unparsing-utilities.js' @@ -13,6 +14,9 @@ export const unparse = ( value: Atom | Molecule, notation: Notation, ): Either => - typeof value === 'object' - ? notation.molecule(value, notation) - : notation.atom(value) + either.map( + typeof value === 'object' + ? notation.molecule(value, notation) + : notation.atom(value), + output => output.concat(notation.suffix), + ) diff --git a/src/language/unparsing/inline-plz.ts b/src/language/unparsing/inline-plz.ts index 28d7cf3..0f0d4f8 100644 --- a/src/language/unparsing/inline-plz.ts +++ b/src/language/unparsing/inline-plz.ts @@ -39,4 +39,5 @@ const unparseMolecule = moleculeUnparser( export const inlinePlz: Notation = { atom: unparseAtom, molecule: unparseMolecule, + suffix: '', } diff --git a/src/language/unparsing/pretty-json.ts b/src/language/unparsing/pretty-json.ts index 20a16f8..73a57c8 100644 --- a/src/language/unparsing/pretty-json.ts +++ b/src/language/unparsing/pretty-json.ts @@ -52,4 +52,5 @@ const unparseAtomOrMolecule = (value: Atom | Molecule) => export const prettyJson: Notation = { atom: unparseAtom, molecule: unparseMolecule, + suffix: '\n', } diff --git a/src/language/unparsing/pretty-plz.ts b/src/language/unparsing/pretty-plz.ts index 2b15cf2..ce13b9f 100644 --- a/src/language/unparsing/pretty-plz.ts +++ b/src/language/unparsing/pretty-plz.ts @@ -39,4 +39,5 @@ const unparseMolecule = moleculeUnparser( export const prettyPlz: Notation = { atom: unparseAtom, molecule: unparseMolecule, + suffix: '\n', } diff --git a/src/language/unparsing/sugar-free-pretty-plz.ts b/src/language/unparsing/sugar-free-pretty-plz.ts index a6742ab..610efad 100644 --- a/src/language/unparsing/sugar-free-pretty-plz.ts +++ b/src/language/unparsing/sugar-free-pretty-plz.ts @@ -30,4 +30,5 @@ const unparseAtomOrMolecule = (value: Atom | Molecule) => export const sugarFreePrettyPlz: Notation = { atom: unparseAtom, molecule: unparseMolecule, + suffix: '\n', } diff --git a/src/language/unparsing/unparsing-utilities.ts b/src/language/unparsing/unparsing-utilities.ts index d716f8c..7878814 100644 --- a/src/language/unparsing/unparsing-utilities.ts +++ b/src/language/unparsing/unparsing-utilities.ts @@ -9,6 +9,7 @@ export type Notation = { value: Molecule, notation: Notation, ) => Either + readonly suffix: string } export const indent = (spaces: number, textToIndent: string) => {