UNPKG

xsd-lookup

Version:
590 lines (512 loc) 21.2 kB
import * as fs from 'fs'; import * as path from 'path'; import { Schema, ElementLocation, EnhancedAttributeInfo, AttributeValidationResult } from './Schema'; import { XsdDetector } from './XsdDetector'; // Re-export interfaces from Schema for public API export type { ElementLocation, EnhancedAttributeInfo, AttributeValidationResult }; /** * Interface for attribute name validation results */ export interface AttributeNameValidationResult { wrongAttributes: string[]; missingRequiredAttributes: string[]; } export class XsdReference { private schemas: Map<string, Schema> = new Map(); private xsdDirectory: string; constructor() { this.xsdDirectory = ''; } public init(xsdDirectory: string) { this.dispose(); this.xsdDirectory = xsdDirectory; } /** * Discover and parse XSD includes from a schema file * @param xsdFilePath The path to the XSD file to scan for includes * @returns Array of absolute paths to included XSD files that exist */ private discoverIncludes(xsdFilePath: string): string[] { try { const content = fs.readFileSync(xsdFilePath, 'utf8'); const includes: string[] = []; // Find all xs:include elements with schemaLocation const includeMatches = content.matchAll(/xs:include\s+schemaLocation\s*=\s*["']([^"']+)["']/g); for (const match of includeMatches) { const includeFile = match[1]; const includePath = path.join(this.xsdDirectory, includeFile); if (fs.existsSync(includePath)) { includes.push(includePath); } } return includes; } catch (error) { console.warn(`Warning: Could not parse includes from ${xsdFilePath}:`, error); return []; } } /** * Load a schema dynamically based on schema name * @param schemaName The name of the schema to load (without .xsd extension) * @returns The loaded Schema instance, or null if loading failed */ private loadSchema(schemaName: string): Schema | null { // Check if already loaded if (this.schemas.has(schemaName)) { return this.schemas.get(schemaName)!; } // Try to find the XSD file const xsdPath = path.join(this.xsdDirectory, `${schemaName}.xsd`); if (!fs.existsSync(xsdPath)) { console.warn(`Schema file not found: ${xsdPath}`); return null; } try { // Discover includes const includes = this.discoverIncludes(xsdPath); // Create and cache the schema const schema = new Schema(xsdPath, includes); this.schemas.set(schemaName, schema); return schema; } catch (error) { console.error(`Error loading schema ${schemaName}:`, error); return null; } } /** * Get the appropriate schema for a given XML file (legacy method) * @deprecated Use XsdDetector.detectSchemaFromXml() and then getSchema() instead */ public getSchemaForFile(xmlFilePath: string): Schema | null { const fileInfo = XsdDetector.detectSchemaFromXml(xmlFilePath); if (!fileInfo.schemaType) { return null; } // Try to load the schema dynamically return this.loadSchema(fileInfo.schemaType); } /** * Get schema by type name (loads on demand) * @param schemaType The type/name of the schema to retrieve * @returns The Schema instance if found and loaded successfully, null otherwise */ public getSchema(schemaType: string): Schema | null { return this.loadSchema(schemaType); } /** * Get all currently loaded schema types * @returns Array of schema type names that are currently loaded in memory */ public getAvailableSchemas(): string[] { return Array.from(this.schemas.keys()); } /** * Get all discoverable schema files in the XSD directory * @returns Array of schema names (without .xsd extension) found in the XSD directory */ public getDiscoverableSchemas(): string[] { try { const files = fs.readdirSync(this.xsdDirectory); return files .filter(file => file.endsWith('.xsd')) .map(file => path.basename(file, '.xsd')); } catch (error) { console.warn(`Could not read XSD directory ${this.xsdDirectory}:`, error); return []; } } /** * Get element definition using a specific schema by name * @param schemaName The schema to use * @param elementName The element name to find * @param hierarchy The element hierarchy in bottom-up order (parent → root) */ public getElementDefinition(schemaName: string, elementName: string, hierarchy: string[] = []): Element | undefined { const schema = this.loadSchema(schemaName); if (!schema) { return undefined; } return schema.getElementDefinition(elementName, hierarchy); } /** * Get the element source location: file URI and position in the source file. * Returns undefined if source file is unknown. */ public static getElementLocation(element: Element): ElementLocation | undefined { return Schema.getElementLocation(element); } /** * Get element attributes using a specific schema by name * @param schemaName The schema to use * @param elementName The element name to find * @param hierarchy The element hierarchy in bottom-up order (parent → root) */ public getElementAttributes(schemaName: string, elementName: string, hierarchy: string[] = []): Record<string, Element> { const schema = this.loadSchema(schemaName); if (!schema) { return {}; } return schema.getElementAttributes(elementName, hierarchy); } /** * Get element attributes with type information * @param schemaName The schema to use * @param elementName The element name to find * @param hierarchy The element hierarchy in bottom-up order (parent → root) */ public getElementAttributesWithTypes(schemaName: string, elementName: string, hierarchy: string[] = [], element?: Element): Record<string, EnhancedAttributeInfo> { const schema = this.loadSchema(schemaName); if (!schema) { return {}; } return schema.getElementAttributesWithTypes(elementName, hierarchy, element); } /** * Validate an attribute value against the schema * @param schemaName The schema to use * @param elementName The element name * @param attributeName The attribute name * @param attributeValue The attribute value to validate * @param hierarchy The element hierarchy in bottom-up order (parent → root) */ public validateAttributeValue(schemaName: string, elementName: string, attributeName: string, attributeValue: string, hierarchy: string[] = []): AttributeValidationResult { const schema = this.loadSchema(schemaName); if (!schema) { return { isValid: false, errorMessage: 'Schema not found' }; } return schema.validateAttributeValue(elementName, attributeName, attributeValue, hierarchy); } /** * Validate attributes against a list of provided attribute names * @param attributeInfos The attribute info array from getElementAttributesWithTypes * @param providedAttributes Array of attribute names that are provided * @returns Object containing wrong attributes and missing required attributes */ public static validateAttributeNames( attributeInfos: Record<string, EnhancedAttributeInfo>, providedAttributes: string[] ): AttributeNameValidationResult { // Filter out XML infrastructure attributes from provided attributes const filteredProvidedAttributes = this.filterOutInfrastructureAttributes(providedAttributes); // Get all valid attribute names from schema const validAttributeNames = new Set(Object.keys(attributeInfos)); // Get required attribute names const requiredAttributeNames = new Set( Object.values(attributeInfos).filter(attr => attr.required).map(attr => attr.name) ); // Find wrong attributes (provided but not in schema) const wrongAttributes = filteredProvidedAttributes.filter(attr => !validAttributeNames.has(attr)); // Find missing required attributes (required in schema but not provided) const providedAttributeSet = new Set(filteredProvidedAttributes); const missingRequiredAttributes = Array.from(requiredAttributeNames) .filter(attr => !providedAttributeSet.has(attr)); return { wrongAttributes, missingRequiredAttributes }; } /** * Filter attributes by type * @param attributeInfos The attribute info array from getElementAttributesWithTypes * @param attributeType The type to filter by (e.g., 'xs:string', 'xs:int', etc.) * @returns Array of attributes that match the specified type */ public static filterAttributesByType( attributeInfos: Record<string, EnhancedAttributeInfo>, attributeType: string ): string[] { return Object.values(attributeInfos) .filter(attr => attr.type === attributeType && !this.isXmlInfrastructureAttribute(attr.name)) .map(attr => attr.name); } /** * Filter attributes by restriction type * @param attributeInfos The attribute info array from getElementAttributesWithTypes * @param restrictionType The restriction to filter by ('enumeration', 'pattern', 'length', 'range') * @returns Array of attributes that have the specified restriction type */ public static filterAttributesByRestriction( attributeInfos: Record<string, EnhancedAttributeInfo>, restrictionType: 'enumeration' | 'pattern' | 'length' | 'range' ): string[] { return Object.values(attributeInfos) .filter(attr => { // Skip infrastructure attributes if (this.isXmlInfrastructureAttribute(attr.name)) { return false; } switch (restrictionType) { case 'enumeration': return attr.enumValues && attr.enumValues.length > 0; case 'pattern': return attr.patterns && attr.patterns.length > 0; case 'length': return (attr.minLength !== undefined) || (attr.maxLength !== undefined); case 'range': return (attr.minInclusive !== undefined) || (attr.maxInclusive !== undefined) || (attr.minExclusive !== undefined) || (attr.maxExclusive !== undefined); default: return false; } }) .map(attr => attr.name); } /** * Validate an attribute value against its schema rules * @param attributeInfos The attribute info array from getElementAttributesWithTypes * @param attributeName The attribute name to validate * @param attributeValue The value to validate * @returns Validation result with details */ public static validateAttributeValueAgainstRules( attributeInfos: Record<string, EnhancedAttributeInfo>, attributeName: string, attributeValue: string ): { isValid: boolean; errorMessage?: string; violatedRules?: string[] } { // Return empty/skip result for XML infrastructure attributes if (this.isXmlInfrastructureAttribute(attributeName)) { return { isValid: true, // Infrastructure attributes are always considered valid but ignored errorMessage: undefined }; } const attribute = attributeInfos[attributeName]; if (!attribute) { return { isValid: false, errorMessage: `Attribute '${attributeName}' not found in attribute info` }; } const violatedRules: string[] = []; let isPatternsMatched = true; // Check patterns if (attribute.patterns && attribute.patterns.length > 0) { const patternMatches = attribute.patterns.some((pattern: string) => { try { const regex = new RegExp(pattern); return regex.test(attributeValue); } catch (e) { return false; } }); if (!patternMatches) { isPatternsMatched = false; violatedRules.push(`Value must match pattern(s): ${attribute.patterns.join(' or ')}`); } } else { // If no patterns defined, treat it as not matched for enumeration checks isPatternsMatched = !(attribute.enumValues && attribute.enumValues.length > 0); } // Check enumeration values if (attribute.enumValues && attribute.enumValues.length > 0) { if (!attribute.enumValues.includes(attributeValue) && !isPatternsMatched) { violatedRules.push(`Value must be one of: ${attribute.enumValues.join(', ')}`); } } // Check length restrictions if (attribute.minLength !== undefined && attributeValue.length < attribute.minLength) { violatedRules.push(`Value must be at least ${attribute.minLength} characters long`); } if (attribute.maxLength !== undefined && attributeValue.length > attribute.maxLength) { violatedRules.push(`Value must be at most ${attribute.maxLength} characters long`); } // Check numeric range restrictions (if value is numeric) const numericValue = parseFloat(attributeValue); if (!isNaN(numericValue)) { if (attribute.minInclusive !== undefined && numericValue < attribute.minInclusive) { violatedRules.push(`Value must be >= ${attribute.minInclusive}`); } if (attribute.maxInclusive !== undefined && numericValue > attribute.maxInclusive) { violatedRules.push(`Value must be <= ${attribute.maxInclusive}`); } if (attribute.minExclusive !== undefined && numericValue <= attribute.minExclusive) { violatedRules.push(`Value must be > ${attribute.minExclusive}`); } if (attribute.maxExclusive !== undefined && numericValue >= attribute.maxExclusive) { violatedRules.push(`Value must be < ${attribute.maxExclusive}`); } } const isValid = violatedRules.length === 0; return { isValid, errorMessage: isValid ? undefined : violatedRules.join('; '), violatedRules: isValid ? undefined : violatedRules }; } /** * Get possible values for an attribute if it has enumeration restrictions * @param attributeInfos The attribute info array from getElementAttributesWithTypes * @param attributeName The attribute name * @returns Map where key is the enum value and value is its annotation text, or empty map if no enumeration exists */ public static getAttributePossibleValues( attributeInfos: Record<string, EnhancedAttributeInfo>, attributeName: string ): Map<string, string> { // Return empty map for XML infrastructure attributes if (this.isXmlInfrastructureAttribute(attributeName)) { return new Map(); } const attribute = attributeInfos[attributeName]; if (!attribute || !attribute.enumValues) { return new Map(); } const result = new Map<string, string>(); // Build map with enum values and their annotations for (const enumValue of attribute.enumValues) { const annotation = attribute.enumValuesAnnotations?.get(enumValue) || ''; result.set(enumValue, annotation); } return result; } /** * Get all simple types that use a specific type as their base * @param schemaName The schema name to search * @param baseType The base type to filter by (e.g., 'lvalueexpression') * @returns Array of simple type names that use the specified base type */ public getSimpleTypesWithBaseType( schemaName: string, baseType: string ): string[] { const schema = this.loadSchema(schemaName); if (!schema) { return []; } const results: string[] = []; // Access the schema index to get all types const schemaIndex = (schema as any).schemaIndex; if (!schemaIndex || !schemaIndex.types) { return []; } // Iterate through all types and filter simple types with the specified base type Object.entries(schemaIndex.types).forEach(([typeName, typeNode]) => { const element = typeNode as Element; if (element.nodeName.includes('simpleType')) { // Look for xs:restriction element with the specified base const restrictions = Array.from(element.childNodes).filter( (node): node is Element => node.nodeType === 1 && (node as Element).nodeName.includes('restriction') ); for (const restrictionNode of restrictions) { const base = restrictionNode.getAttribute('base'); if (base === baseType) { results.push(typeName); break; // Found it, no need to check other restrictions for this type } } } }); return results; } /** * Get enumeration values for a named SimpleType * @param schemaName The schema name to search in * @param simpleTypeName The name of the SimpleType to get enumeration values from * @returns Object containing enumeration values and their annotations, or null if not found or not an enumeration */ public getSimpleTypeEnumerationValues( schemaName: string, simpleTypeName: string ): { values: string[], annotations: Map<string, string> } | null { const schema = this.loadSchema(schemaName); if (!schema) { return null; } return schema.getSimpleTypeEnumerationValues(simpleTypeName); } /** * Check if an attribute name is an XML infrastructure attribute that should be ignored * @param attributeName The attribute name to check * @returns True if it's an infrastructure attribute (xmlns, xsi, etc.) */ private static isXmlInfrastructureAttribute(attributeName: string): boolean { return attributeName.startsWith('xmlns:') || attributeName.startsWith('xsi:') || attributeName === 'xmlns'; } /** * Filter out XML infrastructure attributes from a list * @param attributeNames Array of attribute names to filter * @returns Array with infrastructure attributes removed */ private static filterOutInfrastructureAttributes(attributeNames: string[]): string[] { return attributeNames.filter(attr => !this.isXmlInfrastructureAttribute(attr)); } /** * Get possible child elements for a given element by name and hierarchy * @param schemaName The schema to use * @param elementName The parent element name * @param hierarchy The element hierarchy in bottom-up order (parent → root) * @param previousSibling Optional previous sibling element name to filter results based on sequence constraints * @returns Map where key is child element name and value is its annotation text */ public getPossibleChildElements(schemaName: string, elementName: string, hierarchy: string[] = [], previousSibling?: string): Map<string, string> { const schema = this.loadSchema(schemaName); if (!schema) { return new Map<string, string>(); } return schema.getPossibleChildElements(elementName, hierarchy, previousSibling); } /** * Check if a specific element is valid as a child of a given parent in the provided hierarchy, * using the same engine and constraints as getPossibleChildElements, but without calling it directly. * * Contract: * - Inputs: elementName, parentName, parentHierarchy (bottom-up: [immediate_parent, ..., root]), optional previousSibling * - Output: boolean indicating if elementName can appear next under parentName respecting content model and sequence rules */ public isValidChild( schemaName: string, elementName: string, parentName: string, parentHierarchy: string[] = [], previousSibling?: string ): boolean { const schema = this.loadSchema(schemaName); if (!schema) { return false; } // Check if the element is valid under the parent element return schema.isValidChild(elementName, parentName, parentHierarchy, previousSibling); } /** * Extract annotation text from an XML element. * This static method provides access to Schema's annotation extraction functionality. * @param element The XML element to extract annotation from * @returns The annotation text, or undefined if no annotation found */ public extractAnnotationText(schemaName: string, element: Element): string | undefined { const schema = this.loadSchema(schemaName); if (!schema) { return undefined; } return schema.getAnnotationCached(element); } /** * Clear a specific schema from the cache * @param schemaName The name of the schema to clear */ private clearSchema(schemaName: string): void { // Clear the specific schema from the cache if (this.schemas.has(schemaName)) { this.schemas.get(schemaName)?.dispose(); // Clear the schema's internal caches this.schemas.delete(schemaName); } // Optionally, you can also clear the XSD directory if needed // this.xsdDirectory = ''; } /** * Clear all schemas and their caches * This method clears all loaded schemas and their internal caches. * It can be used to release resources when the XSD reference is no longer needed. */ public dispose(): void { // Clear all schemas from the cache this.schemas.forEach((_schema, name) => this.clearSchema(name)); // Clear each schema's internal caches this.schemas.clear(); this.xsdDirectory = ''; // Reset the directory if needed } } export const xsdReference: XsdReference = new XsdReference();