File indexing completed on 2024-05-19 15:23:32
0001 # -*- coding: utf-8 -*- 0002 # 0003 # SPDX-FileCopyrightText: 2020 Alex Turbov <i.zaufi@gmail.com> 0004 # SPDX-FileContributor: Juraj Oravec <jurajoravec@mailo.com> 0005 # SPDX-License-Identifier: MIT 0006 # 0007 0008 ''' 0009 CLI utility to convert old `kate`s schema/hlcolors files to the 0010 new JSON theme format. 0011 ''' 0012 0013 from __future__ import annotations 0014 0015 import configparser 0016 import enum 0017 import functools 0018 import itertools 0019 import json 0020 import pathlib 0021 import re 0022 import textwrap 0023 0024 from xml.etree import ElementTree 0025 from typing import \ 0026 Dict \ 0027 , Final \ 0028 , Iterable \ 0029 , Generator \ 0030 , List \ 0031 , Literal \ 0032 , Pattern \ 0033 , Set \ 0034 , TextIO \ 0035 , Tuple \ 0036 , TypedDict \ 0037 , TypeVar 0038 0039 import click 0040 import columnize # type: ignore 0041 0042 # BEGIN Type declarations 0043 T = TypeVar('T') # pylint: disable=invalid-name 0044 PropName = Literal[ 0045 'background-color' 0046 , 'bold' 0047 , 'italic' 0048 , 'selected-text-color' 0049 , 'seleted-background-color' 0050 , 'strike-through' 0051 , 'text-color' 0052 , 'text-color' 0053 , 'underline' 0054 ] 0055 StylePropsDict = TypedDict( 0056 'StylePropsDict' 0057 , { 0058 'background-color': str 0059 , 'bold': bool 0060 , 'italic': bool 0061 , 'selected-text-color': str 0062 , 'seleted-background-color': str 0063 , 'strike-through': bool 0064 , 'text-color': str 0065 , 'underline': bool 0066 } 0067 , total=False 0068 ) 0069 CustomStyleDict = Dict[str, StylePropsDict] 0070 CustomStylesDict = Dict[str, CustomStyleDict] 0071 EditorColorsDict = Dict[str, str] 0072 TextStylesDict = Dict[str, StylePropsDict] 0073 MetadataDict = TypedDict( 0074 'MetadataDict' 0075 , { 0076 'name': str 0077 , 'revision': int 0078 } 0079 ) 0080 ThemeDict = TypedDict( 0081 'ThemeDict' 0082 , { 0083 'custom-styles': CustomStylesDict 0084 , 'editor-colors': EditorColorsDict 0085 , 'metadata': MetadataDict 0086 , 'text-styles': TextStylesDict 0087 } 0088 , total=False 0089 ) 0090 SyntaxesDict = Dict[str, Set[str]] 0091 # END Type declarations 0092 0093 0094 class QtColorItemOffset(enum.IntEnum): 0095 ''' 0096 Enumeration class with offsets in the CSV record 0097 of the old style definition. 0098 ''' 0099 _UNKNOWN = 0 0100 TEXT = enum.auto() 0101 SELECTED_TEXT = enum.auto() 0102 BOLD = enum.auto() 0103 ITALIC = enum.auto() 0104 STRIKE_THROUGH = enum.auto() 0105 UNDERLINE = enum.auto() 0106 BACKGROUND = enum.auto() 0107 SELECTED_BACKGROUND = enum.auto() 0108 _IGNORED_FONT_FAMILY = enum.auto() 0109 _TRAILING_DASHES = enum.auto() 0110 # Special item to validate the components count 0111 CUSTOM_COLOR_EXPECTED_SIZE = enum.auto() 0112 STANDARD_COLOR_EXPECTED_SIZE = 10 0113 0114 0115 _EXPECTED_OLD_COLOR_LEN: Final[int] = 8 0116 _OLD_COLOR_LEADING_STRIP_SIZE: Final[int] = 2 0117 _HIGHLIGHTING_PFX: Final[str] = 'Highlighting ' 0118 _COLUMIZED_LIST_INDENT_PFX: Final[str] = ' ' 0119 _EDITOR_COLORS: Final[Dict[str, str]] = { 0120 "Color Background": "BackgroundColor" 0121 , "Color Code Folding": "CodeFolding" 0122 , "Color Current Line Number": "CurrentLineNumber" 0123 , "Color Highlighted Bracket": "BracketMatching" 0124 , "Color Highlighted Line": "CurrentLine" 0125 , "Color Icon Bar": "IconBorder" 0126 , "Color Indentation Line": "IndentationLine" 0127 , "Color Line Number": "LineNumbers" 0128 , "Color MarkType 1": "MarkBookmark" 0129 , "Color MarkType 2": "MarkBreakpointActive" 0130 , "Color MarkType 3": "MarkBreakpointReached" 0131 , "Color MarkType 4": "MarkBreakpointDisabled" 0132 , "Color MarkType 5": "MarkExecution" 0133 , "Color MarkType 6": "MarkWarning" 0134 , "Color MarkType 7": "MarkError" 0135 , "Color Modified Lines": "ModifiedLines" 0136 , "Color Replace Highlight": "ReplaceHighlight" 0137 , "Color Saved Lines": "SavedLines" 0138 , "Color Search Highlight": "SearchHighlight" 0139 , "Color Selection": "TextSelection" 0140 , "Color Separator": "Separator" 0141 , "Color Spelling Mistake Line": "SpellChecking" 0142 , "Color Tab Marker": "TabMarker" 0143 , "Color Template Background": "TemplateBackground" 0144 , "Color Template Editable Placeholder": "TemplatePlaceholder" 0145 , "Color Template Focused Editable Placeholder": "TemplateFocusedPlaceholder" 0146 , "Color Template Not Editable Placeholder": "TemplateReadOnlyPlaceholder" 0147 , "Color Word Wrap Marker": "WordWrapMarker" 0148 } 0149 _TEXT_STYLES: Final[Dict[str, str]] = { 0150 "Alert": "Alert" 0151 , "Annotation": "Annotation" 0152 , "Attribute": "Attribute" 0153 , "Base-N Integer": "BaseN" 0154 , "Built-in": "BuiltIn" 0155 , "Character": "Char" 0156 , "Comment": "Comment" 0157 , "Comment Variable": "CommentVar" 0158 , "Constant": "Constant" 0159 , "Control Flow": "ControlFlow" 0160 , "Data Type": "DataType" 0161 , "Decimal/Value": "DecVal" 0162 , "Documentation": "Documentation" 0163 , "Error": "Error" 0164 , "Extension": "Extension" 0165 , "Floating Point": "Float" 0166 , "Function": "Function" 0167 , "Import": "Import" 0168 , "Information": "Information" 0169 , "Keyword": "Keyword" 0170 , "Normal": "Normal" 0171 , "Operator": "Operator" 0172 , "Others": "Others" 0173 , "Preprocessor": "Preprocessor" 0174 , "Region Marker": "RegionMarker" 0175 , "Special Character": "SpecialChar" 0176 , "Special String": "SpecialString" 0177 , "String": "String" 0178 , "Variable": "Variable" 0179 , "Verbatim String": "VerbatimString" 0180 , "Warning": "Warning" 0181 } 0182 _OFFSET2NAME: Final[Dict[QtColorItemOffset, PropName]] = { 0183 QtColorItemOffset.TEXT: 'text-color' 0184 , QtColorItemOffset.SELECTED_TEXT: 'selected-text-color' 0185 , QtColorItemOffset.BOLD: 'bold' 0186 , QtColorItemOffset.ITALIC: 'italic' 0187 , QtColorItemOffset.STRIKE_THROUGH: 'strike-through' 0188 , QtColorItemOffset.UNDERLINE: 'underline' 0189 , QtColorItemOffset.BACKGROUND: 'background-color' 0190 , QtColorItemOffset.SELECTED_BACKGROUND: 'seleted-background-color' 0191 } 0192 _META_SECTIONS: Final[List[str]] = ['KateSchema', 'KateHLColors'] 0193 _SECTION_MATCH: Final[Pattern] = re.compile(r'\[(?P<header>[^]]+?)( - Schema .*)?\]') 0194 0195 0196 @click.command() 0197 @click.help_option( 0198 '--help' 0199 , '-h' 0200 ) 0201 @click.version_option() 0202 @click.option( 0203 '--skip-included' 0204 , '-d' 0205 , default=True 0206 , is_flag=True 0207 , help='Do not write custom colors included from another syntax files.' 0208 ) 0209 @click.option( 0210 '-s' 0211 , '--syntax-dirs' 0212 , multiple=True 0213 , metavar='DIRECTORY...' 0214 , type=click.Path(exists=True, file_okay=False, dir_okay=True) 0215 , help='Specify the directory to search for syntax files. ' 0216 'If given, extra validation going to happen. Multiple ' 0217 'options allowed.' 0218 ) 0219 @click.argument( 0220 'input-file' 0221 , type=click.File('r') 0222 , default='-' 0223 ) 0224 def kateschema2theme(skip_included: bool, syntax_dirs: List[click.Path], input_file: TextIO) -> int: 0225 ''' Kate colors/schema to theme converter. ''' 0226 config = configparser.ConfigParser( 0227 delimiters=['='] 0228 , interpolation=None 0229 ) 0230 setattr(config, 'optionxform', str) 0231 setattr(config, 'SECTCRE', _SECTION_MATCH) 0232 0233 try: 0234 config.read_file(input_file) 0235 except configparser.DuplicateOptionError as ex: 0236 eerror(f'{ex!s}') 0237 return 1 0238 0239 result: ThemeDict = {} 0240 sections: List[str] = config.sections() 0241 0242 if 'Editor Colors' in sections: 0243 result['editor-colors'] = functools.reduce( 0244 convert_editor_color 0245 , config.items('Editor Colors') 0246 , {} 0247 ) 0248 0249 if 'Default Item Styles' in sections: 0250 result['text-styles'] = functools.reduce( 0251 collect_standard_colors 0252 , config.items('Default Item Styles') 0253 , {} 0254 ) 0255 0256 custom_styles: CustomStylesDict = functools.reduce( 0257 collect_custom_colors 0258 , hl_colors(config, skip_included) 0259 , {} 0260 ) 0261 0262 if bool(custom_styles): 0263 known_syntaxes: SyntaxesDict = get_syntaxes_available(syntax_dirs) \ 0264 if bool(syntax_dirs) else {} 0265 if bool(known_syntaxes): 0266 custom_styles = verify_converted_styles(custom_styles, known_syntaxes) 0267 0268 result['custom-styles'] = custom_styles 0269 0270 meta_section_name = first_true(lambda name: name in sections, _META_SECTIONS) 0271 if meta_section_name is not None: 0272 result['metadata'] = { 0273 'name': config[meta_section_name]['schema'] 0274 , 'revision': 1 0275 } 0276 0277 print(json.dumps(result, sort_keys=True, indent=4)) 0278 return 0 0279 0280 0281 def convert_editor_color(state: Dict[str, str], color_line: Tuple[str, str]) -> Dict[str, str]: 0282 '''Convert standard editor color names from old to new using the mapping table.''' 0283 name, color_settings = color_line 0284 assert name in _EDITOR_COLORS 0285 state[_EDITOR_COLORS[name]] = decode_rgb_set(color_settings) 0286 return state 0287 0288 0289 def decode_rgb_set(color_settings: str) -> str: 0290 '''Transform the RGB record given as CSV string to web-hex format.''' 0291 return rgb2hex(*map(int, color_settings.split(','))) 0292 0293 0294 def rgb2hex(red: int, green: int, blue: int) -> str: 0295 '''Convert R,G,B integers to web-hex string''' 0296 return f'#{red:02x}{green:02x}{blue:02x}' 0297 0298 0299 def collect_standard_colors(state, item): 0300 '''Convert standard text styles from old to new names using the mapping table.''' 0301 name, value = item 0302 state[_TEXT_STYLES[name]] = parse_qcolor_value(value) 0303 return state 0304 0305 0306 def collect_custom_colors(state: CustomStylesDict, item: Tuple[str, str, str]) -> CustomStylesDict: 0307 '''A functor to convert one old style setting to the new format 0308 and update the given `state` (a dict). 0309 ''' 0310 syntax, syntax_item, value = item 0311 0312 props = parse_qcolor_value(value) 0313 if bool(props): 0314 syntax_node: CustomStyleDict = state.get(syntax, {}) 0315 syntax_node[syntax_item] = props 0316 state[syntax] = syntax_node 0317 0318 return state 0319 0320 0321 def hl_colors(config: configparser.ConfigParser, skip_included: bool) \ 0322 -> Generator[Tuple[str, str, str], None, None]: 0323 '''A generator function to iterate over custom styles in the old format.''' 0324 for section in config.sections(): 0325 if not section.startswith(_HIGHLIGHTING_PFX): 0326 continue 0327 0328 for name, value in config.items(section): 0329 syntax, *parts = name.split(':') 0330 if not bool(parts): 0331 ewarn(f'Unexpected color name: `{name}` in section `{section}`') 0332 0333 elif not skip_included or section[len(_HIGHLIGHTING_PFX):] == syntax: 0334 yield syntax, ':'.join(parts), value 0335 0336 0337 def parse_qcolor_value(value: str) -> StylePropsDict: 0338 '''Convert old color settings (QColor stored as a CSV config item) 0339 into a dict of new styles. 0340 ''' 0341 components = value.split(',') 0342 if len(components) == QtColorItemOffset.CUSTOM_COLOR_EXPECTED_SIZE: 0343 components.pop() 0344 assert len(components) == QtColorItemOffset.STANDARD_COLOR_EXPECTED_SIZE 0345 return transform_qcolor_to_dict(components) 0346 0347 0348 def transform_qcolor_to_dict(components: List[str]) -> StylePropsDict: 0349 '''Convert old color settings given as a list of items 0350 into a dict of new styles. 0351 ''' 0352 init: StylePropsDict = {} 0353 return functools.reduce(convert_color_property, enumerate(components), init) 0354 0355 0356 def convert_color_property(state: StylePropsDict, prop: Tuple[int, str]) -> StylePropsDict: 0357 '''A reducer functor to convert one item of the former color record (CSV) 0358 into a new property name and a value. 0359 ''' 0360 offset = QtColorItemOffset(prop[0]) 0361 value = prop[1] 0362 assert offset < QtColorItemOffset.CUSTOM_COLOR_EXPECTED_SIZE 0363 0364 if bool(value) and offset in _OFFSET2NAME: 0365 custom_prop_name = _OFFSET2NAME[offset] 0366 if custom_prop_name.endswith('-color'): 0367 if len(value) == _EXPECTED_OLD_COLOR_LEN: 0368 state[custom_prop_name] = '#' + value[_OLD_COLOR_LEADING_STRIP_SIZE:] 0369 else: 0370 state[custom_prop_name] = bool(value == '1') 0371 0372 return state 0373 0374 0375 def first_true(pred, iterable: Iterable[T], default=None) -> T: 0376 '''A helper function to return first item for which predicate is true.''' 0377 return next(filter(pred, iterable), default) 0378 0379 0380 def get_syntaxes_available(dirs: List[click.Path]) -> SyntaxesDict: 0381 '''Collect syntaxs available in the given path. 0382 0383 Returns a dict of syntax names to a list of syntax items in it. 0384 ''' 0385 return functools.reduce( 0386 load_syntax_data 0387 , filter( 0388 lambda p: p.suffix == '.xml' 0389 , itertools.chain( 0390 *map( 0391 lambda p: pathlib.Path(str(p)).iterdir() 0392 , dirs 0393 ) 0394 ) 0395 ) 0396 , {} 0397 ) 0398 0399 0400 def load_syntax_data(state: SyntaxesDict, syntax_file: pathlib.Path) -> SyntaxesDict: 0401 '''A reducer functor to obtain syntax items.''' 0402 tree = ElementTree.parse(syntax_file) 0403 root = tree.getroot() 0404 0405 syntax_name = root.get('name') 0406 assert syntax_name is not None 0407 0408 if syntax_name in state: 0409 ewarn( 0410 f'Use `{syntax_name}` found ' 0411 f'in `{click.format_filename(str(syntax_file))}`' 0412 ) 0413 0414 state[syntax_name] = functools.reduce( 0415 collect_syntax_item_data 0416 , root.iterfind('highlighting/itemDatas/itemData') 0417 , set() 0418 ) 0419 return state 0420 0421 0422 def verify_converted_styles(custom_styles: CustomStylesDict, known_syntaxes: SyntaxesDict) \ 0423 -> CustomStylesDict: 0424 '''Validate the given `custom_styles` according to actual syntax items 0425 described in the known syntax files. 0426 0427 Returns a dict of syntaxes without unused syntax items. 0428 ''' 0429 for syntax, styles in custom_styles.items(): 0430 if syntax not in known_syntaxes: 0431 ewarn(f'The `{syntax}` is not known. Ignoring validation.') 0432 continue 0433 0434 found_custom_items = set(styles.keys()) 0435 0436 if unused_items := [*found_custom_items.difference(known_syntaxes[syntax])]: 0437 ewarn( 0438 f'The following styles are not used by `{syntax}` syntax anymore:' 0439 + '\n' 0440 + format_columns(unused_items) 0441 ) 0442 custom_styles[syntax] = functools.reduce( 0443 remove_unused_syntax_item 0444 , unused_items 0445 , styles 0446 ) 0447 0448 if undefined_items := [*known_syntaxes[syntax].difference(found_custom_items)]: 0449 ewarn( 0450 f'The following styles are not defined in the converted `{syntax}` syntax:' 0451 + '\n' 0452 + format_columns(undefined_items) 0453 ) 0454 0455 return custom_styles 0456 0457 0458 def remove_unused_syntax_item(state: CustomStyleDict, item: str) -> CustomStyleDict: 0459 '''Remove the given `item` from the `state`.''' 0460 assert item in state 0461 del state[item] 0462 return state 0463 0464 0465 def format_columns(iterable: Iterable[str]) -> str: 0466 '''A helper functor to output the list in columns.''' 0467 term_width = click.get_terminal_size()[0] - len(_COLUMIZED_LIST_INDENT_PFX) 0468 return textwrap.indent( 0469 columnize.columnize(iterable, displaywidth=term_width, colsep=' │ ') 0470 , prefix=_COLUMIZED_LIST_INDENT_PFX 0471 ) 0472 0473 0474 def collect_syntax_item_data(items: Set[str], node: ElementTree.Element) -> Set[str]: 0475 '''A reducer functor to append a syntax item name to the given set.''' 0476 name = node.get('name') 0477 assert name is not None 0478 items.add(name) 0479 0480 return items 0481 0482 0483 def eerror(msg: str): 0484 '''A helper function to display an error message.''' 0485 click.echo(' ' + click.style('*', fg='red', bold=True) + f' {msg}', err=True) 0486 0487 0488 def ewarn(msg: str): 0489 '''A helper function to display a warning message.''' 0490 click.echo(' ' + click.style('*', fg='yellow') + f' {msg}', err=True)