|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Generate spec-compliance-matrix.md from YAML files. |
| 4 | +""" |
| 5 | + |
| 6 | +import re |
| 7 | +import yaml |
| 8 | +from pathlib import Path |
| 9 | +from typing import Dict, Any, Optional, List |
| 10 | + |
| 11 | +class MarkdownGenerator: |
| 12 | + def __init__(self): |
| 13 | + self.template_data = None |
| 14 | + self.language_data = {} |
| 15 | + self.languages = [] |
| 16 | + |
| 17 | + def load_yaml_files(self, yaml_dir: Path): |
| 18 | + """Load template.yaml and all language-specific YAML files.""" |
| 19 | + template_file = yaml_dir / 'template.yaml' |
| 20 | + |
| 21 | + with open(template_file, 'r', encoding='utf-8') as f: |
| 22 | + self.template_data = yaml.safe_load(f) |
| 23 | + |
| 24 | + for lang_config in self.template_data['languages']: |
| 25 | + lang_name = lang_config['name'] |
| 26 | + lang_file_path = yaml_dir / lang_config['location'] |
| 27 | + |
| 28 | + if not lang_file_path.exists(): |
| 29 | + raise FileNotFoundError(f"Language file {lang_file_path} not found for {lang_name}") |
| 30 | + |
| 31 | + with open(lang_file_path, 'r', encoding='utf-8') as f: |
| 32 | + self.language_data[lang_name] = yaml.safe_load(f) |
| 33 | + |
| 34 | + self.languages = [lang['name'] for lang in self.template_data['languages']] |
| 35 | + |
| 36 | + def update_markdown_content(self, md_file_path: Path) -> str: |
| 37 | + """Update the markdown content with new tables generated from YAML data.""" |
| 38 | + with open(md_file_path, 'r', encoding='utf-8') as f: |
| 39 | + content = f.read() |
| 40 | + |
| 41 | + for section in self.template_data['sections']: |
| 42 | + section_name = section['name'] |
| 43 | + new_table = self._generate_table(section_name, section) |
| 44 | + content = self._replace_section_table(content, section_name, new_table) |
| 45 | + |
| 46 | + return content |
| 47 | + |
| 48 | + def _generate_table(self, section_name: str, section_data: Dict[str, Any]) -> str: |
| 49 | + """Generate markdown table for a section.""" |
| 50 | + features = section_data['features'] |
| 51 | + if not features: |
| 52 | + raise ValueError(f"Section '{section_name}' has no features defined") |
| 53 | + |
| 54 | + # Use section-specific languages if available, otherwise fall back to global |
| 55 | + languages = section_data.get('languages', self.languages) |
| 56 | + |
| 57 | + has_optional_column = not section_data.get('hide_optional_column', False) |
| 58 | + |
| 59 | + # Build header |
| 60 | + header_columns = ['Feature'] |
| 61 | + if has_optional_column: |
| 62 | + header_columns.append('Optional') |
| 63 | + header_columns.extend(languages) |
| 64 | + |
| 65 | + rows = self._create_table_header(header_columns) |
| 66 | + |
| 67 | + # Process features |
| 68 | + for feature in features: |
| 69 | + if 'features' in feature: |
| 70 | + # Subsection header |
| 71 | + heading = feature['heading'] |
| 72 | + cells = [heading] |
| 73 | + if has_optional_column: |
| 74 | + cells.append('Optional') |
| 75 | + cells.extend(languages) |
| 76 | + rows.append(self._create_table_row(cells)) |
| 77 | + |
| 78 | + for sub_feature in feature['features']: |
| 79 | + rows.append(self._build_feature_row(sub_feature, languages, has_optional_column, section_name, heading)) |
| 80 | + else: |
| 81 | + rows.append(self._build_feature_row(feature, languages, has_optional_column, section_name)) |
| 82 | + |
| 83 | + return '\n'.join(rows) |
| 84 | + |
| 85 | + def _replace_section_table(self, content: str, section_name: str, new_table: str) -> str: |
| 86 | + """Replace the table in a specific section.""" |
| 87 | + # Pattern to match a section header and its content until the next section or end |
| 88 | + section_pattern = rf'(## {re.escape(section_name)}.*?\n)(.*?)(?=\n## |\Z)' |
| 89 | + |
| 90 | + def replace_section(match): |
| 91 | + section_header = match.group(1) |
| 92 | + existing_content = match.group(2) |
| 93 | + |
| 94 | + # Pattern to match markdown tables (lines starting and ending with |) |
| 95 | + table_pattern = r'\|[^\n]+\|(?:\n\|[^\n]+\|)*' |
| 96 | + |
| 97 | + if re.search(table_pattern, existing_content): |
| 98 | + # Replace existing table |
| 99 | + new_content = re.sub(table_pattern, new_table, existing_content, count=1) |
| 100 | + else: |
| 101 | + # Add new table if none exists |
| 102 | + new_content = existing_content.rstrip() + '\n\n' + new_table + '\n' |
| 103 | + |
| 104 | + return section_header + new_content |
| 105 | + |
| 106 | + return re.sub(section_pattern, replace_section, content, flags=re.DOTALL) |
| 107 | + |
| 108 | + def _build_feature_row(self, feature: Dict, languages: List[str], has_optional_column: bool, section_name: str, heading_name: str = None) -> str: |
| 109 | + """Build a single feature row.""" |
| 110 | + cells = [feature['name']] |
| 111 | + |
| 112 | + if has_optional_column: |
| 113 | + cells.append(self._get_optional_marker(feature)) |
| 114 | + |
| 115 | + for lang in languages: |
| 116 | + cells.append(self._get_language_status(lang, section_name, feature['name'], heading_name)) |
| 117 | + |
| 118 | + return self._create_table_row(cells) |
| 119 | + |
| 120 | + def _create_table_header(self, header_columns: List[str]) -> List[str]: |
| 121 | + """Create markdown table header with both header row and separator row.""" |
| 122 | + header_row = self._create_table_row(header_columns) |
| 123 | + separators = ['-' * len(col) for col in header_columns] # Sized to match header column widths |
| 124 | + separator_row = self._create_table_row(separators) |
| 125 | + return [header_row, separator_row] |
| 126 | + |
| 127 | + def _create_table_row(self, cells: List[str]) -> str: |
| 128 | + """Create a markdown table row from cells.""" |
| 129 | + joined_cells = ' | '.join(cells) |
| 130 | + return f"| {joined_cells} |" |
| 131 | + |
| 132 | + def _get_language_status(self, lang: str, section_name: str, feature_name: str, heading_name: str = None) -> str: |
| 133 | + """Get the status of a feature for a specific language.""" |
| 134 | + lang_sections = self.language_data[lang]['sections'] |
| 135 | + |
| 136 | + # Find the section with matching name |
| 137 | + lang_section = None |
| 138 | + for sect in lang_sections: |
| 139 | + if sect['name'] == section_name: |
| 140 | + lang_section = sect |
| 141 | + break |
| 142 | + |
| 143 | + if lang_section: |
| 144 | + status = self._find_feature_status(lang_section['features'], feature_name, heading_name) |
| 145 | + return status if status is not None else '' |
| 146 | + |
| 147 | + return '' |
| 148 | + |
| 149 | + def _find_feature_status(self, features: List[Dict], feature_name: str, heading_name: str = None) -> Optional[str]: |
| 150 | + """Search for a feature status in a flat or one-level hierarchical structure.""" |
| 151 | + |
| 152 | + for feature in features: |
| 153 | + if feature.get('name') == feature_name: |
| 154 | + # Direct feature match (for top-level features or when no heading specified) |
| 155 | + if heading_name is None: |
| 156 | + status = feature['status'] |
| 157 | + return self._convert_status_to_symbol(status) |
| 158 | + elif 'features' in feature and feature.get('heading'): |
| 159 | + # This is a heading with nested features |
| 160 | + if heading_name is None or feature.get('heading') == heading_name: |
| 161 | + # Search within this heading if it matches or if no specific heading required |
| 162 | + for nested_feature in feature['features']: |
| 163 | + if nested_feature.get('name') == feature_name: |
| 164 | + status = nested_feature['status'] |
| 165 | + return self._convert_status_to_symbol(status) |
| 166 | + |
| 167 | + # If we were looking for a specific heading but didn't find it, try without heading constraint |
| 168 | + if heading_name is not None: |
| 169 | + return self._find_feature_status(features, feature_name, None) |
| 170 | + |
| 171 | + return None # Return None when not found |
| 172 | + |
| 173 | + def _get_optional_marker(self, feature_item: Dict) -> str: |
| 174 | + """Get the optional marker for a feature.""" |
| 175 | + if feature_item.get('optional') is True: |
| 176 | + return 'X' |
| 177 | + elif feature_item.get('optional_one_of_group_is_required') is True: |
| 178 | + return '*' |
| 179 | + elif 'optional' in feature_item: |
| 180 | + return str(feature_item['optional']) |
| 181 | + return '' |
| 182 | + |
| 183 | + def _convert_status_to_symbol(self, status) -> str: |
| 184 | + """Convert status values to markdown symbols.""" |
| 185 | + if status == '?': |
| 186 | + return '' |
| 187 | + else: |
| 188 | + return status |
| 189 | + |
| 190 | +def main(): |
| 191 | + """Main function to regenerate markdown from YAML files.""" |
| 192 | + # Get the repository root (3 levels up from this script) |
| 193 | + repo_root = Path(__file__).parent.parent.parent |
| 194 | + yaml_dir = repo_root / 'spec-compliance-matrix' |
| 195 | + md_file = repo_root / 'spec-compliance-matrix.md' |
| 196 | + |
| 197 | + generator = MarkdownGenerator() |
| 198 | + generator.load_yaml_files(yaml_dir) |
| 199 | + |
| 200 | + # Generate the updated markdown content and write to file |
| 201 | + updated_content = generator.update_markdown_content(md_file) |
| 202 | + with open(md_file, 'w', encoding='utf-8') as f: |
| 203 | + f.write(updated_content) |
| 204 | + |
| 205 | +if __name__ == '__main__': |
| 206 | + main() |
0 commit comments