import { SupportLanguage, Parser, Printer } from 'prettier'; import type { Plugin } from 'prettier'; import * as yaml from 'js-yaml'; import { getVendorExtensions } from './extensions/vendor-loader.js'; import { RootKeys, InfoKeys, ContactKeys, LicenseKeys, ComponentsKeys, OperationKeys, ParameterKeys, SchemaKeys, ResponseKeys, SecuritySchemeKeys, OAuthFlowKeys, ServerKeys, ServerVariableKeys, TagKeys, ExternalDocsKeys, WebhookKeys, PathItemKeys, RequestBodyKeys, MediaTypeKeys, EncodingKeys, HeaderKeys, LinkKeys, ExampleKeys, DiscriminatorKeys, XMLKeys, } from './keys.js'; // Type definitions for better type safety interface OpenAPINode { type: 'openapi'; content: any; originalText: string; format: 'json' | 'yaml'; } interface PrettierPath { getValue(): OpenAPINode; } interface OpenAPIPluginOptions { tabWidth?: number; printWidth?: number; } // Load vendor extensions const vendorExtensions = getVendorExtensions(); // ============================================================================ // UNIFIED PARSER FUNCTIONS // ============================================================================ /** * Unified parser that can handle both JSON and YAML OpenAPI files */ function parseOpenAPIFile(text: string, options?: any): OpenAPINode { // Try to detect the format based on file extension or content const filePath = options?.filepath || ''; const isYamlFile = filePath.endsWith('.yaml') || filePath.endsWith('.yml'); const isJsonFile = filePath.endsWith('.json'); // If we can't determine from extension, try to detect from content let format: 'json' | 'yaml' = 'json'; // default to JSON if (isYamlFile) { format = 'yaml'; } else if (isJsonFile) { format = 'json'; } else { // Try to detect format from content const trimmedText = text.trim(); if (trimmedText.startsWith('{') || trimmedText.startsWith('[')) { format = 'json'; } else { format = 'yaml'; } } try { let parsed: any; if (format === 'json') { parsed = JSON.parse(text); } else { parsed = yaml.load(text, { schema: yaml.DEFAULT_SCHEMA, onWarning: (warning) => { // Handle YAML warnings if needed console.warn('YAML parsing warning:', warning); } }); } // Check if this is an OpenAPI file if (!isOpenAPIFile(parsed, filePath)) { throw new Error('Not an OpenAPI file'); } return { type: 'openapi', content: parsed, originalText: text, format: format, }; } catch (error) { throw new Error(`Failed to parse OpenAPI ${format.toUpperCase()}: ${error}`); } } // ============================================================================ // FILE DETECTION FUNCTIONS // ============================================================================ /** * Detects if a file is an OpenAPI-related file based on content and structure */ function isOpenAPIFile(content: any, filePath?: string): boolean { if (!content || typeof content !== 'object') { return false; } // Check for root-level OpenAPI indicators (most important) if (content.openapi || content.swagger) { return true; } // Check file path patterns for common OpenAPI file structures // Only accept files in OpenAPI-related directories if (filePath) { const path = filePath.toLowerCase(); // Check for component directory patterns if (path.includes('/components/') || path.includes('/schemas/') || path.includes('/parameters/') || path.includes('/responses/') || path.includes('/requestbodies/') || path.includes('/headers/') || path.includes('/examples/') || path.includes('/securityschemes/') || path.includes('/links/') || path.includes('/callbacks/') || path.includes('/webhooks/') || path.includes('/paths/')) { return true; } } // Check for component-like structures (only if we have strong indicators) if (content.components || content.definitions || content.parameters || content.responses || content.securityDefinitions) { return true; } // Check for path-like structures (operations) if (content.paths || isPathObject(content)) { return true; } // Check for schema-like structures (but be more strict) // Only accept if we have strong schema indicators if (isSchemaObject(content) && (content.$ref || content.allOf || content.oneOf || content.anyOf || content.not || content.properties || content.items)) { return true; } // Check for parameter-like structures if (isParameterObject(content)) { return true; } // Check for response-like structures if (isResponseObject(content)) { return true; } // Check for security scheme-like structures if (isSecuritySchemeObject(content)) { return true; } // Check for server-like structures if (isServerObject(content)) { return true; } // Check for tag-like structures if (isTagObject(content)) { return true; } // Check for external docs-like structures if (isExternalDocsObject(content)) { return true; } // Check for webhook-like structures if (isWebhookObject(content)) { return true; } // Additional strict check: reject objects that look like generic data // If an object only has simple properties like name, age, etc. without any OpenAPI structure, reject it 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) ); if (hasOnlyGenericProperties) { return false; } // If none of the above conditions are met, it's not an OpenAPI file return false; } /** * Detects if an object represents a path with operations */ function isPathObject(obj: any): boolean { if (!obj || typeof obj !== 'object') { return false; } const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; return Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase())); } const plugin: Plugin = { languages: [ { name: 'openapi', extensions: [ // Accepting all JSON and YAML files so that component files used by $ref work '.json', '.yaml', '.yml' ], parsers: ['openapi-parser'], }, ], parsers: { 'openapi-parser': { parse: (text: string, options?: any): OpenAPINode => { return parseOpenAPIFile(text, options); }, astFormat: 'openapi-ast', locStart: (node: OpenAPINode) => 0, locEnd: (node: OpenAPINode) => node.originalText?.length || 0, }, }, printers: { 'openapi-ast': { print: (path: PrettierPath, options?: any, print?: any, ...rest: any[]): string => { const node = path.getValue(); return formatOpenAPI(node.content, node.format, options); }, }, }, }; /** * Unified formatter that outputs in the detected format */ function formatOpenAPI(content: any, format: 'json' | 'yaml', options?: OpenAPIPluginOptions): string { // Sort keys for better organization const sortedContent = sortOpenAPIKeys(content); if (format === 'json') { // Format with proper indentation return JSON.stringify(sortedContent, null, options?.tabWidth || 2); } else { // Format YAML with proper indentation and line breaks return yaml.dump(sortedContent, { indent: options?.tabWidth || 2, lineWidth: options?.printWidth || 80, noRefs: true, quotingType: '"', forceQuotes: false, }); } } function formatOpenAPIJSON(content: any, options?: OpenAPIPluginOptions): string { return formatOpenAPI(content, 'json', options); } function formatOpenAPIYAML(content: any, options?: OpenAPIPluginOptions): string { return formatOpenAPI(content, 'yaml', options); } function sortOpenAPIKeys(obj: any): any { if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { return obj; } // Get vendor extensions for top-level const topLevelExtensions = vendorExtensions['top-level'] || {}; const sortedKeys = Object.keys(obj).sort((a, b) => { // Use the unified sorting function return sortKeys(a, b, RootKeys, topLevelExtensions); }); const sortedObj: any = {}; for (const key of sortedKeys) { sortedObj[key] = sortOpenAPIKeysEnhanced(obj[key], key); } return sortedObj; } // Enhanced sorting for nested OpenAPI structures function sortOpenAPIKeysEnhanced(obj: any, path: string = ''): any { if (typeof obj !== 'object' || obj === null) { return obj; } // Handle arrays by recursively sorting each element if (Array.isArray(obj)) { return obj.map((item, index) => sortOpenAPIKeysEnhanced(item, `${path}[${index}]`)); } const contextKey = getContextKey(path, obj); const standardKeys = getStandardKeysForContext(contextKey); const customExtensions = vendorExtensions[contextKey] || {}; const sortedKeys = Object.keys(obj).sort((a, b) => { // Special handling for paths (sort by path pattern) if (path === 'paths') { return sortPathKeys(a, b); } // Special handling for response codes (sort numerically) if (path === 'responses') { return sortResponseCodes(a, b); } // Use the unified sorting function for all other cases return sortKeys(a, b, standardKeys, customExtensions); }); const sortedObj: any = {}; for (const key of sortedKeys) { const newPath = path ? `${path}.${key}` : key; sortedObj[key] = sortOpenAPIKeysEnhanced(obj[key], newPath); } return sortedObj; } function sortPathKeys(a: string, b: string): number { // Sort paths by specificity (more specific paths first) const aSpecificity = (a.match(/\{/g) || []).length; const bSpecificity = (b.match(/\{/g) || []).length; if (aSpecificity !== bSpecificity) { return aSpecificity - bSpecificity; } return a.localeCompare(b); } function sortResponseCodes(a: string, b: string): number { // Sort response codes numerically const aNum = parseInt(a); const bNum = parseInt(b); if (!Number.isNaN(aNum) && !Number.isNaN(bNum)) { return aNum - bNum; } return a.localeCompare(b); } //#region Object type detection functions function isOperationObject(obj: any): boolean { const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; return Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase())); } function isParameterObject(obj: any): boolean { return obj && typeof obj === 'object' && 'name' in obj && 'in' in obj; } function isSchemaObject(obj: any): boolean { if (!obj || typeof obj !== 'object') { return false; } // Check for JSON Schema keywords - be very strict const hasSchemaKeywords = '$ref' in obj || 'allOf' in obj || 'oneOf' in obj || 'anyOf' in obj || 'not' in obj; const hasValidType = 'type' in obj && obj.type && ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'].includes(obj.type); // Only return true if we have clear schema indicators // Must have either schema keywords OR valid type with schema properties // Also require additional schema-specific properties to be more strict return hasSchemaKeywords || (hasValidType && ('properties' in obj || 'items' in obj || 'enum' in obj || 'format' in obj || 'pattern' in obj)); } function isResponseObject(obj: any): boolean { return obj && typeof obj === 'object' && ('description' in obj || 'content' in obj); } function isSecuritySchemeObject(obj: any): boolean { return obj && typeof obj === 'object' && 'type' in obj && ['apiKey', 'http', 'oauth2', 'openIdConnect'].includes(obj.type); } function isServerObject(obj: any): boolean { return obj && typeof obj === 'object' && 'url' in obj; } function isTagObject(obj: any): boolean { return obj && typeof obj === 'object' && 'name' in obj && typeof obj.name === 'string' && (Object.keys(obj).length === 1 || // Only name 'description' in obj || // name + description 'externalDocs' in obj); // name + externalDocs } function isExternalDocsObject(obj: any): boolean { return obj && typeof obj === 'object' && 'url' in obj; } function isWebhookObject(obj: any): boolean { const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; return obj && typeof obj === 'object' && Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase())); } function isPathItemObject(obj: any): boolean { const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; return obj && typeof obj === 'object' && Object.keys(obj).some(key => httpMethods.includes(key.toLowerCase())); } function isRequestBodyObject(obj: any): boolean { return obj && typeof obj === 'object' && ('content' in obj || 'description' in obj); } function isMediaTypeObject(obj: any): boolean { return obj && typeof obj === 'object' && ('schema' in obj || 'example' in obj || 'examples' in obj); } function isEncodingObject(obj: any): boolean { return obj && typeof obj === 'object' && ('contentType' in obj || 'style' in obj || 'explode' in obj); } function isHeaderObject(obj: any): boolean { return obj && typeof obj === 'object' && ('description' in obj || 'schema' in obj || 'required' in obj); } function isLinkObject(obj: any): boolean { return obj && typeof obj === 'object' && ('operationRef' in obj || 'operationId' in obj); } function isExampleObject(obj: any): boolean { return obj && typeof obj === 'object' && ('summary' in obj || 'value' in obj || 'externalValue' in obj); } function isDiscriminatorObject(obj: any): boolean { return obj && typeof obj === 'object' && 'propertyName' in obj; } function isXMLObject(obj: any): boolean { return obj && typeof obj === 'object' && ('name' in obj || 'namespace' in obj || 'attribute' in obj); } function isContactObject(obj: any): boolean { return obj && typeof obj === 'object' && ('name' in obj || 'url' in obj || 'email' in obj); } function isLicenseObject(obj: any): boolean { return obj && typeof obj === 'object' && ('name' in obj || 'identifier' in obj || 'url' in obj); } function isOAuthFlowObject(obj: any): boolean { return obj && typeof obj === 'object' && ('authorizationUrl' in obj || 'tokenUrl' in obj || 'scopes' in obj); } function isServerVariableObject(obj: any): boolean { return obj && typeof obj === 'object' && ('enum' in obj || 'default' in obj); } //#endregion //#region Unified sorting function /** * Universal sorting function that handles all OpenAPI key sorting * @param a First key to compare * @param b Second key to compare * @param standardKeys Array of standard keys in order * @param customExtensions Custom extension positions * @returns Comparison result */ function sortKeys(a: string, b: string, standardKeys: readonly string[], customExtensions: Record = {}): number { const aCustomPos = customExtensions[a]; const bCustomPos = customExtensions[b]; // Handle custom extensions first if (aCustomPos !== undefined && bCustomPos !== undefined) { return aCustomPos - bCustomPos; } if (aCustomPos !== undefined) { if (aCustomPos < standardKeys.length) { return -1; // Custom key should come before standard keys } } if (bCustomPos !== undefined) { if (bCustomPos < standardKeys.length) { return 1; // Custom key should come before standard keys } } // Standard sorting const aIndex = standardKeys.indexOf(a); const bIndex = standardKeys.indexOf(b); if (aIndex !== -1 && bIndex !== -1) { return aIndex - bIndex; } if (aIndex !== -1) return -1; if (bIndex !== -1) return 1; // Handle custom extensions after standard keys if (aCustomPos !== undefined) return -1; if (bCustomPos !== undefined) return 1; // For unknown keys, sort alphabetically at the end return a.localeCompare(b); } //#endregion //#region Helper functions for custom extensions function getContextKey(path: string, obj: any): string { // Determine the context based on path and object properties if (path === 'info') return 'info'; if (path === 'components') return 'components'; if (path === 'servers' || path.startsWith('servers[')) return 'server'; if (path === 'tags' || path.startsWith('tags[')) return 'tag'; if (path === 'externalDocs') return 'externalDocs'; if (path === 'webhooks') return 'webhook'; if (path === 'definitions') return 'definitions'; if (path === 'securityDefinitions') return 'securityDefinitions'; // Check if this is a path operation (e.g., "paths./users.get") if (path.includes('.') && path.split('.').length >= 3) { const pathParts = path.split('.'); const lastPart = pathParts[pathParts.length - 1]; const httpMethods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']; if (httpMethods.includes(lastPart.toLowerCase())) { return 'operation'; } } // Handle nested paths for components if (path.startsWith('components.')) { if (path.includes('schemas.')) return 'schema'; if (path.includes('parameters.')) return 'parameter'; if (path.includes('responses.')) return 'response'; if (path.includes('securitySchemes.')) return 'securityScheme'; if (path.includes('requestBodies.')) return 'requestBody'; if (path.includes('headers.')) return 'header'; if (path.includes('examples.')) return 'example'; if (path.includes('links.')) return 'link'; if (path.includes('callbacks.')) return 'callback'; if (path.includes('pathItems.')) return 'pathItem'; } // Handle nested paths for Swagger 2.0 if (path.startsWith('definitions.')) return 'definitions'; if (path.startsWith('securityDefinitions.')) return 'securityDefinitions'; // Handle nested paths for operations (parameters, responses, etc.) if (path.includes('.parameters.') && path.split('.').length > 3) return 'parameter'; if (path.includes('.responses.') && path.split('.').length > 3) return 'response'; if (path.includes('.requestBody.')) return 'requestBody'; if (path.includes('.headers.')) return 'header'; if (path.includes('.examples.')) return 'example'; if (path.includes('.links.')) return 'link'; if (path.includes('.content.')) return 'mediaType'; if (path.includes('.encoding.')) return 'encoding'; if (path.includes('.discriminator.')) return 'discriminator'; if (path.includes('.xml.')) return 'xml'; if (path.includes('.contact.')) return 'contact'; if (path.includes('.license.')) return 'license'; if (path.includes('.flows.')) return 'oauthFlow'; if (path.includes('.variables.')) return 'serverVariable'; // Check object types as fallback if (isOperationObject(obj)) return 'operation'; if (isParameterObject(obj)) return 'parameter'; if (isSchemaObject(obj)) return 'schema'; if (isResponseObject(obj)) return 'response'; if (isSecuritySchemeObject(obj)) return 'securityScheme'; if (isServerObject(obj)) return 'server'; if (isTagObject(obj)) return 'tag'; if (isExternalDocsObject(obj)) return 'externalDocs'; if (isWebhookObject(obj)) return 'webhook'; if (isPathItemObject(obj)) return 'pathItem'; if (isRequestBodyObject(obj)) return 'requestBody'; if (isMediaTypeObject(obj)) return 'mediaType'; if (isEncodingObject(obj)) return 'encoding'; if (isHeaderObject(obj)) return 'header'; if (isLinkObject(obj)) return 'link'; if (isExampleObject(obj)) return 'example'; if (isDiscriminatorObject(obj)) return 'discriminator'; if (isXMLObject(obj)) return 'xml'; if (isContactObject(obj)) return 'contact'; if (isLicenseObject(obj)) return 'license'; if (isOAuthFlowObject(obj)) return 'oauthFlow'; if (isServerVariableObject(obj)) return 'serverVariable'; return 'top-level'; } function getStandardKeysForContext(contextKey: string): readonly string[] { switch (contextKey) { case 'info': return InfoKeys; case 'components': return ComponentsKeys; case 'operation': return OperationKeys; case 'parameter': return ParameterKeys; case 'schema': return SchemaKeys; case 'response': return ResponseKeys; case 'securityScheme': return SecuritySchemeKeys; case 'server': return ServerKeys; case 'tag': return TagKeys; case 'externalDocs': return ExternalDocsKeys; case 'webhook': return WebhookKeys; case 'pathItem': return PathItemKeys; case 'requestBody': return RequestBodyKeys; case 'mediaType': return MediaTypeKeys; case 'encoding': return EncodingKeys; case 'header': return HeaderKeys; case 'link': return LinkKeys; case 'example': return ExampleKeys; case 'discriminator': return DiscriminatorKeys; case 'xml': return XMLKeys; case 'contact': return ContactKeys; case 'license': return LicenseKeys; case 'oauthFlow': return OAuthFlowKeys; case 'serverVariable': return ServerVariableKeys; case 'definitions': return SchemaKeys; // Definitions use schema keys case 'securityDefinitions': return SecuritySchemeKeys; // Security definitions use security scheme keys default: return RootKeys; } } export default plugin;