From bc14eaa636abeeaa8f7e143518cb758a09d7cbb2 Mon Sep 17 00:00:00 2001 From: Tim Haselaars Date: Wed, 3 Jun 2026 21:10:24 +0200 Subject: [PATCH 1/2] feat: Keep quote preference --- bin/__snapshots__/cli.test.js.snap | 1 + bin/cli.js | 9 +- bin/cli.test.js | 113 +++++++++++++ readme.md | 8 +- test/util-file.test.js | 158 ++++++++++++++++++ .../yaml-quote-style-config-double/input.yaml | 3 + .../options.yaml | 4 + .../output.yaml | 3 + test/yaml-quote-style-detect/input.yaml | 4 + test/yaml-quote-style-detect/options.yaml | 3 + test/yaml-quote-style-detect/output.yaml | 4 + .../input.yaml | 8 + .../options.yaml | 3 + .../output.yaml | 8 + .../input.yaml | 8 + .../options.yaml | 3 + .../output.yaml | 8 + types/openapi-format.d.ts | 1 + utils/file.js | 76 ++++++++- 19 files changed, 421 insertions(+), 4 deletions(-) create mode 100644 test/yaml-quote-style-config-double/input.yaml create mode 100644 test/yaml-quote-style-config-double/options.yaml create mode 100644 test/yaml-quote-style-config-double/output.yaml create mode 100644 test/yaml-quote-style-detect/input.yaml create mode 100644 test/yaml-quote-style-detect/options.yaml create mode 100644 test/yaml-quote-style-detect/output.yaml create mode 100644 test/yaml-quote-style-mixed-detect-double/input.yaml create mode 100644 test/yaml-quote-style-mixed-detect-double/options.yaml create mode 100644 test/yaml-quote-style-mixed-detect-double/output.yaml create mode 100644 test/yaml-quote-style-mixed-detect-single/input.yaml create mode 100644 test/yaml-quote-style-mixed-detect-single/options.yaml create mode 100644 test/yaml-quote-style-mixed-detect-single/output.yaml diff --git a/bin/__snapshots__/cli.test.js.snap b/bin/__snapshots__/cli.test.js.snap index 0ada71a..0d3a635 100644 --- a/bin/__snapshots__/cli.test.js.snap +++ b/bin/__snapshots__/cli.test.js.snap @@ -116,6 +116,7 @@ Options: -c, --configFile the file with the OpenAPI-format CLI options --no-sort don't sort the OpenAPI file --keepComments don't remove the comments from the OpenAPI YAML file (default: false) + --yamlQuoteStyle YAML quote style: single, double, or detect --sortComponentsFile the file with components to sort alphabetically --sortComponentsProps sort properties within schema components alphabetically (default: false) --lineWidth max line width of YAML output (default: -1) diff --git a/bin/cli.js b/bin/cli.js index 823b7a4..73e8652 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -26,6 +26,7 @@ program .option('-c, --configFile ', 'the file with the OpenAPI-format CLI options') .option('--no-sort', `don't sort the OpenAPI file`) .option('--keepComments', `don't remove the comments from the OpenAPI YAML file`, false) + .option('--yamlQuoteStyle ', 'YAML quote style: single, double, or detect') .option('--sortComponentsFile ', 'the file with components to sort alphabetically') .option('--sortComponentsProps', 'sort properties within schema components alphabetically', false) .option('--lineWidth ', 'max line width of YAML output', -1) @@ -245,7 +246,11 @@ async function run(oaFile, options) { let resObj = {}; let output = {}; let input = {}; - let fileOptions = {keepComments: options.keepComments ?? false, bundle: options.bundle ?? true}; + let fileOptions = { + keepComments: options.keepComments ?? false, + bundle: options.bundle ?? true, + yamlQuoteStyle: options.yamlQuoteStyle + }; try { if (!options?.overlaySet?.extends) { @@ -347,6 +352,8 @@ async function run(oaFile, options) { options.yamlComments = fileOptions.yamlComments || {}; options.yamlValueFormats = fileOptions.yamlValueFormats || {}; + options.detectedYamlQuoteStyle = fileOptions.detectedYamlQuoteStyle; + options.detectedYamlQuoteStyleHasQuotedScalars = fileOptions.detectedYamlQuoteStyleHasQuotedScalars; if (options.output) { if (options.split !== true) { try { diff --git a/bin/cli.test.js b/bin/cli.test.js index c214572..8e6ff58 100644 --- a/bin/cli.test.js +++ b/bin/cli.test.js @@ -116,6 +116,119 @@ describe('openapi-format CLI command', () => { expect(output).toStrictEqual(snap); }); + it('should detect YAML quote style by default from config-driven output', async () => { + const testName = 'yaml-quote-style-detect'; + const testPath = `test/${testName}`; + const expectedOutputFile = `${testPath}/output.yaml`; + const tempOutputFile = path.join(os.tmpdir(), `openapi-format-${testName}-output.yaml`); + const expectedOutput = fs.readFileSync(expectedOutputFile, 'utf8'); + + const result = await testUtils.cli( + ['input.yaml', '--configFile options.yaml', `--output ${tempOutputFile}`], + testPath + ); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('formatted successfully'); + expect(fs.readFileSync(tempOutputFile, 'utf8')).toBe(expectedOutput); + }); + + it('should apply yamlQuoteStyle from config files', async () => { + const testName = 'yaml-quote-style-config-double'; + const testPath = `test/${testName}`; + const expectedOutputFile = `${testPath}/output.yaml`; + const tempOutputFile = path.join(os.tmpdir(), `openapi-format-${testName}-output.yaml`); + const expectedOutput = fs.readFileSync(expectedOutputFile, 'utf8'); + + const result = await testUtils.cli( + ['input.yaml', '--configFile options.yaml', `--output ${tempOutputFile}`], + testPath + ); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('formatted successfully'); + expect(fs.readFileSync(tempOutputFile, 'utf8')).toBe(expectedOutput); + }); + + it('should let the CLI yamlQuoteStyle flag override config file values', async () => { + const testName = 'yaml-quote-style-config-double'; + const testPath = `test/${testName}`; + const tempOutputFile = path.join(os.tmpdir(), `openapi-format-${testName}-override-output.yaml`); + + const result = await testUtils.cli( + ['input.yaml', '--configFile options.yaml', '--yamlQuoteStyle single', `--output ${tempOutputFile}`], + testPath + ); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('formatted successfully'); + expect(fs.readFileSync(tempOutputFile, 'utf8')).toContain("description: 'Hello: world'"); + expect(fs.readFileSync(tempOutputFile, 'utf8')).not.toContain('description: "Hello: world"'); + }); + + it('should not force quotes onto plain YAML strings for explicit yamlQuoteStyle values', async () => { + const testName = 'yaml-quote-style-config-double'; + const testPath = `test/${testName}`; + const tempOutputFile = path.join(os.tmpdir(), `openapi-format-${testName}-plain-output.yaml`); + + const result = await testUtils.cli( + ['input.yaml', '--configFile options.yaml', `--output ${tempOutputFile}`], + testPath + ); + + const output = fs.readFileSync(tempOutputFile, 'utf8'); + expect(result.code).toBe(0); + expect(result.stdout).toContain('formatted successfully'); + expect(output).toContain('name: John'); + expect(output).not.toContain('name: "John"'); + expect(output).not.toContain("name: 'John'"); + }); + + it('should keep comments for YAML with explicit quote style', async () => { + const tempOutputFile = path.join(os.tmpdir(), 'openapi-format-yaml-quote-style-comments-output.yaml'); + const result = await testUtils.cli( + [ + 'input.yaml', + '--filterFile customFilter.yaml', + '--configFile options.yaml', + '--keepComments', + '--yamlQuoteStyle double', + `--output ${tempOutputFile}` + ], + 'test/yaml-no-sort-keep-comments' + ); + + const output = fs.readFileSync(tempOutputFile, 'utf8'); + expect(result.code).toBe(0); + expect(result.stdout).toContain('formatted successfully'); + expect(output).toContain('#'); + expect(output).toContain('"'); + }); + + it('should apply detected quote style uniformly to required quoted keys and values', async () => { + const path = 'test/yaml-preserve-example-props'; + const inputFile = `${path}/input.yaml`; + const outputFile = `${path}/output.yaml`; + const output = await getLocalFile(outputFile); + + const result = await testUtils.cli([inputFile, '--no-sort'], '.'); + expect(result.code).toBe(0); + expect(result.stdout).toContain('formatted successfully'); + expect(sanitize(result.stderr)).toStrictEqual(sanitize(output)); + }); + + it('should keep JSON output unaffected by yamlQuoteStyle CLI option', async () => { + const path = `test/json-default`; + const inputFile = `${path}/input.json`; + const outputFile = `${path}/output.json`; + const output = await getLocalFile(outputFile); + + let result = await testUtils.cli([inputFile, '--json', '--yamlQuoteStyle double'], '.'); + expect(result.code).toBe(0); + expect(result.stdout).toContain('formatted successfully'); + expect(sanitize(result.stderr)).toStrictEqual(sanitize(output)); + }); + it('should load the default .openapiformatrc if configFile is not provided', async () => { // Mock the existence of the .openapiformatrc file const defaultConfigPath = '.openapiformatrc'; diff --git a/readme.md b/readme.md index 831ec8f..401d7e3 100644 --- a/readme.md +++ b/readme.md @@ -168,6 +168,7 @@ Options: --no-sort Don't sort the OpenAPI file [boolean] --keepComments Don't remove the comments from the OpenAPI YAML file [boolean] + --yamlQuoteStyle Preferred YAML quote style: single, double, detect [string] --sortComponentsFile The file with components to sort alphabetically [path] --sortComponentsProps Sort properties within schema components alphabetically [boolean] @@ -204,6 +205,7 @@ Options: | --overlayFile | -l | the file to specify OpenAPI overlay actions | path to file | | optional | | --no-sort | | don't sort the OpenAPI file | boolean | FALSE | optional | | --keepComments | | don't remove the comments from the OpenAPI YAML file | boolean | FALSE | optional | +| --yamlQuoteStyle | | preferred YAML quote style for YAML output (`single`, `double`, `detect`) | string | detect | optional | | --sortComponentsFile | | sort the items of the components (schemas, parameters, ...) by alphabet | path to file | defaultSortComponents.json | optional | | --sortComponentsProps | | sort properties within schema components alphabetically | boolean | FALSE | optional | | --no-bundle | | don't bundle the local and remote $ref in the OpenAPI document | boolean | FALSE | optional | @@ -1697,7 +1699,11 @@ const { const input = await parseFile('openapi.yaml'); // local path or remote URL const {data} = await openapiSort(input, {sort: true}); -const output = await stringify(data, {format: 'yaml', lineWidth: -1}); +const output = await stringify(data, { + format: 'yaml', + lineWidth: -1, + yamlQuoteStyle: 'detect' +}); await writeFile('openapi.sorted.yaml', output, {format: 'yaml'}); ``` diff --git a/test/util-file.test.js b/test/util-file.test.js index 405eeb9..5331699 100644 --- a/test/util-file.test.js +++ b/test/util-file.test.js @@ -172,6 +172,123 @@ describe('openapi-format CLI file tests', () => { const expectedJSON = JSON.stringify(obj, null, 2); expect(result).toEqual(expectedJSON); }); + + test('should not add quotes to plain YAML strings when yamlQuoteStyle is single', async () => { + const obj = {name: 'John'}; + + const result = await stringify(obj, {format: 'yaml', yamlQuoteStyle: 'single'}); + + expect(result).toContain('name: John'); + expect(result).not.toContain("name: 'John'"); + }); + + test('should not add quotes to plain YAML strings when yamlQuoteStyle is double', async () => { + const obj = {name: 'John'}; + + const result = await stringify(obj, {format: 'yaml', yamlQuoteStyle: 'double'}); + + expect(result).toContain('name: John'); + expect(result).not.toContain('name: "John"'); + }); + + test('should use single quotes when YAML quoting is required and yamlQuoteStyle is single', async () => { + const obj = {name: 'Hello: world'}; + + const result = await stringify(obj, {format: 'yaml', yamlQuoteStyle: 'single'}); + + expect(result).toContain("name: 'Hello: world'"); + }); + + test('should use double quotes when YAML quoting is required and yamlQuoteStyle is double', async () => { + const obj = {name: 'Hello: world'}; + + const result = await stringify(obj, {format: 'yaml', yamlQuoteStyle: 'double'}); + + expect(result).toContain('name: "Hello: world"'); + }); + + test('should use detected quote style when YAML quoting is required', async () => { + const obj = {name: 'Hello: world'}; + + const result = await stringify(obj, { + format: 'yaml', + yamlQuoteStyle: 'detect', + detectedYamlQuoteStyle: 'double', + detectedYamlQuoteStyleHasQuotedScalars: true + }); + + expect(result).toContain('name: "Hello: world"'); + }); + + test('should use detected quote style for keys when YAML key quoting is required', async () => { + const obj = {responses: {'200': {description: 'ok'}}}; + + const result = await stringify(obj, { + format: 'yaml', + yamlQuoteStyle: 'detect', + detectedYamlQuoteStyle: 'single', + detectedYamlQuoteStyleHasQuotedScalars: true + }); + + expect(result).toContain(" '200':"); + expect(result).not.toContain(' "200":'); + }); + + test('should not add quotes in detect mode when YAML quoting is not required', async () => { + const obj = {name: 'John'}; + + const result = await stringify(obj, { + format: 'yaml', + yamlQuoteStyle: 'detect', + detectedYamlQuoteStyle: 'double', + detectedYamlQuoteStyleHasQuotedScalars: true + }); + + expect(result).toContain('name: John'); + expect(result).not.toContain('name: "John"'); + }); + + test('should preserve comments while honoring the resolved quote style', async () => { + const parsed = yaml.parseDocument('name: "Hello: world" # person\ncity: London\n'); + const options = { + format: 'yaml', + yamlQuoteStyle: 'double', + keepComments: true, + yamlComments: [ + { + path: ['name'], + type: 'inlineValue', + text: ' person' + } + ] + }; + + const result = await stringify(parsed.toJS(), options); + + expect(result).toContain('name: "Hello: world" # person'); + }); + + test('should preserve YAML value formats while honoring the resolved quote style', async () => { + const input = yaml.parseDocument('name: "Hello: world"\nx-version: 1.00\n'); + const options = { + format: 'yaml', + yamlQuoteStyle: 'double', + yamlValueFormats: extractYamlValueFormats(input) + }; + + const result = await stringify(input.toJS({keepScalar: false}), options); + + expect(result).toContain('name: "Hello: world"'); + expect(result).toContain('x-version: 1.00'); + }); + + test('should leave JSON output unchanged when yamlQuoteStyle is set', async () => { + const obj = {name: 'John'}; + + const result = await stringify(obj, {format: 'json', yamlQuoteStyle: 'double'}); + + expect(result).toEqual(JSON.stringify(obj, null, 2)); + }); }); describe('parseString', () => { @@ -193,6 +310,47 @@ describe('openapi-format CLI file tests', () => { expect(result).toEqual({name: 'John', age: 30}); }); + it('should detect dominant double quotes from YAML input', async () => { + const yamlString = 'name: "John"\ncity: "London"\ncountry: \'UK\'\n'; + const options = {yamlQuoteStyle: 'detect'}; + + const result = await parseString(yamlString, options); + + expect(result).toEqual({name: 'John', city: 'London', country: 'UK'}); + expect(options.detectedYamlQuoteStyle).toBe('double'); + }); + + it('should detect quote style from quoted keys when they dominate', async () => { + const yamlString = 'responses:\n "200": ok\n "404": nope\nmeta: \'value\'\n'; + const options = {yamlQuoteStyle: 'detect'}; + + const result = await parseString(yamlString, options); + + expect(result).toEqual({ + responses: {'200': 'ok', '404': 'nope'}, + meta: 'value' + }); + expect(options.detectedYamlQuoteStyle).toBe('double'); + }); + + it('should resolve tied YAML quote detection to single', async () => { + const yamlString = 'name: "John"\ncity: \'London\'\n'; + const options = {yamlQuoteStyle: 'detect'}; + + await parseString(yamlString, options); + + expect(options.detectedYamlQuoteStyle).toBe('single'); + }); + + it('should resolve YAML quote detection without quoted scalars to single', async () => { + const yamlString = 'name: John\ncity: London\n'; + const options = {yamlQuoteStyle: 'detect'}; + + await parseString(yamlString, options); + + expect(options.detectedYamlQuoteStyle).toBe('single'); + }); + it('should return YAML parsing error if YAML parsing fail', async () => { const invalidString = '#name 1John\nage 30#'; // Invalid YAML const result = await parseString(invalidString, {format: 'yaml'}); diff --git a/test/yaml-quote-style-config-double/input.yaml b/test/yaml-quote-style-config-double/input.yaml new file mode 100644 index 0000000..e4e593e --- /dev/null +++ b/test/yaml-quote-style-config-double/input.yaml @@ -0,0 +1,3 @@ +name: John +description: 'Hello: world' +city: London diff --git a/test/yaml-quote-style-config-double/options.yaml b/test/yaml-quote-style-config-double/options.yaml new file mode 100644 index 0000000..f06b1d7 --- /dev/null +++ b/test/yaml-quote-style-config-double/options.yaml @@ -0,0 +1,4 @@ +lineWidth: -1 +sort: false +yamlQuoteStyle: double +output: output.yaml diff --git a/test/yaml-quote-style-config-double/output.yaml b/test/yaml-quote-style-config-double/output.yaml new file mode 100644 index 0000000..d388fcb --- /dev/null +++ b/test/yaml-quote-style-config-double/output.yaml @@ -0,0 +1,3 @@ +name: John +description: "Hello: world" +city: London diff --git a/test/yaml-quote-style-detect/input.yaml b/test/yaml-quote-style-detect/input.yaml new file mode 100644 index 0000000..401f019 --- /dev/null +++ b/test/yaml-quote-style-detect/input.yaml @@ -0,0 +1,4 @@ +name: John +description: "Hello: world" +summary: "Cost: 10" +country: 'Label: UK' diff --git a/test/yaml-quote-style-detect/options.yaml b/test/yaml-quote-style-detect/options.yaml new file mode 100644 index 0000000..cd6ae9f --- /dev/null +++ b/test/yaml-quote-style-detect/options.yaml @@ -0,0 +1,3 @@ +lineWidth: -1 +sort: false +output: output.yaml diff --git a/test/yaml-quote-style-detect/output.yaml b/test/yaml-quote-style-detect/output.yaml new file mode 100644 index 0000000..0379d51 --- /dev/null +++ b/test/yaml-quote-style-detect/output.yaml @@ -0,0 +1,4 @@ +name: John +description: "Hello: world" +summary: "Cost: 10" +country: "Label: UK" diff --git a/test/yaml-quote-style-mixed-detect-double/input.yaml b/test/yaml-quote-style-mixed-detect-double/input.yaml new file mode 100644 index 0000000..36a1a30 --- /dev/null +++ b/test/yaml-quote-style-mixed-detect-double/input.yaml @@ -0,0 +1,8 @@ +plainName: John +quotedLabel: "Hello: world" +responses: + "200": + description: ok + '404': + description: 'Not: found' +note: "Label: A" diff --git a/test/yaml-quote-style-mixed-detect-double/options.yaml b/test/yaml-quote-style-mixed-detect-double/options.yaml new file mode 100644 index 0000000..cd6ae9f --- /dev/null +++ b/test/yaml-quote-style-mixed-detect-double/options.yaml @@ -0,0 +1,3 @@ +lineWidth: -1 +sort: false +output: output.yaml diff --git a/test/yaml-quote-style-mixed-detect-double/output.yaml b/test/yaml-quote-style-mixed-detect-double/output.yaml new file mode 100644 index 0000000..1cf32f7 --- /dev/null +++ b/test/yaml-quote-style-mixed-detect-double/output.yaml @@ -0,0 +1,8 @@ +plainName: John +quotedLabel: "Hello: world" +responses: + "200": + description: ok + "404": + description: "Not: found" +note: "Label: A" diff --git a/test/yaml-quote-style-mixed-detect-single/input.yaml b/test/yaml-quote-style-mixed-detect-single/input.yaml new file mode 100644 index 0000000..ec6cf2d --- /dev/null +++ b/test/yaml-quote-style-mixed-detect-single/input.yaml @@ -0,0 +1,8 @@ +plainName: John +quotedLabel: 'Hello: world' +responses: + '200': + description: ok + "404": + description: "Not: found" +note: 'Label: A' diff --git a/test/yaml-quote-style-mixed-detect-single/options.yaml b/test/yaml-quote-style-mixed-detect-single/options.yaml new file mode 100644 index 0000000..cd6ae9f --- /dev/null +++ b/test/yaml-quote-style-mixed-detect-single/options.yaml @@ -0,0 +1,3 @@ +lineWidth: -1 +sort: false +output: output.yaml diff --git a/test/yaml-quote-style-mixed-detect-single/output.yaml b/test/yaml-quote-style-mixed-detect-single/output.yaml new file mode 100644 index 0000000..e0d2913 --- /dev/null +++ b/test/yaml-quote-style-mixed-detect-single/output.yaml @@ -0,0 +1,8 @@ +plainName: John +quotedLabel: 'Hello: world' +responses: + '200': + description: ok + '404': + description: 'Not: found' +note: 'Label: A' diff --git a/types/openapi-format.d.ts b/types/openapi-format.d.ts index 29ff985..26bfcff 100644 --- a/types/openapi-format.d.ts +++ b/types/openapi-format.d.ts @@ -183,6 +183,7 @@ declare module 'openapi-format' { format?: string; json?: boolean; keepComments?: boolean; + yamlQuoteStyle?: 'single' | 'double' | 'detect'; yamlComments?: Record; lineWidth?: string | number; mode?: string; diff --git a/utils/file.js b/utils/file.js index d69d1e4..8c9abfb 100644 --- a/utils/file.js +++ b/utils/file.js @@ -13,6 +13,11 @@ const COMMENT_TYPE = /** @type {const} */ ({ }); const YAML_VALUE_FORMATS_PROP = '__openapiFormatYamlValueFormats'; +const YAML_QUOTE_STYLE = /** @type {const} */ ({ + SINGLE: 'single', + DOUBLE: 'double', + DETECT: 'detect' +}); /** * @typedef {typeof COMMENT_TYPE[keyof typeof COMMENT_TYPE]} CommentType @@ -114,6 +119,66 @@ function injectComments(doc, comments) { } } +/** + * Detect the dominant quote style from a parsed YAML document. + * @param {import('yaml').Document} doc + * @returns {{style: 'single' | 'double', hasQuotedScalars: boolean}} + */ +function detectYamlQuoteStyle(doc) { + let singleQuotes = 0; + let doubleQuotes = 0; + + yaml.visit(doc, { + Scalar(_, node) { + if (node.type === 'QUOTE_SINGLE') { + singleQuotes += 1; + } + + if (node.type === 'QUOTE_DOUBLE') { + doubleQuotes += 1; + } + } + }); + + return { + style: doubleQuotes > singleQuotes ? YAML_QUOTE_STYLE.DOUBLE : YAML_QUOTE_STYLE.SINGLE, + hasQuotedScalars: singleQuotes + doubleQuotes > 0 + }; +} + +/** + * Resolve the configured YAML quote style to an explicit mode. + * @param {object} [options] + * @returns {'single' | 'double'} + */ +function resolveYamlQuoteStyle(options = {}) { + const configuredQuoteStyle = options.yamlQuoteStyle || YAML_QUOTE_STYLE.DETECT; + + if (configuredQuoteStyle === YAML_QUOTE_STYLE.DOUBLE) { + return YAML_QUOTE_STYLE.DOUBLE; + } + + if (configuredQuoteStyle === YAML_QUOTE_STYLE.DETECT) { + return options.detectedYamlQuoteStyle || YAML_QUOTE_STYLE.SINGLE; + } + + return YAML_QUOTE_STYLE.SINGLE; +} + +/** + * Build YAML stringify/toString options, including quote-style control. + * @param {number} lineWidth + * @param {object} [options] + * @returns {{lineWidth: number, singleQuote: boolean}} + */ +function buildYamlStringifyOptions(lineWidth, options = {}) { + const style = resolveYamlQuoteStyle(options); + return { + lineWidth, + singleQuote: style !== YAML_QUOTE_STYLE.DOUBLE + }; +} + /** * Converts a string object to a JSON/YAML object. * @param {string} str - The input string to be parsed (either JSON or YAML). @@ -136,10 +201,16 @@ async function parseString(str, options = {}) { if (toYaml) { try { const doc = yaml.parseDocument(encodedContent); + const configuredQuoteStyle = options.yamlQuoteStyle || YAML_QUOTE_STYLE.DETECT; if (options?.keepComments) { options.yamlComments = extractComments(doc); } options.yamlValueFormats = extractYamlValueFormats(doc); + if (configuredQuoteStyle === YAML_QUOTE_STYLE.DETECT) { + const detectedQuoteStyle = detectYamlQuoteStyle(doc); + options.detectedYamlQuoteStyle = detectedQuoteStyle.style; + options.detectedYamlQuoteStyleHasQuotedScalars = detectedQuoteStyle.hasQuotedScalars; + } const obj = doc.toJS(); if (typeof obj === 'object') { return obj; @@ -284,9 +355,10 @@ async function stringify(obj, options = {}) { if (toYaml) { const lineWidth = (options.lineWidth && options.lineWidth === -1 ? 0 : options.lineWidth) || 0; + const yamlStringifyOptions = buildYamlStringifyOptions(lineWidth, options); // Convert object to YAML string - output = yaml.stringify(obj, {lineWidth, singleQuote: true}); + output = yaml.stringify(obj, yamlStringifyOptions); if ( (options?.yamlComments?.length > 0 && options?.keepComments === true) || @@ -297,7 +369,7 @@ async function stringify(obj, options = {}) { if (options?.yamlComments?.length > 0 && options?.keepComments === true) { injectComments(newDoc, options.yamlComments); } - output = newDoc.toString({lineWidth}); + output = newDoc.toString(yamlStringifyOptions); } // Decode large number YAML values safely before writing output From 0415aa6f66fbd121aaa6f7273dafd05f792e8cdb Mon Sep 17 00:00:00 2001 From: Tim Haselaars Date: Wed, 3 Jun 2026 21:48:45 +0200 Subject: [PATCH 2/2] feat: Keep quote preference --- bin/cli.test.js | 17 +++++++++ readme.md | 6 +-- test/yaml-convert-3.0-3.2/output.yaml | 12 +++--- test/yaml-convert-3.1-3.2/output.yaml | 12 +++--- .../yaml-quote-style-detect-bundle/input.yaml | 9 +++++ .../options.yaml | 4 ++ .../output.yaml | 12 ++++++ .../yaml-quote-style-detect-bundle/thing.yaml | 7 ++++ utils/file.js | 38 ++++++++++++++----- 9 files changed, 90 insertions(+), 27 deletions(-) create mode 100644 test/yaml-quote-style-detect-bundle/input.yaml create mode 100644 test/yaml-quote-style-detect-bundle/options.yaml create mode 100644 test/yaml-quote-style-detect-bundle/output.yaml create mode 100644 test/yaml-quote-style-detect-bundle/thing.yaml diff --git a/bin/cli.test.js b/bin/cli.test.js index 8e6ff58..0e31443 100644 --- a/bin/cli.test.js +++ b/bin/cli.test.js @@ -217,6 +217,23 @@ describe('openapi-format CLI command', () => { expect(sanitize(result.stderr)).toStrictEqual(sanitize(output)); }); + it('should preserve detected root quote style when bundling refs', async () => { + const testName = 'yaml-quote-style-detect-bundle'; + const testPath = `test/${testName}`; + const expectedOutputFile = `${testPath}/output.yaml`; + const tempOutputFile = path.join(os.tmpdir(), `openapi-format-${testName}-output.yaml`); + const expectedOutput = fs.readFileSync(expectedOutputFile, 'utf8'); + + const result = await testUtils.cli( + ['input.yaml', '--configFile options.yaml', `--output ${tempOutputFile}`], + testPath + ); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('formatted successfully'); + expect(fs.readFileSync(tempOutputFile, 'utf8')).toBe(expectedOutput); + }); + it('should keep JSON output unaffected by yamlQuoteStyle CLI option', async () => { const path = `test/json-default`; const inputFile = `${path}/input.json`; diff --git a/readme.md b/readme.md index 401d7e3..b542c19 100644 --- a/readme.md +++ b/readme.md @@ -1699,11 +1699,7 @@ const { const input = await parseFile('openapi.yaml'); // local path or remote URL const {data} = await openapiSort(input, {sort: true}); -const output = await stringify(data, { - format: 'yaml', - lineWidth: -1, - yamlQuoteStyle: 'detect' -}); +const output = await stringify(data, {format: 'yaml', lineWidth: -1}); await writeFile('openapi.sorted.yaml', output, {format: 'yaml'}); ``` diff --git a/test/yaml-convert-3.0-3.2/output.yaml b/test/yaml-convert-3.0-3.2/output.yaml index 1bff912..bed45c6 100644 --- a/test/yaml-convert-3.0-3.2/output.yaml +++ b/test/yaml-convert-3.0-3.2/output.yaml @@ -9,14 +9,14 @@ paths: get: summary: List items responses: - '200': + "200": description: OK content: application/json: schema: type: array items: - $ref: '#/components/schemas/Item' + $ref: "#/components/schemas/Item" /upload-binary: post: summary: Upload a raw binary file @@ -25,7 +25,7 @@ paths: content: application/octet-stream: {} responses: - '204': + "204": description: No Content /upload-base64: post: @@ -38,7 +38,7 @@ paths: type: string contentEncoding: base64 responses: - '204': + "204": description: No Content /upload-multipart: post: @@ -56,7 +56,7 @@ paths: type: string contentMediaType: application/octet-stream responses: - '204': + "204": description: No Content components: schemas: @@ -69,7 +69,7 @@ components: name: type: - string - - 'null' + - "null" examples: - fedora required: diff --git a/test/yaml-convert-3.1-3.2/output.yaml b/test/yaml-convert-3.1-3.2/output.yaml index 4d0e047..8313a67 100644 --- a/test/yaml-convert-3.1-3.2/output.yaml +++ b/test/yaml-convert-3.1-3.2/output.yaml @@ -23,14 +23,14 @@ paths: tags: - items responses: - '200': + "200": description: OK content: application/json: schema: type: array items: - $ref: '#/components/schemas/Item' + $ref: "#/components/schemas/Item" /upload-binary: post: summary: Upload a raw binary file @@ -41,7 +41,7 @@ paths: content: application/octet-stream: {} responses: - '204': + "204": description: No Content /upload-base64: post: @@ -57,7 +57,7 @@ paths: contentEncoding: base64 contentMediaType: image/png responses: - '204': + "204": description: No Content /upload-multipart: post: @@ -77,7 +77,7 @@ paths: type: string contentMediaType: application/octet-stream responses: - '204': + "204": description: No Content components: schemas: @@ -90,7 +90,7 @@ components: name: type: - string - - 'null' + - "null" examples: - fedora required: diff --git a/test/yaml-quote-style-detect-bundle/input.yaml b/test/yaml-quote-style-detect-bundle/input.yaml new file mode 100644 index 0000000..de3d966 --- /dev/null +++ b/test/yaml-quote-style-detect-bundle/input.yaml @@ -0,0 +1,9 @@ +openapi: 3.0.2 +info: + title: Repro + description: "Hello: world" +paths: {} +components: + schemas: + Thing: + $ref: "./thing.yaml#/components/schemas/Thing" diff --git a/test/yaml-quote-style-detect-bundle/options.yaml b/test/yaml-quote-style-detect-bundle/options.yaml new file mode 100644 index 0000000..e17dc7d --- /dev/null +++ b/test/yaml-quote-style-detect-bundle/options.yaml @@ -0,0 +1,4 @@ +lineWidth: -1 +sort: false +yamlQuoteStyle: detect +output: output.yaml diff --git a/test/yaml-quote-style-detect-bundle/output.yaml b/test/yaml-quote-style-detect-bundle/output.yaml new file mode 100644 index 0000000..280d16e --- /dev/null +++ b/test/yaml-quote-style-detect-bundle/output.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.2 +info: + title: Repro + description: "Hello: world" +paths: {} +components: + schemas: + Thing: + type: object + properties: + id: + type: string diff --git a/test/yaml-quote-style-detect-bundle/thing.yaml b/test/yaml-quote-style-detect-bundle/thing.yaml new file mode 100644 index 0000000..ab48253 --- /dev/null +++ b/test/yaml-quote-style-detect-bundle/thing.yaml @@ -0,0 +1,7 @@ +components: + schemas: + Thing: + type: object + properties: + id: + type: string diff --git a/utils/file.js b/utils/file.js index 8c9abfb..2433fc7 100644 --- a/utils/file.js +++ b/utils/file.js @@ -179,6 +179,27 @@ function buildYamlStringifyOptions(lineWidth, options = {}) { }; } +/** + * Extract YAML parse metadata that later stringify steps rely on. + * @param {import('yaml').Document} doc + * @param {object} [options] + */ +function applyYamlParseMetadata(doc, options = {}) { + const configuredQuoteStyle = options.yamlQuoteStyle || YAML_QUOTE_STYLE.DETECT; + + if (options?.keepComments) { + options.yamlComments = extractComments(doc); + } + + options.yamlValueFormats = extractYamlValueFormats(doc); + + if (configuredQuoteStyle === YAML_QUOTE_STYLE.DETECT) { + const detectedQuoteStyle = detectYamlQuoteStyle(doc); + options.detectedYamlQuoteStyle = detectedQuoteStyle.style; + options.detectedYamlQuoteStyleHasQuotedScalars = detectedQuoteStyle.hasQuotedScalars; + } +} + /** * Converts a string object to a JSON/YAML object. * @param {string} str - The input string to be parsed (either JSON or YAML). @@ -201,16 +222,7 @@ async function parseString(str, options = {}) { if (toYaml) { try { const doc = yaml.parseDocument(encodedContent); - const configuredQuoteStyle = options.yamlQuoteStyle || YAML_QUOTE_STYLE.DETECT; - if (options?.keepComments) { - options.yamlComments = extractComments(doc); - } - options.yamlValueFormats = extractYamlValueFormats(doc); - if (configuredQuoteStyle === YAML_QUOTE_STYLE.DETECT) { - const detectedQuoteStyle = detectYamlQuoteStyle(doc); - options.detectedYamlQuoteStyle = detectedQuoteStyle.style; - options.detectedYamlQuoteStyleHasQuotedScalars = detectedQuoteStyle.hasQuotedScalars; - } + applyYamlParseMetadata(doc, options); const obj = doc.toJS(); if (typeof obj === 'object') { return obj; @@ -311,6 +323,12 @@ async function parseFile(filePath, options = {}) { // Read local or remote file content and get format JSON or YAML let rawContent = await readFile(filePath, options); + if (options.format === 'yaml') { + const encodedContent = addQuotesToRefInString(encodeLargeNumbers(rawContent)); + const doc = yaml.parseDocument(encodedContent); + applyYamlParseMetadata(doc, options); + } + if (rawContent.includes('$ref') && options.bundle === true) { // Handler to Resolve references const resolver = async sourcePath => {