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)