diff --git a/src/index.ts b/src/index.ts index cd40fb5..9830078 100644 --- a/src/index.ts +++ b/src/index.ts @@ -136,7 +136,7 @@ function isOpenAPIFile(content: any, filePath?: string): boolean { } // Check file path patterns for common OpenAPI file structures - // Only accept files in OpenAPI-related directories + // Only accept files in OpenAPI-related directories, but validate content first if (filePath) { const path = filePath.toLowerCase(); @@ -155,7 +155,33 @@ function isOpenAPIFile(content: any, filePath?: string): boolean { path.includes("/webhooks/") || path.includes("/paths/") ) { - return true; + // Fast-path: if filepath matches, still validate content to prevent + // generic JSON from being misclassified as OpenAPI + const keys = Object.keys(content); + const hasOnlyGenericProperties = keys.every( + (key) => + !key.startsWith("x-") && // Not a custom extension + ![ + "openapi", + "swagger", + "info", + "paths", + "components", + "definitions", + "parameters", + "responses", + "securityDefinitions", + "tags", + "servers", + "webhooks", + ].includes(key) + ); + + // Only accept if content doesn't have only generic properties + if (!hasOnlyGenericProperties) { + return true; + } + // If it has only generic properties, fall through to continue validation } } diff --git a/src/prettier-markdown/adapter-prettier-utils.js b/src/prettier-markdown/adapter-prettier-utils.js index 978d632..e5fffbc 100644 --- a/src/prettier-markdown/adapter-prettier-utils.js +++ b/src/prettier-markdown/adapter-prettier-utils.js @@ -53,7 +53,7 @@ if (!utils) { const hasSingle = str.includes("'"); const hasDouble = str.includes('"'); if (hasSingle && !hasDouble) return '"'; - return "'"; + return '"'; }, }; } diff --git a/src/prettier-markdown/embed.js b/src/prettier-markdown/embed.js index 34aea03..3166689 100644 --- a/src/prettier-markdown/embed.js +++ b/src/prettier-markdown/embed.js @@ -4,10 +4,17 @@ import getMaxContinuousCount from "../utils/get-max-continuous-count.js"; import inferParser from "../utils/infer-parser.js"; import { getFencedCodeBlockValue } from "./utils.js"; +// Skip embedding extremely large code blocks to avoid expensive formatting passes +const MAX_EMBED_CODE_SIZE = 32 * 1024; // 32KB + function embed(path, options) { const { node } = path; if (node.type === "code" && node.lang !== null) { + // Fast path: large code blocks are printed as-is without embedding + if (typeof node.value === "string" && node.value.length > MAX_EMBED_CODE_SIZE) { + return null; + } const parser = inferParser(options, { language: node.lang }); if (parser) { return async (textToDoc) => { diff --git a/src/prettier-markdown/parser-markdown.js b/src/prettier-markdown/parser-markdown.js index f6049a9..ff8b0f5 100644 --- a/src/prettier-markdown/parser-markdown.js +++ b/src/prettier-markdown/parser-markdown.js @@ -24,21 +24,49 @@ import wikiLink from "./unified-plugins/wiki-link.js"; * interface Sentence { children: Array } * interface InlineCode { children: Array } */ +// Memoized processors keyed by feature signature to avoid rebuilding pipelines +const processorCache = new Map(); + +function detectFeatures(text) { + // Cheap checks to decide whether to enable heavier plugins + const hasMath = /(\$\$|\\\[|\\\(|\$)/u.test(text); + const hasLiquid = /(\{\{|\{%)/u.test(text); + const hasWiki = /\[\[/u.test(text); + return { hasMath, hasLiquid, hasWiki }; +} + +function getProcessor({ isMDX, features }) { + const key = `${isMDX ? 1 : 0}:${features.hasMath ? 1 : 0}${features.hasLiquid ? 1 : 0}${features.hasWiki ? 1 : 0}`; + const cached = processorCache.get(key); + if (cached) { + return cached; + } + + const processor = unified() + .use(remarkParse, { + commonmark: true, + ...(isMDX && { blocks: [BLOCKS_REGEX] }), + }) + // inexpensive plugins first + .use(footnotes) + .use(frontMatter) + .use(isMDX ? esSyntax : noop) + // conditional heavy plugins + .use(features.hasMath ? remarkMath : noop) + .use(features.hasLiquid ? liquid : noop) + // html -> jsx only matters in MDX + .use(isMDX ? htmlToJsx : noop) + .use(features.hasWiki ? wikiLink : noop); + + processorCache.set(key, processor); + return processor; +} + function createParse({ isMDX }) { return (text) => { - const processor = unified() - .use(remarkParse, { - commonmark: true, - ...(isMDX && { blocks: [BLOCKS_REGEX] }), - }) - .use(footnotes) - .use(frontMatter) - .use(remarkMath) - .use(isMDX ? esSyntax : noop) - .use(liquid) - .use(isMDX ? htmlToJsx : noop) - .use(wikiLink); - return processor.run(processor.parse(text)); + const features = detectFeatures(text); + const processor = getProcessor({ isMDX, features }); + return processor.runSync(processor.parse(text)); }; } diff --git a/src/prettier-markdown/utils.js b/src/prettier-markdown/utils.js index 13a3745..ad9c1f3 100644 --- a/src/prettier-markdown/utils.js +++ b/src/prettier-markdown/utils.js @@ -186,9 +186,17 @@ function getOrderedListItemInfo(orderListItem, options) { orderListItem.position.end.offset, ); - const { numberText, leadingSpaces } = text.match( + const m = text.match( /^\s*(?\d+)(\.|\))(?\s*)/u, - ).groups; + ); + + if (!m) { + throw new Error( + `Failed to parse ordered list item: expected pattern matching /^\\s*(?\\d+)(\\.|\\))(?\\s*)/u, but got: ${JSON.stringify(text)}`, + ); + } + + const { numberText, leadingSpaces } = m.groups; return { number: Number(numberText), leadingSpaces }; } diff --git a/test/coverage.test.ts b/test/coverage.test.ts index fbae22f..f7dc442 100644 --- a/test/coverage.test.ts +++ b/test/coverage.test.ts @@ -301,4 +301,1225 @@ description: External documentation`; } }); }); + + describe('Format detection', () => { + it('should detect JSON format from content when filepath is missing', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const jsonContent = `{"openapi": "3.0.0", "info": {"title": "Test", "version": "1.0.0"}}`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(jsonContent, {}); + expect(result).toBeDefined(); + expect(result?.format).toBe('json'); + }); + + it('should detect YAML format from content when filepath is missing', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const yamlContent = `openapi: 3.0.0\ninfo:\n title: Test\n version: 1.0.0`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(yamlContent, {}); + expect(result).toBeDefined(); + expect(result?.format).toBe('yaml'); + }); + + it('should detect JSON format from array content', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const jsonArray = `[{"test": "value"}]`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(jsonArray, {}); + expect(result).toBeDefined(); + // Format detection happens before parsing, so format should be 'json' + // But since arrays aren't valid OpenAPI objects, isOpenAPI will be false + // The format might be undefined if parsing fails early, so we check both cases + if (result?.format) { + expect(result.format).toBe('json'); + } + expect(result?.isOpenAPI).toBeFalse(); + }); + }); + + describe('Error handling', () => { + it('should handle parse errors gracefully', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const invalidJson = `{invalid json}`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(invalidJson, { filepath: 'test.json' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeFalse(); + }); + + it('should handle non-object content', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const stringContent = `"just a string"`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(stringContent, { filepath: 'test.json' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeFalse(); + }); + + it('should handle null content', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const nullContent = `null`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(nullContent, { filepath: 'test.json' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeFalse(); + }); + + it('should handle errors in isOpenAPIFile check', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + // Create content that might cause isOpenAPIFile to throw + const problematicContent = `{"openapi": "3.0.0"}`; + + // Mock a scenario where isOpenAPIFile might throw + // This tests the try-catch around isOpenAPIFile + // @ts-expect-error We are testing edge cases + const result = parser?.parse(problematicContent, { filepath: 'test.json' }); + expect(result).toBeDefined(); + }); + }); + + describe('Printer edge cases', () => { + it('should return originalText when node is not OpenAPI', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: false, + content: {}, + originalText: 'original content', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { originalText: 'original content', tabWidth: 2 }, () => ''); + expect(result).toBe('original content'); + }); + + it('should handle null/undefined node', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: false, + content: null, + originalText: 'original content', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { originalText: 'original content', tabWidth: 2 }, () => ''); + expect(result).toBe('original content'); + }); + }); + + describe('Markdown formatting edge cases', () => { + it('should handle non-string markdown input', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'yaml', + content: { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0', + description: null, // null description + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should skip markdown formatting when formatMarkdown is false', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'yaml', + content: { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0', + description: 'This has extra spaces', + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2, formatMarkdown: false }, () => ''); + expect(result).toBeDefined(); + if (result && typeof result === 'string') { + // When formatMarkdown is false, extra spaces should remain + expect(result).toContain('description'); + } + }); + + it('should handle empty string markdown', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'yaml', + content: { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0', + description: ' ', // whitespace-only string + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + }); + + describe('Context key detection fallbacks', () => { + it('should detect parameter context from object type', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + components: { + parameters: { + TestParam: { + name: 'test', + in: 'query', + schema: { type: 'string' }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should detect response context from object type', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + components: { + responses: { + TestResponse: { + description: 'Test response', + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should detect header context from object type', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + components: { + headers: { + TestHeader: { + description: 'Test header', + schema: { type: 'string' }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should detect requestBody context from object type', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + components: { + requestBodies: { + TestBody: { + description: 'Test body', + content: {}, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + }); + + describe('Response code sorting edge cases', () => { + it('should handle non-numeric response codes', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/test': { + get: { + responses: { + 'default': { description: 'Default' }, + '2xx': { description: 'Success range' }, + '200': { description: 'OK' }, + }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + }); + + describe('Object type detection edge cases', () => { + it('should handle null/undefined in isOperationObject', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + // Test with null + const nullYaml = `null`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(nullYaml, { filepath: 'test.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeFalse(); + }); + + it('should handle non-object in isSchemaObject', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const stringYaml = `"just a string"`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(stringYaml, { filepath: 'test.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeFalse(); + }); + + it('should detect Swagger 2.0 files with definitions', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const swaggerYaml = `swagger: "2.0" +info: + title: Test API + version: 1.0.0 +definitions: + User: + type: object`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(swaggerYaml, { filepath: 'test.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect Swagger 2.0 files with parameters', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const swaggerYaml = `swagger: "2.0" +info: + title: Test API + version: 1.0.0 +parameters: + userId: + name: id + in: path`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(swaggerYaml, { filepath: 'test.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect Swagger 2.0 files with responses', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const swaggerYaml = `swagger: "2.0" +info: + title: Test API + version: 1.0.0 +responses: + NotFound: + description: Not found`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(swaggerYaml, { filepath: 'test.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect Swagger 2.0 files with securityDefinitions', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const swaggerYaml = `swagger: "2.0" +info: + title: Test API + version: 1.0.0 +securityDefinitions: + BearerAuth: + type: apiKey`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(swaggerYaml, { filepath: 'test.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect standalone header objects', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const headerYaml = `description: Rate limit header +schema: + type: integer +required: true`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(headerYaml, { filepath: 'components/headers/RateLimit.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect standalone link objects', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const linkYaml = `operationId: getUser +parameters: + userId: $response.body#/id`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(linkYaml, { filepath: 'components/links/UserLink.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect link objects with operationRef', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const linkYaml = `operationRef: '#/paths/~1users~1{id}/get' +parameters: + userId: $response.body#/id`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(linkYaml, { filepath: 'components/links/UserLink.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should distinguish link objects from operation objects', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + // Link with operationId but no responses should be detected as link + const linkYaml = `operationId: getUser +parameters: + userId: $response.body#/id`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(linkYaml, { filepath: 'components/links/UserLink.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + + // Operation with operationId AND responses should be detected as operation + const operationYaml = `operationId: getUser +responses: + '200': + description: Success`; + + // @ts-expect-error We are testing edge cases + const result2 = parser?.parse(operationYaml, { filepath: 'paths/users.yaml' }); + expect(result2).toBeDefined(); + expect(result2?.isOpenAPI).toBeTrue(); + }); + + it('should detect standalone requestBody objects', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const requestBodyYaml = `description: User creation payload +content: + application/json: + schema: + type: object`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(requestBodyYaml, { filepath: 'components/requestBodies/UserCreate.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect standalone security scheme objects', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const securityYaml = `type: http +scheme: bearer +bearerFormat: JWT`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(securityYaml, { filepath: 'components/securitySchemes/BearerAuth.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect standalone server objects', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const serverYaml = `url: https://api.example.com +description: Production server`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(serverYaml, { filepath: 'servers/production.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect standalone tag objects', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const tagYaml = `name: users`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(tagYaml, { filepath: 'tags/users.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect tag objects with description', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const tagYaml = `name: users +description: User management operations`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(tagYaml, { filepath: 'tags/users.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect tag objects with externalDocs', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const tagYaml = `name: users +externalDocs: + url: https://example.com/docs`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(tagYaml, { filepath: 'tags/users.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect standalone external docs objects', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const externalDocsYaml = `url: https://example.com/docs +description: External documentation`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(externalDocsYaml, { filepath: 'externalDocs/api.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should detect standalone webhook objects', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const webhookYaml = `post: + summary: New message webhook + responses: + '200': + description: Success`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(webhookYaml, { filepath: 'webhooks/messageCreated.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + }); + + describe('Custom extension sorting', () => { + it('should sort custom extensions relative to standard keys', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0', + 'x-custom-before-title': 'before', + 'x-custom-after-title': 'after', + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle custom extensions with same position as standard keys', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { + title: 'Test API', + 'x-custom-at-title': 'value', + version: '1.0.0', + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + }); + + describe('Context key mapping', () => { + it('should handle encoding context', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + encoding: { + field1: { + contentType: 'text/plain', + }, + }, + }, + }, + }, + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle mediaType context', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/test': { + post: { + requestBody: { + content: { + 'application/json': { + schema: { type: 'object' }, + }, + }, + }, + responses: { '200': { description: 'OK' } }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle example context', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + components: { + examples: { + UserExample: { + summary: 'User example', + value: { id: 1 }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle discriminator context', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + components: { + schemas: { + Pet: { + discriminator: { + propertyName: 'petType', + }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle xml context', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + components: { + schemas: { + User: { + type: 'object', + xml: { + name: 'user', + }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle contact context', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + contact: { + name: 'API Support', + email: 'support@example.com', + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle license context', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { + title: 'Test', + version: '1.0.0', + license: { + name: 'MIT', + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle oauthFlow context', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + components: { + securitySchemes: { + OAuth2: { + type: 'oauth2', + flows: { + authorizationCode: { + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + scopes: {}, + }, + }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle serverVariable context', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + servers: [ + { + url: 'https://{username}.example.com', + variables: { + username: { + default: 'demo', + enum: ['demo', 'prod'], + }, + }, + }, + ], + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + }); + + describe('Additional edge cases', () => { + it('should handle objects with only generic properties', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + // Object with only generic properties that don't indicate OpenAPI + // Using properties that are generic and don't match OpenAPI patterns + const genericYaml = `someProperty: value +anotherProperty: value2 +type: string`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(genericYaml, { filepath: 'test.yaml' }); + expect(result).toBeDefined(); + // Should not be detected as OpenAPI since it only has generic properties + // Note: This tests the hasOnlyGenericProperties check + expect(result?.isOpenAPI).toBeFalse(); + }); + + it('should reject generic content in component directories (security check)', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + // Generic JSON/YAML that should NOT be accepted even in component directories + const genericContent = `firstName: John +lastName: Doe +email: john@example.com +age: 30`; + + // Test various component directory paths + const componentPaths = [ + 'components/schemas/User.yaml', + 'components/parameters/UserId.yaml', + 'components/responses/UserResponse.yaml', + 'components/requestBodies/UserCreate.yaml', + 'components/headers/RateLimit.yaml', + 'components/examples/UserExample.yaml', + 'components/securitySchemes/BearerAuth.yaml', + 'components/links/UserLink.yaml', + 'components/callbacks/NewMessageCallback.yaml', + 'webhooks/messageCreated.yaml', + 'paths/users.yaml', + ]; + + componentPaths.forEach((path) => { + // @ts-expect-error We are testing edge cases + const result = parser?.parse(genericContent, { filepath: path }); + expect(result).toBeDefined(); + // Should be rejected even though path matches component directory pattern + expect(result?.isOpenAPI).toBeFalse(); + }); + }); + + it('should handle response code sorting with mixed numeric and non-numeric codes', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + paths: { + '/test': { + get: { + responses: { + 'default': { description: 'Default' }, + '2xx': { description: 'Success range' }, + '200': { description: 'OK' }, + '400': { description: 'Bad Request' }, + '5xx': { description: 'Server Error' }, + }, + }, + }, + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + if (result && typeof result === 'string') { + const formatted = JSON.parse(result); + const responseKeys = Object.keys(formatted.paths['/test'].get.responses); + // Numeric codes should be sorted numerically, non-numeric alphabetically + expect(responseKeys).toContain('200'); + expect(responseKeys).toContain('400'); + expect(responseKeys).toContain('default'); + expect(responseKeys).toContain('2xx'); + expect(responseKeys).toContain('5xx'); + } + }); + + it('should handle operation objects with requestBody and parameters', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const operationYaml = `requestBody: + content: + application/json: + schema: + type: object +parameters: + - name: id + in: path`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(operationYaml, { filepath: 'paths/users.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should handle operation objects with callbacks and security', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + // Operation object with callbacks AND security (both operation-specific) + const operationYaml = `callbacks: + myCallback: + '{$request.body#/callbackUrl}': + post: + responses: + '200': + description: Success +parameters: + - name: id + in: path +security: + - BearerAuth: []`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(operationYaml, { filepath: 'paths/users.yaml' }); + expect(result).toBeDefined(); + // This should be detected as OpenAPI because it has callbacks AND parameters/security + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should handle link objects with operationId but no responses', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + // Link object: has operationId but NOT responses + const linkYaml = `operationId: getUserById +parameters: + userId: $response.body#/id`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(linkYaml, { filepath: 'components/links/UserLink.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should handle custom extensions sorting when both are custom', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0', + 'x-extension-1': 'value1', + 'x-extension-2': 'value2', + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle custom extensions when one is custom and one is standard', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { + 'x-custom-extension': 'value', + title: 'Test API', + version: '1.0.0', + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle custom extensions positioned before standard keys', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { + 'x-before-title': 'before', + title: 'Test API', + 'x-after-title': 'after', + version: '1.0.0', + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle custom extensions positioned after standard keys', () => { + const printer = printers?.['openapi-ast']; + expect(printer).toBeDefined(); + + const testData = { + isOpenAPI: true, + format: 'json', + content: { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0', + 'x-after-version': 'after', + }, + }, + originalText: '', + }; + + // @ts-expect-error We are testing edge cases + const result = printer?.print({ getNode: () => testData }, { tabWidth: 2 }, () => ''); + expect(result).toBeDefined(); + }); + + it('should handle external docs objects without description', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const externalDocsYaml = `url: https://example.com/docs`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(externalDocsYaml, { filepath: 'externalDocs/api.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + + it('should handle webhook objects with different HTTP methods', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + const webhookYaml = `put: + summary: Update webhook + responses: + '200': + description: Success`; + + // @ts-expect-error We are testing edge cases + const result = parser?.parse(webhookYaml, { filepath: 'webhooks/update.yaml' }); + expect(result).toBeDefined(); + expect(result?.isOpenAPI).toBeTrue(); + }); + }); }); diff --git a/test/extensions-api.test.ts b/test/extensions-api.test.ts new file mode 100644 index 0000000..71f3c3f --- /dev/null +++ b/test/extensions-api.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'bun:test'; +import { + createContextExtensions, + isValidExtensionKey, + createPositionHelpers, +} from '../src/extensions/index.js'; + +describe('Extension API Tests', () => { + describe('isValidExtensionKey', () => { + it('should return true for valid extension keys starting with x-', () => { + expect(isValidExtensionKey('x-test')).toBeTrue(); + expect(isValidExtensionKey('x-custom-field')).toBeTrue(); + expect(isValidExtensionKey('x-speakeasy-sdk-name')).toBeTrue(); + }); + + it('should return false for keys not starting with x-', () => { + expect(isValidExtensionKey('test')).toBeFalse(); + expect(isValidExtensionKey('custom-field')).toBeFalse(); + expect(isValidExtensionKey('description')).toBeFalse(); + }); + + it('should handle edge cases', () => { + expect(isValidExtensionKey('')).toBeFalse(); + expect(isValidExtensionKey('x')).toBeFalse(); + expect(isValidExtensionKey('x-')).toBeTrue(); + }); + }); + + describe('createContextExtensions', () => { + it('should create context extensions for info context', () => { + const extensions = createContextExtensions('info', (before, after) => ({ + 'x-custom-before-title': before('title'), + 'x-custom-after-title': after('title'), + })); + + expect(extensions).toBeDefined(); + expect(extensions.info).toBeDefined(); + expect(typeof extensions.info).toBe('function'); + }); + + it('should create context extensions for operation context', () => { + const extensions = createContextExtensions('operation', (before, after) => ({ + 'x-custom-before-summary': before('summary'), + 'x-custom-after-summary': after('summary'), + })); + + expect(extensions).toBeDefined(); + expect(extensions.operation).toBeDefined(); + }); + + it('should create context extensions for schema context', () => { + const extensions = createContextExtensions('schema', (before, after) => ({ + 'x-custom-before-type': before('type'), + 'x-custom-after-type': after('type'), + })); + + expect(extensions).toBeDefined(); + expect(extensions.schema).toBeDefined(); + }); + }); + + describe('createPositionHelpers', () => { + it('should create position helpers for info context', () => { + const helpers = createPositionHelpers('info'); + + expect(helpers).toBeDefined(); + expect(helpers.before).toBeDefined(); + expect(helpers.after).toBeDefined(); + expect(helpers.getAvailableKeys).toBeDefined(); + expect(helpers.isValidKey).toBeDefined(); + + expect(typeof helpers.before).toBe('function'); + expect(typeof helpers.after).toBe('function'); + expect(typeof helpers.getAvailableKeys).toBe('function'); + expect(typeof helpers.isValidKey).toBe('function'); + }); + + it('should return available keys for info context', () => { + const helpers = createPositionHelpers('info'); + const keys = helpers.getAvailableKeys(); + + expect(Array.isArray(keys)).toBeTrue(); + expect(keys.length).toBeGreaterThan(0); + expect(keys).toContain('title'); + expect(keys).toContain('version'); + }); + + it('should validate keys for info context', () => { + const helpers = createPositionHelpers('info'); + + expect(helpers.isValidKey('title')).toBeTrue(); + expect(helpers.isValidKey('version')).toBeTrue(); + expect(helpers.isValidKey('invalid-key')).toBeFalse(); + }); + + it('should create position helpers for operation context', () => { + const helpers = createPositionHelpers('operation'); + + expect(helpers).toBeDefined(); + const keys = helpers.getAvailableKeys(); + expect(keys.length).toBeGreaterThan(0); + expect(keys).toContain('summary'); + expect(keys).toContain('operationId'); + }); + + it('should create position helpers for schema context', () => { + const helpers = createPositionHelpers('schema'); + + expect(helpers).toBeDefined(); + const keys = helpers.getAvailableKeys(); + expect(keys.length).toBeGreaterThan(0); + expect(keys).toContain('type'); + expect(keys).toContain('properties'); + }); + }); +}); + diff --git a/test/file-detection.test.ts b/test/file-detection.test.ts index 49ae2f7..2519537 100644 --- a/test/file-detection.test.ts +++ b/test/file-detection.test.ts @@ -159,17 +159,40 @@ city: New York`; expect(parsedData?.isOpenAPI).toBeFalse(); }); - it('should accept files in OpenAPI directories even with simple content', () => { + it('should reject generic content in OpenAPI component directories', () => { const parser = parsers?.['openapi-parser']; expect(parser).toBeDefined(); - const simpleYaml = `name: John + // Generic content that doesn't match OpenAPI structure + const genericYaml = `name: John age: 30 city: New York`; // @ts-expect-error We are mocking things here - const result = parser?.parse(simpleYaml, { filepath: 'components/schemas/User.yaml' }); + const result = parser?.parse(genericYaml, { filepath: 'components/schemas/User.yaml' }); expect(result).toBeDefined(); + // Should be rejected even though it's in a component directory + expect(result?.isOpenAPI).toBeFalse(); + // Format may be undefined when isOpenAPI is false + // The important thing is that it's rejected + }); + + it('should accept valid OpenAPI content in component directories', () => { + const parser = parsers?.['openapi-parser']; + expect(parser).toBeDefined(); + + // Valid schema content in component directory + const schemaYaml = `type: object +properties: + name: + type: string + age: + type: integer`; + + // @ts-expect-error We are mocking things here + const result = parser?.parse(schemaYaml, { filepath: 'components/schemas/User.yaml' }); + expect(result).toBeDefined(); + // Should be accepted because content is valid OpenAPI schema expect(result?.isOpenAPI).toBeTrue(); expect(result?.format).toBe('yaml'); }); diff --git a/test/markdown-formatting.test.ts b/test/markdown-formatting.test.ts index dc2d130..df31de2 100644 --- a/test/markdown-formatting.test.ts +++ b/test/markdown-formatting.test.ts @@ -44,14 +44,14 @@ describe("Markdown Formatting in Descriptions", () => { throw new Error("Result is undefined"); } - const resultString = result.toString(); + const resultString = String(result); // Check that multiple spaces are normalized in the original content // Note: YAML may format this differently, but the content should be processed // The description field should exist and be formatted expect(resultString).toContain("description:"); - // Check that multiple blank lines are normalized + // Check that multiple blank lines are normalized (no quad-blank-lines) expect(resultString).not.toMatch(/\n{4,}/); }); @@ -79,7 +79,7 @@ describe("Markdown Formatting in Descriptions", () => { throw new Error("Result is undefined"); } - const resultString = result.toString(); + const resultString = String(result); // Code blocks (4+ spaces) should be preserved expect(resultString).toContain(" const x = 1;"); @@ -127,7 +127,7 @@ describe("Markdown Formatting in Descriptions", () => { throw new Error("Result is undefined"); } - const resultString = result.toString(); + const resultString = String(result); // Both parameter and response descriptions should be formatted expect(resultString).toContain("description:"); @@ -167,7 +167,7 @@ describe("Markdown Formatting in Descriptions", () => { throw new Error("Result is undefined"); } - const resultString = result.toString(); + const resultString = String(result); // Summary fields should be processed expect(resultString).toContain("summary:");