diff --git a/plugins/README.txt b/plugins/README.txt new file mode 100644 index 0000000..4173ddd --- /dev/null +++ b/plugins/README.txt @@ -0,0 +1,2 @@ +### Note +Copy the example and rename it to your desired name. The Main class and passed in arguments are required. You don't necessarily need to use the passed in socket_id or event_system. diff --git a/plugins/autopairs/__init__.py b/plugins/autopairs/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/autopairs/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/autopairs/__main__.py b/plugins/autopairs/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/autopairs/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/autopairs/manifest.json b/plugins/autopairs/manifest.json new file mode 100644 index 0000000..9f4c039 --- /dev/null +++ b/plugins/autopairs/manifest.json @@ -0,0 +1,12 @@ +{ + "manifest": { + "name": "Autopairs", + "author": "ITDominator", + "credit": "Hamad Al Marri", + "version": "0.0.1", + "support": "", + "requests": { + "pass_events": "true" + } + } +} \ No newline at end of file diff --git a/plugins/autopairs/plugin.py b/plugins/autopairs/plugin.py new file mode 100644 index 0000000..848c84e --- /dev/null +++ b/plugins/autopairs/plugin.py @@ -0,0 +1,140 @@ +# Python imports +import os +import threading +import subprocess +import time + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from plugins.plugin_base import PluginBase + + + + +# NOTE: Threads WILL NOT die with parent's destruction. +def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=False).start() + return wrapper + +# NOTE: Threads WILL die with parent's destruction. +def daemon_threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True).start() + return wrapper + + + + +class Plugin(PluginBase): + def __init__(self): + super().__init__() + + self.name = "Autopairs" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus + # where self.name should not be needed for message comms + + self.chars = { + "quotedbl": "\"", + "apostrophe": "'", + "parenleft": "(", + "bracketleft": "[", + "braceleft": "{", + "less": "<", + "grave": "`", + } + + self.close = { + "\"": "\"", + "'": "'", + "(": ")", + "[": "]", + "{": "}", + "<": ">", + "`": "`", + } + + def generate_reference_ui_element(self): + ... + + def run(self): + ... + + def subscribe_to_events(self): + self._event_system.subscribe("set_active_src_view", self._set_active_src_view) + self._event_system.subscribe("autopairs", self._autopairs) + + def _set_active_src_view(self, source_view): + self._active_src_view = source_view + self._buffer = self._active_src_view.get_buffer() + self._tag_table = self._buffer.get_tag_table() + + def _autopairs(self, keyval_name, ctrl, alt, shift): + if keyval_name in self.chars: + return self.text_insert(self._buffer, keyval_name) + + # NOTE: All of below to EOF, lovingly taken from Hamad Al Marri's Gamma + # text editor. I did do some cleanup of comments but otherwise pretty + # much the same code just fitted to my plugin architecture. + # Link: https://gitlab.com/hamadmarri/gamma-text-editor + def text_insert(self, buffer, text): + selection = buffer.get_selection_bounds() + if selection == (): + return self.add_close(buffer, text, ) + else: + return self.add_enclose(buffer, text, selection) + + def add_close(self, buffer, text): + text = self.chars[text] + text += self.close[text] + + position = buffer.get_iter_at_mark( buffer.get_insert() ) + + c = position.get_char() + if not c in (" ", "", ";", ":", "\t", ",", ".", "\n", "\r") \ + and not c in list(self.close.values()): + return False + + buffer.insert(position, text) + + position = buffer.get_iter_at_mark(buffer.get_insert()) + position.backward_char() + buffer.place_cursor(position) + + return True + + def add_enclose(self, buffer, text, selection): + (start, end) = selection + selected = buffer.get_text(start, end, False) + if len(selected) <= 3 and selected in ("<", ">", ">>>" + "<<", ">>", + "\"", "'", "`", + "(", ")", + "[", "]", + "{", "}", + "=", "==", + "!=", "==="): + return False + + start_mark = buffer.create_mark("startclose", start, False) + end_mark = buffer.create_mark("endclose", end, False) + + buffer.begin_user_action() + + t = self.chars[text] + buffer.insert(start, t) + end = buffer.get_iter_at_mark(end_mark) + t = self.close[t] + buffer.insert(end, t) + + start = buffer.get_iter_at_mark(start_mark) + end = buffer.get_iter_at_mark(end_mark) + end.backward_char() + buffer.select_range(start, end) + + buffer.end_user_action() + + return True \ No newline at end of file diff --git a/plugins/colorize/__init__.py b/plugins/colorize/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/colorize/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/colorize/__main__.py b/plugins/colorize/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/colorize/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/colorize/color_converter_mixin.py b/plugins/colorize/color_converter_mixin.py new file mode 100644 index 0000000..0e8cd39 --- /dev/null +++ b/plugins/colorize/color_converter_mixin.py @@ -0,0 +1,107 @@ +# Python imports +import colorsys + +# Lib imports + +# Application imports + + +class ColorConverterMixin: + # NOTE: HSV HSL, and Hex Alpha parsing are available in Gtk 4.0- not lower. + # So, for compatability we're gunna convert to rgba string ourselves... + def get_color_text(self, buffer, start, end): + text = buffer.get_text(start, end, include_hidden_chars = False) + + try: + if "hsl" in text: + text = self.hsl_to_rgb(text) + + if "hsv" in text: + text = self.hsv_to_rgb(text) + + if "#" == text[0]: + hex = text[1:] + size = len(hex) + if size in [4, 8, 16]: + rgba = self.hex_to_rgba(hex, size) + print(rgba) + + except Exception as e: + ... + + return text + + def hex_to_rgba(self, hex, size): + rgba = [] + slots = None + step = 2 + bytes = 16 + + if size == 4: # NOTE: RGBA + step = 1 + slots = (0, 1, 2, 3) + + if size == 6: # NOTE: RR GG BB + slots = (0, 2, 4) + + if size == 8: # NOTE: RR GG BB AA + step = 2 + slots = (0, 2, 4, 6) + + if size == 16: # NOTE: RRRR GGGG BBBB AAAA + step = 4 + slots = (0, 4, 8, 12) + + for i in slots: + v = int(hex[i : i + step], bytes) + rgba.append(v) + + + rgb_sub = ','.join(map(str, tuple(rgba))) + + return f"rgba({rgb_sub})" + + # return tuple(rgba) + + + + def hsl_to_rgb(self, text): + _h, _s , _l = text.replace("hsl", "") \ + .replace("deg", "") \ + .replace("(", "") \ + .replace(")", "") \ + .replace("%", "") \ + .replace(" ", "") \ + .split(",") + + h = None + s = None + l = None + + h, s , l = int(_h) / 360, float(_s) / 100, float(_l) / 100 + + rgb = tuple(round(i * 255) for i in colorsys.hls_to_rgb(h, l, s)) + rgb_sub = ','.join(map(str, rgb)) + + return f"rgb({rgb_sub})" + + + def hsv_to_rgb(self, text): + _h, _s , _v = text.replace("hsv", "") \ + .replace("deg", "") \ + .replace("(", "") \ + .replace(")", "") \ + .replace("%", "") \ + .replace(" ", "") \ + .split(",") + + h = None + s = None + v = None + + h, s , v = int(_h) / 360, float(_s) / 100, float(_v) / 100 + + rgb = tuple(round(i * 255) for i in colorsys.hsv_to_rgb(h,s,v)) + rgb_sub = ','.join(map(str, rgb)) + + return f"rgb({rgb_sub})" diff --git a/plugins/colorize/manifest.json b/plugins/colorize/manifest.json new file mode 100644 index 0000000..cba5e3d --- /dev/null +++ b/plugins/colorize/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest": { + "name": "Colorize", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": { + "pass_events": "true" + } + } +} \ No newline at end of file diff --git a/plugins/colorize/plugin.py b/plugins/colorize/plugin.py new file mode 100644 index 0000000..ba16125 --- /dev/null +++ b/plugins/colorize/plugin.py @@ -0,0 +1,228 @@ +# Python imports +import random + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') +from gi.repository import Gtk +from gi.repository import Gdk + +# Application imports +from plugins.plugin_base import PluginBase +from .color_converter_mixin import ColorConverterMixin + + + +class Plugin(ColorConverterMixin, PluginBase): + def __init__(self): + super().__init__() + + self.name = "Colorize" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus + # where self.name should not be needed for message comms + self.tag_stub_name = "colorize_tag" + self._buffer = None + + + def run(self): + ... + + def generate_reference_ui_element(self): + ... + + def subscribe_to_events(self): + self._event_system.subscribe("set_active_src_view", self._set_active_src_view) + self._event_system.subscribe("buffer_changed_first_load", self._buffer_changed_first_load) + self._event_system.subscribe("buffer_changed", self._buffer_changed) + + + def _set_active_src_view(self, source_view): + self._active_src_view = source_view + + + def _buffer_changed_first_load(self, buffer): + self._buffer = buffer + self._do_colorize(buffer) + + def _buffer_changed(self, buffer): + self._event_system.emit("pause_event_processing") + self._handle_colorize(buffer) + self._event_system.emit("resume_event_processing") + + def _handle_colorize(self, buffer): + self._buffer = buffer + tag_table = buffer.get_tag_table() + mark = buffer.get_insert() + start = None + end = buffer.get_iter_at_mark(mark) + + i = 0 + walker_iter = end.copy() + working_tag = self.find_working_tag(walker_iter, i) + if working_tag: + start = self.find_start_range(walker_iter, working_tag) + + self.find_end_range(end, working_tag) + buffer.remove_tag(working_tag, start, end) + else: + start = self.traverse_backward_25_or_less(walker_iter) + self.traverse_forward_25_or_less(end) + + self._do_colorize(buffer, start, end) + + + + def find_working_tag(self, walker_iter, i): + tags = walker_iter.get_tags() + for tag in tags: + if tag.props.name and self.tag_stub_name in tag.props.name: + return tag + + res = walker_iter.backward_char() + + if not res: return + if i > 25: return + return self.find_working_tag(walker_iter, i + 1) + + def find_start_range(self, walker_iter, working_tag): + tags = walker_iter.get_tags() + for tag in tags: + if tag.props.name and working_tag.props.name in tag.props.name: + res = walker_iter.backward_char() + if res: + self.find_start_range(walker_iter, working_tag) + + return walker_iter + + def find_end_range(self, end, working_tag): + tags = end.get_tags() + for tag in tags: + if tag.props.name and working_tag.props.name in tag.props.name: + res = end.forward_char() + if res: + self.find_end_range(end, working_tag) + + def traverse_backward_25_or_less(self, walker_iter): + i = 1 + while i <= 25: + res = walker_iter.backward_char() + if not res: break + i += 1 + + def traverse_forward_25_or_less(self, end): + i = 1 + while i <= 25: + res = end.forward_char() + if not res: break + i += 1 + + def _do_colorize(self, buffer = None, start_itr = None, end_itr = None): + # rgb(a), hsl, hsv + results = self.finalize_non_hex_matches( self.collect_preliminary_results(buffer, start_itr, end_itr) ) + self.process_results(buffer, results) + + # hex color search + results = self.finalize_hex_matches( self.collect_preliminary_hex_results(buffer, start_itr, end_itr) ) + self.process_results(buffer, results) + + + def collect_preliminary_results(self, buffer = None, start_itr = None, end_itr = None): + if not buffer: return [] + + if not start_itr: + start_itr = buffer.get_start_iter() + + results1 = self.search(start_itr, end_itr, "rgb") + results2 = self.search(start_itr, end_itr, "hsl") + results3 = self.search(start_itr, end_itr, "hsv") + + return results1 + results2 + results3 + + def collect_preliminary_hex_results(self, buffer = None, start_itr = None, end_itr = None): + if not buffer: return [] + + if not start_itr: + start_itr = buffer.get_start_iter() + + results1 = self.search(start_itr, end_itr, "#") + + return results1 + + def search(self, start_itr = None, end_itr = None, query = None): + if not start_itr or not query: return None, None + + results = [] + flags = Gtk.TextSearchFlags.VISIBLE_ONLY | Gtk.TextSearchFlags.TEXT_ONLY + while True: + result = start_itr.forward_search(query, flags, end_itr) + if not result: break + + results.append(result) + start_itr = result[1] + + return results + + def finalize_non_hex_matches(self, result_hits: [] = []): + results = [] + + for start, end in result_hits: + if end.get_char() == "a": + end.forward_char() + + if end.get_char() != "(": + continue + + end.forward_chars(21) + if end.get_char() == ")": + end.forward_char() + results.append([start, end]) + continue + + while end.get_char() != "(": + if end.get_char() == ")": + end.forward_char() + results.append([start, end]) + break + + end.forward_chars(-1) + + return results + + def finalize_hex_matches(self, result_hits: [] = []): + results = [] + + for start, end in result_hits: + i = 0 + _ch = end.get_char() + ch = ord(end.get_char()) if _ch else -1 + + while ((ch >= 48 and ch <= 57) or (ch >= 65 and ch <= 70) or (ch >= 97 and ch <= 102)): + if i > 16: break + + i += 1 + end.forward_char() + _ch = end.get_char() + ch = ord(end.get_char()) if _ch else -1 + + if i in [3, 4, 6, 8, 9, 12, 16]: + results.append([start, end]) + + return results + + def process_results(self, buffer, results): + for start, end in results: + text = self.get_color_text(buffer, start, end) + color = Gdk.RGBA() + + if color.parse(text): + tag = self.get_colorized_tag(buffer, text, color) + buffer.apply_tag(tag, start, end) + + def get_colorized_tag(self, buffer, tag, color: Gdk.RGBA): + tag_table = buffer.get_tag_table() + colorize_tag = f"{self.tag_stub_name}_{tag}" + search_tag = tag_table.lookup(colorize_tag) + if not search_tag: + search_tag = buffer.create_tag(colorize_tag, background_rgba = color) + + return search_tag diff --git a/plugins/commentzar/__init__.py b/plugins/commentzar/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/commentzar/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/commentzar/__main__.py b/plugins/commentzar/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/commentzar/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/commentzar/add_comment_mixin.py b/plugins/commentzar/add_comment_mixin.py new file mode 100755 index 0000000..6e123f3 --- /dev/null +++ b/plugins/commentzar/add_comment_mixin.py @@ -0,0 +1,66 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class AddCommentMixin: + def add_comment_characters(self, buffer, start_tag, end_tag, start, end, deselect, oldPos): + smark = buffer.create_mark("start", start, False) + imark = buffer.create_mark("iter", start, False) + emark = buffer.create_mark("end", end, False) + number_lines = end.get_line() - start.get_line() + 1 + comment_pos_iter = None + count = 0 + + buffer.begin_user_action() + + for i in range(0, number_lines): + iter = buffer.get_iter_at_mark(imark) + if not comment_pos_iter: + (comment_pos_iter, count) = self.discard_white_spaces(iter) + + if self.is_commented(comment_pos_iter, start_tag): + new_code = self.remove_comment_characters(buffer, start_tag, end_tag, start, end) + return + else: + comment_pos_iter = iter + for i in range(count): + c = iter.get_char() + if not c in (" ", "\t"): + break + + iter.forward_char() + + buffer.insert(comment_pos_iter, start_tag) + buffer.insert(comment_pos_iter, " ") + + if end_tag: + if i != number_lines -1: + iter = buffer.get_iter_at_mark(imark) + iter.forward_to_line_end() + buffer.insert(iter, end_tag) + else: + iter = buffer.get_iter_at_mark(emark) + buffer.insert(iter, end_tag) + + iter = buffer.get_iter_at_mark(imark) + iter.forward_line() + buffer.delete_mark(imark) + imark = buffer.create_mark("iter", iter, True) + + buffer.end_user_action() + + buffer.delete_mark(imark) + new_start = buffer.get_iter_at_mark(smark) + new_end = buffer.get_iter_at_mark(emark) + + buffer.select_range(new_start, new_end) + buffer.delete_mark(smark) + buffer.delete_mark(emark) + + if deselect: + oldPosIter = buffer.get_iter_at_offset(oldPos + 2) + buffer.place_cursor(oldPosIter) diff --git a/plugins/commentzar/codecomment_tags.py b/plugins/commentzar/codecomment_tags.py new file mode 100755 index 0000000..ac7d110 --- /dev/null +++ b/plugins/commentzar/codecomment_tags.py @@ -0,0 +1,30 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class CodeCommentTags: + def get_comment_tags(self, lang): + (s, e) = self.get_line_comment_tags(lang) + if (s, e) == (None, None): + (s, e) = self.get_block_comment_tags(lang) + + return (s, e) + + def get_block_comment_tags(self, lang): + start_tag = lang.get_metadata('block-comment-start') + end_tag = lang.get_metadata('block-comment-end') + if start_tag and end_tag: + return (start_tag, end_tag) + + return (None, None) + + def get_line_comment_tags(self, lang): + start_tag = lang.get_metadata('line-comment-start') + if start_tag: + return (start_tag, None) + + return (None, None) diff --git a/plugins/commentzar/manifest.json b/plugins/commentzar/manifest.json new file mode 100644 index 0000000..cd1d1f7 --- /dev/null +++ b/plugins/commentzar/manifest.json @@ -0,0 +1,13 @@ +{ + "manifest": { + "name": "Commentzar", + "author": "ITDominator", + "credit": "Hamad Al Marri", + "version": "0.0.1", + "support": "", + "requests": { + "pass_events": "true", + "bind_keys": ["Commentzar||keyboard_tggl_comment:slash"] + } + } +} diff --git a/plugins/commentzar/plugin.py b/plugins/commentzar/plugin.py new file mode 100644 index 0000000..cbf41bf --- /dev/null +++ b/plugins/commentzar/plugin.py @@ -0,0 +1,118 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from plugins.plugin_base import PluginBase +from .codecomment_tags import CodeCommentTags +from .remove_comment_mixin import RemoveCommentMixin +from .add_comment_mixin import AddCommentMixin + + + +class Plugin(AddCommentMixin, RemoveCommentMixin, CodeCommentTags, PluginBase): + def __init__(self): + super().__init__() + + self.name = "Commentzar" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus + # where self.name should not be needed for message comms + + + def generate_reference_ui_element(self): + ... + + def run(self): + ... + + def subscribe_to_events(self): + self._event_system.subscribe("keyboard_tggl_comment", self._keyboard_tggl_comment) + self._event_system.subscribe("set_active_src_view", self._set_active_src_view) + + def _set_active_src_view(self, source_view): + self._active_src_view = source_view + self._buffer = self._active_src_view.get_buffer() + self._tag_table = self._buffer.get_tag_table() + + + def _keyboard_tggl_comment(self): + buffer = self._buffer + lang = buffer.get_language() + if lang is None: + return + + (start_tag, end_tag) = self.get_comment_tags(lang) + if not start_tag and not end_tag: + return + + sel = buffer.get_selection_bounds() + currentPosMark = buffer.get_insert() + oldPos = 0 + + # if user selected chars or multilines + if sel != (): + deselect = False + (start, end) = sel + if not start.starts_line(): + start.set_line_offset(0) + if not end.ends_line(): + end.forward_to_line_end() + else: + deselect = True + start = buffer.get_iter_at_mark(currentPosMark) + oldPos = buffer.get_iter_at_mark(currentPosMark).get_offset() + start.set_line_offset(0) + end = start.copy() + + if not end.ends_line(): + end.forward_to_line_end() + + if start.get_offset() == end.get_offset(): + buffer.begin_user_action() + buffer.insert(start, start_tag) + buffer.insert(start, " ") + buffer.end_user_action() + return + + self._event_system.emit("pause_event_processing") + new_code = self.add_comment_characters(buffer, start_tag, end_tag, start, end, deselect, oldPos) + self._event_system.emit("resume_event_processing") + + def discard_white_spaces(self, iter): + count = 0 + while not iter.ends_line(): + c = iter.get_char() + if not c in (" ", "\t"): + return (iter, count) + + iter.forward_char() + count += 1 + + return (iter, 0) + + def is_commented(self, comment_pos_iter, start_tag): + head_iter = comment_pos_iter.copy() + self.forward_tag(head_iter, start_tag) + s = comment_pos_iter.get_slice(head_iter) + if s == start_tag: + return True + + return False + + def forward_tag(self, iter, tag): + iter.forward_chars(len(tag)) + + def backward_tag(self, iter, tag): + iter.backward_chars(len(tag)) + + def get_tag_position_in_line(self, tag, head_iter, iter): + while not iter.ends_line(): + s = iter.get_slice(head_iter) + if s == tag: + return True + else: + head_iter.forward_char() + iter.forward_char() + return False diff --git a/plugins/commentzar/remove_comment_mixin.py b/plugins/commentzar/remove_comment_mixin.py new file mode 100755 index 0000000..0e956f2 --- /dev/null +++ b/plugins/commentzar/remove_comment_mixin.py @@ -0,0 +1,49 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class RemoveCommentMixin: + def remove_comment_characters(self, buffer, start_tag, end_tag, start, end): + smark = buffer.create_mark("start", start, False) + emark = buffer.create_mark("end", end, False) + number_lines = end.get_line() - start.get_line() + 1 + iter = start.copy() + head_iter = iter.copy() + self.forward_tag(head_iter, start_tag) + + buffer.begin_user_action() + + for i in range(0, number_lines): + if self.get_tag_position_in_line(start_tag, head_iter, iter): + dmark = buffer.create_mark("delete", iter, False) + buffer.delete(iter, head_iter) + + space_iter = head_iter.copy() + space_iter.forward_char() + s = head_iter.get_slice(space_iter) + if s == " ": + buffer.delete(head_iter, space_iter) + + if end_tag: + iter = buffer.get_iter_at_mark(dmark) + head_iter = iter.copy() + self.forward_tag(head_iter, end_tag) + if self.get_tag_position_in_line(end_tag, head_iter, iter): + buffer.delete(iter, head_iter) + buffer.delete_mark(dmark) + + iter = buffer.get_iter_at_mark(smark) + iter.forward_line() + buffer.delete_mark(smark) + head_iter = iter.copy() + self.forward_tag(head_iter, start_tag) + smark = buffer.create_mark("iter", iter, True) + + buffer.end_user_action() + + buffer.delete_mark(smark) + buffer.delete_mark(emark) diff --git a/plugins/lsp_client/__init__.py b/plugins/lsp_client/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/lsp_client/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/lsp_client/__main__.py b/plugins/lsp_client/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/lsp_client/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/lsp_client/capabilities.py b/plugins/lsp_client/capabilities.py new file mode 100644 index 0000000..8cd31d2 --- /dev/null +++ b/plugins/lsp_client/capabilities.py @@ -0,0 +1,201 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class Capabilities: + data = { + "textDocument": { + "codeAction": { + "dynamicRegistration": True + }, + "codeLens": { + "dynamicRegistration": True + }, + "colorProvider": { + "dynamicRegistration": True + }, + "completion": { + "completionItem": { + "commitCharactersSupport": True, + "documentationFormat": [ + "markdown", + "plaintext" + ], + "snippetSupport": True + }, + "completionItemKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25 + ] + }, + "contextSupport": True, + "dynamicRegistration": True + }, + "definition": { + "dynamicRegistration": True + }, + "documentHighlight": { + "dynamicRegistration": True + }, + "documentLink": { + "dynamicRegistration": True + }, + "documentSymbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + } + }, + "formatting": { + "dynamicRegistration": True + }, + "hover": { + "contentFormat": [ + "markdown", + "plaintext" + ], + "dynamicRegistration": True + }, + "implementation": { + "dynamicRegistration": True + }, + "onTypeFormatting": { + "dynamicRegistration": True + }, + "publishDiagnostics": { + "relatedInformation": True + }, + "rangeFormatting": { + "dynamicRegistration": True + }, + "references": { + "dynamicRegistration": True + }, + "rename": { + "dynamicRegistration": True + }, + "signatureHelp": { + "dynamicRegistration": True, + "signatureInformation": { + "documentationFormat": [ + "markdown", + "plaintext" + ] + } + }, + "synchronization": { + "didSave": True, + "dynamicRegistration": True, + "willSave": True, + "willSaveWaitUntil": True + }, + "typeDefinition": { + "dynamicRegistration": True + } + }, + "workspace": { + "applyEdit": True, + "configuration": True, + "didChangeConfiguration": { + "dynamicRegistration": True + }, + "didChangeWatchedFiles": { + "dynamicRegistration": True + }, + "executeCommand": { + "dynamicRegistration": True + }, + "symbol": { + "dynamicRegistration": True, + "symbolKind": { + "valueSet": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26 + ] + } + }, + "workspaceEdit": { + "documentChanges": True + }, + "workspaceFolders": True + } +} \ No newline at end of file diff --git a/plugins/lsp_client/lsp_controller.py b/plugins/lsp_client/lsp_controller.py new file mode 100644 index 0000000..902b555 --- /dev/null +++ b/plugins/lsp_client/lsp_controller.py @@ -0,0 +1,187 @@ +# Python imports +import subprocess +import threading + +# Lib imports +from . import pylspclient + +# Application imports +from .capabilities import Capabilities + + + +class ReadPipe(threading.Thread): + def __init__(self, pipe): + threading.Thread.__init__(self) + + self.daemon = True + self.pipe = pipe + + + def run(self): + line = self.pipe.readline().decode('utf-8') + while line: + line = self.pipe.readline().decode('utf-8') + + +class LSPController: + def __init__(self, lsp_servers_config = {}): + super().__init__() + + self.lsp_servers_config = lsp_servers_config + self.lsp_clients = {} + + + def _blame(self, response): + for d in response['diagnostics']: + if d['severity'] == 1: + print(f"An error occurs in {response['uri']} at {d['range']}:") + print(f"\t[{d['source']}] {d['message']}") + + def _shutting_down(self): + keys = self.lsp_clients.keys() + for key in keys: + print(f"LSP Server: ( {key} ) Shutting Down...") + self.lsp_clients[key].shutdown() + self.lsp_clients[key].exit() + + def _generate_client(self, language = "", server_proc = None): + if not language or not server_proc: return False + + json_rpc_endpoint = pylspclient.JsonRpcEndpoint(server_proc.stdin, server_proc.stdout) + + callbacks = { + "window/showMessage": print, + "textDocument/symbolStatus": print, + "textDocument/publishDiagnostics": self._blame, + } + + lsp_endpoint = pylspclient.LspEndpoint(json_rpc_endpoint, notify_callbacks = callbacks) + lsp_client = pylspclient.LspClient(lsp_endpoint) + + self.lsp_clients[language] = lsp_client + return lsp_client + + def create_client(self, language = "", server_proc = None, initialization_options = None): + if not language or not server_proc: return False + + root_path = None + # root_uri = 'file:///home/abaddon/Coding/Projects/Active/Python_Projects/000_Usable/gtk/Newton_Editor/src/' + # workspace_folders = [{'name': 'python-lsp', 'uri': root_uri}] + root_uri = '' + workspace_folders = [{'name': '', 'uri': root_uri}] + + lsp_client = self._generate_client(language, server_proc) + lsp_client.initialize( + processId = server_proc.pid, \ + rootPath = root_path, \ + rootUri = root_uri, \ + initializationOptions = initialization_options, \ + capabilities = Capabilities.data, \ + trace = "off", \ + # trace = "on", \ + workspaceFolders = workspace_folders + ) + + lsp_client.initialized() + + return True + + def create_lsp_server(self, server_command: [] = []): + if not server_command: return None + + server_proc = subprocess.Popen(server_command, stdin = subprocess.PIPE, stdout = subprocess.PIPE, stderr = subprocess.PIPE) + read_pipe = ReadPipe(server_proc.stderr) + read_pipe.start() + + return server_proc + + + def do_open(self, language_id, uri): + if language_id in self.lsp_clients.keys(): + lsp_client = self.lsp_clients[language_id] + else: + lsp_client = self.load_lsp_server(language_id) + + if lsp_client: + self.register_opened_file(language_id, uri, lsp_client) + + def do_save(self, language_id, uri): + if language_id in self.lsp_clients.keys(): + self.lsp_clients[language_id].didSave( + pylspclient.lsp_structs.TextDocumentIdentifier(uri) + ) + + def do_close(self, language_id, uri): + if language_id in self.lsp_clients.keys(): + self.lsp_clients[language_id].didClose( + pylspclient.lsp_structs.TextDocumentIdentifier(uri) + ) + + def do_goto(self, language_id, uri, line, offset): + if language_id in self.lsp_clients.keys(): + return self.lsp_clients[language_id].definition( + pylspclient.lsp_structs.TextDocumentIdentifier(uri), + pylspclient.lsp_structs.Position(line, offset) + ) + + return [] + + def do_change(self, uri, language_id, line, start, end, text): + if language_id in self.lsp_clients.keys(): + + start_pos = pylspclient.lsp_structs.Position(line, start.get_line_offset()) + end_pos = pylspclient.lsp_structs.Position(line, end.get_line_offset()) + range_info = pylspclient.lsp_structs.Range(start_pos, end_pos) + text_length = len(text) + text_document = pylspclient.lsp_structs.TextDocumentItem(uri, language_id, 1, text) + change_event = pylspclient.lsp_structs.TextDocumentContentChangeEvent(range_info, text_length, text) + + return self.lsp_clients[language_id].didChange( text_document, change_event ) + + return [] + + def do_completion(self, language_id, uri, line, offset, _char, is_invoked = False): + if language_id in self.lsp_clients.keys(): + trigger = pylspclient.lsp_structs.CompletionTriggerKind.TriggerCharacter + + if _char in [".", " "]: + trigger = pylspclient.lsp_structs.CompletionTriggerKind.TriggerCharacter + elif is_invoked: + trigger = pylspclient.lsp_structs.CompletionTriggerKind.Invoked + else: + trigger = pylspclient.lsp_structs.CompletionTriggerKind.TriggerForIncompleteCompletions + + return self.lsp_clients[language_id].completion( + pylspclient.lsp_structs.TextDocumentIdentifier(uri), + pylspclient.lsp_structs.Position(line, offset), + None + # pylspclient.lsp_structs.CompletionContext(trigger, _char) + ) + + return [] + + + def load_lsp_server(self, language_id): + if not language_id in self.lsp_servers_config.keys(): + return + + command = self.lsp_servers_config[language_id]["command"] + config_options = self.lsp_servers_config[language_id]["initialization_options"] + + if command: + server_proc = self.create_lsp_server(command) + if self.create_client(language_id, server_proc, config_options): + return self.lsp_clients[language_id] + + return None + + def register_opened_file(self, language_id = "", uri = "", lsp_client = None): + if not language_id or not uri: return + + text = open(uri[7:], "r").read() + version = 1 + + lsp_client.didOpen( + pylspclient.lsp_structs.TextDocumentItem(uri, language_id, version, text) + ) \ No newline at end of file diff --git a/plugins/lsp_client/manifest.json b/plugins/lsp_client/manifest.json new file mode 100644 index 0000000..255c6d6 --- /dev/null +++ b/plugins/lsp_client/manifest.json @@ -0,0 +1,12 @@ +{ + "manifest": { + "name": "LSP Client", + "author": "ITDominator", + "version": "0.0.1", + "credit": "Avi Yeger for the pylspclient used by this plugin. Link: https://github.com/yeger00/pylspclient", + "support": "", + "requests": { + "pass_events": "true" + } + } +} \ No newline at end of file diff --git a/plugins/lsp_client/plugin.py b/plugins/lsp_client/plugin.py new file mode 100644 index 0000000..b566e93 --- /dev/null +++ b/plugins/lsp_client/plugin.py @@ -0,0 +1,114 @@ +# Python imports +import os +import json +import threading + +# Lib imports +from gi.repository import GLib + +# Application imports + + +from plugins.plugin_base import PluginBase +from .lsp_controller import LSPController + + + +class LSPPliginException(Exception): + ... + + + +class Plugin(PluginBase): + def __init__(self): + super().__init__() + + self.name = "LSP Client" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus + # where self.name should not be needed for message comms + self.lsp_config_path: str = os.path.dirname(os.path.realpath(__file__)) + "/../../lsp_servers_config.json" + self.lsp_servers_config: dict = {} + self.lsp_controller = None + self.timer = None + + + def generate_reference_ui_element(self): + ... + + def run(self): + if os.path.exists(self.lsp_config_path): + with open(self.lsp_config_path, "r") as f: + self.lsp_servers_config = json.load(f) + else: + text = f"LSP NOT Enabled.\nFile:\n\t{self.lsp_config_path}\ndoes no exsist..." + self._event_system.emit("bubble_message", ("warning", self.name, text,)) + return + + if len(self.lsp_servers_config.keys()) > 0: + self.lsp_controller = LSPController(self.lsp_servers_config) + self.inner_subscribe_to_events() + + def subscribe_to_events(self): + ... + + def inner_subscribe_to_events(self): + self._event_system.subscribe("shutting_down", self._shutting_down) + + # self._event_system.subscribe("buffer_changed", self._buffer_changed) + self._event_system.subscribe("textDocument/didChange", self._buffer_changed) + self._event_system.subscribe("textDocument/didOpen", self.lsp_controller.do_open) + self._event_system.subscribe("textDocument/didSave", self.lsp_controller.do_save) + self._event_system.subscribe("textDocument/didClose", self.lsp_controller.do_close) + self._event_system.subscribe("textDocument/definition", self._do_goto) + self._event_system.subscribe("textDocument/completion", self._do_completion) + + def _shutting_down(self): + self.lsp_controller._shutting_down() + + def _buffer_changed(self, file_type, buffer): + iter = buffer.get_iter_at_mark( buffer.get_insert() ) + line = iter.get_line() + start = iter.copy() + end = iter.copy() + + start.backward_line() + start.forward_line() + end.forward_to_line_end() + + text = buffer.get_text(start, end, include_hidden_chars = False) + result = self.lsp_controller.do_change(buffer.uri, buffer.language_id, line, start, end, text) + + + def _do_completion(self, source_view): + filepath = source_view.get_current_file() + + if not filepath: return + + uri = filepath.get_uri() + buffer = source_view.get_buffer() + iter = buffer.get_iter_at_mark( buffer.get_insert() ) + line = iter.get_line() + + _char = iter.get_char() + if iter.backward_char(): + _char = iter.get_char() + + offset = iter.get_line_offset() + result = self.lsp_controller.do_completion( + source_view.get_filetype(), + uri, + line, + offset, + _char + ) + + return result + + def _do_goto(self, language_id, uri, line, offset): + results = self.lsp_controller.do_goto(language_id, uri, line, offset) + + if len(results) == 1: + result = results[0] + file = result.uri[7:] + line = result.range.end.line + message = f"FILE|{file}:{line}" + self._event_system.emit("post_file_to_ipc", message) \ No newline at end of file diff --git a/plugins/lsp_client/pylspclient/LICENSE b/plugins/lsp_client/pylspclient/LICENSE new file mode 100644 index 0000000..0728ddb --- /dev/null +++ b/plugins/lsp_client/pylspclient/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Avi Yeger + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/lsp_client/pylspclient/__init__.py b/plugins/lsp_client/pylspclient/__init__.py new file mode 100644 index 0000000..58572a1 --- /dev/null +++ b/plugins/lsp_client/pylspclient/__init__.py @@ -0,0 +1,6 @@ +__all__ = [] + +from .json_rpc_endpoint import JsonRpcEndpoint +from .lsp_client import LspClient +from .lsp_endpoint import LspEndpoint +from . import lsp_structs \ No newline at end of file diff --git a/plugins/lsp_client/pylspclient/json_rpc_endpoint.py b/plugins/lsp_client/pylspclient/json_rpc_endpoint.py new file mode 100644 index 0000000..30561bb --- /dev/null +++ b/plugins/lsp_client/pylspclient/json_rpc_endpoint.py @@ -0,0 +1,105 @@ +from __future__ import print_function + +import threading +import json + +from . import lsp_structs + + + +JSON_RPC_REQ_FORMAT = "Content-Length: {json_string_len}\r\n\r\n{json_string}" +LEN_HEADER = "Content-Length: " +TYPE_HEADER = "Content-Type: " + + +# TODO: add content-type + + +class MyEncoder(json.JSONEncoder): + """ + Encodes an object in JSON + """ + + def default(self, o): # pylint: disable=E0202 + return o.__dict__ + + +class JsonRpcEndpoint(object): + ''' + Thread safe JSON RPC endpoint implementation. Responsible to recieve and + send JSON RPC messages, as described in the protocol. More information can + be found: https://www.jsonrpc.org/ + ''' + + def __init__(self, stdin, stdout): + self.stdin = stdin + self.stdout = stdout + self.read_lock = threading.Lock() + self.write_lock = threading.Lock() + + @staticmethod + def __add_header(json_string): + ''' + Adds a header for the given json string + + :param str json_string: The string + :return: the string with the header + ''' + return JSON_RPC_REQ_FORMAT.format(json_string_len = len(json_string), json_string = json_string) + + def send_request(self, message): + ''' + Sends the given message. + + :param dict message: The message to send. + ''' + json_string = json.dumps(message, cls = MyEncoder) + jsonrpc_req = self.__add_header(json_string) + with self.write_lock: + self.stdin.write(jsonrpc_req.encode()) + self.stdin.flush() + + def recv_response(self): + ''' + Recives a message. + + :return: a message + ''' + with self.read_lock: + message_size = None + while True: + # read header + line = self.stdout.readline() + if not line: + # server quit + return None + line = line.decode("utf-8") + if not line.endswith("\r\n"): + raise lsp_structs.ResponseError( + lsp_structs.ErrorCodes.ParseError, + "Bad header: missing newline") + # remove the "\r\n" + line = line[:-2] + if line == "": + # done with the headers + break + elif line.startswith(LEN_HEADER): + line = line[len(LEN_HEADER):] + if not line.isdigit(): + raise lsp_structs.ResponseError( + lsp_structs.ErrorCodes.ParseError, + "Bad header: size is not int") + message_size = int(line) + elif line.startswith(TYPE_HEADER): + ... + else: + raise lsp_structs.ResponseError( + lsp_structs.ErrorCodes.ParseError, + "Bad header: unkown header") + if not message_size: + raise lsp_structs.ResponseError( + lsp_structs.ErrorCodes.ParseError, + "Bad header: missing size") + + jsonrpc_res = self.stdout.read(message_size).decode("utf-8") + return json.loads(jsonrpc_res) \ No newline at end of file diff --git a/plugins/lsp_client/pylspclient/lsp_client.py b/plugins/lsp_client/pylspclient/lsp_client.py new file mode 100644 index 0000000..ec42b5e --- /dev/null +++ b/plugins/lsp_client/pylspclient/lsp_client.py @@ -0,0 +1,257 @@ +# Python imports + +# Lib imports + +# Application imports +from . import lsp_structs + + + +class LspClient(object): + def __init__(self, lsp_endpoint): + """ + Constructs a new LspClient instance. + + :param lsp_endpoint: TODO + """ + + self.lsp_endpoint = lsp_endpoint + + def initialize(self, processId, rootPath, rootUri, initializationOptions, capabilities, trace, workspaceFolders): + """ + The initialize request is sent as the first request from the client to the server. If the server receives a request or notification + before the initialize request it should act as follows: + + 1. For a request the response should be an error with code: -32002. The message can be picked by the server. + 2. Notifications should be dropped, except for the exit notification. This will allow the exit of a server without an initialize request. + + Until the server has responded to the initialize request with an InitializeResult, the client must not send any additional requests or + notifications to the server. In addition the server is not allowed to send any requests or notifications to the client until it has responded + with an InitializeResult, with the exception that during the initialize request the server is allowed to send the notifications window/showMessage, + window/logMessage and telemetry/event as well as the window/showMessageRequest request to the client. + + The initialize request may only be sent once. + + :param int processId: The process Id of the parent process that started the server. Is null if the process has not been started by another process. + If the parent process is not alive then the server should exit (see exit notification) its process. + :param str rootPath: The rootPath of the workspace. Is null if no folder is open. Deprecated in favour of rootUri. + :param DocumentUri rootUri: The rootUri of the workspace. Is null if no folder is open. If both `rootPath` and `rootUri` are set + `rootUri` wins. + :param any initializationOptions: User provided initialization options. + :param ClientCapabilities capabilities: The capabilities provided by the client (editor or tool). + :param Trace trace: The initial trace setting. If omitted trace is disabled ('off'). + :param list workspaceFolders: The workspace folders configured in the client when the server starts. This property is only available if the client supports workspace folders. + It can be `null` if the client supports workspace folders but none are configured. + """ + + self.lsp_endpoint.start() + return self.lsp_endpoint.call_method("initialize", + processId = processId, + rootPath = rootPath, + rootUri = rootUri, + initializationOptions = initializationOptions, + capabilities = capabilities, + trace = trace, + workspaceFolders = workspaceFolders + ) + + def initialized(self): + """ + The initialized notification is sent from the client to the server after the client received the result of the initialize request + but before the client is sending any other request or notification to the server. The server can use the initialized notification + for example to dynamically register capabilities. The initialized notification may only be sent once. + """ + + self.lsp_endpoint.send_notification("initialized") + + def shutdown(self): + """ + """ + + return self.lsp_endpoint.call_method("shutdown") + + def exit(self): + """ + """ + + self.lsp_endpoint.send_notification("exit") + self.lsp_endpoint.stop() + + + def didOpen(self, textDocument): + """ + The document open notification is sent from the client to the server to signal newly opened text documents. The document's truth is + now managed by the client and the server must not try to read the document's truth using the document's uri. Open in this sense + means it is managed by the client. It doesn't necessarily mean that its content is presented in an editor. An open notification must + not be sent more than once without a corresponding close notification send before. This means open and close notification must be + balanced and the max open count for a particular textDocument is one. Note that a server's ability to fulfill requests is independent + of whether a text document is open or closed. + + The DidOpenTextDocumentParams contain the language id the document is associated with. If the language Id of a document changes, the + client needs to send a textDocument/didClose to the server followed by a textDocument/didOpen with the new language id if the server + handles the new language id as well. + + :param TextDocumentItem textDocument: The document that was opened. + """ + + return self.lsp_endpoint.send_notification("textDocument/didOpen", textDocument = textDocument) + + def didSave(self, textDocument): + """ + :param TextDocumentIdentifier textDocument: The document that was saved. + """ + + return self.lsp_endpoint.send_notification("textDocument/didSave", textDocument = textDocument) + + def didClose(self, textDocument): + """ + :param TextDocumentIdentifier textDocument: The document that was closed. + """ + return self.lsp_endpoint.send_notification("textDocument/didClose", textDocument = textDocument) + + def didChange(self, textDocument, contentChanges): + """ + The document change notification is sent from the client to the server to signal changes to a text document. + In 2.0 the shape of the params has changed to include proper version numbers and language ids. + + :param TextDocumentItem textDocument: The text document. + :param TextDocumentContentChangeEvent[] contentChanges: The actual content changes. The content changes describe single state changes + to the document. So if there are two content changes c1 and c2 for a document in state S then c1 move the document + to S' and c2 to S''. + """ + + return self.lsp_endpoint.send_notification("textDocument/didChange", textDocument = textDocument, contentChanges = contentChanges) + + def documentSymbol(self, textDocument): + """ + The document symbol request is sent from the client to the server to + return a flat list of all symbols found in a given text document. + Neither the symbol's location range nor the symbol's container name + should be used to infer a hierarchy. + + :param TextDocumentItem textDocument: The text document. + """ + result_dict = self.lsp_endpoint.call_method( "textDocument/documentSymbol", textDocument=textDocument ) + + if not result_dict: return [] + return [lsp_structs.SymbolInformation(**sym) for sym in result_dict] + + def declaration(self, textDocument, position): + """ + The go to declaration request is sent from the client to the server to + resolve the declaration location of a symbol at a given text document + position. + + The result type LocationLink[] got introduce with version 3.14.0 and + depends in the corresponding client capability + `clientCapabilities.textDocument.declaration.linkSupport`. + + :param TextDocumentItem textDocument: The text document. + :param Position position: The position inside the text document. + """ + + result_dict = self.lsp_endpoint.call_method("textDocument/declaration", + textDocument = textDocument, + position = position + ) + + if not result_dict: return [] + + if "uri" in result_dict: + return lsp_structs.Location(**result_dict) + + return [lsp_structs.Location(**loc) if "uri" in loc else lsp_structs.LinkLocation(**loc) for loc in result_dict] + + def definition(self, textDocument, position): + """ + The goto definition request is sent from the client to the server to + resolve the definition location of a symbol at a given text document + position. + + :param TextDocumentItem textDocument: The text document. + :param Position position: The position inside the text document. + """ + + result_dict = self.lsp_endpoint.call_method("textDocument/definition", + textDocument = textDocument, + position = position + ) + + if not result_dict: return [] + return [lsp_structs.Location(**loc) for loc in result_dict] + + def typeDefinition(self, textDocument, position): + """ + The goto type definition request is sent from the client to the server + to resolve the type definition location of a symbol at a given text + document position. + + :param TextDocumentItem textDocument: The text document. + :param Position position: The position inside the text document. + """ + + result_dict = self.lsp_endpoint.call_method("textDocument/typeDefinition", + textDocument = textDocument, + position = position + ) + + if not result_dict: return [] + return [lsp_structs.Location(**loc) for loc in result_dict] + + def signatureHelp(self, textDocument, position): + """ + The signature help request is sent from the client to the server to + request signature information at a given cursor position. + + :param TextDocumentItem textDocument: The text document. + :param Position position: The position inside the text document. + """ + + result_dict = self.lsp_endpoint.call_method( "textDocument/signatureHelp", + textDocument = textDocument, + position = position + ) + + if not result_dict: return [] + return lsp_structs.SignatureHelp(**result_dict) + + def completion(self, textDocument, position, context): + """ + The signature help request is sent from the client to the server to + request signature information at a given cursor position. + + :param TextDocumentItem textDocument: The text document. + :param Position position: The position inside the text document. + :param CompletionContext context: The completion context. This is only + available if the client specifies + to send this using `ClientCapabilities.textDocument.completion.contextSupport === true` + """ + + result_dict = self.lsp_endpoint.call_method("textDocument/completion", + textDocument = textDocument, + position = position, + context = context + ) + if not result_dict: return [] + + if "isIncomplete" in result_dict: + return lsp_structs.CompletionList(**result_dict) + + return [lsp_structs.CompletionItem(**loc) for loc in result_dict] + + def references(self, textDocument, position): + """ + The references request is sent from the client to the server to resolve + project-wide references for the symbol denoted by the given text + document position. + + :param TextDocumentItem textDocument: The text document. + :param Position position: The position inside the text document. + """ + + result_dict = self.lsp_endpoint.call_method("textDocument/references", + textDocument = textDocument, + position = position) + + if not result_dict: return [] + return [lsp_structs.Location(**loc) for loc in result_dict] \ No newline at end of file diff --git a/plugins/lsp_client/pylspclient/lsp_endpoint.py b/plugins/lsp_client/pylspclient/lsp_endpoint.py new file mode 100644 index 0000000..655833e --- /dev/null +++ b/plugins/lsp_client/pylspclient/lsp_endpoint.py @@ -0,0 +1,111 @@ +from __future__ import print_function + +import threading + +from . import lsp_structs + + + +class LspEndpoint(threading.Thread): + def __init__(self, json_rpc_endpoint, method_callbacks = {}, notify_callbacks = {}, timeout = 2): + threading.Thread.__init__(self) + self.json_rpc_endpoint = json_rpc_endpoint + self.notify_callbacks = notify_callbacks + self.method_callbacks = method_callbacks + self.event_dict = {} + self.response_dict = {} + self.next_id = 0 + self._timeout = timeout + self.shutdown_flag = False + + def handle_result(self, rpc_id, result, error): + self.response_dict[rpc_id] = (result, error) + cond = self.event_dict[rpc_id] + cond.acquire() + cond.notify() + cond.release() + + def stop(self): + self.shutdown_flag = True + + def run(self): + while not self.shutdown_flag: + try: + jsonrpc_message = self.json_rpc_endpoint.recv_response() + if jsonrpc_message is None: break + + method = jsonrpc_message.get("method") + result = jsonrpc_message.get("result") + error = jsonrpc_message.get("error") + rpc_id = jsonrpc_message.get("id") + params = jsonrpc_message.get("params") + + if method: + if rpc_id is not None: + if method not in self.method_callbacks: + raise lsp_structs.ResponseError( + lsp_structs.ErrorCodes.MethodNotFound, + "Method not found: {method}" + .format(method=method)) + result = self.method_callbacks[method](params) + self.send_response(rpc_id, result, None) + else: + if method not in self.notify_callbacks: + print("Notify method not found: {method}.".format(method=method)) + else: + self.notify_callbacks[method](params) + else: + self.handle_result(rpc_id, result, error) + except lsp_structs.ResponseError as e: + self.send_response(rpc_id, None, e) + + def send_response(self, id, result, error): + message_dict = {} + message_dict["jsonrpc"] = "2.0" + message_dict["id"] = id + + if result: + message_dict["result"] = result + if error: + message_dict["error"] = error + + self.json_rpc_endpoint.send_request(message_dict) + + def send_message(self, method_name, params, id=None): + message_dict = {} + message_dict["jsonrpc"] = "2.0" + + if id is not None: + message_dict["id"] = id + + message_dict["method"] = method_name + message_dict["params"] = params + + self.json_rpc_endpoint.send_request(message_dict) + + def call_method(self, method_name, **kwargs): + current_id = self.next_id + self.next_id += 1 + cond = threading.Condition() + self.event_dict[current_id] = cond + + cond.acquire() + self.send_message(method_name, kwargs, current_id) + if self.shutdown_flag: + return None + + if not cond.wait(timeout=self._timeout): + raise TimeoutError() + cond.release() + + self.event_dict.pop(current_id) + result, error = self.response_dict.pop(current_id) + if error: + raise lsp_structs.ResponseError(error.get("code"), + error.get("message"), + error.get("data")) + + return result + + def send_notification(self, method_name, **kwargs): + self.send_message(method_name, kwargs) \ No newline at end of file diff --git a/plugins/lsp_client/pylspclient/lsp_structs.py b/plugins/lsp_client/pylspclient/lsp_structs.py new file mode 100644 index 0000000..70804a5 --- /dev/null +++ b/plugins/lsp_client/pylspclient/lsp_structs.py @@ -0,0 +1,566 @@ +import enum + + + +def to_type(o, new_type): + ''' + Helper funciton that receives an object or a dict and convert it to a new + given type. + + :param object|dict o: The object to convert + :param Type new_type: The type to convert to. + ''' + + return o if new_type == type(o) else new_type(**o) + + +class Position(object): + def __init__(self, line, character): + """ + Constructs a new Position instance. + + :param int line: Line position in a document (zero-based). + :param int character: Character offset on a line in a document + (zero-based). + """ + self.line = line + self.character = character + + +class Range(object): + def __init__(self, start, end): + """ + Constructs a new Range instance. + + :param Position start: The range's start position. + :param Position end: The range's end position. + """ + self.start = to_type(start, Position) + self.end = to_type(end, Position) + + +class Location(object): + """ + Represents a location inside a resource, such as a line inside a text file. + """ + + def __init__(self, uri, range): + """ + Constructs a new Location instance. + + :param str uri: Resource file. + :param Range range: The range inside the file + """ + self.uri = uri + self.range = to_type(range, Range) + + +class LocationLink(object): + """ + Represents a link between a source and a target location. + """ + + def __init__(self, originSelectionRange, targetUri, targetRange, targetSelectionRange): + """ + Constructs a new LocationLink instance. + + :param Range originSelectionRange: Span of the origin of this link. + Used as the underlined span for mouse interaction. Defaults to the word range at the mouse position. + :param str targetUri: The target resource identifier of this link. + :param Range targetRange: The full target range of this link. If the target for example is a symbol then target + range is the range enclosing this symbol not including leading/trailing whitespace but everything else + like comments. This information is typically used to highlight the range in the editor. + :param Range targetSelectionRange: The range that should be selected and revealed when this link is being followed, + e.g the name of a function. Must be contained by the the `targetRange`. See also `DocumentSymbol#range` + """ + self.originSelectionRange = to_type(originSelectionRange, Range) + self.targetUri = targetUri + self.targetRange = to_type(targetRange, Range) + self.targetSelectionRange = to_type(targetSelectionRange, Range) + + +class Diagnostic(object): + def __init__(self, range, severity, code, source, message, relatedInformation): + """ + Constructs a new Diagnostic instance. + :param Range range: The range at which the message applies.Resource file. + :param int severity: The diagnostic's severity. Can be omitted. If omitted it is up to the + client to interpret diagnostics as error, warning, info or hint. + :param str code: The diagnostic's code, which might appear in the user interface. + :param str source: A human-readable string describing the source of this + diagnostic, e.g. 'typescript' or 'super lint'. + :param str message: The diagnostic's message. + :param list relatedInformation: An array of related diagnostic information, e.g. when symbol-names within + a scope collide all definitions can be marked via this property. + """ + self.range = range + self.severity = severity + self.code = code + self.source = source + self.message = message + self.relatedInformation = relatedInformation + + +class DiagnosticSeverity(object): + Error = 1 + Warning = 2 # TODO: warning is known in python + Information = 3 + Hint = 4 + + +class DiagnosticRelatedInformation(object): + def __init__(self, location, message): + """ + Constructs a new Diagnostic instance. + :param Location location: The location of this related diagnostic information. + :param str message: The message of this related diagnostic information. + """ + self.location = location + self.message = message + + +class Command(object): + def __init__(self, title, command, arguments): + """ + Constructs a new Diagnostic instance. + :param str title: Title of the command, like `save`. + :param str command: The identifier of the actual command handler. + :param list argusments: Arguments that the command handler should be invoked with. + """ + self.title = title + self.command = command + self.arguments = arguments + + +class TextDocumentItem(object): + """ + An item to transfer a text document from the client to the server. + """ + def __init__(self, uri, languageId, version, text): + """ + Constructs a new Diagnostic instance. + + :param DocumentUri uri: Title of the command, like `save`. + :param str languageId: The identifier of the actual command handler. + :param int version: Arguments that the command handler should be invoked with. + :param str text: Arguments that the command handler should be invoked with. + """ + self.uri = uri + self.languageId = languageId + self.version = version + self.text = text + + +class TextDocumentIdentifier(object): + """ + Text documents are identified using a URI. On the protocol level, URIs are passed as strings. + """ + def __init__(self, uri): + """ + Constructs a new TextDocumentIdentifier instance. + + :param DocumentUri uri: The text document's URI. + """ + self.uri = uri + + +class VersionedTextDocumentIdentifier(TextDocumentIdentifier): + """ + An identifier to denote a specific version of a text document. + """ + def __init__(self, uri, version): + """ + Constructs a new TextDocumentIdentifier instance. + + :param DocumentUri uri: The text document's URI. + :param int version: The version number of this document. If a versioned + text document identifier is sent from the server to the client and + the file is not open in the editor (the server has not received an + open notification before) the server can send `null` to indicate + that the version is known and the content on disk is the truth (as + speced with document content ownership). + The version number of a document will increase after each change, including + undo/redo. The number doesn't need to be consecutive. + """ + super(VersionedTextDocumentIdentifier, self).__init__(uri) + self.version = version + + +class TextDocumentContentChangeEvent(object): + """ + An event describing a change to a text document. If range and rangeLength are omitted + the new text is considered to be the full content of the document. + """ + def __init__(self, range, rangeLength, text): + """ + Constructs a new TextDocumentContentChangeEvent instance. + + :param Range range: The range of the document that changed. + :param int rangeLength: The length of the range that got replaced. + :param str text: The new text of the range/document. + """ + self.range = range + self.rangeLength = rangeLength + self.text = text + + +class TextDocumentPositionParams(object): + """ + A parameter literal used in requests to pass a text document and a position inside that document. + """ + def __init__(self, textDocument, position): + """ + Constructs a new TextDocumentPositionParams instance. + + :param TextDocumentIdentifier textDocument: The text document. + :param Position position: The position inside the text document. + """ + self.textDocument = textDocument + self.position = position + + +class LANGUAGE_IDENTIFIER(object): + BAT = "bat" + BIBTEX = "bibtex" + CLOJURE = "clojure" + COFFESCRIPT = "coffeescript" + C = "c" + CPP = "cpp" + CSHARP = "csharp" + CSS = "css" + DIFF = "diff" + DOCKERFILE = "dockerfile" + FSHARP = "fsharp" + GIT_COMMIT = "git-commit" + GIT_REBASE = "git-rebase" + GO = "go" + GROOVY = "groovy" + HANDLEBARS = "handlebars" + HTML = "html" + INI = "ini" + JAVA = "java" + JAVASCRIPT = "javascript" + JSON = "json" + LATEX = "latex" + LESS = "less" + LUA = "lua" + MAKEFILE = "makefile" + MARKDOWN = "markdown" + OBJECTIVE_C = "objective-c" + OBJECTIVE_CPP = "objective-cpp" + Perl = "perl" + PHP = "php" + POWERSHELL = "powershell" + PUG = "jade" + PYTHON = "python" + R = "r" + RAZOR = "razor" + RUBY = "ruby" + RUST = "rust" + SASS = "sass" + SCSS = "scss" + ShaderLab = "shaderlab" + SHELL_SCRIPT = "shellscript" + SQL = "sql" + SWIFT = "swift" + TYPE_SCRIPT = "typescript" + TEX = "tex" + VB = "vb" + XML = "xml" + XSL = "xsl" + YAML = "yaml" + + +class SymbolKind(enum.Enum): + File = 1 + Module = 2 + Namespace = 3 + Package = 4 + Class = 5 + Method = 6 + Property = 7 + Field = 8 + Constructor = 9 + Enum = 10 + Interface = 11 + Function = 12 + Variable = 13 + Constant = 14 + String = 15 + Number = 16 + Boolean = 17 + Array = 18 + Object = 19 + Key = 20 + Null = 21 + EnumMember = 22 + Struct = 23 + Event = 24 + Operator = 25 + TypeParameter = 26 + + +class SymbolInformation(object): + """ + Represents information about programming constructs like variables, classes, interfaces etc. + """ + def __init__(self, name, kind, location, containerName = None, deprecated = False): + """ + Constructs a new SymbolInformation instance. + + :param str name: The name of this symbol. + :param int kind: The kind of this symbol. + :param bool Location: The location of this symbol. The location's range is used by a tool + to reveal the location in the editor. If the symbol is selected in the + tool the range's start information is used to position the cursor. So + the range usually spans more then the actual symbol's name and does + normally include things like visibility modifiers. + + The range doesn't have to denote a node range in the sense of a abstract + syntax tree. It can therefore not be used to re-construct a hierarchy of + the symbols. + :param str containerName: The name of the symbol containing this symbol. This information is for + user interface purposes (e.g. to render a qualifier in the user interface + if necessary). It can't be used to re-infer a hierarchy for the document + symbols. + :param bool deprecated: Indicates if this symbol is deprecated. + """ + self.name = name + self.kind = SymbolKind(kind) + self.deprecated = deprecated + self.location = to_type(location, Location) + self.containerName = containerName + + +class ParameterInformation(object): + """ + Represents a parameter of a callable-signature. A parameter can + have a label and a doc-comment. + """ + def __init__(self, label, documentation = ""): + """ + Constructs a new ParameterInformation instance. + + :param str label: The label of this parameter. Will be shown in the UI. + :param str documentation: The human-readable doc-comment of this parameter. Will be shown in the UI but can be omitted. + """ + self.label = label + self.documentation = documentation + + +class SignatureInformation(object): + """ + Represents the signature of something callable. A signature + can have a label, like a function-name, a doc-comment, and + a set of parameters. + """ + def __init__(self, label, documentation = "", parameters = []): + """ + Constructs a new SignatureInformation instance. + + :param str label: The label of this signature. Will be shown in the UI. + :param str documentation: The human-readable doc-comment of this signature. Will be shown in the UI but can be omitted. + :param ParameterInformation[] parameters: The parameters of this signature. + """ + self.label = label + self.documentation = documentation + self.parameters = [to_type(parameter, ParameterInformation) for parameter in parameters] + + +class SignatureHelp(object): + """ + Signature help represents the signature of something + callable. There can be multiple signature but only one + active and only one active parameter. + """ + def __init__(self, signatures, activeSignature = 0, activeParameter = 0): + """ + Constructs a new SignatureHelp instance. + + :param SignatureInformation[] signatures: One or more signatures. + :param int activeSignature: + :param int activeParameter: + """ + self.signatures = [to_type(signature, SignatureInformation) for signature in signatures] + self.activeSignature = activeSignature + self.activeParameter = activeParameter + + +class CompletionTriggerKind(object): + Invoked = 1 + TriggerCharacter = 2 + TriggerForIncompleteCompletions = 3 + + +class CompletionContext(object): + """ + Contains additional information about the context in which a completion request is triggered. + """ + def __init__(self, triggerKind, triggerCharacter = None): + """ + Constructs a new CompletionContext instance. + + :param CompletionTriggerKind triggerKind: How the completion was triggered. + :param str triggerCharacter: The trigger character (a single character) that has trigger code complete. + Is undefined if `triggerKind !== CompletionTriggerKind.TriggerCharacter` + """ + self.triggerKind = triggerKind + if triggerCharacter: + self.triggerCharacter = triggerCharacter + + +class TextEdit(object): + """ + A textual edit applicable to a text document. + """ + def __init__(self, range, newText): + """ + :param Range range: The range of the text document to be manipulated. To insert + text into a document create a range where start === end. + :param str newText: The string to be inserted. For delete operations use an empty string. + """ + self.range = range + self.newText = newText + + +class InsertTextFormat(object): + PlainText = 1 + Snippet = 2 + + +class CompletionItem(object): + """ + """ + def __init__(self, label, \ + kind = None, \ + detail = None, \ + documentation = None, \ + deprecated = None, \ + preselect = None, \ + sortText = None, \ + filterText = None, \ + insertText = None, \ + insertTextFormat = None, \ + textEdit = None, \ + additionalTextEdits = None, \ + commitCharacters = None, \ + command = None, \ + data = None, \ + score = 0.0 + ): + """ + :param str label: The label of this completion item. By default also the text that is inserted when selecting + this completion. + :param int kind: The kind of this completion item. Based of the kind an icon is chosen by the editor. + :param str detail: A human-readable string with additional information about this item, like type or symbol information. + :param tr ocumentation: A human-readable string that represents a doc-comment. + :param bool deprecated: Indicates if this item is deprecated. + :param bool preselect: Select this item when showing. Note: that only one completion item can be selected and that the + tool / client decides which item that is. The rule is that the first item of those that match best is selected. + :param str sortText: A string that should be used when comparing this item with other items. When `falsy` the label is used. + :param str filterText: A string that should be used when filtering a set of completion items. When `falsy` the label is used. + :param str insertText: A string that should be inserted into a document when selecting this completion. When `falsy` the label is used. + The `insertText` is subject to interpretation by the client side. Some tools might not take the string literally. For example + VS Code when code complete is requested in this example `con` and a completion item with an `insertText` of `console` is provided it + will only insert `sole`. Therefore it is recommended to use `textEdit` instead since it avoids additional client side interpretation. + @deprecated Use textEdit instead. + :param InsertTextFormat insertTextFormat: The format of the insert text. The format applies to both the `insertText` property + and the `newText` property of a provided `textEdit`. + :param TextEdit textEdit: An edit which is applied to a document when selecting this completion. When an edit is provided the value of `insertText` is ignored. + Note:* The range of the edit must be a single line range and it must contain the position at which completion + has been requested. + :param TextEdit additionalTextEdits: An optional array of additional text edits that are applied when selecting this completion. + Edits must not overlap (including the same insert position) with the main edit nor with themselves. + Additional text edits should be used to change text unrelated to the current cursor position + (for example adding an import statement at the top of the file if the completion item will + insert an unqualified type). + :param str commitCharacters: An optional set of characters that when pressed while this completion is active will accept it first and + then type that character. *Note* that all commit characters should have `length=1` and that superfluous + characters will be ignored. + :param Command command: An optional command that is executed *after* inserting this completion. Note: that + additional modifications to the current document should be described with the additionalTextEdits-property. + :param data: An data entry field that is preserved on a completion item between a completion and a completion resolve request. + :param float score: Score of the code completion item. + """ + self.label = label + self.kind = kind + self.detail = detail + self.documentation = documentation + self.deprecated = deprecated + self.preselect = preselect + self.sortText = sortText + self.filterText = filterText + self.insertText = insertText + self.insertTextFormat = insertTextFormat + self.textEdit = textEdit + self.additionalTextEdits = additionalTextEdits + self.commitCharacters = commitCharacters + self.command = command + self.data = data + self.score = score + + +class CompletionItemKind(enum.Enum): + Text = 1 + Method = 2 + Function = 3 + Constructor = 4 + Field = 5 + Variable = 6 + Class = 7 + Interface = 8 + Module = 9 + Property = 10 + Unit = 11 + Value = 12 + Enum = 13 + Keyword = 14 + Snippet = 15 + Color = 16 + File = 17 + Reference = 18 + Folder = 19 + EnumMember = 20 + Constant = 21 + Struct = 22 + Event = 23 + Operator = 24 + TypeParameter = 25 + + +class CompletionList(object): + """ + Represents a collection of [completion items](#CompletionItem) to be preselect in the editor. + """ + def __init__(self, isIncomplete, items): + """ + Constructs a new CompletionContext instance. + + :param bool isIncomplete: This list it not complete. Further typing should result in recomputing this list. + :param CompletionItem items: The completion items. + """ + self.isIncomplete = isIncomplete + self.items = [to_type(i, CompletionItem) for i in items] + +class ErrorCodes(enum.Enum): + # Defined by JSON RPC + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 + InvalidParams = -32602 + InternalError = -32603 + serverErrorStart = -32099 + serverErrorEnd = -32000 + ServerNotInitialized = -32002 + UnknownErrorCode = -32001 + + # Defined by the protocol. + RequestCancelled = -32800 + ContentModified = -32801 + +class ResponseError(Exception): + def __init__(self, code, message, data = None): + self.code = code + self.message = message + if data: + self.data = data \ No newline at end of file diff --git a/plugins/markdown_preview/__init__.py b/plugins/markdown_preview/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/markdown_preview/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/markdown_preview/__main__.py b/plugins/markdown_preview/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/markdown_preview/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/markdown_preview/manifest.json b/plugins/markdown_preview/manifest.json new file mode 100644 index 0000000..d8be8ec --- /dev/null +++ b/plugins/markdown_preview/manifest.json @@ -0,0 +1,13 @@ +{ + "manifest": { + "name": "Markdown Preview", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": { + "pass_events": "true", + "pass_ui_objects": ["separator_right"], + "bind_keys": ["Markdown Preview||tggle_markdown_preview:m"] + } + } +} \ No newline at end of file diff --git a/plugins/markdown_preview/markdown/__init__.py b/plugins/markdown_preview/markdown/__init__.py new file mode 100644 index 0000000..9674d6e --- /dev/null +++ b/plugins/markdown_preview/markdown/__init__.py @@ -0,0 +1,48 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# - Documentation: https://python-markdown.github.io/ +# - GitHub: https://github.com/Python-Markdown/markdown/ +# - PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# - Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# - Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# - Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +Python-Markdown provides two public functions ([`markdown.markdown`][] and [`markdown.markdownFromFile`][]) +both of which wrap the public class [`markdown.Markdown`][]. All submodules support these public functions +and class and/or provide extension support. + +Modules: + core: Core functionality. + preprocessors: Pre-processors. + blockparser: Core Markdown block parser. + blockprocessors: Block processors. + treeprocessors: Tree processors. + inlinepatterns: Inline patterns. + postprocessors: Post-processors. + serializers: Serializers. + util: Utility functions. + htmlparser: HTML parser. + test_tools: Testing utilities. + extensions: Markdown extensions. +""" + +from __future__ import annotations + +from .core import Markdown, markdown, markdownFromFile +from .__meta__ import __version__, __version_info__ # noqa + +# For backward compatibility as some extensions expect it... +from .extensions import Extension # noqa + +__all__ = ['Markdown', 'markdown', 'markdownFromFile'] diff --git a/plugins/markdown_preview/markdown/__main__.py b/plugins/markdown_preview/markdown/__main__.py new file mode 100644 index 0000000..c323aaa --- /dev/null +++ b/plugins/markdown_preview/markdown/__main__.py @@ -0,0 +1,151 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +from __future__ import annotations + +import sys +import optparse +import codecs +import warnings +import markdown +try: + # We use `unsafe_load` because users may need to pass in actual Python + # objects. As this is only available from the CLI, the user has much + # worse problems if an attacker can use this as an attach vector. + from yaml import unsafe_load as yaml_load +except ImportError: # pragma: no cover + try: + # Fall back to PyYAML <5.1 + from yaml import load as yaml_load + except ImportError: + # Fall back to JSON + from json import load as yaml_load + +import logging +from logging import DEBUG, WARNING, CRITICAL + +logger = logging.getLogger('MARKDOWN') + + +def parse_options(args=None, values=None): + """ + Define and parse `optparse` options for command-line usage. + """ + usage = """%prog [options] [INPUTFILE] + (STDIN is assumed if no INPUTFILE is given)""" + desc = "A Python implementation of John Gruber's Markdown. " \ + "https://Python-Markdown.github.io/" + ver = "%%prog %s" % markdown.__version__ + + parser = optparse.OptionParser(usage=usage, description=desc, version=ver) + parser.add_option("-f", "--file", dest="filename", default=None, + help="Write output to OUTPUT_FILE. Defaults to STDOUT.", + metavar="OUTPUT_FILE") + parser.add_option("-e", "--encoding", dest="encoding", + help="Encoding for input and output files.",) + parser.add_option("-o", "--output_format", dest="output_format", + default='xhtml', metavar="OUTPUT_FORMAT", + help="Use output format 'xhtml' (default) or 'html'.") + parser.add_option("-n", "--no_lazy_ol", dest="lazy_ol", + action='store_false', default=True, + help="Observe number of first item of ordered lists.") + parser.add_option("-x", "--extension", action="append", dest="extensions", + help="Load extension EXTENSION.", metavar="EXTENSION") + parser.add_option("-c", "--extension_configs", + dest="configfile", default=None, + help="Read extension configurations from CONFIG_FILE. " + "CONFIG_FILE must be of JSON or YAML format. YAML " + "format requires that a python YAML library be " + "installed. The parsed JSON or YAML must result in a " + "python dictionary which would be accepted by the " + "'extension_configs' keyword on the markdown.Markdown " + "class. The extensions must also be loaded with the " + "`--extension` option.", + metavar="CONFIG_FILE") + parser.add_option("-q", "--quiet", default=CRITICAL, + action="store_const", const=CRITICAL+10, dest="verbose", + help="Suppress all warnings.") + parser.add_option("-v", "--verbose", + action="store_const", const=WARNING, dest="verbose", + help="Print all warnings.") + parser.add_option("--noisy", + action="store_const", const=DEBUG, dest="verbose", + help="Print debug messages.") + + (options, args) = parser.parse_args(args, values) + + if len(args) == 0: + input_file = None + else: + input_file = args[0] + + if not options.extensions: + options.extensions = [] + + extension_configs = {} + if options.configfile: + with codecs.open( + options.configfile, mode="r", encoding=options.encoding + ) as fp: + try: + extension_configs = yaml_load(fp) + except Exception as e: + message = "Failed parsing extension config file: %s" % \ + options.configfile + e.args = (message,) + e.args[1:] + raise + + opts = { + 'input': input_file, + 'output': options.filename, + 'extensions': options.extensions, + 'extension_configs': extension_configs, + 'encoding': options.encoding, + 'output_format': options.output_format, + 'lazy_ol': options.lazy_ol + } + + return opts, options.verbose + + +def run(): # pragma: no cover + """Run Markdown from the command line.""" + + # Parse options and adjust logging level if necessary + options, logging_level = parse_options() + if not options: + sys.exit(2) + logger.setLevel(logging_level) + console_handler = logging.StreamHandler() + logger.addHandler(console_handler) + if logging_level <= WARNING: + # Ensure deprecation warnings get displayed + warnings.filterwarnings('default') + logging.captureWarnings(True) + warn_logger = logging.getLogger('py.warnings') + warn_logger.addHandler(console_handler) + + # Run + markdown.markdownFromFile(**options) + + +if __name__ == '__main__': # pragma: no cover + # Support running module as a command line command. + # python -m markdown [options] [args] + run() diff --git a/plugins/markdown_preview/markdown/__meta__.py b/plugins/markdown_preview/markdown/__meta__.py new file mode 100644 index 0000000..21fceac --- /dev/null +++ b/plugins/markdown_preview/markdown/__meta__.py @@ -0,0 +1,51 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +# __version_info__ format: +# (major, minor, patch, dev/alpha/beta/rc/final, #) +# (1, 1, 2, 'dev', 0) => "1.1.2.dev0" +# (1, 1, 2, 'alpha', 1) => "1.1.2a1" +# (1, 2, 0, 'beta', 2) => "1.2b2" +# (1, 2, 0, 'rc', 4) => "1.2rc4" +# (1, 2, 0, 'final', 0) => "1.2" + +from __future__ import annotations + + +__version_info__ = (3, 5, 1, 'final', 0) + + +def _get_version(version_info): + " Returns a PEP 440-compliant version number from `version_info`. " + assert len(version_info) == 5 + assert version_info[3] in ('dev', 'alpha', 'beta', 'rc', 'final') + + parts = 2 if version_info[2] == 0 else 3 + v = '.'.join(map(str, version_info[:parts])) + + if version_info[3] == 'dev': + v += '.dev' + str(version_info[4]) + elif version_info[3] != 'final': + mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'rc'} + v += mapping[version_info[3]] + str(version_info[4]) + + return v + + +__version__ = _get_version(__version_info__) diff --git a/plugins/markdown_preview/markdown/blockparser.py b/plugins/markdown_preview/markdown/blockparser.py new file mode 100644 index 0000000..549c9ec --- /dev/null +++ b/plugins/markdown_preview/markdown/blockparser.py @@ -0,0 +1,160 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +The block parser handles basic parsing of Markdown blocks. It doesn't concern +itself with inline elements such as `**bold**` or `*italics*`, but rather just +catches blocks, lists, quotes, etc. + +The `BlockParser` is made up of a bunch of `BlockProcessors`, each handling a +different type of block. Extensions may add/replace/remove `BlockProcessors` +as they need to alter how Markdown blocks are parsed. +""" + +from __future__ import annotations + +import xml.etree.ElementTree as etree +from typing import TYPE_CHECKING, Iterable, Any +from . import util + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + from .blockprocessors import BlockProcessor + + +class State(list): + """ Track the current and nested state of the parser. + + This utility class is used to track the state of the `BlockParser` and + support multiple levels if nesting. It's just a simple API wrapped around + a list. Each time a state is set, that state is appended to the end of the + list. Each time a state is reset, that state is removed from the end of + the list. + + Therefore, each time a state is set for a nested block, that state must be + reset when we back out of that level of nesting or the state could be + corrupted. + + While all the methods of a list object are available, only the three + defined below need be used. + + """ + + def set(self, state: Any): + """ Set a new state. """ + self.append(state) + + def reset(self) -> None: + """ Step back one step in nested state. """ + self.pop() + + def isstate(self, state: Any) -> bool: + """ Test that top (current) level is of given state. """ + if len(self): + return self[-1] == state + else: + return False + + +class BlockParser: + """ Parse Markdown blocks into an `ElementTree` object. + + A wrapper class that stitches the various `BlockProcessors` together, + looping through them and creating an `ElementTree` object. + + """ + + def __init__(self, md: Markdown): + """ Initialize the block parser. + + Arguments: + md: A Markdown instance. + + Attributes: + BlockParser.md (Markdown): A Markdown instance. + BlockParser.state (State): Tracks the nesting level of current location in document being parsed. + BlockParser.blockprocessors (util.Registry): A collection of + [`blockprocessors`][markdown.blockprocessors]. + + """ + self.blockprocessors: util.Registry[BlockProcessor] = util.Registry() + self.state = State() + self.md = md + + def parseDocument(self, lines: Iterable[str]) -> etree.ElementTree: + """ Parse a Markdown document into an `ElementTree`. + + Given a list of lines, an `ElementTree` object (not just a parent + `Element`) is created and the root element is passed to the parser + as the parent. The `ElementTree` object is returned. + + This should only be called on an entire document, not pieces. + + Arguments: + lines: A list of lines (strings). + + Returns: + An element tree. + """ + # Create an `ElementTree` from the lines + self.root = etree.Element(self.md.doc_tag) + self.parseChunk(self.root, '\n'.join(lines)) + return etree.ElementTree(self.root) + + def parseChunk(self, parent: etree.Element, text: str) -> None: + """ Parse a chunk of Markdown text and attach to given `etree` node. + + While the `text` argument is generally assumed to contain multiple + blocks which will be split on blank lines, it could contain only one + block. Generally, this method would be called by extensions when + block parsing is required. + + The `parent` `etree` Element passed in is altered in place. + Nothing is returned. + + Arguments: + parent: The parent element. + text: The text to parse. + + """ + self.parseBlocks(parent, text.split('\n\n')) + + def parseBlocks(self, parent: etree.Element, blocks: list[str]) -> None: + """ Process blocks of Markdown text and attach to given `etree` node. + + Given a list of `blocks`, each `blockprocessor` is stepped through + until there are no blocks left. While an extension could potentially + call this method directly, it's generally expected to be used + internally. + + This is a public method as an extension may need to add/alter + additional `BlockProcessors` which call this method to recursively + parse a nested block. + + Arguments: + parent: The parent element. + blocks: The blocks of text to parse. + + """ + while blocks: + for processor in self.blockprocessors: + if processor.test(parent, blocks[0]): + if processor.run(parent, blocks) is not False: + # run returns True or None + break diff --git a/plugins/markdown_preview/markdown/blockprocessors.py b/plugins/markdown_preview/markdown/blockprocessors.py new file mode 100644 index 0000000..d808468 --- /dev/null +++ b/plugins/markdown_preview/markdown/blockprocessors.py @@ -0,0 +1,636 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +A block processor parses blocks of text and adds new elements to the ElementTree. Blocks of text, +separated from other text by blank lines, may have a different syntax and produce a differently +structured tree than other Markdown. Block processors excel at handling code formatting, equation +layouts, tables, etc. +""" + +from __future__ import annotations + +import logging +import re +import xml.etree.ElementTree as etree +from typing import TYPE_CHECKING, Any +from . import util +from .blockparser import BlockParser + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + +logger = logging.getLogger('MARKDOWN') + + +def build_block_parser(md: Markdown, **kwargs: Any) -> BlockParser: + """ Build the default block parser used by Markdown. """ + parser = BlockParser(md) + parser.blockprocessors.register(EmptyBlockProcessor(parser), 'empty', 100) + parser.blockprocessors.register(ListIndentProcessor(parser), 'indent', 90) + parser.blockprocessors.register(CodeBlockProcessor(parser), 'code', 80) + parser.blockprocessors.register(HashHeaderProcessor(parser), 'hashheader', 70) + parser.blockprocessors.register(SetextHeaderProcessor(parser), 'setextheader', 60) + parser.blockprocessors.register(HRProcessor(parser), 'hr', 50) + parser.blockprocessors.register(OListProcessor(parser), 'olist', 40) + parser.blockprocessors.register(UListProcessor(parser), 'ulist', 30) + parser.blockprocessors.register(BlockQuoteProcessor(parser), 'quote', 20) + parser.blockprocessors.register(ReferenceProcessor(parser), 'reference', 15) + parser.blockprocessors.register(ParagraphProcessor(parser), 'paragraph', 10) + return parser + + +class BlockProcessor: + """ Base class for block processors. + + Each subclass will provide the methods below to work with the source and + tree. Each processor will need to define it's own `test` and `run` + methods. The `test` method should return True or False, to indicate + whether the current block should be processed by this processor. If the + test passes, the parser will call the processors `run` method. + + Attributes: + BlockProcessor.parser (BlockParser): The `BlockParser` instance this is attached to. + BlockProcessor.tab_length (int): The tab length set on the `Markdown` instance. + + """ + + def __init__(self, parser: BlockParser): + self.parser = parser + self.tab_length = parser.md.tab_length + + def lastChild(self, parent: etree.Element) -> etree.Element | None: + """ Return the last child of an `etree` element. """ + if len(parent): + return parent[-1] + else: + return None + + def detab(self, text: str, length: int | None = None) -> tuple[str, str]: + """ Remove a tab from the front of each line of the given text. """ + if length is None: + length = self.tab_length + newtext = [] + lines = text.split('\n') + for line in lines: + if line.startswith(' ' * length): + newtext.append(line[length:]) + elif not line.strip(): + newtext.append('') + else: + break + return '\n'.join(newtext), '\n'.join(lines[len(newtext):]) + + def looseDetab(self, text: str, level: int = 1) -> str: + """ Remove a tab from front of lines but allowing dedented lines. """ + lines = text.split('\n') + for i in range(len(lines)): + if lines[i].startswith(' '*self.tab_length*level): + lines[i] = lines[i][self.tab_length*level:] + return '\n'.join(lines) + + def test(self, parent: etree.Element, block: str) -> bool: + """ Test for block type. Must be overridden by subclasses. + + As the parser loops through processors, it will call the `test` + method on each to determine if the given block of text is of that + type. This method must return a boolean `True` or `False`. The + actual method of testing is left to the needs of that particular + block type. It could be as simple as `block.startswith(some_string)` + or a complex regular expression. As the block type may be different + depending on the parent of the block (i.e. inside a list), the parent + `etree` element is also provided and may be used as part of the test. + + Keyword arguments: + parent: An `etree` element which will be the parent of the block. + block: A block of text from the source which has been split at blank lines. + """ + pass # pragma: no cover + + def run(self, parent: etree.Element, blocks: list[str]) -> bool | None: + """ Run processor. Must be overridden by subclasses. + + When the parser determines the appropriate type of a block, the parser + will call the corresponding processor's `run` method. This method + should parse the individual lines of the block and append them to + the `etree`. + + Note that both the `parent` and `etree` keywords are pointers + to instances of the objects which should be edited in place. Each + processor must make changes to the existing objects as there is no + mechanism to return new/different objects to replace them. + + This means that this method should be adding `SubElements` or adding text + to the parent, and should remove (`pop`) or add (`insert`) items to + the list of blocks. + + If `False` is returned, this will have the same effect as returning `False` + from the `test` method. + + Keyword arguments: + parent: An `etree` element which is the parent of the current block. + blocks: A list of all remaining blocks of the document. + """ + pass # pragma: no cover + + +class ListIndentProcessor(BlockProcessor): + """ Process children of list items. + + Example + + * a list item + process this part + + or this part + + """ + + ITEM_TYPES = ['li'] + """ List of tags used for list items. """ + LIST_TYPES = ['ul', 'ol'] + """ Types of lists this processor can operate on. """ + + def __init__(self, *args): + super().__init__(*args) + self.INDENT_RE = re.compile(r'^(([ ]{%s})+)' % self.tab_length) + + def test(self, parent, block): + return block.startswith(' '*self.tab_length) and \ + not self.parser.state.isstate('detabbed') and \ + (parent.tag in self.ITEM_TYPES or + (len(parent) and parent[-1] is not None and + (parent[-1].tag in self.LIST_TYPES))) + + def run(self, parent, blocks): + block = blocks.pop(0) + level, sibling = self.get_level(parent, block) + block = self.looseDetab(block, level) + + self.parser.state.set('detabbed') + if parent.tag in self.ITEM_TYPES: + # It's possible that this parent has a `ul` or `ol` child list + # with a member. If that is the case, then that should be the + # parent. This is intended to catch the edge case of an indented + # list whose first member was parsed previous to this point + # see `OListProcessor` + if len(parent) and parent[-1].tag in self.LIST_TYPES: + self.parser.parseBlocks(parent[-1], [block]) + else: + # The parent is already a `li`. Just parse the child block. + self.parser.parseBlocks(parent, [block]) + elif sibling.tag in self.ITEM_TYPES: + # The sibling is a `li`. Use it as parent. + self.parser.parseBlocks(sibling, [block]) + elif len(sibling) and sibling[-1].tag in self.ITEM_TYPES: + # The parent is a list (`ol` or `ul`) which has children. + # Assume the last child `li` is the parent of this block. + if sibling[-1].text: + # If the parent `li` has text, that text needs to be moved to a `p` + # The `p` must be 'inserted' at beginning of list in the event + # that other children already exist i.e.; a nested sub-list. + p = etree.Element('p') + p.text = sibling[-1].text + sibling[-1].text = '' + sibling[-1].insert(0, p) + self.parser.parseChunk(sibling[-1], block) + else: + self.create_item(sibling, block) + self.parser.state.reset() + + def create_item(self, parent: etree.Element, block: str) -> None: + """ Create a new `li` and parse the block with it as the parent. """ + li = etree.SubElement(parent, 'li') + self.parser.parseBlocks(li, [block]) + + def get_level(self, parent: etree.Element, block: str) -> tuple[int, etree.Element]: + """ Get level of indentation based on list level. """ + # Get indent level + m = self.INDENT_RE.match(block) + if m: + indent_level = len(m.group(1))/self.tab_length + else: + indent_level = 0 + if self.parser.state.isstate('list'): + # We're in a tight-list - so we already are at correct parent. + level = 1 + else: + # We're in a loose-list - so we need to find parent. + level = 0 + # Step through children of tree to find matching indent level. + while indent_level > level: + child = self.lastChild(parent) + if (child is not None and + (child.tag in self.LIST_TYPES or child.tag in self.ITEM_TYPES)): + if child.tag in self.LIST_TYPES: + level += 1 + parent = child + else: + # No more child levels. If we're short of `indent_level`, + # we have a code block. So we stop here. + break + return level, parent + + +class CodeBlockProcessor(BlockProcessor): + """ Process code blocks. """ + + def test(self, parent, block): + return block.startswith(' '*self.tab_length) + + def run(self, parent, blocks): + sibling = self.lastChild(parent) + block = blocks.pop(0) + theRest = '' + if (sibling is not None and sibling.tag == "pre" and + len(sibling) and sibling[0].tag == "code"): + # The previous block was a code block. As blank lines do not start + # new code blocks, append this block to the previous, adding back + # line breaks removed from the split into a list. + code = sibling[0] + block, theRest = self.detab(block) + code.text = util.AtomicString( + '{}\n{}\n'.format(code.text, util.code_escape(block.rstrip())) + ) + else: + # This is a new code block. Create the elements and insert text. + pre = etree.SubElement(parent, 'pre') + code = etree.SubElement(pre, 'code') + block, theRest = self.detab(block) + code.text = util.AtomicString('%s\n' % util.code_escape(block.rstrip())) + if theRest: + # This block contained unindented line(s) after the first indented + # line. Insert these lines as the first block of the master blocks + # list for future processing. + blocks.insert(0, theRest) + + +class BlockQuoteProcessor(BlockProcessor): + """ Process blockquotes. """ + + RE = re.compile(r'(^|\n)[ ]{0,3}>[ ]?(.*)') + + def test(self, parent, block): + return bool(self.RE.search(block)) and not util.nearing_recursion_limit() + + def run(self, parent, blocks): + block = blocks.pop(0) + m = self.RE.search(block) + if m: + before = block[:m.start()] # Lines before blockquote + # Pass lines before blockquote in recursively for parsing first. + self.parser.parseBlocks(parent, [before]) + # Remove `> ` from beginning of each line. + block = '\n'.join( + [self.clean(line) for line in block[m.start():].split('\n')] + ) + sibling = self.lastChild(parent) + if sibling is not None and sibling.tag == "blockquote": + # Previous block was a blockquote so set that as this blocks parent + quote = sibling + else: + # This is a new blockquote. Create a new parent element. + quote = etree.SubElement(parent, 'blockquote') + # Recursively parse block with blockquote as parent. + # change parser state so blockquotes embedded in lists use `p` tags + self.parser.state.set('blockquote') + self.parser.parseChunk(quote, block) + self.parser.state.reset() + + def clean(self, line: str) -> str: + """ Remove `>` from beginning of a line. """ + m = self.RE.match(line) + if line.strip() == ">": + return "" + elif m: + return m.group(2) + else: + return line + + +class OListProcessor(BlockProcessor): + """ Process ordered list blocks. """ + + TAG: str = 'ol' + """ The tag used for the the wrapping element. """ + STARTSWITH: str = '1' + """ + The integer (as a string ) with which the list starts. For example, if a list is initialized as + `3. Item`, then the `ol` tag will be assigned an HTML attribute of `starts="3"`. Default: `"1"`. + """ + LAZY_OL: bool = True + """ Ignore `STARTSWITH` if `True`. """ + SIBLING_TAGS: list[str] = ['ol', 'ul'] + """ + Markdown does not require the type of a new list item match the previous list item type. + This is the list of types which can be mixed. + """ + + def __init__(self, parser: BlockParser): + super().__init__(parser) + # Detect an item (`1. item`). `group(1)` contains contents of item. + self.RE = re.compile(r'^[ ]{0,%d}\d+\.[ ]+(.*)' % (self.tab_length - 1)) + # Detect items on secondary lines. they can be of either list type. + self.CHILD_RE = re.compile(r'^[ ]{0,%d}((\d+\.)|[*+-])[ ]+(.*)' % + (self.tab_length - 1)) + # Detect indented (nested) items of either type + self.INDENT_RE = re.compile(r'^[ ]{%d,%d}((\d+\.)|[*+-])[ ]+.*' % + (self.tab_length, self.tab_length * 2 - 1)) + + def test(self, parent, block): + return bool(self.RE.match(block)) + + def run(self, parent, blocks): + # Check for multiple items in one block. + items = self.get_items(blocks.pop(0)) + sibling = self.lastChild(parent) + + if sibling is not None and sibling.tag in self.SIBLING_TAGS: + # Previous block was a list item, so set that as parent + lst = sibling + # make sure previous item is in a `p` - if the item has text, + # then it isn't in a `p` + if lst[-1].text: + # since it's possible there are other children for this + # sibling, we can't just `SubElement` the `p`, we need to + # insert it as the first item. + p = etree.Element('p') + p.text = lst[-1].text + lst[-1].text = '' + lst[-1].insert(0, p) + # if the last item has a tail, then the tail needs to be put in a `p` + # likely only when a header is not followed by a blank line + lch = self.lastChild(lst[-1]) + if lch is not None and lch.tail: + p = etree.SubElement(lst[-1], 'p') + p.text = lch.tail.lstrip() + lch.tail = '' + + # parse first block differently as it gets wrapped in a `p`. + li = etree.SubElement(lst, 'li') + self.parser.state.set('looselist') + firstitem = items.pop(0) + self.parser.parseBlocks(li, [firstitem]) + self.parser.state.reset() + elif parent.tag in ['ol', 'ul']: + # this catches the edge case of a multi-item indented list whose + # first item is in a blank parent-list item: + # * * subitem1 + # * subitem2 + # see also `ListIndentProcessor` + lst = parent + else: + # This is a new list so create parent with appropriate tag. + lst = etree.SubElement(parent, self.TAG) + # Check if a custom start integer is set + if not self.LAZY_OL and self.STARTSWITH != '1': + lst.attrib['start'] = self.STARTSWITH + + self.parser.state.set('list') + # Loop through items in block, recursively parsing each with the + # appropriate parent. + for item in items: + if item.startswith(' '*self.tab_length): + # Item is indented. Parse with last item as parent + self.parser.parseBlocks(lst[-1], [item]) + else: + # New item. Create `li` and parse with it as parent + li = etree.SubElement(lst, 'li') + self.parser.parseBlocks(li, [item]) + self.parser.state.reset() + + def get_items(self, block: str) -> list[str]: + """ Break a block into list items. """ + items = [] + for line in block.split('\n'): + m = self.CHILD_RE.match(line) + if m: + # This is a new list item + # Check first item for the start index + if not items and self.TAG == 'ol': + # Detect the integer value of first list item + INTEGER_RE = re.compile(r'(\d+)') + self.STARTSWITH = INTEGER_RE.match(m.group(1)).group() + # Append to the list + items.append(m.group(3)) + elif self.INDENT_RE.match(line): + # This is an indented (possibly nested) item. + if items[-1].startswith(' '*self.tab_length): + # Previous item was indented. Append to that item. + items[-1] = '{}\n{}'.format(items[-1], line) + else: + items.append(line) + else: + # This is another line of previous item. Append to that item. + items[-1] = '{}\n{}'.format(items[-1], line) + return items + + +class UListProcessor(OListProcessor): + """ Process unordered list blocks. """ + + TAG: str = 'ul' + """ The tag used for the the wrapping element. """ + + def __init__(self, parser: BlockParser): + super().__init__(parser) + # Detect an item (`1. item`). `group(1)` contains contents of item. + self.RE = re.compile(r'^[ ]{0,%d}[*+-][ ]+(.*)' % (self.tab_length - 1)) + + +class HashHeaderProcessor(BlockProcessor): + """ Process Hash Headers. """ + + # Detect a header at start of any line in block + RE = re.compile(r'(?:^|\n)(?P#{1,6})(?P
(?:\\.|[^\\])*?)#*(?:\n|$)') + + def test(self, parent, block): + return bool(self.RE.search(block)) + + def run(self, parent, blocks): + block = blocks.pop(0) + m = self.RE.search(block) + if m: + before = block[:m.start()] # All lines before header + after = block[m.end():] # All lines after header + if before: + # As the header was not the first line of the block and the + # lines before the header must be parsed first, + # recursively parse this lines as a block. + self.parser.parseBlocks(parent, [before]) + # Create header using named groups from RE + h = etree.SubElement(parent, 'h%d' % len(m.group('level'))) + h.text = m.group('header').strip() + if after: + # Insert remaining lines as first block for future parsing. + blocks.insert(0, after) + else: # pragma: no cover + # This should never happen, but just in case... + logger.warn("We've got a problem header: %r" % block) + + +class SetextHeaderProcessor(BlockProcessor): + """ Process Setext-style Headers. """ + + # Detect Setext-style header. Must be first 2 lines of block. + RE = re.compile(r'^.*?\n[=-]+[ ]*(\n|$)', re.MULTILINE) + + def test(self, parent, block): + return bool(self.RE.match(block)) + + def run(self, parent, blocks): + lines = blocks.pop(0).split('\n') + # Determine level. `=` is 1 and `-` is 2. + if lines[1].startswith('='): + level = 1 + else: + level = 2 + h = etree.SubElement(parent, 'h%d' % level) + h.text = lines[0].strip() + if len(lines) > 2: + # Block contains additional lines. Add to master blocks for later. + blocks.insert(0, '\n'.join(lines[2:])) + + +class HRProcessor(BlockProcessor): + """ Process Horizontal Rules. """ + + # Python's `re` module doesn't officially support atomic grouping. However you can fake it. + # See https://stackoverflow.com/a/13577411/866026 + RE = r'^[ ]{0,3}(?=(?P(-+[ ]{0,2}){3,}|(_+[ ]{0,2}){3,}|(\*+[ ]{0,2}){3,}))(?P=atomicgroup)[ ]*$' + # Detect hr on any line of a block. + SEARCH_RE = re.compile(RE, re.MULTILINE) + + def test(self, parent, block): + m = self.SEARCH_RE.search(block) + if m: + # Save match object on class instance so we can use it later. + self.match = m + return True + return False + + def run(self, parent, blocks): + block = blocks.pop(0) + match = self.match + # Check for lines in block before `hr`. + prelines = block[:match.start()].rstrip('\n') + if prelines: + # Recursively parse lines before `hr` so they get parsed first. + self.parser.parseBlocks(parent, [prelines]) + # create hr + etree.SubElement(parent, 'hr') + # check for lines in block after `hr`. + postlines = block[match.end():].lstrip('\n') + if postlines: + # Add lines after `hr` to master blocks for later parsing. + blocks.insert(0, postlines) + + +class EmptyBlockProcessor(BlockProcessor): + """ Process blocks that are empty or start with an empty line. """ + + def test(self, parent, block): + return not block or block.startswith('\n') + + def run(self, parent, blocks): + block = blocks.pop(0) + filler = '\n\n' + if block: + # Starts with empty line + # Only replace a single line. + filler = '\n' + # Save the rest for later. + theRest = block[1:] + if theRest: + # Add remaining lines to master blocks for later. + blocks.insert(0, theRest) + sibling = self.lastChild(parent) + if (sibling is not None and sibling.tag == 'pre' and + len(sibling) and sibling[0].tag == 'code'): + # Last block is a code block. Append to preserve whitespace. + sibling[0].text = util.AtomicString( + '{}{}'.format(sibling[0].text, filler) + ) + + +class ReferenceProcessor(BlockProcessor): + """ Process link references. """ + RE = re.compile( + r'^[ ]{0,3}\[([^\[\]]*)\]:[ ]*\n?[ ]*([^\s]+)[ ]*(?:\n[ ]*)?((["\'])(.*)\4[ ]*|\((.*)\)[ ]*)?$', re.MULTILINE + ) + + def test(self, parent, block): + return True + + def run(self, parent, blocks): + block = blocks.pop(0) + m = self.RE.search(block) + if m: + id = m.group(1).strip().lower() + link = m.group(2).lstrip('<').rstrip('>') + title = m.group(5) or m.group(6) + self.parser.md.references[id] = (link, title) + if block[m.end():].strip(): + # Add any content after match back to blocks as separate block + blocks.insert(0, block[m.end():].lstrip('\n')) + if block[:m.start()].strip(): + # Add any content before match back to blocks as separate block + blocks.insert(0, block[:m.start()].rstrip('\n')) + return True + # No match. Restore block. + blocks.insert(0, block) + return False + + +class ParagraphProcessor(BlockProcessor): + """ Process Paragraph blocks. """ + + def test(self, parent, block): + return True + + def run(self, parent, blocks): + block = blocks.pop(0) + if block.strip(): + # Not a blank block. Add to parent, otherwise throw it away. + if self.parser.state.isstate('list'): + # The parent is a tight-list. + # + # Check for any children. This will likely only happen in a + # tight-list when a header isn't followed by a blank line. + # For example: + # + # * # Header + # Line 2 of list item - not part of header. + sibling = self.lastChild(parent) + if sibling is not None: + # Insert after sibling. + if sibling.tail: + sibling.tail = '{}\n{}'.format(sibling.tail, block) + else: + sibling.tail = '\n%s' % block + else: + # Append to parent.text + if parent.text: + parent.text = '{}\n{}'.format(parent.text, block) + else: + parent.text = block.lstrip() + else: + # Create a regular paragraph + p = etree.SubElement(parent, 'p') + p.text = block.lstrip() diff --git a/plugins/markdown_preview/markdown/core.py b/plugins/markdown_preview/markdown/core.py new file mode 100644 index 0000000..6b556b4 --- /dev/null +++ b/plugins/markdown_preview/markdown/core.py @@ -0,0 +1,510 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +from __future__ import annotations + +import codecs +import sys +import logging +import importlib +from typing import TYPE_CHECKING, Any, Callable, ClassVar, Mapping, Sequence, TextIO +from . import util +from .preprocessors import build_preprocessors +from .blockprocessors import build_block_parser +from .treeprocessors import build_treeprocessors +from .inlinepatterns import build_inlinepatterns +from .postprocessors import build_postprocessors +from .extensions import Extension +from .serializers import to_html_string, to_xhtml_string +from .util import BLOCK_LEVEL_ELEMENTS + +if TYPE_CHECKING: # pragma: no cover + from xml.etree.ElementTree import Element + +__all__ = ['Markdown', 'markdown', 'markdownFromFile'] + + +logger = logging.getLogger('MARKDOWN') + + +class Markdown: + """ + A parser which converts Markdown to HTML. + + Attributes: + Markdown.tab_length (int): The number of spaces which correspond to a single tab. Default: `4`. + Markdown.ESCAPED_CHARS (list[str]): List of characters which get the backslash escape treatment. + Markdown.block_level_elements (list[str]): List of HTML tags which get treated as block-level elements. + See [`markdown.util.BLOCK_LEVEL_ELEMENTS`][] for the full list of elements. + Markdown.registeredExtensions (list[Extension]): List of extensions which have called + [`registerExtension`][markdown.Markdown.registerExtension] during setup. + Markdown.doc_tag (str): Element used to wrap document. Default: `div`. + Markdown.stripTopLevelTags (bool): Indicates whether the `doc_tag` should be removed. Default: 'True'. + Markdown.references (dict[str, tuple[str, str]]): A mapping of link references found in a parsed document + where the key is the reference name and the value is a tuple of the URL and title. + Markdown.htmlStash (util.HtmlStash): The instance of the `HtmlStash` used by an instance of this class. + Markdown.output_formats (dict[str, Callable[xml.etree.ElementTree.Element]]): A mapping of known output + formats by name and their respective serializers. Each serializer must be a callable which accepts an + [`Element`][xml.etree.ElementTree.Element] and returns a `str`. + Markdown.output_format (str): The output format set by + [`set_output_format`][markdown.Markdown.set_output_format]. + Markdown.serializer (Callable[xml.etree.ElementTree.Element]): The serializer set by + [`set_output_format`][markdown.Markdown.set_output_format]. + Markdown.preprocessors (util.Registry): A collection of [`preprocessors`][markdown.preprocessors]. + Markdown.parser (blockparser.BlockParser): A collection of [`blockprocessors`][markdown.blockprocessors]. + Markdown.inlinePatterns (util.Registry): A collection of [`inlinepatterns`][markdown.inlinepatterns]. + Markdown.treeprocessors (util.Registry): A collection of [`treeprocessors`][markdown.treeprocessors]. + Markdown.postprocessors (util.Registry): A collection of [`postprocessors`][markdown.postprocessors]. + + """ + + doc_tag = "div" # Element used to wrap document - later removed + + output_formats: ClassVar[dict[str, Callable[[Element], str]]] = { + 'html': to_html_string, + 'xhtml': to_xhtml_string, + } + """ + A mapping of known output formats by name and their respective serializers. Each serializer must be a + callable which accepts an [`Element`][xml.etree.ElementTree.Element] and returns a `str`. + """ + + def __init__(self, **kwargs): + """ + Creates a new Markdown instance. + + Keyword Arguments: + extensions (list[Extension | str]): A list of extensions. + + If an item is an instance of a subclass of [`markdown.extensions.Extension`][], + the instance will be used as-is. If an item is of type `str`, it is passed + to [`build_extension`][markdown.Markdown.build_extension] with its corresponding + `extension_configs` and the returned instance of [`markdown.extensions.Extension`][] + is used. + extension_configs (dict[str, dict[str, Any]]): Configuration settings for extensions. + output_format (str): Format of output. Supported formats are: + + * `xhtml`: Outputs XHTML style tags. Default. + * `html`: Outputs HTML style tags. + tab_length (int): Length of tabs in the source. Default: `4` + + """ + + self.tab_length: int = kwargs.get('tab_length', 4) + + self.ESCAPED_CHARS: list[str] = [ + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!' + ] + """ List of characters which get the backslash escape treatment. """ + + self.block_level_elements: list[str] = BLOCK_LEVEL_ELEMENTS.copy() + + self.registeredExtensions: list[Extension] = [] + self.docType = "" # TODO: Maybe delete this. It does not appear to be used anymore. + self.stripTopLevelTags: bool = True + + self.build_parser() + + self.references: dict[str, tuple[str, str]] = {} + self.htmlStash: util.HtmlStash = util.HtmlStash() + self.registerExtensions(extensions=kwargs.get('extensions', []), + configs=kwargs.get('extension_configs', {})) + self.set_output_format(kwargs.get('output_format', 'xhtml')) + self.reset() + + def build_parser(self) -> Markdown: + """ + Build the parser from the various parts. + + Assigns a value to each of the following attributes on the class instance: + + * **`Markdown.preprocessors`** ([`Registry`][markdown.util.Registry]) -- A collection of + [`preprocessors`][markdown.preprocessors]. + * **`Markdown.parser`** ([`BlockParser`][markdown.blockparser.BlockParser]) -- A collection of + [`blockprocessors`][markdown.blockprocessors]. + * **`Markdown.inlinePatterns`** ([`Registry`][markdown.util.Registry]) -- A collection of + [`inlinepatterns`][markdown.inlinepatterns]. + * **`Markdown.treeprocessors`** ([`Registry`][markdown.util.Registry]) -- A collection of + [`treeprocessors`][markdown.treeprocessors]. + * **`Markdown.postprocessors`** ([`Registry`][markdown.util.Registry]) -- A collection of + [`postprocessors`][markdown.postprocessors]. + + This method could be redefined in a subclass to build a custom parser which is made up of a different + combination of processors and patterns. + + """ + self.preprocessors = build_preprocessors(self) + self.parser = build_block_parser(self) + self.inlinePatterns = build_inlinepatterns(self) + self.treeprocessors = build_treeprocessors(self) + self.postprocessors = build_postprocessors(self) + return self + + def registerExtensions( + self, + extensions: Sequence[Extension | str], + configs: Mapping[str, Mapping[str, Any]] + ) -> Markdown: + """ + Load a list of extensions into an instance of the `Markdown` class. + + Arguments: + extensions (list[Extension | str]): A list of extensions. + + If an item is an instance of a subclass of [`markdown.extensions.Extension`][], + the instance will be used as-is. If an item is of type `str`, it is passed + to [`build_extension`][markdown.Markdown.build_extension] with its corresponding `configs` and the + returned instance of [`markdown.extensions.Extension`][] is used. + configs (dict[str, dict[str, Any]]): Configuration settings for extensions. + + """ + for ext in extensions: + if isinstance(ext, str): + ext = self.build_extension(ext, configs.get(ext, {})) + if isinstance(ext, Extension): + ext.extendMarkdown(self) + logger.debug( + 'Successfully loaded extension "%s.%s".' + % (ext.__class__.__module__, ext.__class__.__name__) + ) + elif ext is not None: + raise TypeError( + 'Extension "{}.{}" must be of type: "{}.{}"'.format( + ext.__class__.__module__, ext.__class__.__name__, + Extension.__module__, Extension.__name__ + ) + ) + return self + + def build_extension(self, ext_name: str, configs: Mapping[str, Any]) -> Extension: + """ + Build extension from a string name, then return an instance using the given `configs`. + + Arguments: + ext_name: Name of extension as a string. + configs: Configuration settings for extension. + + Returns: + An instance of the extension with the given configuration settings. + + First attempt to load an entry point. The string name must be registered as an entry point in the + `markdown.extensions` group which points to a subclass of the [`markdown.extensions.Extension`][] class. + If multiple distributions have registered the same name, the first one found is returned. + + If no entry point is found, assume dot notation (`path.to.module:ClassName`). Load the specified class and + return an instance. If no class is specified, import the module and call a `makeExtension` function and return + the [`markdown.extensions.Extension`][] instance returned by that function. + """ + configs = dict(configs) + + entry_points = [ep for ep in util.get_installed_extensions() if ep.name == ext_name] + if entry_points: + ext = entry_points[0].load() + return ext(**configs) + + # Get class name (if provided): `path.to.module:ClassName` + ext_name, class_name = ext_name.split(':', 1) if ':' in ext_name else (ext_name, '') + + try: + module = importlib.import_module(ext_name) + logger.debug( + 'Successfully imported extension module "%s".' % ext_name + ) + except ImportError as e: + message = 'Failed loading extension "%s".' % ext_name + e.args = (message,) + e.args[1:] + raise + + if class_name: + # Load given class name from module. + return getattr(module, class_name)(**configs) + else: + # Expect `makeExtension()` function to return a class. + try: + return module.makeExtension(**configs) + except AttributeError as e: + message = e.args[0] + message = "Failed to initiate extension " \ + "'%s': %s" % (ext_name, message) + e.args = (message,) + e.args[1:] + raise + + def registerExtension(self, extension: Extension) -> Markdown: + """ + Register an extension as having a resettable state. + + Arguments: + extension: An instance of the extension to register. + + This should get called once by an extension during setup. A "registered" extension's + `reset` method is called by [`Markdown.reset()`][markdown.Markdown.reset]. Not all extensions have or need a + resettable state, and so it should not be assumed that all extensions are "registered." + + """ + self.registeredExtensions.append(extension) + return self + + def reset(self) -> Markdown: + """ + Resets all state variables to prepare the parser instance for new input. + + Called once upon creation of a class instance. Should be called manually between calls + to [`Markdown.convert`][markdown.Markdown.convert]. + """ + self.htmlStash.reset() + self.references.clear() + + for extension in self.registeredExtensions: + if hasattr(extension, 'reset'): + extension.reset() + + return self + + def set_output_format(self, format: str) -> Markdown: + """ + Set the output format for the class instance. + + Arguments: + format: Must be a known value in `Markdown.output_formats`. + + """ + self.output_format = format.lower().rstrip('145') # ignore number + try: + self.serializer = self.output_formats[self.output_format] + except KeyError as e: + valid_formats = list(self.output_formats.keys()) + valid_formats.sort() + message = 'Invalid Output Format: "%s". Use one of %s.' \ + % (self.output_format, + '"' + '", "'.join(valid_formats) + '"') + e.args = (message,) + e.args[1:] + raise + return self + + # Note: the `tag` argument is type annotated `Any` as ElementTree uses many various objects as tags. + # As there is no standardization in ElementTree, the type of a given tag is unpredictable. + def is_block_level(self, tag: Any) -> bool: + """ + Check if the given `tag` is a block level HTML tag. + + Returns `True` for any string listed in `Markdown.block_level_elements`. A `tag` which is + not a string always returns `False`. + + """ + if isinstance(tag, str): + return tag.lower().rstrip('/') in self.block_level_elements + # Some ElementTree tags are not strings, so return False. + return False + + def convert(self, source: str) -> str: + """ + Convert a Markdown string to a string in the specified output format. + + Arguments: + source: Markdown formatted text as Unicode or ASCII string. + + Returns: + A string in the specified output format. + + Markdown parsing takes place in five steps: + + 1. A bunch of [`preprocessors`][markdown.preprocessors] munge the input text. + 2. A [`BlockParser`][markdown.blockparser.BlockParser] parses the high-level structural elements of the + pre-processed text into an [`ElementTree`][xml.etree.ElementTree.ElementTree] object. + 3. A bunch of [`treeprocessors`][markdown.treeprocessors] are run against the + [`ElementTree`][xml.etree.ElementTree.ElementTree] object. One such `treeprocessor` + ([`markdown.treeprocessors.InlineProcessor`][]) runs [`inlinepatterns`][markdown.inlinepatterns] + against the [`ElementTree`][xml.etree.ElementTree.ElementTree] object, parsing inline markup. + 4. Some [`postprocessors`][markdown.postprocessors] are run against the text after the + [`ElementTree`][xml.etree.ElementTree.ElementTree] object has been serialized into text. + 5. The output is returned as a string. + + """ + + # Fix up the source text + if not source.strip(): + return '' # a blank Unicode string + + try: + source = str(source) + except UnicodeDecodeError as e: # pragma: no cover + # Customize error message while maintaining original traceback + e.reason += '. -- Note: Markdown only accepts Unicode input!' + raise + + # Split into lines and run the line preprocessors. + self.lines = source.split("\n") + for prep in self.preprocessors: + self.lines = prep.run(self.lines) + + # Parse the high-level elements. + root = self.parser.parseDocument(self.lines).getroot() + + # Run the tree-processors + for treeprocessor in self.treeprocessors: + newRoot = treeprocessor.run(root) + if newRoot is not None: + root = newRoot + + # Serialize _properly_. Strip top-level tags. + output = self.serializer(root) + if self.stripTopLevelTags: + try: + start = output.index( + '<%s>' % self.doc_tag) + len(self.doc_tag) + 2 + end = output.rindex('' % self.doc_tag) + output = output[start:end].strip() + except ValueError as e: # pragma: no cover + if output.strip().endswith('<%s />' % self.doc_tag): + # We have an empty document + output = '' + else: + # We have a serious problem + raise ValueError('Markdown failed to strip top-level ' + 'tags. Document=%r' % output.strip()) from e + + # Run the text post-processors + for pp in self.postprocessors: + output = pp.run(output) + + return output.strip() + + def convertFile( + self, + input: str | TextIO | None = None, + output: str | TextIO | None = None, + encoding: str | None = None, + ) -> Markdown: + """ + Converts a Markdown file and returns the HTML as a Unicode string. + + Decodes the file using the provided encoding (defaults to `utf-8`), + passes the file content to markdown, and outputs the HTML to either + the provided stream or the file with provided name, using the same + encoding as the source file. The + [`xmlcharrefreplace`](https://docs.python.org/3/library/codecs.html#error-handlers) + error handler is used when encoding the output. + + **Note:** This is the only place that decoding and encoding of Unicode + takes place in Python-Markdown. (All other code is Unicode-in / + Unicode-out.) + + Arguments: + input: File object or path. Reads from `stdin` if `None`. + output: File object or path. Writes to `stdout` if `None`. + encoding: Encoding of input and output files. Defaults to `utf-8`. + + """ + + encoding = encoding or "utf-8" + + # Read the source + if input: + if isinstance(input, str): + input_file = codecs.open(input, mode="r", encoding=encoding) + else: + input_file = codecs.getreader(encoding)(input) + text = input_file.read() + input_file.close() + else: + text = sys.stdin.read() + if not isinstance(text, str): # pragma: no cover + text = text.decode(encoding) + + text = text.lstrip('\ufeff') # remove the byte-order mark + + # Convert + html = self.convert(text) + + # Write to file or stdout + if output: + if isinstance(output, str): + output_file = codecs.open(output, "w", + encoding=encoding, + errors="xmlcharrefreplace") + output_file.write(html) + output_file.close() + else: + writer = codecs.getwriter(encoding) + output_file = writer(output, errors="xmlcharrefreplace") + output_file.write(html) + # Don't close here. User may want to write more. + else: + # Encode manually and write bytes to stdout. + html = html.encode(encoding, "xmlcharrefreplace") + try: + # Write bytes directly to buffer (Python 3). + sys.stdout.buffer.write(html) + except AttributeError: # pragma: no cover + # Probably Python 2, which works with bytes by default. + sys.stdout.write(html) + + return self + + +""" +EXPORTED FUNCTIONS +============================================================================= + +Those are the two functions we really mean to export: `markdown()` and +`markdownFromFile()`. +""" + + +def markdown(text: str, **kwargs: Any) -> str: + """ + Convert a markdown string to HTML and return HTML as a Unicode string. + + This is a shortcut function for [`Markdown`][markdown.Markdown] class to cover the most + basic use case. It initializes an instance of [`Markdown`][markdown.Markdown], loads the + necessary extensions and runs the parser on the given text. + + Arguments: + text: Markdown formatted text as Unicode or ASCII string. + + Keyword arguments: + **kwargs: Any arguments accepted by the Markdown class. + + Returns: + A string in the specified output format. + + """ + md = Markdown(**kwargs) + return md.convert(text) + + +def markdownFromFile(**kwargs: Any): + """ + Read Markdown text from a file and write output to a file or a stream. + + This is a shortcut function which initializes an instance of [`Markdown`][markdown.Markdown], + and calls the [`convertFile`][markdown.Markdown.convertFile] method rather than + [`convert`][markdown.Markdown.convert]. + + Keyword arguments: + input (str | TextIO): A file name or readable object. + output (str | TextIO): A file name or writable object. + encoding (str): Encoding of input and output. + **kwargs: Any arguments accepted by the `Markdown` class. + + """ + md = Markdown(**kwargs) + md.convertFile(kwargs.get('input', None), + kwargs.get('output', None), + kwargs.get('encoding', None)) diff --git a/plugins/markdown_preview/markdown/extensions/__init__.py b/plugins/markdown_preview/markdown/extensions/__init__.py new file mode 100644 index 0000000..070c4cc --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/__init__.py @@ -0,0 +1,145 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +Markdown accepts an [`Extension`][markdown.extensions.Extension] instance for each extension. Therefore, each extension +must to define a class that extends [`Extension`][markdown.extensions.Extension] and over-rides the +[`extendMarkdown`][markdown.extensions.Extension.extendMarkdown] method. Within this class one can manage configuration +options for their extension and attach the various processors and patterns which make up an extension to the +[`Markdown`][markdown.Markdown] instance. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Mapping, Sequence +from ..util import parseBoolValue + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +class Extension: + """ Base class for extensions to subclass. """ + + config: Mapping[str, list] = {} + """ + Default configuration for an extension. + + This attribute is to be defined in a subclass and must be of the following format: + + ``` python + config = { + 'key': ['value', 'description'] + } + ``` + + Note that [`setConfig`][markdown.extensions.Extension.setConfig] will raise a [`KeyError`][] + if a default is not set for each option. + """ + + def __init__(self, **kwargs): + """ Initiate Extension and set up configs. """ + self.setConfigs(kwargs) + + def getConfig(self, key: str, default: Any = '') -> Any: + """ + Return a single configuration option value. + + Arguments: + key: The configuration option name. + default: Default value to return if key is not set. + + Returns: + Value of stored configuration option. + """ + if key in self.config: + return self.config[key][0] + else: + return default + + def getConfigs(self) -> dict[str, Any]: + """ + Return all configuration options. + + Returns: + All configuration options. + """ + return {key: self.getConfig(key) for key in self.config.keys()} + + def getConfigInfo(self) -> list[tuple[str, str]]: + """ + Return descriptions of all configuration options. + + Returns: + All descriptions of configuration options. + """ + return [(key, self.config[key][1]) for key in self.config.keys()] + + def setConfig(self, key: str, value: Any) -> None: + """ + Set a configuration option. + + If the corresponding default value set in [`config`][markdown.extensions.Extension.config] + is a `bool` value or `None`, then `value` is passed through + [`parseBoolValue`][markdown.util.parseBoolValue] before being stored. + + Arguments: + key: Name of configuration option to set. + value: Value to assign to option. + + Raises: + KeyError: If `key` is not known. + """ + if isinstance(self.config[key][0], bool): + value = parseBoolValue(value) + if self.config[key][0] is None: + value = parseBoolValue(value, preserve_none=True) + self.config[key][0] = value + + def setConfigs(self, items: Mapping[str, Any] | Sequence[tuple[str, Any]]): + """ + Loop through a collection of configuration options, passing each to + [`setConfig`][markdown.extensions.Extension.setConfig]. + + Arguments: + items: Collection of configuration options. + + Raises: + KeyError: for any unknown key. + """ + if hasattr(items, 'items'): + # it's a dict + items = items.items() + for key, value in items: + self.setConfig(key, value) + + def extendMarkdown(self, md: Markdown) -> None: + """ + Add the various processors and patterns to the Markdown Instance. + + This method must be overridden by every extension. + + Arguments: + md: The Markdown instance. + + """ + raise NotImplementedError( + 'Extension "%s.%s" must define an "extendMarkdown"' + 'method.' % (self.__class__.__module__, self.__class__.__name__) + ) diff --git a/plugins/markdown_preview/markdown/extensions/abbr.py b/plugins/markdown_preview/markdown/extensions/abbr.py new file mode 100644 index 0000000..c060f47 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/abbr.py @@ -0,0 +1,105 @@ +# Abbreviation Extension for Python-Markdown +# ========================================== + +# This extension adds abbreviation handling to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/abbreviations +# for documentation. + +# Original code Copyright 2007-2008 [Waylan Limberg](http://achinghead.com/) +# and [Seemant Kulleen](http://www.kulleen.org/) + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +This extension adds abbreviation handling to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/abbreviations) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +from ..inlinepatterns import InlineProcessor +from ..util import AtomicString +import re +import xml.etree.ElementTree as etree + + +class AbbrExtension(Extension): + """ Abbreviation Extension for Python-Markdown. """ + + def extendMarkdown(self, md): + """ Insert `AbbrPreprocessor` before `ReferencePreprocessor`. """ + md.parser.blockprocessors.register(AbbrPreprocessor(md.parser), 'abbr', 16) + + +class AbbrPreprocessor(BlockProcessor): + """ Abbreviation Preprocessor - parse text for abbr references. """ + + RE = re.compile(r'^[*]\[(?P[^\]]*)\][ ]?:[ ]*\n?[ ]*(?P.*)$', re.MULTILINE) + + def test(self, parent, block): + return True + + def run(self, parent, blocks): + """ + Find and remove all Abbreviation references from the text. + Each reference is set as a new `AbbrPattern` in the markdown instance. + + """ + block = blocks.pop(0) + m = self.RE.search(block) + if m: + abbr = m.group('abbr').strip() + title = m.group('title').strip() + self.parser.md.inlinePatterns.register( + AbbrInlineProcessor(self._generate_pattern(abbr), title), 'abbr-%s' % abbr, 2 + ) + if block[m.end():].strip(): + # Add any content after match back to blocks as separate block + blocks.insert(0, block[m.end():].lstrip('\n')) + if block[:m.start()].strip(): + # Add any content before match back to blocks as separate block + blocks.insert(0, block[:m.start()].rstrip('\n')) + return True + # No match. Restore block. + blocks.insert(0, block) + return False + + def _generate_pattern(self, text): + """ + Given a string, returns an regex pattern to match that string. + + 'HTML' -> r'(?P<abbr>[H][T][M][L])' + + Note: we force each char as a literal match (in brackets) as we don't + know what they will be beforehand. + + """ + chars = list(text) + for i in range(len(chars)): + chars[i] = r'[%s]' % chars[i] + return r'(?P<abbr>\b%s\b)' % (r''.join(chars)) + + +class AbbrInlineProcessor(InlineProcessor): + """ Abbreviation inline pattern. """ + + def __init__(self, pattern, title): + super().__init__(pattern) + self.title = title + + def handleMatch(self, m, data): + abbr = etree.Element('abbr') + abbr.text = AtomicString(m.group('abbr')) + abbr.set('title', self.title) + return abbr, m.start(0), m.end(0) + + +def makeExtension(**kwargs): # pragma: no cover + return AbbrExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/admonition.py b/plugins/markdown_preview/markdown/extensions/admonition.py new file mode 100644 index 0000000..f05d089 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/admonition.py @@ -0,0 +1,179 @@ +# Admonition extension for Python-Markdown +# ======================================== + +# Adds rST-style admonitions. Inspired by [rST][] feature with the same name. + +# [rST]: http://docutils.sourceforge.net/docs/ref/rst/directives.html#specific-admonitions + +# See https://Python-Markdown.github.io/extensions/admonition +# for documentation. + +# Original code Copyright [Tiago Serafim](https://www.tiagoserafim.com/). + +# All changes Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + + +""" +Adds rST-style admonitions. Inspired by [rST][] feature with the same name. + +[rST]: http://docutils.sourceforge.net/docs/ref/rst/directives.html#specific-admonitions + +See the [documentation](https://Python-Markdown.github.io/extensions/admonition) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +import xml.etree.ElementTree as etree +import re + + +class AdmonitionExtension(Extension): + """ Admonition extension for Python-Markdown. """ + + def extendMarkdown(self, md): + """ Add Admonition to Markdown instance. """ + md.registerExtension(self) + + md.parser.blockprocessors.register(AdmonitionProcessor(md.parser), 'admonition', 105) + + +class AdmonitionProcessor(BlockProcessor): + + CLASSNAME = 'admonition' + CLASSNAME_TITLE = 'admonition-title' + RE = re.compile(r'(?:^|\n)!!! ?([\w\-]+(?: +[\w\-]+)*)(?: +"(.*?)")? *(?:\n|$)') + RE_SPACES = re.compile(' +') + + def __init__(self, parser): + """Initialization.""" + + super().__init__(parser) + + self.current_sibling = None + self.content_indention = 0 + + def parse_content(self, parent, block): + """Get sibling admonition. + + Retrieve the appropriate sibling element. This can get tricky when + dealing with lists. + + """ + + old_block = block + the_rest = '' + + # We already acquired the block via test + if self.current_sibling is not None: + sibling = self.current_sibling + block, the_rest = self.detab(block, self.content_indent) + self.current_sibling = None + self.content_indent = 0 + return sibling, block, the_rest + + sibling = self.lastChild(parent) + + if sibling is None or sibling.tag != 'div' or sibling.get('class', '').find(self.CLASSNAME) == -1: + sibling = None + else: + # If the last child is a list and the content is sufficiently indented + # to be under it, then the content's sibling is in the list. + last_child = self.lastChild(sibling) + indent = 0 + while last_child is not None: + if ( + sibling is not None and block.startswith(' ' * self.tab_length * 2) and + last_child is not None and last_child.tag in ('ul', 'ol', 'dl') + ): + + # The expectation is that we'll find an `<li>` or `<dt>`. + # We should get its last child as well. + sibling = self.lastChild(last_child) + last_child = self.lastChild(sibling) if sibling is not None else None + + # Context has been lost at this point, so we must adjust the + # text's indentation level so it will be evaluated correctly + # under the list. + block = block[self.tab_length:] + indent += self.tab_length + else: + last_child = None + + if not block.startswith(' ' * self.tab_length): + sibling = None + + if sibling is not None: + indent += self.tab_length + block, the_rest = self.detab(old_block, indent) + self.current_sibling = sibling + self.content_indent = indent + + return sibling, block, the_rest + + def test(self, parent, block): + + if self.RE.search(block): + return True + else: + return self.parse_content(parent, block)[0] is not None + + def run(self, parent, blocks): + block = blocks.pop(0) + m = self.RE.search(block) + + if m: + if m.start() > 0: + self.parser.parseBlocks(parent, [block[:m.start()]]) + block = block[m.end():] # removes the first line + block, theRest = self.detab(block) + else: + sibling, block, theRest = self.parse_content(parent, block) + + if m: + klass, title = self.get_class_and_title(m) + div = etree.SubElement(parent, 'div') + div.set('class', '{} {}'.format(self.CLASSNAME, klass)) + if title: + p = etree.SubElement(div, 'p') + p.text = title + p.set('class', self.CLASSNAME_TITLE) + else: + # Sibling is a list item, but we need to wrap it's content should be wrapped in <p> + if sibling.tag in ('li', 'dd') and sibling.text: + text = sibling.text + sibling.text = '' + p = etree.SubElement(sibling, 'p') + p.text = text + + div = sibling + + self.parser.parseChunk(div, block) + + if theRest: + # This block contained unindented line(s) after the first indented + # line. Insert these lines as the first block of the master blocks + # list for future processing. + blocks.insert(0, theRest) + + def get_class_and_title(self, match): + klass, title = match.group(1).lower(), match.group(2) + klass = self.RE_SPACES.sub(' ', klass) + if title is None: + # no title was provided, use the capitalized class name as title + # e.g.: `!!! note` will render + # `<p class="admonition-title">Note</p>` + title = klass.split(' ', 1)[0].capitalize() + elif title == '': + # an explicit blank title should not be rendered + # e.g.: `!!! warning ""` will *not* render `p` with a title + title = None + return klass, title + + +def makeExtension(**kwargs): # pragma: no cover + return AdmonitionExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/attr_list.py b/plugins/markdown_preview/markdown/extensions/attr_list.py new file mode 100644 index 0000000..0c317d1 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/attr_list.py @@ -0,0 +1,179 @@ +# Attribute List Extension for Python-Markdown +# ============================================ + +# Adds attribute list syntax. Inspired by +# [Maruku](http://maruku.rubyforge.org/proposal.html#attribute_lists)'s +# feature of the same name. + +# See https://Python-Markdown.github.io/extensions/attr_list +# for documentation. + +# Original code Copyright 2011 [Waylan Limberg](http://achinghead.com/). + +# All changes Copyright 2011-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" + Adds attribute list syntax. Inspired by +[Maruku](http://maruku.rubyforge.org/proposal.html#attribute_lists)'s +feature of the same name. + +See the [documentation](https://Python-Markdown.github.io/extensions/attr_list) +for details. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING + +from . import Extension +from ..treeprocessors import Treeprocessor +import re + +if TYPE_CHECKING: # pragma: no cover + from xml.etree.ElementTree import Element + + +def _handle_double_quote(s, t): + k, v = t.split('=', 1) + return k, v.strip('"') + + +def _handle_single_quote(s, t): + k, v = t.split('=', 1) + return k, v.strip("'") + + +def _handle_key_value(s, t): + return t.split('=', 1) + + +def _handle_word(s, t): + if t.startswith('.'): + return '.', t[1:] + if t.startswith('#'): + return 'id', t[1:] + return t, t + + +_scanner = re.Scanner([ + (r'[^ =]+=".*?"', _handle_double_quote), + (r"[^ =]+='.*?'", _handle_single_quote), + (r'[^ =]+=[^ =]+', _handle_key_value), + (r'[^ =]+', _handle_word), + (r' ', None) +]) + + +def get_attrs(str: str) -> list[tuple[str, str]]: + """ Parse attribute list and return a list of attribute tuples. """ + return _scanner.scan(str)[0] + + +def isheader(elem: Element) -> bool: + return elem.tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] + + +class AttrListTreeprocessor(Treeprocessor): + + BASE_RE = r'\{\:?[ ]*([^\}\n ][^\}\n]*)[ ]*\}' + HEADER_RE = re.compile(r'[ ]+{}[ ]*$'.format(BASE_RE)) + BLOCK_RE = re.compile(r'\n[ ]*{}[ ]*$'.format(BASE_RE)) + INLINE_RE = re.compile(r'^{}'.format(BASE_RE)) + NAME_RE = re.compile(r'[^A-Z_a-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02ff' + r'\u0370-\u037d\u037f-\u1fff\u200c-\u200d' + r'\u2070-\u218f\u2c00-\u2fef\u3001-\ud7ff' + r'\uf900-\ufdcf\ufdf0-\ufffd' + r'\:\-\.0-9\u00b7\u0300-\u036f\u203f-\u2040]+') + + def run(self, doc: Element): + for elem in doc.iter(): + if self.md.is_block_level(elem.tag): + # Block level: check for `attrs` on last line of text + RE = self.BLOCK_RE + if isheader(elem) or elem.tag in ['dt', 'td', 'th']: + # header, def-term, or table cell: check for attributes at end of element + RE = self.HEADER_RE + if len(elem) and elem.tag == 'li': + # special case list items. children may include a `ul` or `ol`. + pos = None + # find the `ul` or `ol` position + for i, child in enumerate(elem): + if child.tag in ['ul', 'ol']: + pos = i + break + if pos is None and elem[-1].tail: + # use tail of last child. no `ul` or `ol`. + m = RE.search(elem[-1].tail) + if m: + self.assign_attrs(elem, m.group(1)) + elem[-1].tail = elem[-1].tail[:m.start()] + elif pos is not None and pos > 0 and elem[pos-1].tail: + # use tail of last child before `ul` or `ol` + m = RE.search(elem[pos-1].tail) + if m: + self.assign_attrs(elem, m.group(1)) + elem[pos-1].tail = elem[pos-1].tail[:m.start()] + elif elem.text: + # use text. `ul` is first child. + m = RE.search(elem.text) + if m: + self.assign_attrs(elem, m.group(1)) + elem.text = elem.text[:m.start()] + elif len(elem) and elem[-1].tail: + # has children. Get from tail of last child + m = RE.search(elem[-1].tail) + if m: + self.assign_attrs(elem, m.group(1)) + elem[-1].tail = elem[-1].tail[:m.start()] + if isheader(elem): + # clean up trailing #s + elem[-1].tail = elem[-1].tail.rstrip('#').rstrip() + elif elem.text: + # no children. Get from text. + m = RE.search(elem.text) + if m: + self.assign_attrs(elem, m.group(1)) + elem.text = elem.text[:m.start()] + if isheader(elem): + # clean up trailing #s + elem.text = elem.text.rstrip('#').rstrip() + else: + # inline: check for `attrs` at start of tail + if elem.tail: + m = self.INLINE_RE.match(elem.tail) + if m: + self.assign_attrs(elem, m.group(1)) + elem.tail = elem.tail[m.end():] + + def assign_attrs(self, elem: Element, attrs: str) -> None: + """ Assign `attrs` to element. """ + for k, v in get_attrs(attrs): + if k == '.': + # add to class + cls = elem.get('class') + if cls: + elem.set('class', '{} {}'.format(cls, v)) + else: + elem.set('class', v) + else: + # assign attribute `k` with `v` + elem.set(self.sanitize_name(k), v) + + def sanitize_name(self, name: str) -> str: + """ + Sanitize name as 'an XML Name, minus the ":"'. + See https://www.w3.org/TR/REC-xml-names/#NT-NCName + """ + return self.NAME_RE.sub('_', name) + + +class AttrListExtension(Extension): + """ Attribute List extension for Python-Markdown """ + def extendMarkdown(self, md): + md.treeprocessors.register(AttrListTreeprocessor(md), 'attr_list', 8) + md.registerExtension(self) + + +def makeExtension(**kwargs): # pragma: no cover + return AttrListExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/codehilite.py b/plugins/markdown_preview/markdown/extensions/codehilite.py new file mode 100644 index 0000000..f8d25b0 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/codehilite.py @@ -0,0 +1,338 @@ +# CodeHilite Extension for Python-Markdown +# ======================================== + +# Adds code/syntax highlighting to standard Python-Markdown code blocks. + +# See https://Python-Markdown.github.io/extensions/code_hilite +# for documentation. + +# Original code Copyright 2006-2008 [Waylan Limberg](http://achinghead.com/). + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Adds code/syntax highlighting to standard Python-Markdown code blocks. + +See the [documentation](https://Python-Markdown.github.io/extensions/code_hilite) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..treeprocessors import Treeprocessor +from ..util import parseBoolValue + +try: # pragma: no cover + from pygments import highlight + from pygments.lexers import get_lexer_by_name, guess_lexer + from pygments.formatters import get_formatter_by_name + from pygments.util import ClassNotFound + pygments = True +except ImportError: # pragma: no cover + pygments = False + + +def parse_hl_lines(expr: str) -> list[int]: + """Support our syntax for emphasizing certain lines of code. + + `expr` should be like '1 2' to emphasize lines 1 and 2 of a code block. + Returns a list of integers, the line numbers to emphasize. + """ + if not expr: + return [] + + try: + return list(map(int, expr.split())) + except ValueError: # pragma: no cover + return [] + + +# ------------------ The Main CodeHilite Class ---------------------- +class CodeHilite: + """ + Determine language of source code, and pass it on to the Pygments highlighter. + + Usage: + + ```python + code = CodeHilite(src=some_code, lang='python') + html = code.hilite() + ``` + + Arguments: + src: Source string or any object with a `.readline` attribute. + + Keyword arguments: + lang (str): String name of Pygments lexer to use for highlighting. Default: `None`. + guess_lang (bool): Auto-detect which lexer to use. + Ignored if `lang` is set to a valid value. Default: `True`. + use_pygments (bool): Pass code to Pygments for code highlighting. If `False`, the code is + instead wrapped for highlighting by a JavaScript library. Default: `True`. + pygments_formatter (str): The name of a Pygments formatter or a formatter class used for + highlighting the code blocks. Default: `html`. + linenums (bool): An alias to Pygments `linenos` formatter option. Default: `None`. + css_class (str): An alias to Pygments `cssclass` formatter option. Default: 'codehilite'. + lang_prefix (str): Prefix prepended to the language. Default: "language-". + + Other Options: + + Any other options are accepted and passed on to the lexer and formatter. Therefore, + valid options include any options which are accepted by the `html` formatter or + whichever lexer the code's language uses. Note that most lexers do not have any + options. However, a few have very useful options, such as PHP's `startinline` option. + Any invalid options are ignored without error. + + * **Formatter options**: <https://pygments.org/docs/formatters/#HtmlFormatter> + * **Lexer Options**: <https://pygments.org/docs/lexers/> + + Additionally, when Pygments is enabled, the code's language is passed to the + formatter as an extra option `lang_str`, whose value being `{lang_prefix}{lang}`. + This option has no effect to the Pygments' builtin formatters. + + Advanced Usage: + + ```python + code = CodeHilite( + src = some_code, + lang = 'php', + startinline = True, # Lexer option. Snippet does not start with `<?php`. + linenostart = 42, # Formatter option. Snippet starts on line 42. + hl_lines = [45, 49, 50], # Formatter option. Highlight lines 45, 49, and 50. + linenos = 'inline' # Formatter option. Avoid alignment problems. + ) + html = code.hilite() + ``` + + """ + + def __init__(self, src: str, **options): + self.src = src + self.lang = options.pop('lang', None) + self.guess_lang = options.pop('guess_lang', True) + self.use_pygments = options.pop('use_pygments', True) + self.lang_prefix = options.pop('lang_prefix', 'language-') + self.pygments_formatter = options.pop('pygments_formatter', 'html') + + if 'linenos' not in options: + options['linenos'] = options.pop('linenums', None) + if 'cssclass' not in options: + options['cssclass'] = options.pop('css_class', 'codehilite') + if 'wrapcode' not in options: + # Override Pygments default + options['wrapcode'] = True + # Disallow use of `full` option + options['full'] = False + + self.options = options + + def hilite(self, shebang=True) -> str: + """ + Pass code to the [Pygments](https://pygments.org/) highlighter with + optional line numbers. The output should then be styled with CSS to + your liking. No styles are applied by default - only styling hooks + (i.e.: `<span class="k">`). + + returns : A string of html. + + """ + + self.src = self.src.strip('\n') + + if self.lang is None and shebang: + self._parseHeader() + + if pygments and self.use_pygments: + try: + lexer = get_lexer_by_name(self.lang, **self.options) + except ValueError: + try: + if self.guess_lang: + lexer = guess_lexer(self.src, **self.options) + else: + lexer = get_lexer_by_name('text', **self.options) + except ValueError: # pragma: no cover + lexer = get_lexer_by_name('text', **self.options) + if not self.lang: + # Use the guessed lexer's language instead + self.lang = lexer.aliases[0] + lang_str = f'{self.lang_prefix}{self.lang}' + if isinstance(self.pygments_formatter, str): + try: + formatter = get_formatter_by_name(self.pygments_formatter, **self.options) + except ClassNotFound: + formatter = get_formatter_by_name('html', **self.options) + else: + formatter = self.pygments_formatter(lang_str=lang_str, **self.options) + return highlight(self.src, lexer, formatter) + else: + # just escape and build markup usable by JavaScript highlighting libraries + txt = self.src.replace('&', '&') + txt = txt.replace('<', '<') + txt = txt.replace('>', '>') + txt = txt.replace('"', '"') + classes = [] + if self.lang: + classes.append('{}{}'.format(self.lang_prefix, self.lang)) + if self.options['linenos']: + classes.append('linenums') + class_str = '' + if classes: + class_str = ' class="{}"'.format(' '.join(classes)) + return '<pre class="{}"><code{}>{}\n</code></pre>\n'.format( + self.options['cssclass'], + class_str, + txt + ) + + def _parseHeader(self): + """ + Determines language of a code block from shebang line and whether the + said line should be removed or left in place. If the shebang line + contains a path (even a single /) then it is assumed to be a real + shebang line and left alone. However, if no path is given + (e.i.: `#!python` or `:::python`) then it is assumed to be a mock shebang + for language identification of a code fragment and removed from the + code block prior to processing for code highlighting. When a mock + shebang (e.i: `#!python`) is found, line numbering is turned on. When + colons are found in place of a shebang (e.i.: `:::python`), line + numbering is left in the current state - off by default. + + Also parses optional list of highlight lines, like: + + :::python hl_lines="1 3" + """ + + import re + + # split text into lines + lines = self.src.split("\n") + # pull first line to examine + fl = lines.pop(0) + + c = re.compile(r''' + (?:(?:^::+)|(?P<shebang>^[#]!)) # Shebang or 2 or more colons + (?P<path>(?:/\w+)*[/ ])? # Zero or 1 path + (?P<lang>[\w#.+-]*) # The language + \s* # Arbitrary whitespace + # Optional highlight lines, single- or double-quote-delimited + (hl_lines=(?P<quot>"|')(?P<hl_lines>.*?)(?P=quot))? + ''', re.VERBOSE) + # search first line for shebang + m = c.search(fl) + if m: + # we have a match + try: + self.lang = m.group('lang').lower() + except IndexError: # pragma: no cover + self.lang = None + if m.group('path'): + # path exists - restore first line + lines.insert(0, fl) + if self.options['linenos'] is None and m.group('shebang'): + # Overridable and Shebang exists - use line numbers + self.options['linenos'] = True + + self.options['hl_lines'] = parse_hl_lines(m.group('hl_lines')) + else: + # No match + lines.insert(0, fl) + + self.src = "\n".join(lines).strip("\n") + + +# ------------------ The Markdown Extension ------------------------------- + + +class HiliteTreeprocessor(Treeprocessor): + """ Highlight source code in code blocks. """ + + def code_unescape(self, text): + """Unescape code.""" + text = text.replace("<", "<") + text = text.replace(">", ">") + # Escaped '&' should be replaced at the end to avoid + # conflicting with < and >. + text = text.replace("&", "&") + return text + + def run(self, root): + """ Find code blocks and store in `htmlStash`. """ + blocks = root.iter('pre') + for block in blocks: + if len(block) == 1 and block[0].tag == 'code': + local_config = self.config.copy() + code = CodeHilite( + self.code_unescape(block[0].text), + tab_length=self.md.tab_length, + style=local_config.pop('pygments_style', 'default'), + **local_config + ) + placeholder = self.md.htmlStash.store(code.hilite()) + # Clear code block in `etree` instance + block.clear() + # Change to `p` element which will later + # be removed when inserting raw html + block.tag = 'p' + block.text = placeholder + + +class CodeHiliteExtension(Extension): + """ Add source code highlighting to markdown code blocks. """ + + def __init__(self, **kwargs): + # define default configs + self.config = { + 'linenums': [ + None, "Use lines numbers. True|table|inline=yes, False=no, None=auto. Default: `None`." + ], + 'guess_lang': [ + True, "Automatic language detection - Default: `True`." + ], + 'css_class': [ + "codehilite", "Set class name for wrapper <div> - Default: `codehilite`." + ], + 'pygments_style': [ + 'default', 'Pygments HTML Formatter Style (Colorscheme). Default: `default`.' + ], + 'noclasses': [ + False, 'Use inline styles instead of CSS classes - Default `False`.' + ], + 'use_pygments': [ + True, 'Highlight code blocks with pygments. Disable if using a JavaScript library. Default: `True`.' + ], + 'lang_prefix': [ + 'language-', 'Prefix prepended to the language when `use_pygments` is false. Default: `language-`.' + ], + 'pygments_formatter': [ + 'html', 'Use a specific formatter for Pygments highlighting. Default: `html`.' + ], + } + """ Default configuration options. """ + + for key, value in kwargs.items(): + if key in self.config: + self.setConfig(key, value) + else: + # manually set unknown keywords. + if isinstance(value, str): + try: + # Attempt to parse `str` as a boolean value + value = parseBoolValue(value, preserve_none=True) + except ValueError: + pass # Assume it's not a boolean value. Use as-is. + self.config[key] = [value, ''] + + def extendMarkdown(self, md): + """ Add `HilitePostprocessor` to Markdown instance. """ + hiliter = HiliteTreeprocessor(md) + hiliter.config = self.getConfigs() + md.treeprocessors.register(hiliter, 'hilite', 30) + + md.registerExtension(self) + + +def makeExtension(**kwargs): # pragma: no cover + return CodeHiliteExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/def_list.py b/plugins/markdown_preview/markdown/extensions/def_list.py new file mode 100644 index 0000000..54273b6 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/def_list.py @@ -0,0 +1,119 @@ +# Definition List Extension for Python-Markdown +# ============================================= + +# Adds parsing of Definition Lists to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/definition_lists +# for documentation. + +# Original code Copyright 2008 [Waylan Limberg](http://achinghead.com) + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Adds parsing of Definition Lists to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/definition_lists) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor, ListIndentProcessor +import xml.etree.ElementTree as etree +import re + + +class DefListProcessor(BlockProcessor): + """ Process Definition Lists. """ + + RE = re.compile(r'(^|\n)[ ]{0,3}:[ ]{1,3}(.*?)(\n|$)') + NO_INDENT_RE = re.compile(r'^[ ]{0,3}[^ :]') + + def test(self, parent, block): + return bool(self.RE.search(block)) + + def run(self, parent, blocks): + + raw_block = blocks.pop(0) + m = self.RE.search(raw_block) + terms = [term.strip() for term in + raw_block[:m.start()].split('\n') if term.strip()] + block = raw_block[m.end():] + no_indent = self.NO_INDENT_RE.match(block) + if no_indent: + d, theRest = (block, None) + else: + d, theRest = self.detab(block) + if d: + d = '{}\n{}'.format(m.group(2), d) + else: + d = m.group(2) + sibling = self.lastChild(parent) + if not terms and sibling is None: + # This is not a definition item. Most likely a paragraph that + # starts with a colon at the beginning of a document or list. + blocks.insert(0, raw_block) + return False + if not terms and sibling.tag == 'p': + # The previous paragraph contains the terms + state = 'looselist' + terms = sibling.text.split('\n') + parent.remove(sibling) + # Acquire new sibling + sibling = self.lastChild(parent) + else: + state = 'list' + + if sibling is not None and sibling.tag == 'dl': + # This is another item on an existing list + dl = sibling + if not terms and len(dl) and dl[-1].tag == 'dd' and len(dl[-1]): + state = 'looselist' + else: + # This is a new list + dl = etree.SubElement(parent, 'dl') + # Add terms + for term in terms: + dt = etree.SubElement(dl, 'dt') + dt.text = term + # Add definition + self.parser.state.set(state) + dd = etree.SubElement(dl, 'dd') + self.parser.parseBlocks(dd, [d]) + self.parser.state.reset() + + if theRest: + blocks.insert(0, theRest) + + +class DefListIndentProcessor(ListIndentProcessor): + """ Process indented children of definition list items. """ + + # Definition lists need to be aware of all list types + ITEM_TYPES = ['dd', 'li'] + """ Include `dd` in list item types. """ + LIST_TYPES = ['dl', 'ol', 'ul'] + """ Include `dl` is list types. """ + + def create_item(self, parent, block): + """ Create a new `dd` or `li` (depending on parent) and parse the block with it as the parent. """ + + dd = etree.SubElement(parent, 'dd') + self.parser.parseBlocks(dd, [block]) + + +class DefListExtension(Extension): + """ Add definition lists to Markdown. """ + + def extendMarkdown(self, md): + """ Add an instance of `DefListProcessor` to `BlockParser`. """ + md.parser.blockprocessors.register(DefListIndentProcessor(md.parser), 'defindent', 85) + md.parser.blockprocessors.register(DefListProcessor(md.parser), 'deflist', 25) + + +def makeExtension(**kwargs): # pragma: no cover + return DefListExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/extra.py b/plugins/markdown_preview/markdown/extensions/extra.py new file mode 100644 index 0000000..74ebc19 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/extra.py @@ -0,0 +1,66 @@ +# Python-Markdown Extra Extension +# =============================== + +# A compilation of various Python-Markdown extensions that imitates +# [PHP Markdown Extra](http://michelf.com/projects/php-markdown/extra/). + +# See https://Python-Markdown.github.io/extensions/extra +# for documentation. + +# Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +A compilation of various Python-Markdown extensions that imitates +[PHP Markdown Extra](http://michelf.com/projects/php-markdown/extra/). + +Note that each of the individual extensions still need to be available +on your `PYTHONPATH`. This extension simply wraps them all up as a +convenience so that only one extension needs to be listed when +initiating Markdown. See the documentation for each individual +extension for specifics about that extension. + +There may be additional extensions that are distributed with +Python-Markdown that are not included here in Extra. Those extensions +are not part of PHP Markdown Extra, and therefore, not part of +Python-Markdown Extra. If you really would like Extra to include +additional extensions, we suggest creating your own clone of Extra +under a different name. You could also edit the `extensions` global +variable defined below, but be aware that such changes may be lost +when you upgrade to any future version of Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/extra) +for details. +""" + +from __future__ import annotations + +from . import Extension + +extensions = [ + 'fenced_code', + 'footnotes', + 'attr_list', + 'def_list', + 'tables', + 'abbr', + 'md_in_html' +] +""" The list of included extensions. """ + + +class ExtraExtension(Extension): + """ Add various extensions to Markdown class.""" + + def __init__(self, **kwargs): + """ `config` is a dumb holder which gets passed to the actual extension later. """ + self.config = kwargs + + def extendMarkdown(self, md): + """ Register extension instances. """ + md.registerExtensions(extensions, self.config) + + +def makeExtension(**kwargs): # pragma: no cover + return ExtraExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/fenced_code.py b/plugins/markdown_preview/markdown/extensions/fenced_code.py new file mode 100644 index 0000000..241bb6d --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/fenced_code.py @@ -0,0 +1,182 @@ +# Fenced Code Extension for Python Markdown +# ========================================= + +# This extension adds Fenced Code Blocks to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/fenced_code_blocks +# for documentation. + +# Original code Copyright 2007-2008 [Waylan Limberg](http://achinghead.com/). + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +This extension adds Fenced Code Blocks to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/fenced_code_blocks) +for details. +""" + +from __future__ import annotations + +from textwrap import dedent +from . import Extension +from ..preprocessors import Preprocessor +from .codehilite import CodeHilite, CodeHiliteExtension, parse_hl_lines +from .attr_list import get_attrs, AttrListExtension +from ..util import parseBoolValue +from ..serializers import _escape_attrib_html +import re + + +class FencedCodeExtension(Extension): + def __init__(self, **kwargs): + self.config = { + 'lang_prefix': ['language-', 'Prefix prepended to the language. Default: "language-"'] + } + """ Default configuration options. """ + super().__init__(**kwargs) + + def extendMarkdown(self, md): + """ Add `FencedBlockPreprocessor` to the Markdown instance. """ + md.registerExtension(self) + + md.preprocessors.register(FencedBlockPreprocessor(md, self.getConfigs()), 'fenced_code_block', 25) + + +class FencedBlockPreprocessor(Preprocessor): + """ Find and extract fenced code blocks. """ + + FENCED_BLOCK_RE = re.compile( + dedent(r''' + (?P<fence>^(?:~{3,}|`{3,}))[ ]* # opening fence + ((\{(?P<attrs>[^\}\n]*)\})| # (optional {attrs} or + (\.?(?P<lang>[\w#.+-]*)[ ]*)? # optional (.)lang + (hl_lines=(?P<quot>"|')(?P<hl_lines>.*?)(?P=quot)[ ]*)?) # optional hl_lines) + \n # newline (end of opening fence) + (?P<code>.*?)(?<=\n) # the code block + (?P=fence)[ ]*$ # closing fence + '''), + re.MULTILINE | re.DOTALL | re.VERBOSE + ) + + def __init__(self, md, config): + super().__init__(md) + self.config = config + self.checked_for_deps = False + self.codehilite_conf = {} + self.use_attr_list = False + # List of options to convert to boolean values + self.bool_options = [ + 'linenums', + 'guess_lang', + 'noclasses', + 'use_pygments' + ] + + def run(self, lines): + """ Match and store Fenced Code Blocks in the `HtmlStash`. """ + + # Check for dependent extensions + if not self.checked_for_deps: + for ext in self.md.registeredExtensions: + if isinstance(ext, CodeHiliteExtension): + self.codehilite_conf = ext.getConfigs() + if isinstance(ext, AttrListExtension): + self.use_attr_list = True + + self.checked_for_deps = True + + text = "\n".join(lines) + while 1: + m = self.FENCED_BLOCK_RE.search(text) + if m: + lang, id, classes, config = None, '', [], {} + if m.group('attrs'): + id, classes, config = self.handle_attrs(get_attrs(m.group('attrs'))) + if len(classes): + lang = classes.pop(0) + else: + if m.group('lang'): + lang = m.group('lang') + if m.group('hl_lines'): + # Support `hl_lines` outside of `attrs` for backward-compatibility + config['hl_lines'] = parse_hl_lines(m.group('hl_lines')) + + # If `config` is not empty, then the `codehighlite` extension + # is enabled, so we call it to highlight the code + if self.codehilite_conf and self.codehilite_conf['use_pygments'] and config.get('use_pygments', True): + local_config = self.codehilite_conf.copy() + local_config.update(config) + # Combine classes with `cssclass`. Ensure `cssclass` is at end + # as Pygments appends a suffix under certain circumstances. + # Ignore ID as Pygments does not offer an option to set it. + if classes: + local_config['css_class'] = '{} {}'.format( + ' '.join(classes), + local_config['css_class'] + ) + highliter = CodeHilite( + m.group('code'), + lang=lang, + style=local_config.pop('pygments_style', 'default'), + **local_config + ) + + code = highliter.hilite(shebang=False) + else: + id_attr = lang_attr = class_attr = kv_pairs = '' + if lang: + prefix = self.config.get('lang_prefix', 'language-') + lang_attr = f' class="{prefix}{_escape_attrib_html(lang)}"' + if classes: + class_attr = f' class="{_escape_attrib_html(" ".join(classes))}"' + if id: + id_attr = f' id="{_escape_attrib_html(id)}"' + if self.use_attr_list and config and not config.get('use_pygments', False): + # Only assign key/value pairs to code element if `attr_list` extension is enabled, key/value + # pairs were defined on the code block, and the `use_pygments` key was not set to `True`. The + # `use_pygments` key could be either set to `False` or not defined. It is omitted from output. + kv_pairs = ''.join( + f' {k}="{_escape_attrib_html(v)}"' for k, v in config.items() if k != 'use_pygments' + ) + code = self._escape(m.group('code')) + code = f'<pre{id_attr}{class_attr}><code{lang_attr}{kv_pairs}>{code}</code></pre>' + + placeholder = self.md.htmlStash.store(code) + text = f'{text[:m.start()]}\n{placeholder}\n{text[m.end():]}' + else: + break + return text.split("\n") + + def handle_attrs(self, attrs): + """ Return tuple: `(id, [list, of, classes], {configs})` """ + id = '' + classes = [] + configs = {} + for k, v in attrs: + if k == 'id': + id = v + elif k == '.': + classes.append(v) + elif k == 'hl_lines': + configs[k] = parse_hl_lines(v) + elif k in self.bool_options: + configs[k] = parseBoolValue(v, fail_on_errors=False, preserve_none=True) + else: + configs[k] = v + return id, classes, configs + + def _escape(self, txt): + """ basic html escaping """ + txt = txt.replace('&', '&') + txt = txt.replace('<', '<') + txt = txt.replace('>', '>') + txt = txt.replace('"', '"') + return txt + + +def makeExtension(**kwargs): # pragma: no cover + return FencedCodeExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/footnotes.py b/plugins/markdown_preview/markdown/extensions/footnotes.py new file mode 100644 index 0000000..2424dbc --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/footnotes.py @@ -0,0 +1,416 @@ +# Footnotes Extension for Python-Markdown +# ======================================= + +# Adds footnote handling to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/footnotes +# for documentation. + +# Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Adds footnote handling to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/footnotes) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +from ..inlinepatterns import InlineProcessor +from ..treeprocessors import Treeprocessor +from ..postprocessors import Postprocessor +from .. import util +from collections import OrderedDict +import re +import copy +import xml.etree.ElementTree as etree + +FN_BACKLINK_TEXT = util.STX + "zz1337820767766393qq" + util.ETX +NBSP_PLACEHOLDER = util.STX + "qq3936677670287331zz" + util.ETX +RE_REF_ID = re.compile(r'(fnref)(\d+)') + + +class FootnoteExtension(Extension): + """ Footnote Extension. """ + + def __init__(self, **kwargs): + """ Setup configs. """ + + self.config = { + 'PLACE_MARKER': [ + '///Footnotes Go Here///', 'The text string that marks where the footnotes go' + ], + 'UNIQUE_IDS': [ + False, 'Avoid name collisions across multiple calls to `reset()`.' + ], + 'BACKLINK_TEXT': [ + '↩', "The text string that links from the footnote to the reader's place." + ], + 'SUPERSCRIPT_TEXT': [ + '{}', "The text string that links from the reader's place to the footnote." + ], + 'BACKLINK_TITLE': [ + 'Jump back to footnote %d in the text', + 'The text string used for the title HTML attribute of the backlink. ' + '%d will be replaced by the footnote number.' + ], + 'SEPARATOR': [ + ':', 'Footnote separator.' + ] + } + """ Default configuration options. """ + super().__init__(**kwargs) + + # In multiple invocations, emit links that don't get tangled. + self.unique_prefix = 0 + self.found_refs = {} + self.used_refs = set() + + self.reset() + + def extendMarkdown(self, md): + """ Add pieces to Markdown. """ + md.registerExtension(self) + self.parser = md.parser + self.md = md + # Insert a `blockprocessor` before `ReferencePreprocessor` + md.parser.blockprocessors.register(FootnoteBlockProcessor(self), 'footnote', 17) + + # Insert an inline pattern before `ImageReferencePattern` + FOOTNOTE_RE = r'\[\^([^\]]*)\]' # blah blah [^1] blah + md.inlinePatterns.register(FootnoteInlineProcessor(FOOTNOTE_RE, self), 'footnote', 175) + # Insert a tree-processor that would actually add the footnote div + # This must be before all other tree-processors (i.e., `inline` and + # `codehilite`) so they can run on the the contents of the div. + md.treeprocessors.register(FootnoteTreeprocessor(self), 'footnote', 50) + + # Insert a tree-processor that will run after inline is done. + # In this tree-processor we want to check our duplicate footnote tracker + # And add additional `backrefs` to the footnote pointing back to the + # duplicated references. + md.treeprocessors.register(FootnotePostTreeprocessor(self), 'footnote-duplicate', 15) + + # Insert a postprocessor after amp_substitute processor + md.postprocessors.register(FootnotePostprocessor(self), 'footnote', 25) + + def reset(self) -> None: + """ Clear footnotes on reset, and prepare for distinct document. """ + self.footnotes: OrderedDict[str, str] = OrderedDict() + self.unique_prefix += 1 + self.found_refs = {} + self.used_refs = set() + + def unique_ref(self, reference, found: bool = False): + """ Get a unique reference if there are duplicates. """ + if not found: + return reference + + original_ref = reference + while reference in self.used_refs: + ref, rest = reference.split(self.get_separator(), 1) + m = RE_REF_ID.match(ref) + if m: + reference = '%s%d%s%s' % (m.group(1), int(m.group(2))+1, self.get_separator(), rest) + else: + reference = '%s%d%s%s' % (ref, 2, self.get_separator(), rest) + + self.used_refs.add(reference) + if original_ref in self.found_refs: + self.found_refs[original_ref] += 1 + else: + self.found_refs[original_ref] = 1 + return reference + + def findFootnotesPlaceholder(self, root): + """ Return ElementTree Element that contains Footnote placeholder. """ + def finder(element): + for child in element: + if child.text: + if child.text.find(self.getConfig("PLACE_MARKER")) > -1: + return child, element, True + if child.tail: + if child.tail.find(self.getConfig("PLACE_MARKER")) > -1: + return child, element, False + child_res = finder(child) + if child_res is not None: + return child_res + return None + + res = finder(root) + return res + + def setFootnote(self, id, text) -> None: + """ Store a footnote for later retrieval. """ + self.footnotes[id] = text + + def get_separator(self): + """ Get the footnote separator. """ + return self.getConfig("SEPARATOR") + + def makeFootnoteId(self, id): + """ Return footnote link id. """ + if self.getConfig("UNIQUE_IDS"): + return 'fn%s%d-%s' % (self.get_separator(), self.unique_prefix, id) + else: + return 'fn{}{}'.format(self.get_separator(), id) + + def makeFootnoteRefId(self, id, found: bool = False): + """ Return footnote back-link id. """ + if self.getConfig("UNIQUE_IDS"): + return self.unique_ref('fnref%s%d-%s' % (self.get_separator(), self.unique_prefix, id), found) + else: + return self.unique_ref('fnref{}{}'.format(self.get_separator(), id), found) + + def makeFootnotesDiv(self, root): + """ Return `div` of footnotes as `etree` Element. """ + + if not list(self.footnotes.keys()): + return None + + div = etree.Element("div") + div.set('class', 'footnote') + etree.SubElement(div, "hr") + ol = etree.SubElement(div, "ol") + surrogate_parent = etree.Element("div") + + # Backward compatibility with old '%d' placeholder + backlink_title = self.getConfig("BACKLINK_TITLE").replace("%d", "{}") + + for index, id in enumerate(self.footnotes.keys(), start=1): + li = etree.SubElement(ol, "li") + li.set("id", self.makeFootnoteId(id)) + # Parse footnote with surrogate parent as `li` cannot be used. + # List block handlers have special logic to deal with `li`. + # When we are done parsing, we will copy everything over to `li`. + self.parser.parseChunk(surrogate_parent, self.footnotes[id]) + for el in list(surrogate_parent): + li.append(el) + surrogate_parent.remove(el) + backlink = etree.Element("a") + backlink.set("href", "#" + self.makeFootnoteRefId(id)) + backlink.set("class", "footnote-backref") + backlink.set( + "title", + backlink_title.format(index) + ) + backlink.text = FN_BACKLINK_TEXT + + if len(li): + node = li[-1] + if node.tag == "p": + node.text = node.text + NBSP_PLACEHOLDER + node.append(backlink) + else: + p = etree.SubElement(li, "p") + p.append(backlink) + return div + + +class FootnoteBlockProcessor(BlockProcessor): + """ Find all footnote references and store for later use. """ + + RE = re.compile(r'^[ ]{0,3}\[\^([^\]]*)\]:[ ]*(.*)$', re.MULTILINE) + + def __init__(self, footnotes): + super().__init__(footnotes.parser) + self.footnotes = footnotes + + def test(self, parent, block): + return True + + def run(self, parent, blocks): + """ Find, set, and remove footnote definitions. """ + block = blocks.pop(0) + m = self.RE.search(block) + if m: + id = m.group(1) + fn_blocks = [m.group(2)] + + # Handle rest of block + therest = block[m.end():].lstrip('\n') + m2 = self.RE.search(therest) + if m2: + # Another footnote exists in the rest of this block. + # Any content before match is continuation of this footnote, which may be lazily indented. + before = therest[:m2.start()].rstrip('\n') + fn_blocks[0] = '\n'.join([fn_blocks[0], self.detab(before)]).lstrip('\n') + # Add back to blocks everything from beginning of match forward for next iteration. + blocks.insert(0, therest[m2.start():]) + else: + # All remaining lines of block are continuation of this footnote, which may be lazily indented. + fn_blocks[0] = '\n'.join([fn_blocks[0], self.detab(therest)]).strip('\n') + + # Check for child elements in remaining blocks. + fn_blocks.extend(self.detectTabbed(blocks)) + + footnote = "\n\n".join(fn_blocks) + self.footnotes.setFootnote(id, footnote.rstrip()) + + if block[:m.start()].strip(): + # Add any content before match back to blocks as separate block + blocks.insert(0, block[:m.start()].rstrip('\n')) + return True + # No match. Restore block. + blocks.insert(0, block) + return False + + def detectTabbed(self, blocks) -> list[str]: + """ Find indented text and remove indent before further processing. + + Returns: + A list of blocks with indentation removed. + """ + fn_blocks = [] + while blocks: + if blocks[0].startswith(' '*4): + block = blocks.pop(0) + # Check for new footnotes within this block and split at new footnote. + m = self.RE.search(block) + if m: + # Another footnote exists in this block. + # Any content before match is continuation of this footnote, which may be lazily indented. + before = block[:m.start()].rstrip('\n') + fn_blocks.append(self.detab(before)) + # Add back to blocks everything from beginning of match forward for next iteration. + blocks.insert(0, block[m.start():]) + # End of this footnote. + break + else: + # Entire block is part of this footnote. + fn_blocks.append(self.detab(block)) + else: + # End of this footnote. + break + return fn_blocks + + def detab(self, block): + """ Remove one level of indent from a block. + + Preserve lazily indented blocks by only removing indent from indented lines. + """ + lines = block.split('\n') + for i, line in enumerate(lines): + if line.startswith(' '*4): + lines[i] = line[4:] + return '\n'.join(lines) + + +class FootnoteInlineProcessor(InlineProcessor): + """ `InlineProcessor` for footnote markers in a document's body text. """ + + def __init__(self, pattern, footnotes): + super().__init__(pattern) + self.footnotes = footnotes + + def handleMatch(self, m, data): + id = m.group(1) + if id in self.footnotes.footnotes.keys(): + sup = etree.Element("sup") + a = etree.SubElement(sup, "a") + sup.set('id', self.footnotes.makeFootnoteRefId(id, found=True)) + a.set('href', '#' + self.footnotes.makeFootnoteId(id)) + a.set('class', 'footnote-ref') + a.text = self.footnotes.getConfig("SUPERSCRIPT_TEXT").format( + list(self.footnotes.footnotes.keys()).index(id) + 1 + ) + return sup, m.start(0), m.end(0) + else: + return None, None, None + + +class FootnotePostTreeprocessor(Treeprocessor): + """ Amend footnote div with duplicates. """ + + def __init__(self, footnotes): + self.footnotes = footnotes + + def add_duplicates(self, li, duplicates) -> None: + """ Adjust current `li` and add the duplicates: `fnref2`, `fnref3`, etc. """ + for link in li.iter('a'): + # Find the link that needs to be duplicated. + if link.attrib.get('class', '') == 'footnote-backref': + ref, rest = link.attrib['href'].split(self.footnotes.get_separator(), 1) + # Duplicate link the number of times we need to + # and point the to the appropriate references. + links = [] + for index in range(2, duplicates + 1): + sib_link = copy.deepcopy(link) + sib_link.attrib['href'] = '%s%d%s%s' % (ref, index, self.footnotes.get_separator(), rest) + links.append(sib_link) + self.offset += 1 + # Add all the new duplicate links. + el = list(li)[-1] + for link in links: + el.append(link) + break + + def get_num_duplicates(self, li): + """ Get the number of duplicate refs of the footnote. """ + fn, rest = li.attrib.get('id', '').split(self.footnotes.get_separator(), 1) + link_id = '{}ref{}{}'.format(fn, self.footnotes.get_separator(), rest) + return self.footnotes.found_refs.get(link_id, 0) + + def handle_duplicates(self, parent) -> None: + """ Find duplicate footnotes and format and add the duplicates. """ + for li in list(parent): + # Check number of duplicates footnotes and insert + # additional links if needed. + count = self.get_num_duplicates(li) + if count > 1: + self.add_duplicates(li, count) + + def run(self, root): + """ Crawl the footnote div and add missing duplicate footnotes. """ + self.offset = 0 + for div in root.iter('div'): + if div.attrib.get('class', '') == 'footnote': + # Footnotes should be under the first ordered list under + # the footnote div. So once we find it, quit. + for ol in div.iter('ol'): + self.handle_duplicates(ol) + break + + +class FootnoteTreeprocessor(Treeprocessor): + """ Build and append footnote div to end of document. """ + + def __init__(self, footnotes): + self.footnotes = footnotes + + def run(self, root): + footnotesDiv = self.footnotes.makeFootnotesDiv(root) + if footnotesDiv is not None: + result = self.footnotes.findFootnotesPlaceholder(root) + if result: + child, parent, isText = result + ind = list(parent).index(child) + if isText: + parent.remove(child) + parent.insert(ind, footnotesDiv) + else: + parent.insert(ind + 1, footnotesDiv) + child.tail = None + else: + root.append(footnotesDiv) + + +class FootnotePostprocessor(Postprocessor): + """ Replace placeholders with html entities. """ + def __init__(self, footnotes): + self.footnotes = footnotes + + def run(self, text): + text = text.replace( + FN_BACKLINK_TEXT, self.footnotes.getConfig("BACKLINK_TEXT") + ) + return text.replace(NBSP_PLACEHOLDER, " ") + + +def makeExtension(**kwargs): # pragma: no cover + """ Return an instance of the `FootnoteExtension` """ + return FootnoteExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/legacy_attrs.py b/plugins/markdown_preview/markdown/extensions/legacy_attrs.py new file mode 100644 index 0000000..56ad2e8 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/legacy_attrs.py @@ -0,0 +1,67 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +An extension to Python Markdown which implements legacy attributes. + +Prior to Python-Markdown version 3.0, the Markdown class had an `enable_attributes` +keyword which was on by default and provided for attributes to be defined for elements +using the format `{@key=value}`. This extension is provided as a replacement for +backward compatibility. New documents should be authored using `attr_lists`. However, +numerous documents exist which have been using the old attribute format for many +years. This extension can be used to continue to render those documents correctly. +""" + +from __future__ import annotations + +import re +from markdown.treeprocessors import Treeprocessor, isString +from markdown.extensions import Extension + + +ATTR_RE = re.compile(r'\{@([^\}]*)=([^\}]*)}') # {@id=123} + + +class LegacyAttrs(Treeprocessor): + def run(self, doc): + """Find and set values of attributes ({@key=value}). """ + for el in doc.iter(): + alt = el.get('alt', None) + if alt is not None: + el.set('alt', self.handleAttributes(el, alt)) + if el.text and isString(el.text): + el.text = self.handleAttributes(el, el.text) + if el.tail and isString(el.tail): + el.tail = self.handleAttributes(el, el.tail) + + def handleAttributes(self, el, txt): + """ Set attributes and return text without definitions. """ + def attributeCallback(match): + el.set(match.group(1), match.group(2).replace('\n', ' ')) + return ATTR_RE.sub(attributeCallback, txt) + + +class LegacyAttrExtension(Extension): + def extendMarkdown(self, md): + """ Add `LegacyAttrs` to Markdown instance. """ + md.treeprocessors.register(LegacyAttrs(md), 'legacyattrs', 15) + + +def makeExtension(**kwargs): # pragma: no cover + return LegacyAttrExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/legacy_em.py b/plugins/markdown_preview/markdown/extensions/legacy_em.py new file mode 100644 index 0000000..a6f67b7 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/legacy_em.py @@ -0,0 +1,52 @@ +# Legacy Em Extension for Python-Markdown +# ======================================= + +# This extension provides legacy behavior for _connected_words_. + +# Copyright 2015-2018 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +This extension provides legacy behavior for _connected_words_. +""" + +from __future__ import annotations + +from . import Extension +from ..inlinepatterns import UnderscoreProcessor, EmStrongItem, EM_STRONG2_RE, STRONG_EM2_RE +import re + +# _emphasis_ +EMPHASIS_RE = r'(_)([^_]+)\1' + +# __strong__ +STRONG_RE = r'(_{2})(.+?)\1' + +# __strong_em___ +STRONG_EM_RE = r'(_)\1(?!\1)([^_]+?)\1(?!\1)(.+?)\1{3}' + + +class LegacyUnderscoreProcessor(UnderscoreProcessor): + """Emphasis processor for handling strong and em matches inside underscores.""" + + PATTERNS = [ + EmStrongItem(re.compile(EM_STRONG2_RE, re.DOTALL | re.UNICODE), 'double', 'strong,em'), + EmStrongItem(re.compile(STRONG_EM2_RE, re.DOTALL | re.UNICODE), 'double', 'em,strong'), + EmStrongItem(re.compile(STRONG_EM_RE, re.DOTALL | re.UNICODE), 'double2', 'strong,em'), + EmStrongItem(re.compile(STRONG_RE, re.DOTALL | re.UNICODE), 'single', 'strong'), + EmStrongItem(re.compile(EMPHASIS_RE, re.DOTALL | re.UNICODE), 'single', 'em') + ] + + +class LegacyEmExtension(Extension): + """ Add legacy_em extension to Markdown class.""" + + def extendMarkdown(self, md): + """ Modify inline patterns. """ + md.inlinePatterns.register(LegacyUnderscoreProcessor(r'_'), 'em_strong2', 50) + + +def makeExtension(**kwargs): # pragma: no cover + """ Return an instance of the `LegacyEmExtension` """ + return LegacyEmExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/md_in_html.py b/plugins/markdown_preview/markdown/extensions/md_in_html.py new file mode 100644 index 0000000..982d603 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/md_in_html.py @@ -0,0 +1,372 @@ +# Python-Markdown Markdown in HTML Extension +# =============================== + +# An implementation of [PHP Markdown Extra](http://michelf.com/projects/php-markdown/extra/)'s +# parsing of Markdown syntax in raw HTML. + +# See https://Python-Markdown.github.io/extensions/raw_html +# for documentation. + +# Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +An implementation of [PHP Markdown Extra](http://michelf.com/projects/php-markdown/extra/)'s +parsing of Markdown syntax in raw HTML. + +See the [documentation](https://Python-Markdown.github.io/extensions/raw_html) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +from ..preprocessors import Preprocessor +from ..postprocessors import RawHtmlPostprocessor +from .. import util +from ..htmlparser import HTMLExtractor, blank_line_re +import xml.etree.ElementTree as etree + + +class HTMLExtractorExtra(HTMLExtractor): + """ + Override `HTMLExtractor` and create `etree` `Elements` for any elements which should have content parsed as + Markdown. + """ + + def __init__(self, md, *args, **kwargs): + # All block-level tags. + self.block_level_tags = set(md.block_level_elements.copy()) + # Block-level tags in which the content only gets span level parsing + self.span_tags = set( + ['address', 'dd', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'legend', 'li', 'p', 'summary', 'td', 'th'] + ) + # Block-level tags which never get their content parsed. + self.raw_tags = set(['canvas', 'math', 'option', 'pre', 'script', 'style', 'textarea']) + + super().__init__(md, *args, **kwargs) + + # Block-level tags in which the content gets parsed as blocks + self.block_tags = set(self.block_level_tags) - (self.span_tags | self.raw_tags | self.empty_tags) + self.span_and_blocks_tags = self.block_tags | self.span_tags + + def reset(self): + """Reset this instance. Loses all unprocessed data.""" + self.mdstack = [] # When markdown=1, stack contains a list of tags + self.treebuilder = etree.TreeBuilder() + self.mdstate = [] # one of 'block', 'span', 'off', or None + super().reset() + + def close(self): + """Handle any buffered data.""" + super().close() + # Handle any unclosed tags. + if self.mdstack: + # Close the outermost parent. `handle_endtag` will close all unclosed children. + self.handle_endtag(self.mdstack[0]) + + def get_element(self): + """ Return element from `treebuilder` and reset `treebuilder` for later use. """ + element = self.treebuilder.close() + self.treebuilder = etree.TreeBuilder() + return element + + def get_state(self, tag, attrs): + """ Return state from tag and `markdown` attribute. One of 'block', 'span', or 'off'. """ + md_attr = attrs.get('markdown', '0') + if md_attr == 'markdown': + # `<tag markdown>` is the same as `<tag markdown='1'>`. + md_attr = '1' + parent_state = self.mdstate[-1] if self.mdstate else None + if parent_state == 'off' or (parent_state == 'span' and md_attr != '0'): + # Only use the parent state if it is more restrictive than the markdown attribute. + md_attr = parent_state + if ((md_attr == '1' and tag in self.block_tags) or + (md_attr == 'block' and tag in self.span_and_blocks_tags)): + return 'block' + elif ((md_attr == '1' and tag in self.span_tags) or + (md_attr == 'span' and tag in self.span_and_blocks_tags)): + return 'span' + elif tag in self.block_level_tags: + return 'off' + else: # pragma: no cover + return None + + def handle_starttag(self, tag, attrs): + # Handle tags that should always be empty and do not specify a closing tag + if tag in self.empty_tags and (self.at_line_start() or self.intail): + attrs = {key: value if value is not None else key for key, value in attrs} + if "markdown" in attrs: + attrs.pop('markdown') + element = etree.Element(tag, attrs) + data = etree.tostring(element, encoding='unicode', method='html') + else: + data = self.get_starttag_text() + self.handle_empty_tag(data, True) + return + + if tag in self.block_level_tags and (self.at_line_start() or self.intail): + # Valueless attribute (ex: `<tag checked>`) results in `[('checked', None)]`. + # Convert to `{'checked': 'checked'}`. + attrs = {key: value if value is not None else key for key, value in attrs} + state = self.get_state(tag, attrs) + if self.inraw or (state in [None, 'off'] and not self.mdstack): + # fall back to default behavior + attrs.pop('markdown', None) + super().handle_starttag(tag, attrs) + else: + if 'p' in self.mdstack and tag in self.block_level_tags: + # Close unclosed 'p' tag + self.handle_endtag('p') + self.mdstate.append(state) + self.mdstack.append(tag) + attrs['markdown'] = state + self.treebuilder.start(tag, attrs) + else: + # Span level tag + if self.inraw: + super().handle_starttag(tag, attrs) + else: + text = self.get_starttag_text() + if self.mdstate and self.mdstate[-1] == "off": + self.handle_data(self.md.htmlStash.store(text)) + else: + self.handle_data(text) + if tag in self.CDATA_CONTENT_ELEMENTS: + # This is presumably a standalone tag in a code span (see #1036). + self.clear_cdata_mode() + + def handle_endtag(self, tag): + if tag in self.block_level_tags: + if self.inraw: + super().handle_endtag(tag) + elif tag in self.mdstack: + # Close element and any unclosed children + while self.mdstack: + item = self.mdstack.pop() + self.mdstate.pop() + self.treebuilder.end(item) + if item == tag: + break + if not self.mdstack: + # Last item in stack is closed. Stash it + element = self.get_element() + # Get last entry to see if it ends in newlines + # If it is an element, assume there is no newlines + item = self.cleandoc[-1] if self.cleandoc else '' + # If we only have one newline before block element, add another + if not item.endswith('\n\n') and item.endswith('\n'): + self.cleandoc.append('\n') + self.cleandoc.append(self.md.htmlStash.store(element)) + self.cleandoc.append('\n\n') + self.state = [] + # Check if element has a tail + if not blank_line_re.match( + self.rawdata[self.line_offset + self.offset + len(self.get_endtag_text(tag)):]): + # More content exists after `endtag`. + self.intail = True + else: + # Treat orphan closing tag as a span level tag. + text = self.get_endtag_text(tag) + if self.mdstate and self.mdstate[-1] == "off": + self.handle_data(self.md.htmlStash.store(text)) + else: + self.handle_data(text) + else: + # Span level tag + if self.inraw: + super().handle_endtag(tag) + else: + text = self.get_endtag_text(tag) + if self.mdstate and self.mdstate[-1] == "off": + self.handle_data(self.md.htmlStash.store(text)) + else: + self.handle_data(text) + + def handle_startendtag(self, tag, attrs): + if tag in self.empty_tags: + attrs = {key: value if value is not None else key for key, value in attrs} + if "markdown" in attrs: + attrs.pop('markdown') + element = etree.Element(tag, attrs) + data = etree.tostring(element, encoding='unicode', method='html') + else: + data = self.get_starttag_text() + else: + data = self.get_starttag_text() + self.handle_empty_tag(data, is_block=self.md.is_block_level(tag)) + + def handle_data(self, data): + if self.intail and '\n' in data: + self.intail = False + if self.inraw or not self.mdstack: + super().handle_data(data) + else: + self.treebuilder.data(data) + + def handle_empty_tag(self, data, is_block): + if self.inraw or not self.mdstack: + super().handle_empty_tag(data, is_block) + else: + if self.at_line_start() and is_block: + self.handle_data('\n' + self.md.htmlStash.store(data) + '\n\n') + else: + self.handle_data(self.md.htmlStash.store(data)) + + def parse_pi(self, i): + if self.at_line_start() or self.intail or self.mdstack: + # The same override exists in `HTMLExtractor` without the check + # for `mdstack`. Therefore, use parent of `HTMLExtractor` instead. + return super(HTMLExtractor, self).parse_pi(i) + # This is not the beginning of a raw block so treat as plain data + # and avoid consuming any tags which may follow (see #1066). + self.handle_data('<?') + return i + 2 + + def parse_html_declaration(self, i): + if self.at_line_start() or self.intail or self.mdstack: + # The same override exists in `HTMLExtractor` without the check + # for `mdstack`. Therefore, use parent of `HTMLExtractor` instead. + return super(HTMLExtractor, self).parse_html_declaration(i) + # This is not the beginning of a raw block so treat as plain data + # and avoid consuming any tags which may follow (see #1066). + self.handle_data('<!') + return i + 2 + + +class HtmlBlockPreprocessor(Preprocessor): + """Remove html blocks from the text and store them for later retrieval.""" + + def run(self, lines): + source = '\n'.join(lines) + parser = HTMLExtractorExtra(self.md) + parser.feed(source) + parser.close() + return ''.join(parser.cleandoc).split('\n') + + +class MarkdownInHtmlProcessor(BlockProcessor): + """Process Markdown Inside HTML Blocks which have been stored in the `HtmlStash`.""" + + def test(self, parent, block): + # Always return True. `run` will return `False` it not a valid match. + return True + + def parse_element_content(self, element): + """ + Recursively parse the text content of an `etree` Element as Markdown. + + Any block level elements generated from the Markdown will be inserted as children of the element in place + of the text content. All `markdown` attributes are removed. For any elements in which Markdown parsing has + been disabled, the text content of it and its children are wrapped in an `AtomicString`. + """ + + md_attr = element.attrib.pop('markdown', 'off') + + if md_attr == 'block': + # Parse content as block level + # The order in which the different parts are parsed (text, children, tails) is important here as the + # order of elements needs to be preserved. We can't be inserting items at a later point in the current + # iteration as we don't want to do raw processing on elements created from parsing Markdown text (for + # example). Therefore, the order of operations is children, tails, text. + + # Recursively parse existing children from raw HTML + for child in list(element): + self.parse_element_content(child) + + # Parse Markdown text in tail of children. Do this separate to avoid raw HTML parsing. + # Save the position of each item to be inserted later in reverse. + tails = [] + for pos, child in enumerate(element): + if child.tail: + block = child.tail.rstrip('\n') + child.tail = '' + # Use a dummy placeholder element. + dummy = etree.Element('div') + self.parser.parseBlocks(dummy, block.split('\n\n')) + children = list(dummy) + children.reverse() + tails.append((pos + 1, children)) + + # Insert the elements created from the tails in reverse. + tails.reverse() + for pos, tail in tails: + for item in tail: + element.insert(pos, item) + + # Parse Markdown text content. Do this last to avoid raw HTML parsing. + if element.text: + block = element.text.rstrip('\n') + element.text = '' + # Use a dummy placeholder element as the content needs to get inserted before existing children. + dummy = etree.Element('div') + self.parser.parseBlocks(dummy, block.split('\n\n')) + children = list(dummy) + children.reverse() + for child in children: + element.insert(0, child) + + elif md_attr == 'span': + # Span level parsing will be handled by inline processors. + # Walk children here to remove any `markdown` attributes. + for child in list(element): + self.parse_element_content(child) + + else: + # Disable inline parsing for everything else + if element.text is None: + element.text = '' + element.text = util.AtomicString(element.text) + for child in list(element): + self.parse_element_content(child) + if child.tail: + child.tail = util.AtomicString(child.tail) + + def run(self, parent, blocks): + m = util.HTML_PLACEHOLDER_RE.match(blocks[0]) + if m: + index = int(m.group(1)) + element = self.parser.md.htmlStash.rawHtmlBlocks[index] + if isinstance(element, etree.Element): + # We have a matched element. Process it. + blocks.pop(0) + self.parse_element_content(element) + parent.append(element) + # Cleanup stash. Replace element with empty string to avoid confusing postprocessor. + self.parser.md.htmlStash.rawHtmlBlocks.pop(index) + self.parser.md.htmlStash.rawHtmlBlocks.insert(index, '') + # Confirm the match to the `blockparser`. + return True + # No match found. + return False + + +class MarkdownInHTMLPostprocessor(RawHtmlPostprocessor): + def stash_to_string(self, text): + """ Override default to handle any `etree` elements still in the stash. """ + if isinstance(text, etree.Element): + return self.md.serializer(text) + else: + return str(text) + + +class MarkdownInHtmlExtension(Extension): + """Add Markdown parsing in HTML to Markdown class.""" + + def extendMarkdown(self, md): + """ Register extension instances. """ + + # Replace raw HTML preprocessor + md.preprocessors.register(HtmlBlockPreprocessor(md), 'html_block', 20) + # Add `blockprocessor` which handles the placeholders for `etree` elements + md.parser.blockprocessors.register( + MarkdownInHtmlProcessor(md.parser), 'markdown_block', 105 + ) + # Replace raw HTML postprocessor + md.postprocessors.register(MarkdownInHTMLPostprocessor(md), 'raw_html', 30) + + +def makeExtension(**kwargs): # pragma: no cover + return MarkdownInHtmlExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/meta.py b/plugins/markdown_preview/markdown/extensions/meta.py new file mode 100644 index 0000000..8217927 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/meta.py @@ -0,0 +1,85 @@ +# Meta Data Extension for Python-Markdown +# ======================================= + +# This extension adds Meta Data handling to markdown. + +# See https://Python-Markdown.github.io/extensions/meta_data +# for documentation. + +# Original code Copyright 2007-2008 [Waylan Limberg](http://achinghead.com). + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +This extension adds Meta Data handling to markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/meta_data) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..preprocessors import Preprocessor +import re +import logging + +log = logging.getLogger('MARKDOWN') + +# Global Vars +META_RE = re.compile(r'^[ ]{0,3}(?P<key>[A-Za-z0-9_-]+):\s*(?P<value>.*)') +META_MORE_RE = re.compile(r'^[ ]{4,}(?P<value>.*)') +BEGIN_RE = re.compile(r'^-{3}(\s.*)?') +END_RE = re.compile(r'^(-{3}|\.{3})(\s.*)?') + + +class MetaExtension (Extension): + """ Meta-Data extension for Python-Markdown. """ + + def extendMarkdown(self, md): + """ Add `MetaPreprocessor` to Markdown instance. """ + md.registerExtension(self) + self.md = md + md.preprocessors.register(MetaPreprocessor(md), 'meta', 27) + + def reset(self) -> None: + self.md.Meta = {} + + +class MetaPreprocessor(Preprocessor): + """ Get Meta-Data. """ + + def run(self, lines): + """ Parse Meta-Data and store in Markdown.Meta. """ + meta = {} + key = None + if lines and BEGIN_RE.match(lines[0]): + lines.pop(0) + while lines: + line = lines.pop(0) + m1 = META_RE.match(line) + if line.strip() == '' or END_RE.match(line): + break # blank line or end of YAML header - done + if m1: + key = m1.group('key').lower().strip() + value = m1.group('value').strip() + try: + meta[key].append(value) + except KeyError: + meta[key] = [value] + else: + m2 = META_MORE_RE.match(line) + if m2 and key: + # Add another line to existing key + meta[key].append(m2.group('value').strip()) + else: + lines.insert(0, line) + break # no meta data - done + self.md.Meta = meta + return lines + + +def makeExtension(**kwargs): # pragma: no cover + return MetaExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/nl2br.py b/plugins/markdown_preview/markdown/extensions/nl2br.py new file mode 100644 index 0000000..177df1e --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/nl2br.py @@ -0,0 +1,41 @@ +# `NL2BR` Extension +# =============== + +# A Python-Markdown extension to treat newlines as hard breaks; like +# GitHub-flavored Markdown does. + +# See https://Python-Markdown.github.io/extensions/nl2br +# for documentation. + +# Original code Copyright 2011 [Brian Neal](https://deathofagremmie.com/) + +# All changes Copyright 2011-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +A Python-Markdown extension to treat newlines as hard breaks; like +GitHub-flavored Markdown does. + +See the [documentation](https://Python-Markdown.github.io/extensions/nl2br) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..inlinepatterns import SubstituteTagInlineProcessor + +BR_RE = r'\n' + + +class Nl2BrExtension(Extension): + + def extendMarkdown(self, md): + """ Add a `SubstituteTagInlineProcessor` to Markdown. """ + br_tag = SubstituteTagInlineProcessor(BR_RE, 'br') + md.inlinePatterns.register(br_tag, 'nl', 5) + + +def makeExtension(**kwargs): # pragma: no cover + return Nl2BrExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/sane_lists.py b/plugins/markdown_preview/markdown/extensions/sane_lists.py new file mode 100644 index 0000000..305bd99 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/sane_lists.py @@ -0,0 +1,65 @@ +# Sane List Extension for Python-Markdown +# ======================================= + +# Modify the behavior of Lists in Python-Markdown to act in a sane manor. + +# See https://Python-Markdown.github.io/extensions/sane_lists +# for documentation. + +# Original code Copyright 2011 [Waylan Limberg](http://achinghead.com) + +# All changes Copyright 2011-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Modify the behavior of Lists in Python-Markdown to act in a sane manor. + +See [documentation](https://Python-Markdown.github.io/extensions/sane_lists) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import OListProcessor, UListProcessor +import re + + +class SaneOListProcessor(OListProcessor): + """ Override `SIBLING_TAGS` to not include `ul` and set `LAZY_OL` to `False`. """ + + SIBLING_TAGS = ['ol'] + """ Exclude `ul` from list of siblings. """ + LAZY_OL = False + """ Disable lazy list behavior. """ + + def __init__(self, parser): + super().__init__(parser) + self.CHILD_RE = re.compile(r'^[ ]{0,%d}((\d+\.))[ ]+(.*)' % + (self.tab_length - 1)) + + +class SaneUListProcessor(UListProcessor): + """ Override `SIBLING_TAGS` to not include `ol`. """ + + SIBLING_TAGS = ['ul'] + """ Exclude `ol` from list of siblings. """ + + def __init__(self, parser): + super().__init__(parser) + self.CHILD_RE = re.compile(r'^[ ]{0,%d}(([*+-]))[ ]+(.*)' % + (self.tab_length - 1)) + + +class SaneListExtension(Extension): + """ Add sane lists to Markdown. """ + + def extendMarkdown(self, md): + """ Override existing Processors. """ + md.parser.blockprocessors.register(SaneOListProcessor(md.parser), 'olist', 40) + md.parser.blockprocessors.register(SaneUListProcessor(md.parser), 'ulist', 30) + + +def makeExtension(**kwargs): # pragma: no cover + return SaneListExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/smarty.py b/plugins/markdown_preview/markdown/extensions/smarty.py new file mode 100644 index 0000000..3274bf8 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/smarty.py @@ -0,0 +1,265 @@ +# Smarty extension for Python-Markdown +# ==================================== + +# Adds conversion of ASCII dashes, quotes and ellipses to their HTML +# entity equivalents. + +# See https://Python-Markdown.github.io/extensions/smarty +# for documentation. + +# Author: 2013, Dmitry Shachnev <mitya57@gmail.com> + +# All changes Copyright 2013-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +# SmartyPants license: + +# Copyright (c) 2003 John Gruber <https://daringfireball.net/> +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: + +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. + +# * Neither the name "SmartyPants" nor the names of its contributors +# may be used to endorse or promote products derived from this +# software without specific prior written permission. + +# This software is provided by the copyright holders and contributors "as +# is" and any express or implied warranties, including, but not limited +# to, the implied warranties of merchantability and fitness for a +# particular purpose are disclaimed. In no event shall the copyright +# owner or contributors be liable for any direct, indirect, incidental, +# special, exemplary, or consequential damages (including, but not +# limited to, procurement of substitute goods or services; loss of use, +# data, or profits; or business interruption) however caused and on any +# theory of liability, whether in contract, strict liability, or tort +# (including negligence or otherwise) arising in any way out of the use +# of this software, even if advised of the possibility of such damage. + + +# `smartypants.py` license: + +# `smartypants.py` is a derivative work of SmartyPants. +# Copyright (c) 2004, 2007 Chad Miller <http://web.chad.org/> + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: + +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. + +# This software is provided by the copyright holders and contributors "as +# is" and any express or implied warranties, including, but not limited +# to, the implied warranties of merchantability and fitness for a +# particular purpose are disclaimed. In no event shall the copyright +# owner or contributors be liable for any direct, indirect, incidental, +# special, exemplary, or consequential damages (including, but not +# limited to, procurement of substitute goods or services; loss of use, +# data, or profits; or business interruption) however caused and on any +# theory of liability, whether in contract, strict liability, or tort +# (including negligence or otherwise) arising in any way out of the use +# of this software, even if advised of the possibility of such damage. + +""" +Adds conversion of ASCII dashes, quotes and ellipses to their HTML +entity equivalents. + +See the [documentation](https://Python-Markdown.github.io/extensions/smarty) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..inlinepatterns import HtmlInlineProcessor, HTML_RE +from ..treeprocessors import InlineProcessor +from ..util import Registry + + +# Constants for quote education. +punctClass = r"""[!"#\$\%'()*+,-.\/:;<=>?\@\[\\\]\^_`{|}~]""" +endOfWordClass = r"[\s.,;:!?)]" +closeClass = r"[^\ \t\r\n\[\{\(\-\u0002\u0003]" + +openingQuotesBase = ( + r'(\s' # a whitespace char + r'| ' # or a non-breaking space entity + r'|--' # or dashes + r'|–|—' # or Unicode + r'|&[mn]dash;' # or named dash entities + r'|–|—' # or decimal entities + r')' +) + +substitutions = { + 'mdash': '—', + 'ndash': '–', + 'ellipsis': '…', + 'left-angle-quote': '«', + 'right-angle-quote': '»', + 'left-single-quote': '‘', + 'right-single-quote': '’', + 'left-double-quote': '“', + 'right-double-quote': '”', +} + + +# Special case if the very first character is a quote +# followed by punctuation at a non-word-break. Close the quotes by brute force: +singleQuoteStartRe = r"^'(?=%s\B)" % punctClass +doubleQuoteStartRe = r'^"(?=%s\B)' % punctClass + +# Special case for double sets of quotes, e.g.: +# <p>He said, "'Quoted' words in a larger quote."</p> +doubleQuoteSetsRe = r""""'(?=\w)""" +singleQuoteSetsRe = r"""'"(?=\w)""" + +# Special case for decade abbreviations (the '80s): +decadeAbbrRe = r"(?<!\w)'(?=\d{2}s)" + +# Get most opening double quotes: +openingDoubleQuotesRegex = r'%s"(?=\w)' % openingQuotesBase + +# Double closing quotes: +closingDoubleQuotesRegex = r'"(?=\s)' +closingDoubleQuotesRegex2 = '(?<=%s)"' % closeClass + +# Get most opening single quotes: +openingSingleQuotesRegex = r"%s'(?=\w)" % openingQuotesBase + +# Single closing quotes: +closingSingleQuotesRegex = r"(?<=%s)'(?!\s|s\b|\d)" % closeClass +closingSingleQuotesRegex2 = r"'(\s|s\b)" + +# All remaining quotes should be opening ones +remainingSingleQuotesRegex = r"'" +remainingDoubleQuotesRegex = r'"' + +HTML_STRICT_RE = HTML_RE + r'(?!\>)' + + +class SubstituteTextPattern(HtmlInlineProcessor): + def __init__(self, pattern, replace, md): + """ Replaces matches with some text. """ + HtmlInlineProcessor.__init__(self, pattern) + self.replace = replace + self.md = md + + def handleMatch(self, m, data): + result = '' + for part in self.replace: + if isinstance(part, int): + result += m.group(part) + else: + result += self.md.htmlStash.store(part) + return result, m.start(0), m.end(0) + + +class SmartyExtension(Extension): + """ Add Smarty to Markdown. """ + def __init__(self, **kwargs): + self.config = { + 'smart_quotes': [True, 'Educate quotes'], + 'smart_angled_quotes': [False, 'Educate angled quotes'], + 'smart_dashes': [True, 'Educate dashes'], + 'smart_ellipses': [True, 'Educate ellipses'], + 'substitutions': [{}, 'Overwrite default substitutions'], + } + """ Default configuration options. """ + super().__init__(**kwargs) + self.substitutions = dict(substitutions) + self.substitutions.update(self.getConfig('substitutions', default={})) + + def _addPatterns(self, md, patterns, serie, priority): + for ind, pattern in enumerate(patterns): + pattern += (md,) + pattern = SubstituteTextPattern(*pattern) + name = 'smarty-%s-%d' % (serie, ind) + self.inlinePatterns.register(pattern, name, priority-ind) + + def educateDashes(self, md) -> None: + emDashesPattern = SubstituteTextPattern( + r'(?<!-)---(?!-)', (self.substitutions['mdash'],), md + ) + enDashesPattern = SubstituteTextPattern( + r'(?<!-)--(?!-)', (self.substitutions['ndash'],), md + ) + self.inlinePatterns.register(emDashesPattern, 'smarty-em-dashes', 50) + self.inlinePatterns.register(enDashesPattern, 'smarty-en-dashes', 45) + + def educateEllipses(self, md) -> None: + ellipsesPattern = SubstituteTextPattern( + r'(?<!\.)\.{3}(?!\.)', (self.substitutions['ellipsis'],), md + ) + self.inlinePatterns.register(ellipsesPattern, 'smarty-ellipses', 10) + + def educateAngledQuotes(self, md) -> None: + leftAngledQuotePattern = SubstituteTextPattern( + r'\<\<', (self.substitutions['left-angle-quote'],), md + ) + rightAngledQuotePattern = SubstituteTextPattern( + r'\>\>', (self.substitutions['right-angle-quote'],), md + ) + self.inlinePatterns.register(leftAngledQuotePattern, 'smarty-left-angle-quotes', 40) + self.inlinePatterns.register(rightAngledQuotePattern, 'smarty-right-angle-quotes', 35) + + def educateQuotes(self, md) -> None: + lsquo = self.substitutions['left-single-quote'] + rsquo = self.substitutions['right-single-quote'] + ldquo = self.substitutions['left-double-quote'] + rdquo = self.substitutions['right-double-quote'] + patterns = ( + (singleQuoteStartRe, (rsquo,)), + (doubleQuoteStartRe, (rdquo,)), + (doubleQuoteSetsRe, (ldquo + lsquo,)), + (singleQuoteSetsRe, (lsquo + ldquo,)), + (decadeAbbrRe, (rsquo,)), + (openingSingleQuotesRegex, (1, lsquo)), + (closingSingleQuotesRegex, (rsquo,)), + (closingSingleQuotesRegex2, (rsquo, 1)), + (remainingSingleQuotesRegex, (lsquo,)), + (openingDoubleQuotesRegex, (1, ldquo)), + (closingDoubleQuotesRegex, (rdquo,)), + (closingDoubleQuotesRegex2, (rdquo,)), + (remainingDoubleQuotesRegex, (ldquo,)) + ) + self._addPatterns(md, patterns, 'quotes', 30) + + def extendMarkdown(self, md): + configs = self.getConfigs() + self.inlinePatterns: Registry[HtmlInlineProcessor] = Registry() + if configs['smart_ellipses']: + self.educateEllipses(md) + if configs['smart_quotes']: + self.educateQuotes(md) + if configs['smart_angled_quotes']: + self.educateAngledQuotes(md) + # Override `HTML_RE` from `inlinepatterns.py` so that it does not + # process tags with duplicate closing quotes. + md.inlinePatterns.register(HtmlInlineProcessor(HTML_STRICT_RE, md), 'html', 90) + if configs['smart_dashes']: + self.educateDashes(md) + inlineProcessor = InlineProcessor(md) + inlineProcessor.inlinePatterns = self.inlinePatterns + md.treeprocessors.register(inlineProcessor, 'smarty', 2) + md.ESCAPED_CHARS.extend(['"', "'"]) + + +def makeExtension(**kwargs): # pragma: no cover + return SmartyExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/tables.py b/plugins/markdown_preview/markdown/extensions/tables.py new file mode 100644 index 0000000..a9e5f13 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/tables.py @@ -0,0 +1,243 @@ +# Tables Extension for Python-Markdown +# ==================================== + +# Added parsing of tables to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/tables +# for documentation. + +# Original code Copyright 2009 [Waylan Limberg](http://achinghead.com) + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Added parsing of tables to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/tables) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +import xml.etree.ElementTree as etree +import re +PIPE_NONE = 0 +PIPE_LEFT = 1 +PIPE_RIGHT = 2 + + +class TableProcessor(BlockProcessor): + """ Process Tables. """ + + RE_CODE_PIPES = re.compile(r'(?:(\\\\)|(\\`+)|(`+)|(\\\|)|(\|))') + RE_END_BORDER = re.compile(r'(?<!\\)(?:\\\\)*\|$') + + def __init__(self, parser, config): + self.border = False + self.separator = '' + self.config = config + + super().__init__(parser) + + def test(self, parent, block): + """ + Ensure first two rows (column header and separator row) are valid table rows. + + Keep border check and separator row do avoid repeating the work. + """ + is_table = False + rows = [row.strip(' ') for row in block.split('\n')] + if len(rows) > 1: + header0 = rows[0] + self.border = PIPE_NONE + if header0.startswith('|'): + self.border |= PIPE_LEFT + if self.RE_END_BORDER.search(header0) is not None: + self.border |= PIPE_RIGHT + row = self._split_row(header0) + row0_len = len(row) + is_table = row0_len > 1 + + # Each row in a single column table needs at least one pipe. + if not is_table and row0_len == 1 and self.border: + for index in range(1, len(rows)): + is_table = rows[index].startswith('|') + if not is_table: + is_table = self.RE_END_BORDER.search(rows[index]) is not None + if not is_table: + break + + if is_table: + row = self._split_row(rows[1]) + is_table = (len(row) == row0_len) and set(''.join(row)) <= set('|:- ') + if is_table: + self.separator = row + + return is_table + + def run(self, parent, blocks): + """ Parse a table block and build table. """ + block = blocks.pop(0).split('\n') + header = block[0].strip(' ') + rows = [] if len(block) < 3 else block[2:] + + # Get alignment of columns + align = [] + for c in self.separator: + c = c.strip(' ') + if c.startswith(':') and c.endswith(':'): + align.append('center') + elif c.startswith(':'): + align.append('left') + elif c.endswith(':'): + align.append('right') + else: + align.append(None) + + # Build table + table = etree.SubElement(parent, 'table') + thead = etree.SubElement(table, 'thead') + self._build_row(header, thead, align) + tbody = etree.SubElement(table, 'tbody') + if len(rows) == 0: + # Handle empty table + self._build_empty_row(tbody, align) + else: + for row in rows: + self._build_row(row.strip(' '), tbody, align) + + def _build_empty_row(self, parent, align): + """Build an empty row.""" + tr = etree.SubElement(parent, 'tr') + count = len(align) + while count: + etree.SubElement(tr, 'td') + count -= 1 + + def _build_row(self, row, parent, align): + """ Given a row of text, build table cells. """ + tr = etree.SubElement(parent, 'tr') + tag = 'td' + if parent.tag == 'thead': + tag = 'th' + cells = self._split_row(row) + # We use align here rather than cells to ensure every row + # contains the same number of columns. + for i, a in enumerate(align): + c = etree.SubElement(tr, tag) + try: + c.text = cells[i].strip(' ') + except IndexError: # pragma: no cover + c.text = "" + if a: + if self.config['use_align_attribute']: + c.set('align', a) + else: + c.set('style', f'text-align: {a};') + + def _split_row(self, row): + """ split a row of text into list of cells. """ + if self.border: + if row.startswith('|'): + row = row[1:] + row = self.RE_END_BORDER.sub('', row) + return self._split(row) + + def _split(self, row): + """ split a row of text with some code into a list of cells. """ + elements = [] + pipes = [] + tics = [] + tic_points = [] + tic_region = [] + good_pipes = [] + + # Parse row + # Throw out \\, and \| + for m in self.RE_CODE_PIPES.finditer(row): + # Store ` data (len, start_pos, end_pos) + if m.group(2): + # \`+ + # Store length of each tic group: subtract \ + tics.append(len(m.group(2)) - 1) + # Store start of group, end of group, and escape length + tic_points.append((m.start(2), m.end(2) - 1, 1)) + elif m.group(3): + # `+ + # Store length of each tic group + tics.append(len(m.group(3))) + # Store start of group, end of group, and escape length + tic_points.append((m.start(3), m.end(3) - 1, 0)) + # Store pipe location + elif m.group(5): + pipes.append(m.start(5)) + + # Pair up tics according to size if possible + # Subtract the escape length *only* from the opening. + # Walk through tic list and see if tic has a close. + # Store the tic region (start of region, end of region). + pos = 0 + tic_len = len(tics) + while pos < tic_len: + try: + tic_size = tics[pos] - tic_points[pos][2] + if tic_size == 0: + raise ValueError + index = tics[pos + 1:].index(tic_size) + 1 + tic_region.append((tic_points[pos][0], tic_points[pos + index][1])) + pos += index + 1 + except ValueError: + pos += 1 + + # Resolve pipes. Check if they are within a tic pair region. + # Walk through pipes comparing them to each region. + # - If pipe position is less that a region, it isn't in a region + # - If it is within a region, we don't want it, so throw it out + # - If we didn't throw it out, it must be a table pipe + for pipe in pipes: + throw_out = False + for region in tic_region: + if pipe < region[0]: + # Pipe is not in a region + break + elif region[0] <= pipe <= region[1]: + # Pipe is within a code region. Throw it out. + throw_out = True + break + if not throw_out: + good_pipes.append(pipe) + + # Split row according to table delimiters. + pos = 0 + for pipe in good_pipes: + elements.append(row[pos:pipe]) + pos = pipe + 1 + elements.append(row[pos:]) + return elements + + +class TableExtension(Extension): + """ Add tables to Markdown. """ + + def __init__(self, **kwargs): + self.config = { + 'use_align_attribute': [False, 'True to use align attribute instead of style.'], + } + """ Default configuration options. """ + + super().__init__(**kwargs) + + def extendMarkdown(self, md): + """ Add an instance of `TableProcessor` to `BlockParser`. """ + if '|' not in md.ESCAPED_CHARS: + md.ESCAPED_CHARS.append('|') + processor = TableProcessor(md.parser, self.getConfigs()) + md.parser.blockprocessors.register(processor, 'table', 75) + + +def makeExtension(**kwargs): # pragma: no cover + return TableExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/toc.py b/plugins/markdown_preview/markdown/extensions/toc.py new file mode 100644 index 0000000..64c20c8 --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/toc.py @@ -0,0 +1,408 @@ +# Table of Contents Extension for Python-Markdown +# =============================================== + +# See https://Python-Markdown.github.io/extensions/toc +# for documentation. + +# Original code Copyright 2008 [Jack Miller](https://codezen.org/) + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Add table of contents support to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/toc) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..treeprocessors import Treeprocessor +from ..util import code_escape, parseBoolValue, AMP_SUBSTITUTE, HTML_PLACEHOLDER_RE, AtomicString +from ..treeprocessors import UnescapeTreeprocessor +import re +import html +import unicodedata +import xml.etree.ElementTree as etree + + +def slugify(value, separator, unicode=False): + """ Slugify a string, to make it URL friendly. """ + if not unicode: + # Replace Extended Latin characters with ASCII, i.e. `žlutý` => `zluty` + value = unicodedata.normalize('NFKD', value) + value = value.encode('ascii', 'ignore').decode('ascii') + value = re.sub(r'[^\w\s-]', '', value).strip().lower() + return re.sub(r'[{}\s]+'.format(separator), separator, value) + + +def slugify_unicode(value, separator): + """ Slugify a string, to make it URL friendly while preserving Unicode characters. """ + return slugify(value, separator, unicode=True) + + +IDCOUNT_RE = re.compile(r'^(.*)_([0-9]+)$') + + +def unique(id, ids): + """ Ensure id is unique in set of ids. Append '_1', '_2'... if not """ + while id in ids or not id: + m = IDCOUNT_RE.match(id) + if m: + id = '%s_%d' % (m.group(1), int(m.group(2))+1) + else: + id = '%s_%d' % (id, 1) + ids.add(id) + return id + + +def get_name(el): + """Get title name.""" + + text = [] + for c in el.itertext(): + if isinstance(c, AtomicString): + text.append(html.unescape(c)) + else: + text.append(c) + return ''.join(text).strip() + + +def stashedHTML2text(text, md, strip_entities: bool = True): + """ Extract raw HTML from stash, reduce to plain text and swap with placeholder. """ + def _html_sub(m): + """ Substitute raw html with plain text. """ + try: + raw = md.htmlStash.rawHtmlBlocks[int(m.group(1))] + except (IndexError, TypeError): # pragma: no cover + return m.group(0) + # Strip out tags and/or entities - leaving text + res = re.sub(r'(<[^>]+>)', '', raw) + if strip_entities: + res = re.sub(r'(&[\#a-zA-Z0-9]+;)', '', res) + return res + + return HTML_PLACEHOLDER_RE.sub(_html_sub, text) + + +def unescape(text): + """ Unescape escaped text. """ + c = UnescapeTreeprocessor() + return c.unescape(text) + + +def nest_toc_tokens(toc_list): + """Given an unsorted list with errors and skips, return a nested one. + + [{'level': 1}, {'level': 2}] + => + [{'level': 1, 'children': [{'level': 2, 'children': []}]}] + + A wrong list is also converted: + + [{'level': 2}, {'level': 1}] + => + [{'level': 2, 'children': []}, {'level': 1, 'children': []}] + """ + + ordered_list = [] + if len(toc_list): + # Initialize everything by processing the first entry + last = toc_list.pop(0) + last['children'] = [] + levels = [last['level']] + ordered_list.append(last) + parents = [] + + # Walk the rest nesting the entries properly + while toc_list: + t = toc_list.pop(0) + current_level = t['level'] + t['children'] = [] + + # Reduce depth if current level < last item's level + if current_level < levels[-1]: + # Pop last level since we know we are less than it + levels.pop() + + # Pop parents and levels we are less than or equal to + to_pop = 0 + for p in reversed(parents): + if current_level <= p['level']: + to_pop += 1 + else: # pragma: no cover + break + if to_pop: + levels = levels[:-to_pop] + parents = parents[:-to_pop] + + # Note current level as last + levels.append(current_level) + + # Level is the same, so append to + # the current parent (if available) + if current_level == levels[-1]: + (parents[-1]['children'] if parents + else ordered_list).append(t) + + # Current level is > last item's level, + # So make last item a parent and append current as child + else: + last['children'].append(t) + parents.append(last) + levels.append(current_level) + last = t + + return ordered_list + + +class TocTreeprocessor(Treeprocessor): + """ Step through document and build TOC. """ + + def __init__(self, md, config): + super().__init__(md) + + self.marker = config["marker"] + self.title = config["title"] + self.base_level = int(config["baselevel"]) - 1 + self.slugify = config["slugify"] + self.sep = config["separator"] + self.toc_class = config["toc_class"] + self.title_class = config["title_class"] + self.use_anchors = parseBoolValue(config["anchorlink"]) + self.anchorlink_class = config["anchorlink_class"] + self.use_permalinks = parseBoolValue(config["permalink"], False) + if self.use_permalinks is None: + self.use_permalinks = config["permalink"] + self.permalink_class = config["permalink_class"] + self.permalink_title = config["permalink_title"] + self.permalink_leading = parseBoolValue(config["permalink_leading"], False) + self.header_rgx = re.compile("[Hh][123456]") + if isinstance(config["toc_depth"], str) and '-' in config["toc_depth"]: + self.toc_top, self.toc_bottom = [int(x) for x in config["toc_depth"].split('-')] + else: + self.toc_top = 1 + self.toc_bottom = int(config["toc_depth"]) + + def iterparent(self, node): + """ Iterator wrapper to get allowed parent and child all at once. """ + + # We do not allow the marker inside a header as that + # would causes an endless loop of placing a new TOC + # inside previously generated TOC. + for child in node: + if not self.header_rgx.match(child.tag) and child.tag not in ['pre', 'code']: + yield node, child + yield from self.iterparent(child) + + def replace_marker(self, root, elem) -> None: + """ Replace marker with elem. """ + for (p, c) in self.iterparent(root): + text = ''.join(c.itertext()).strip() + if not text: + continue + + # To keep the output from screwing up the + # validation by putting a `<div>` inside of a `<p>` + # we actually replace the `<p>` in its entirety. + + # The `<p>` element may contain more than a single text content + # (`nl2br` can introduce a `<br>`). In this situation, `c.text` returns + # the very first content, ignore children contents or tail content. + # `len(c) == 0` is here to ensure there is only text in the `<p>`. + if c.text and c.text.strip() == self.marker and len(c) == 0: + for i in range(len(p)): + if p[i] == c: + p[i] = elem + break + + def set_level(self, elem) -> None: + """ Adjust header level according to base level. """ + level = int(elem.tag[-1]) + self.base_level + if level > 6: + level = 6 + elem.tag = 'h%d' % level + + def add_anchor(self, c, elem_id) -> None: + anchor = etree.Element("a") + anchor.text = c.text + anchor.attrib["href"] = "#" + elem_id + anchor.attrib["class"] = self.anchorlink_class + c.text = "" + for elem in c: + anchor.append(elem) + while len(c): + c.remove(c[0]) + c.append(anchor) + + def add_permalink(self, c, elem_id) -> None: + permalink = etree.Element("a") + permalink.text = ("%spara;" % AMP_SUBSTITUTE + if self.use_permalinks is True + else self.use_permalinks) + permalink.attrib["href"] = "#" + elem_id + permalink.attrib["class"] = self.permalink_class + if self.permalink_title: + permalink.attrib["title"] = self.permalink_title + if self.permalink_leading: + permalink.tail = c.text + c.text = "" + c.insert(0, permalink) + else: + c.append(permalink) + + def build_toc_div(self, toc_list): + """ Return a string div given a toc list. """ + div = etree.Element("div") + div.attrib["class"] = self.toc_class + + # Add title to the div + if self.title: + header = etree.SubElement(div, "span") + if self.title_class: + header.attrib["class"] = self.title_class + header.text = self.title + + def build_etree_ul(toc_list, parent): + ul = etree.SubElement(parent, "ul") + for item in toc_list: + # List item link, to be inserted into the toc div + li = etree.SubElement(ul, "li") + link = etree.SubElement(li, "a") + link.text = item.get('name', '') + link.attrib["href"] = '#' + item.get('id', '') + if item['children']: + build_etree_ul(item['children'], li) + return ul + + build_etree_ul(toc_list, div) + + if 'prettify' in self.md.treeprocessors: + self.md.treeprocessors['prettify'].run(div) + + return div + + def run(self, doc): + # Get a list of id attributes + used_ids = set() + for el in doc.iter(): + if "id" in el.attrib: + used_ids.add(el.attrib["id"]) + + toc_tokens = [] + for el in doc.iter(): + if isinstance(el.tag, str) and self.header_rgx.match(el.tag): + self.set_level(el) + text = get_name(el) + + # Do not override pre-existing ids + if "id" not in el.attrib: + innertext = unescape(stashedHTML2text(text, self.md)) + el.attrib["id"] = unique(self.slugify(innertext, self.sep), used_ids) + + if int(el.tag[-1]) >= self.toc_top and int(el.tag[-1]) <= self.toc_bottom: + toc_tokens.append({ + 'level': int(el.tag[-1]), + 'id': el.attrib["id"], + 'name': unescape(stashedHTML2text( + code_escape(el.attrib.get('data-toc-label', text)), + self.md, strip_entities=False + )) + }) + + # Remove the data-toc-label attribute as it is no longer needed + if 'data-toc-label' in el.attrib: + del el.attrib['data-toc-label'] + + if self.use_anchors: + self.add_anchor(el, el.attrib["id"]) + if self.use_permalinks not in [False, None]: + self.add_permalink(el, el.attrib["id"]) + + toc_tokens = nest_toc_tokens(toc_tokens) + div = self.build_toc_div(toc_tokens) + if self.marker: + self.replace_marker(doc, div) + + # serialize and attach to markdown instance. + toc = self.md.serializer(div) + for pp in self.md.postprocessors: + toc = pp.run(toc) + self.md.toc_tokens = toc_tokens + self.md.toc = toc + + +class TocExtension(Extension): + + TreeProcessorClass = TocTreeprocessor + + def __init__(self, **kwargs): + self.config = { + 'marker': [ + '[TOC]', + 'Text to find and replace with Table of Contents. Set to an empty string to disable. ' + 'Default: `[TOC]`.' + ], + 'title': [ + '', 'Title to insert into TOC `<div>`. Default: an empty string.' + ], + 'title_class': [ + 'toctitle', 'CSS class used for the title. Default: `toctitle`.' + ], + 'toc_class': [ + 'toc', 'CSS class(es) used for the link. Default: `toclink`.' + ], + 'anchorlink': [ + False, 'True if header should be a self link. Default: `False`.' + ], + 'anchorlink_class': [ + 'toclink', 'CSS class(es) used for the link. Defaults: `toclink`.' + ], + 'permalink': [ + 0, 'True or link text if a Sphinx-style permalink should be added. Default: `False`.' + ], + 'permalink_class': [ + 'headerlink', 'CSS class(es) used for the link. Default: `headerlink`.' + ], + 'permalink_title': [ + 'Permanent link', 'Title attribute of the permalink. Default: `Permanent link`.' + ], + 'permalink_leading': [ + False, + 'True if permalinks should be placed at start of the header, rather than end. Default: False.' + ], + 'baselevel': ['1', 'Base level for headers. Default: `1`.'], + 'slugify': [ + slugify, 'Function to generate anchors based on header text. Default: `slugify`.' + ], + 'separator': ['-', 'Word separator. Default: `-`.'], + 'toc_depth': [ + 6, + 'Define the range of section levels to include in the Table of Contents. A single integer ' + '(b) defines the bottom section level (<h1>..<hb>) only. A string consisting of two digits ' + 'separated by a hyphen in between (`2-5`) defines the top (t) and the bottom (b) (<ht>..<hb>). ' + 'Default: `6` (bottom).' + ], + } + """ Default configuration options. """ + + super().__init__(**kwargs) + + def extendMarkdown(self, md): + """ Add TOC tree processor to Markdown. """ + md.registerExtension(self) + self.md = md + self.reset() + tocext = self.TreeProcessorClass(md, self.getConfigs()) + md.treeprocessors.register(tocext, 'toc', 5) + + def reset(self) -> None: + self.md.toc = '' + self.md.toc_tokens = [] + + +def makeExtension(**kwargs): # pragma: no cover + return TocExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/extensions/wikilinks.py b/plugins/markdown_preview/markdown/extensions/wikilinks.py new file mode 100644 index 0000000..9d5acfa --- /dev/null +++ b/plugins/markdown_preview/markdown/extensions/wikilinks.py @@ -0,0 +1,96 @@ +# WikiLinks Extension for Python-Markdown +# ====================================== + +# Converts [[WikiLinks]] to relative links. + +# See https://Python-Markdown.github.io/extensions/wikilinks +# for documentation. + +# Original code Copyright [Waylan Limberg](http://achinghead.com/). + +# All changes Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Converts `[[WikiLinks]]` to relative links. + +See the [documentation](https://Python-Markdown.github.io/extensions/wikilinks) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..inlinepatterns import InlineProcessor +import xml.etree.ElementTree as etree +import re + + +def build_url(label, base, end): + """ Build a URL from the label, a base, and an end. """ + clean_label = re.sub(r'([ ]+_)|(_[ ]+)|([ ]+)', '_', label) + return '{}{}{}'.format(base, clean_label, end) + + +class WikiLinkExtension(Extension): + """ Add inline processor to Markdown. """ + + def __init__(self, **kwargs): + self.config = { + 'base_url': ['/', 'String to append to beginning or URL.'], + 'end_url': ['/', 'String to append to end of URL.'], + 'html_class': ['wikilink', 'CSS hook. Leave blank for none.'], + 'build_url': [build_url, 'Callable formats URL from label.'], + } + """ Default configuration options. """ + super().__init__(**kwargs) + + def extendMarkdown(self, md): + self.md = md + + # append to end of inline patterns + WIKILINK_RE = r'\[\[([\w0-9_ -]+)\]\]' + wikilinkPattern = WikiLinksInlineProcessor(WIKILINK_RE, self.getConfigs()) + wikilinkPattern.md = md + md.inlinePatterns.register(wikilinkPattern, 'wikilink', 75) + + +class WikiLinksInlineProcessor(InlineProcessor): + """ Build link from `wikilink`. """ + + def __init__(self, pattern, config): + super().__init__(pattern) + self.config = config + + def handleMatch(self, m, data): + if m.group(1).strip(): + base_url, end_url, html_class = self._getMeta() + label = m.group(1).strip() + url = self.config['build_url'](label, base_url, end_url) + a = etree.Element('a') + a.text = label + a.set('href', url) + if html_class: + a.set('class', html_class) + else: + a = '' + return a, m.start(0), m.end(0) + + def _getMeta(self): + """ Return meta data or `config` data. """ + base_url = self.config['base_url'] + end_url = self.config['end_url'] + html_class = self.config['html_class'] + if hasattr(self.md, 'Meta'): + if 'wiki_base_url' in self.md.Meta: + base_url = self.md.Meta['wiki_base_url'][0] + if 'wiki_end_url' in self.md.Meta: + end_url = self.md.Meta['wiki_end_url'][0] + if 'wiki_html_class' in self.md.Meta: + html_class = self.md.Meta['wiki_html_class'][0] + return base_url, end_url, html_class + + +def makeExtension(**kwargs): # pragma: no cover + return WikiLinkExtension(**kwargs) diff --git a/plugins/markdown_preview/markdown/htmlparser.py b/plugins/markdown_preview/markdown/htmlparser.py new file mode 100644 index 0000000..29e2300 --- /dev/null +++ b/plugins/markdown_preview/markdown/htmlparser.py @@ -0,0 +1,334 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +This module imports a copy of [`html.parser.HTMLParser`][] and modifies it heavily through monkey-patches. +A copy is imported rather than the module being directly imported as this ensures that the user can import +and use the unmodified library for their own needs. +""" + +from __future__ import annotations + +import re +import importlib.util +import sys + + +# Import a copy of the html.parser lib as `htmlparser` so we can monkeypatch it. +# Users can still do `from html import parser` and get the default behavior. +spec = importlib.util.find_spec('html.parser') +htmlparser = importlib.util.module_from_spec(spec) +spec.loader.exec_module(htmlparser) +sys.modules['htmlparser'] = htmlparser + +# Monkeypatch `HTMLParser` to only accept `?>` to close Processing Instructions. +htmlparser.piclose = re.compile(r'\?>') +# Monkeypatch `HTMLParser` to only recognize entity references with a closing semicolon. +htmlparser.entityref = re.compile(r'&([a-zA-Z][-.a-zA-Z0-9]*);') +# Monkeypatch `HTMLParser` to no longer support partial entities. We are always feeding a complete block, +# so the 'incomplete' functionality is unnecessary. As the `entityref` regex is run right before incomplete, +# and the two regex are the same, then incomplete will simply never match and we avoid the logic within. +htmlparser.incomplete = htmlparser.entityref +# Monkeypatch `HTMLParser` to not accept a backtick in a tag name, attribute name, or bare value. +htmlparser.locatestarttagend_tolerant = re.compile(r""" + <[a-zA-Z][^`\t\n\r\f />\x00]* # tag name <= added backtick here + (?:[\s/]* # optional whitespace before attribute name + (?:(?<=['"\s/])[^`\s/>][^\s/=>]* # attribute name <= added backtick here + (?:\s*=+\s* # value indicator + (?:'[^']*' # LITA-enclosed value + |"[^"]*" # LIT-enclosed value + |(?!['"])[^`>\s]* # bare value <= added backtick here + ) + (?:\s*,)* # possibly followed by a comma + )?(?:\s|/(?!>))* + )* + )? + \s* # trailing whitespace +""", re.VERBOSE) + +# Match a blank line at the start of a block of text (two newlines). +# The newlines may be preceded by additional whitespace. +blank_line_re = re.compile(r'^([ ]*\n){2}') + + +class HTMLExtractor(htmlparser.HTMLParser): + """ + Extract raw HTML from text. + + The raw HTML is stored in the [`htmlStash`][markdown.util.HtmlStash] of the + [`Markdown`][markdown.Markdown] instance passed to `md` and the remaining text + is stored in `cleandoc` as a list of strings. + """ + + def __init__(self, md, *args, **kwargs): + if 'convert_charrefs' not in kwargs: + kwargs['convert_charrefs'] = False + + # Block tags that should contain no content (self closing) + self.empty_tags = set(['hr']) + + self.lineno_start_cache = [0] + + # This calls self.reset + super().__init__(*args, **kwargs) + self.md = md + + def reset(self): + """Reset this instance. Loses all unprocessed data.""" + self.inraw = False + self.intail = False + self.stack = [] # When `inraw==True`, stack contains a list of tags + self._cache = [] + self.cleandoc = [] + self.lineno_start_cache = [0] + + super().reset() + + def close(self): + """Handle any buffered data.""" + super().close() + if len(self.rawdata): + # Temp fix for https://bugs.python.org/issue41989 + # TODO: remove this when the bug is fixed in all supported Python versions. + if self.convert_charrefs and not self.cdata_elem: # pragma: no cover + self.handle_data(htmlparser.unescape(self.rawdata)) + else: + self.handle_data(self.rawdata) + # Handle any unclosed tags. + if len(self._cache): + self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache))) + self._cache = [] + + @property + def line_offset(self) -> int: + """Returns char index in `self.rawdata` for the start of the current line. """ + for ii in range(len(self.lineno_start_cache)-1, self.lineno-1): + last_line_start_pos = self.lineno_start_cache[ii] + lf_pos = self.rawdata.find('\n', last_line_start_pos) + if lf_pos == -1: + # No more newlines found. Use end of raw data as start of line beyond end. + lf_pos = len(self.rawdata) + self.lineno_start_cache.append(lf_pos+1) + + return self.lineno_start_cache[self.lineno-1] + + def at_line_start(self) -> bool: + """ + Returns True if current position is at start of line. + + Allows for up to three blank spaces at start of line. + """ + if self.offset == 0: + return True + if self.offset > 3: + return False + # Confirm up to first 3 chars are whitespace + return self.rawdata[self.line_offset:self.line_offset + self.offset].strip() == '' + + def get_endtag_text(self, tag: str) -> str: + """ + Returns the text of the end tag. + + If it fails to extract the actual text from the raw data, it builds a closing tag with `tag`. + """ + # Attempt to extract actual tag from raw source text + start = self.line_offset + self.offset + m = htmlparser.endendtag.search(self.rawdata, start) + if m: + return self.rawdata[start:m.end()] + else: # pragma: no cover + # Failed to extract from raw data. Assume well formed and lowercase. + return '</{}>'.format(tag) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str]]): + # Handle tags that should always be empty and do not specify a closing tag + if tag in self.empty_tags: + self.handle_startendtag(tag, attrs) + return + + if self.md.is_block_level(tag) and (self.intail or (self.at_line_start() and not self.inraw)): + # Started a new raw block. Prepare stack. + self.inraw = True + self.cleandoc.append('\n') + + text = self.get_starttag_text() + if self.inraw: + self.stack.append(tag) + self._cache.append(text) + else: + self.cleandoc.append(text) + if tag in self.CDATA_CONTENT_ELEMENTS: + # This is presumably a standalone tag in a code span (see #1036). + self.clear_cdata_mode() + + def handle_endtag(self, tag: str): + text = self.get_endtag_text(tag) + + if self.inraw: + self._cache.append(text) + if tag in self.stack: + # Remove tag from stack + while self.stack: + if self.stack.pop() == tag: + break + if len(self.stack) == 0: + # End of raw block. + if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(text):]): + # Preserve blank line and end of raw block. + self._cache.append('\n') + else: + # More content exists after `endtag`. + self.intail = True + # Reset stack. + self.inraw = False + self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache))) + # Insert blank line between this and next line. + self.cleandoc.append('\n\n') + self._cache = [] + else: + self.cleandoc.append(text) + + def handle_data(self, data: str): + if self.intail and '\n' in data: + self.intail = False + if self.inraw: + self._cache.append(data) + else: + self.cleandoc.append(data) + + def handle_empty_tag(self, data: str, is_block: bool): + """ Handle empty tags (`<data>`). """ + if self.inraw or self.intail: + # Append this to the existing raw block + self._cache.append(data) + elif self.at_line_start() and is_block: + # Handle this as a standalone raw block + if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(data):]): + # Preserve blank line after tag in raw block. + data += '\n' + else: + # More content exists after tag. + self.intail = True + item = self.cleandoc[-1] if self.cleandoc else '' + # If we only have one newline before block element, add another + if not item.endswith('\n\n') and item.endswith('\n'): + self.cleandoc.append('\n') + self.cleandoc.append(self.md.htmlStash.store(data)) + # Insert blank line between this and next line. + self.cleandoc.append('\n\n') + else: + self.cleandoc.append(data) + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str]]): + self.handle_empty_tag(self.get_starttag_text(), is_block=self.md.is_block_level(tag)) + + def handle_charref(self, name: str): + self.handle_empty_tag('&#{};'.format(name), is_block=False) + + def handle_entityref(self, name: str): + self.handle_empty_tag('&{};'.format(name), is_block=False) + + def handle_comment(self, data: str): + self.handle_empty_tag('<!--{}-->'.format(data), is_block=True) + + def handle_decl(self, data: str): + self.handle_empty_tag('<!{}>'.format(data), is_block=True) + + def handle_pi(self, data: str): + self.handle_empty_tag('<?{}?>'.format(data), is_block=True) + + def unknown_decl(self, data: str): + end = ']]>' if data.startswith('CDATA[') else ']>' + self.handle_empty_tag('<![{}{}'.format(data, end), is_block=True) + + def parse_pi(self, i: int) -> int: + if self.at_line_start() or self.intail: + return super().parse_pi(i) + # This is not the beginning of a raw block so treat as plain data + # and avoid consuming any tags which may follow (see #1066). + self.handle_data('<?') + return i + 2 + + def parse_html_declaration(self, i: int) -> int: + if self.at_line_start() or self.intail: + return super().parse_html_declaration(i) + # This is not the beginning of a raw block so treat as plain data + # and avoid consuming any tags which may follow (see #1066). + self.handle_data('<!') + return i + 2 + + # The rest has been copied from base class in standard lib to address #1036. + # As `__startag_text` is private, all references to it must be in this subclass. + # The last few lines of `parse_starttag` are reversed so that `handle_starttag` + # can override `cdata_mode` in certain situations (in a code span). + __starttag_text: str | None = None + + def get_starttag_text(self) -> str: + """Return full source of start tag: `<...>`.""" + return self.__starttag_text + + def parse_starttag(self, i: int) -> int: # pragma: no cover + self.__starttag_text = None + endpos = self.check_for_whole_start_tag(i) + if endpos < 0: + return endpos + rawdata = self.rawdata + self.__starttag_text = rawdata[i:endpos] + + # Now parse the data between `i+1` and `j` into a tag and `attrs` + attrs = [] + match = htmlparser.tagfind_tolerant.match(rawdata, i+1) + assert match, 'unexpected call to parse_starttag()' + k = match.end() + self.lasttag = tag = match.group(1).lower() + while k < endpos: + m = htmlparser.attrfind_tolerant.match(rawdata, k) + if not m: + break + attrname, rest, attrvalue = m.group(1, 2, 3) + if not rest: + attrvalue = None + elif attrvalue[:1] == '\'' == attrvalue[-1:] or \ + attrvalue[:1] == '"' == attrvalue[-1:]: # noqa: E127 + attrvalue = attrvalue[1:-1] + if attrvalue: + attrvalue = htmlparser.unescape(attrvalue) + attrs.append((attrname.lower(), attrvalue)) + k = m.end() + + end = rawdata[k:endpos].strip() + if end not in (">", "/>"): + lineno, offset = self.getpos() + if "\n" in self.__starttag_text: + lineno = lineno + self.__starttag_text.count("\n") + offset = len(self.__starttag_text) \ + - self.__starttag_text.rfind("\n") # noqa: E127 + else: + offset = offset + len(self.__starttag_text) + self.handle_data(rawdata[i:endpos]) + return endpos + if end.endswith('/>'): + # XHTML-style empty tag: `<span attr="value" />` + self.handle_startendtag(tag, attrs) + else: + # *** set `cdata_mode` first so we can override it in `handle_starttag` (see #1036) *** + if tag in self.CDATA_CONTENT_ELEMENTS: + self.set_cdata_mode(tag) + self.handle_starttag(tag, attrs) + return endpos diff --git a/plugins/markdown_preview/markdown/inlinepatterns.py b/plugins/markdown_preview/markdown/inlinepatterns.py new file mode 100644 index 0000000..296ab83 --- /dev/null +++ b/plugins/markdown_preview/markdown/inlinepatterns.py @@ -0,0 +1,992 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +In version 3.0, a new, more flexible inline processor was added, [`markdown.inlinepatterns.InlineProcessor`][]. The +original inline patterns, which inherit from [`markdown.inlinepatterns.Pattern`][] or one of its children are still +supported, though users are encouraged to migrate. + +The new `InlineProcessor` provides two major enhancements to `Patterns`: + +1. Inline Processors no longer need to match the entire block, so regular expressions no longer need to start with + `r'^(.*?)'` and end with `r'(.*?)%'`. This runs faster. The returned [`Match`][re.Match] object will only contain + what is explicitly matched in the pattern, and extension pattern groups now start with `m.group(1)`. + +2. The `handleMatch` method now takes an additional input called `data`, which is the entire block under analysis, + not just what is matched with the specified pattern. The method now returns the element *and* the indexes relative + to `data` that the return element is replacing (usually `m.start(0)` and `m.end(0)`). If the boundaries are + returned as `None`, it is assumed that the match did not take place, and nothing will be altered in `data`. + + This allows handling of more complex constructs than regular expressions can handle, e.g., matching nested + brackets, and explicit control of the span "consumed" by the processor. + +""" + +from __future__ import annotations + +from . import util +from typing import TYPE_CHECKING, Any, Collection, NamedTuple +import re +import xml.etree.ElementTree as etree +try: # pragma: no cover + from html import entities +except ImportError: # pragma: no cover + import htmlentitydefs as entities + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +def build_inlinepatterns(md: Markdown, **kwargs: Any) -> util.Registry[InlineProcessor]: + """ + Build the default set of inline patterns for Markdown. + + The order in which processors and/or patterns are applied is very important - e.g. if we first replace + `http://.../` links with `<a>` tags and _then_ try to replace inline HTML, we would end up with a mess. So, we + apply the expressions in the following order: + + * backticks and escaped characters have to be handled before everything else so that we can preempt any markdown + patterns by escaping them; + + * then we handle the various types of links (auto-links must be handled before inline HTML); + + * then we handle inline HTML. At this point we will simply replace all inline HTML strings with a placeholder + and add the actual HTML to a stash; + + * finally we apply strong, emphasis, etc. + + """ + inlinePatterns = util.Registry() + inlinePatterns.register(BacktickInlineProcessor(BACKTICK_RE), 'backtick', 190) + inlinePatterns.register(EscapeInlineProcessor(ESCAPE_RE, md), 'escape', 180) + inlinePatterns.register(ReferenceInlineProcessor(REFERENCE_RE, md), 'reference', 170) + inlinePatterns.register(LinkInlineProcessor(LINK_RE, md), 'link', 160) + inlinePatterns.register(ImageInlineProcessor(IMAGE_LINK_RE, md), 'image_link', 150) + inlinePatterns.register( + ImageReferenceInlineProcessor(IMAGE_REFERENCE_RE, md), 'image_reference', 140 + ) + inlinePatterns.register( + ShortReferenceInlineProcessor(REFERENCE_RE, md), 'short_reference', 130 + ) + inlinePatterns.register( + ShortImageReferenceInlineProcessor(IMAGE_REFERENCE_RE, md), 'short_image_ref', 125 + ) + inlinePatterns.register(AutolinkInlineProcessor(AUTOLINK_RE, md), 'autolink', 120) + inlinePatterns.register(AutomailInlineProcessor(AUTOMAIL_RE, md), 'automail', 110) + inlinePatterns.register(SubstituteTagInlineProcessor(LINE_BREAK_RE, 'br'), 'linebreak', 100) + inlinePatterns.register(HtmlInlineProcessor(HTML_RE, md), 'html', 90) + inlinePatterns.register(HtmlInlineProcessor(ENTITY_RE, md), 'entity', 80) + inlinePatterns.register(SimpleTextInlineProcessor(NOT_STRONG_RE), 'not_strong', 70) + inlinePatterns.register(AsteriskProcessor(r'\*'), 'em_strong', 60) + inlinePatterns.register(UnderscoreProcessor(r'_'), 'em_strong2', 50) + return inlinePatterns + + +# The actual regular expressions for patterns +# ----------------------------------------------------------------------------- + +NOIMG = r'(?<!\!)' +""" Match not an image. Partial regular expression which matches if not preceded by `!`. """ + +BACKTICK_RE = r'(?:(?<!\\)((?:\\{2})+)(?=`+)|(?<!\\)(`+)(.+?)(?<!`)\2(?!`))' +""" Match backtick quoted string (`` `e=f()` `` or ``` ``e=f("`")`` ```). """ + +ESCAPE_RE = r'\\(.)' +""" Match a backslash escaped character (`\\<` or `\\*`). """ + +EMPHASIS_RE = r'(\*)([^\*]+)\1' +""" Match emphasis with an asterisk (`*emphasis*`). """ + +STRONG_RE = r'(\*{2})(.+?)\1' +""" Match strong with an asterisk (`**strong**`). """ + +SMART_STRONG_RE = r'(?<!\w)(_{2})(?!_)(.+?)(?<!_)\1(?!\w)' +""" Match strong with underscore while ignoring middle word underscores (`__smart__strong__`). """ + +SMART_EMPHASIS_RE = r'(?<!\w)(_)(?!_)(.+?)(?<!_)\1(?!\w)' +""" Match emphasis with underscore while ignoring middle word underscores (`_smart_emphasis_`). """ + +SMART_STRONG_EM_RE = r'(?<!\w)(\_)\1(?!\1)(.+?)(?<!\w)\1(?!\1)(.+?)\1{3}(?!\w)' +""" Match strong emphasis with underscores (`__strong _em__`). """ + +EM_STRONG_RE = r'(\*)\1{2}(.+?)\1(.*?)\1{2}' +""" Match emphasis strong with asterisk (`***strongem***` or `***em*strong**`). """ + +EM_STRONG2_RE = r'(_)\1{2}(.+?)\1(.*?)\1{2}' +""" Match emphasis strong with underscores (`___emstrong___` or `___em_strong__`). """ + +STRONG_EM_RE = r'(\*)\1{2}(.+?)\1{2}(.*?)\1' +""" Match strong emphasis with asterisk (`***strong**em*`). """ + +STRONG_EM2_RE = r'(_)\1{2}(.+?)\1{2}(.*?)\1' +""" Match strong emphasis with underscores (`___strong__em_`). """ + +STRONG_EM3_RE = r'(\*)\1(?!\1)([^*]+?)\1(?!\1)(.+?)\1{3}' +""" Match strong emphasis with asterisk (`**strong*em***`). """ + +LINK_RE = NOIMG + r'\[' +""" Match start of in-line link (`[text](url)` or `[text](<url>)` or `[text](url "title")`). """ + +IMAGE_LINK_RE = r'\!\[' +""" Match start of in-line image link (`![alttxt](url)` or `![alttxt](<url>)`). """ + +REFERENCE_RE = LINK_RE +""" Match start of reference link (`[Label][3]`). """ + +IMAGE_REFERENCE_RE = IMAGE_LINK_RE +""" Match start of image reference (`![alt text][2]`). """ + +NOT_STRONG_RE = r'((^|(?<=\s))(\*{1,3}|_{1,3})(?=\s|$))' +""" Match a stand-alone `*` or `_`. """ + +AUTOLINK_RE = r'<((?:[Ff]|[Hh][Tt])[Tt][Pp][Ss]?://[^<>]*)>' +""" Match an automatic link (`<http://www.example.com>`). """ + +AUTOMAIL_RE = r'<([^<> !]+@[^@<> ]+)>' +""" Match an automatic email link (`<me@example.com>`). """ + +HTML_RE = r'(<(\/?[a-zA-Z][^<>@ ]*( [^<>]*)?|!--(?:(?!<!--|-->).)*--)>)' +""" Match an HTML tag (`<...>`). """ + +ENTITY_RE = r'(&(?:\#[0-9]+|\#x[0-9a-fA-F]+|[a-zA-Z0-9]+);)' +""" Match an HTML entity (`&` (decimal) or `&` (hex) or `&` (named)). """ + +LINE_BREAK_RE = r' \n' +""" Match two spaces at end of line. """ + + +def dequote(string: str) -> str: + """Remove quotes from around a string.""" + if ((string.startswith('"') and string.endswith('"')) or + (string.startswith("'") and string.endswith("'"))): + return string[1:-1] + else: + return string + + +class EmStrongItem(NamedTuple): + """Emphasis/strong pattern item.""" + pattern: re.Pattern[str] + builder: str + tags: str + + +# The pattern classes +# ----------------------------------------------------------------------------- + + +class Pattern: # pragma: no cover + """ + Base class that inline patterns subclass. + + Inline patterns are handled by means of `Pattern` subclasses, one per regular expression. + Each pattern object uses a single regular expression and must support the following methods: + [`getCompiledRegExp`][markdown.inlinepatterns.Pattern.getCompiledRegExp] and + [`handleMatch`][markdown.inlinepatterns.Pattern.handleMatch]. + + All the regular expressions used by `Pattern` subclasses must capture the whole block. For this + reason, they all start with `^(.*)` and end with `(.*)!`. When passing a regular expression on + class initialization, the `^(.*)` and `(.*)!` are added automatically and the regular expression + is pre-compiled. + + It is strongly suggested that the newer style [`markdown.inlinepatterns.InlineProcessor`][] that + use a more efficient and flexible search approach be used instead. However, the older style + `Pattern` remains for backward compatibility with many existing third-party extensions. + + """ + + ANCESTOR_EXCLUDES: Collection[str] = tuple() + """ + A collection of elements which are undesirable ancestors. The processor will be skipped if it + would cause the content to be a descendant of one of the listed tag names. + """ + + def __init__(self, pattern: str, md: Markdown | None = None): + """ + Create an instant of an inline pattern. + + Arguments: + pattern: A regular expression that matches a pattern. + md: An optional pointer to the instance of `markdown.Markdown` and is available as + `self.md` on the class instance. + + + """ + self.pattern = pattern + self.compiled_re = re.compile(r"^(.*?)%s(.*)$" % pattern, + re.DOTALL | re.UNICODE) + + self.md = md + + def getCompiledRegExp(self) -> re.Pattern: + """ Return a compiled regular expression. """ + return self.compiled_re + + def handleMatch(self, m: re.Match[str]) -> etree.Element | str: + """Return a ElementTree element from the given match. + + Subclasses should override this method. + + Arguments: + m: A match object containing a match of the pattern. + + Returns: An ElementTree Element object. + + """ + pass # pragma: no cover + + def type(self) -> str: + """ Return class name, to define pattern type """ + return self.__class__.__name__ + + def unescape(self, text: str) -> str: + """ Return unescaped text given text with an inline placeholder. """ + try: + stash = self.md.treeprocessors['inline'].stashed_nodes + except KeyError: # pragma: no cover + return text + + def get_stash(m): + id = m.group(1) + if id in stash: + value = stash.get(id) + if isinstance(value, str): + return value + else: + # An `etree` Element - return text content only + return ''.join(value.itertext()) + return util.INLINE_PLACEHOLDER_RE.sub(get_stash, text) + + +class InlineProcessor(Pattern): + """ + Base class that inline processors subclass. + + This is the newer style inline processor that uses a more + efficient and flexible search approach. + + """ + + def __init__(self, pattern: str, md: Markdown | None = None): + """ + Create an instant of an inline processor. + + Arguments: + pattern: A regular expression that matches a pattern. + md: An optional pointer to the instance of `markdown.Markdown` and is available as + `self.md` on the class instance. + + """ + self.pattern = pattern + self.compiled_re = re.compile(pattern, re.DOTALL | re.UNICODE) + + # API for Markdown to pass `safe_mode` into instance + self.safe_mode = False + self.md = md + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | str | None, int | None, int | None]: + """Return a ElementTree element from the given match and the + start and end index of the matched text. + + If `start` and/or `end` are returned as `None`, it will be + assumed that the processor did not find a valid region of text. + + Subclasses should override this method. + + Arguments: + m: A re match object containing a match of the pattern. + data: The buffer currently under analysis. + + Returns: + el: The ElementTree element, text or None. + start: The start of the region that has been matched or None. + end: The end of the region that has been matched or None. + + """ + pass # pragma: no cover + + +class SimpleTextPattern(Pattern): # pragma: no cover + """ Return a simple text of `group(2)` of a Pattern. """ + def handleMatch(self, m: re.Match[str]) -> str: + """ Return string content of `group(2)` of a matching pattern. """ + return m.group(2) + + +class SimpleTextInlineProcessor(InlineProcessor): + """ Return a simple text of `group(1)` of a Pattern. """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[str, int, int]: + """ Return string content of `group(1)` of a matching pattern. """ + return m.group(1), m.start(0), m.end(0) + + +class EscapeInlineProcessor(InlineProcessor): + """ Return an escaped character. """ + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[str | None, int, int]: + """ + If the character matched by `group(1)` of a pattern is in [`ESCAPED_CHARS`][markdown.Markdown.ESCAPED_CHARS] + then return the integer representing the character's Unicode code point (as returned by [`ord`][]) wrapped + in [`util.STX`][markdown.util.STX] and [`util.ETX`][markdown.util.ETX]. + + If the matched character is not in [`ESCAPED_CHARS`][markdown.Markdown.ESCAPED_CHARS], then return `None`. + """ + + char = m.group(1) + if char in self.md.ESCAPED_CHARS: + return '{}{}{}'.format(util.STX, ord(char), util.ETX), m.start(0), m.end(0) + else: + return None, m.start(0), m.end(0) + + +class SimpleTagPattern(Pattern): # pragma: no cover + """ + Return element of type `tag` with a text attribute of `group(3)` + of a Pattern. + + """ + def __init__(self, pattern: str, tag: str): + """ + Create an instant of an simple tag pattern. + + Arguments: + pattern: A regular expression that matches a pattern. + tag: Tag of element. + + """ + Pattern.__init__(self, pattern) + self.tag = tag + """ The tag of the rendered element. """ + + def handleMatch(self, m: re.Match[str]) -> etree.Element: + """ + Return [`Element`][xml.etree.ElementTree.Element] of type `tag` with the string in `group(3)` of a + matching pattern as the Element's text. + """ + el = etree.Element(self.tag) + el.text = m.group(3) + return el + + +class SimpleTagInlineProcessor(InlineProcessor): + """ + Return element of type `tag` with a text attribute of `group(2)` + of a Pattern. + + """ + def __init__(self, pattern: str, tag: str): + """ + Create an instant of an simple tag processor. + + Arguments: + pattern: A regular expression that matches a pattern. + tag: Tag of element. + + """ + InlineProcessor.__init__(self, pattern) + self.tag = tag + """ The tag of the rendered element. """ + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: # pragma: no cover + """ + Return [`Element`][xml.etree.ElementTree.Element] of type `tag` with the string in `group(2)` of a + matching pattern as the Element's text. + """ + el = etree.Element(self.tag) + el.text = m.group(2) + return el, m.start(0), m.end(0) + + +class SubstituteTagPattern(SimpleTagPattern): # pragma: no cover + """ Return an element of type `tag` with no children. """ + def handleMatch(self, m: re.Match[str]) -> etree.Element: + """ Return empty [`Element`][xml.etree.ElementTree.Element] of type `tag`. """ + return etree.Element(self.tag) + + +class SubstituteTagInlineProcessor(SimpleTagInlineProcessor): + """ Return an element of type `tag` with no children. """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: + """ Return empty [`Element`][xml.etree.ElementTree.Element] of type `tag`. """ + return etree.Element(self.tag), m.start(0), m.end(0) + + +class BacktickInlineProcessor(InlineProcessor): + """ Return a `<code>` element containing the escaped matching text. """ + def __init__(self, pattern): + InlineProcessor.__init__(self, pattern) + self.ESCAPED_BSLASH = '{}{}{}'.format(util.STX, ord('\\'), util.ETX) + self.tag = 'code' + """ The tag of the rendered element. """ + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | str, int, int]: + """ + If the match contains `group(3)` of a pattern, then return a `code` + [`Element`][xml.etree.ElementTree.Element] which contains HTML escaped text (with + [`code_escape`][markdown.util.code_escape]) as an [`AtomicString`][markdown.util.AtomicString]. + + If the match does not contain `group(3)` then return the text of `group(1)` backslash escaped. + + """ + if m.group(3): + el = etree.Element(self.tag) + el.text = util.AtomicString(util.code_escape(m.group(3).strip())) + return el, m.start(0), m.end(0) + else: + return m.group(1).replace('\\\\', self.ESCAPED_BSLASH), m.start(0), m.end(0) + + +class DoubleTagPattern(SimpleTagPattern): # pragma: no cover + """Return a ElementTree element nested in tag2 nested in tag1. + + Useful for strong emphasis etc. + + """ + def handleMatch(self, m: re.Match[str]) -> etree.Element: + """ + Return [`Element`][xml.etree.ElementTree.Element] in following format: + `<tag1><tag2>group(3)</tag2>group(4)</tag2>` where `group(4)` is optional. + + """ + tag1, tag2 = self.tag.split(",") + el1 = etree.Element(tag1) + el2 = etree.SubElement(el1, tag2) + el2.text = m.group(3) + if len(m.groups()) == 5: + el2.tail = m.group(4) + return el1 + + +class DoubleTagInlineProcessor(SimpleTagInlineProcessor): + """Return a ElementTree element nested in tag2 nested in tag1. + + Useful for strong emphasis etc. + + """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: # pragma: no cover + """ + Return [`Element`][xml.etree.ElementTree.Element] in following format: + `<tag1><tag2>group(2)</tag2>group(3)</tag2>` where `group(3)` is optional. + + """ + tag1, tag2 = self.tag.split(",") + el1 = etree.Element(tag1) + el2 = etree.SubElement(el1, tag2) + el2.text = m.group(2) + if len(m.groups()) == 3: + el2.tail = m.group(3) + return el1, m.start(0), m.end(0) + + +class HtmlInlineProcessor(InlineProcessor): + """ Store raw inline html and return a placeholder. """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[str, int, int]: + """ Store the text of `group(1)` of a pattern and return a placeholder string. """ + rawhtml = self.backslash_unescape(self.unescape(m.group(1))) + place_holder = self.md.htmlStash.store(rawhtml) + return place_holder, m.start(0), m.end(0) + + def unescape(self, text): + """ Return unescaped text given text with an inline placeholder. """ + try: + stash = self.md.treeprocessors['inline'].stashed_nodes + except KeyError: # pragma: no cover + return text + + def get_stash(m): + id = m.group(1) + value = stash.get(id) + if value is not None: + try: + return self.md.serializer(value) + except Exception: + return r'\%s' % value + + return util.INLINE_PLACEHOLDER_RE.sub(get_stash, text) + + def backslash_unescape(self, text): + """ Return text with backslash escapes undone (backslashes are restored). """ + try: + RE = self.md.treeprocessors['unescape'].RE + except KeyError: # pragma: no cover + return text + + def _unescape(m): + return chr(int(m.group(1))) + + return RE.sub(_unescape, text) + + +class AsteriskProcessor(InlineProcessor): + """Emphasis processor for handling strong and em matches inside asterisks.""" + + PATTERNS = [ + EmStrongItem(re.compile(EM_STRONG_RE, re.DOTALL | re.UNICODE), 'double', 'strong,em'), + EmStrongItem(re.compile(STRONG_EM_RE, re.DOTALL | re.UNICODE), 'double', 'em,strong'), + EmStrongItem(re.compile(STRONG_EM3_RE, re.DOTALL | re.UNICODE), 'double2', 'strong,em'), + EmStrongItem(re.compile(STRONG_RE, re.DOTALL | re.UNICODE), 'single', 'strong'), + EmStrongItem(re.compile(EMPHASIS_RE, re.DOTALL | re.UNICODE), 'single', 'em') + ] + """ The various strong and emphasis patterns handled by this processor. """ + + def build_single(self, m, tag, idx): + """Return single tag.""" + el1 = etree.Element(tag) + text = m.group(2) + self.parse_sub_patterns(text, el1, None, idx) + return el1 + + def build_double(self, m, tags, idx): + """Return double tag.""" + + tag1, tag2 = tags.split(",") + el1 = etree.Element(tag1) + el2 = etree.Element(tag2) + text = m.group(2) + self.parse_sub_patterns(text, el2, None, idx) + el1.append(el2) + if len(m.groups()) == 3: + text = m.group(3) + self.parse_sub_patterns(text, el1, el2, idx) + return el1 + + def build_double2(self, m, tags, idx): + """Return double tags (variant 2): `<strong>text <em>text</em></strong>`.""" + + tag1, tag2 = tags.split(",") + el1 = etree.Element(tag1) + el2 = etree.Element(tag2) + text = m.group(2) + self.parse_sub_patterns(text, el1, None, idx) + text = m.group(3) + el1.append(el2) + self.parse_sub_patterns(text, el2, None, idx) + return el1 + + def parse_sub_patterns(self, data, parent, last, idx) -> None: + """ + Parses sub patterns. + + `data` (`str`): + text to evaluate. + + `parent` (`etree.Element`): + Parent to attach text and sub elements to. + + `last` (`etree.Element`): + Last appended child to parent. Can also be None if parent has no children. + + `idx` (`int`): + Current pattern index that was used to evaluate the parent. + + """ + + offset = 0 + pos = 0 + + length = len(data) + while pos < length: + # Find the start of potential emphasis or strong tokens + if self.compiled_re.match(data, pos): + matched = False + # See if the we can match an emphasis/strong pattern + for index, item in enumerate(self.PATTERNS): + # Only evaluate patterns that are after what was used on the parent + if index <= idx: + continue + m = item.pattern.match(data, pos) + if m: + # Append child nodes to parent + # Text nodes should be appended to the last + # child if present, and if not, it should + # be added as the parent's text node. + text = data[offset:m.start(0)] + if text: + if last is not None: + last.tail = text + else: + parent.text = text + el = self.build_element(m, item.builder, item.tags, index) + parent.append(el) + last = el + # Move our position past the matched hunk + offset = pos = m.end(0) + matched = True + if not matched: + # We matched nothing, move on to the next character + pos += 1 + else: + # Increment position as no potential emphasis start was found. + pos += 1 + + # Append any leftover text as a text node. + text = data[offset:] + if text: + if last is not None: + last.tail = text + else: + parent.text = text + + def build_element(self, m, builder, tags, index): + """Element builder.""" + + if builder == 'double2': + return self.build_double2(m, tags, index) + elif builder == 'double': + return self.build_double(m, tags, index) + else: + return self.build_single(m, tags, index) + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: + """Parse patterns.""" + + el = None + start = None + end = None + + for index, item in enumerate(self.PATTERNS): + m1 = item.pattern.match(data, m.start(0)) + if m1: + start = m1.start(0) + end = m1.end(0) + el = self.build_element(m1, item.builder, item.tags, index) + break + return el, start, end + + +class UnderscoreProcessor(AsteriskProcessor): + """Emphasis processor for handling strong and em matches inside underscores.""" + + PATTERNS = [ + EmStrongItem(re.compile(EM_STRONG2_RE, re.DOTALL | re.UNICODE), 'double', 'strong,em'), + EmStrongItem(re.compile(STRONG_EM2_RE, re.DOTALL | re.UNICODE), 'double', 'em,strong'), + EmStrongItem(re.compile(SMART_STRONG_EM_RE, re.DOTALL | re.UNICODE), 'double2', 'strong,em'), + EmStrongItem(re.compile(SMART_STRONG_RE, re.DOTALL | re.UNICODE), 'single', 'strong'), + EmStrongItem(re.compile(SMART_EMPHASIS_RE, re.DOTALL | re.UNICODE), 'single', 'em') + ] + """ The various strong and emphasis patterns handled by this processor. """ + + +class LinkInlineProcessor(InlineProcessor): + """ Return a link element from the given match. """ + RE_LINK = re.compile(r'''\(\s*(?:(<[^<>]*>)\s*(?:('[^']*'|"[^"]*")\s*)?\))?''', re.DOTALL | re.UNICODE) + RE_TITLE_CLEAN = re.compile(r'\s') + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: + """ Return an `a` [`Element`][xml.etree.ElementTree.Element] or `(None, None, None)`. """ + text, index, handled = self.getText(data, m.end(0)) + + if not handled: + return None, None, None + + href, title, index, handled = self.getLink(data, index) + if not handled: + return None, None, None + + el = etree.Element("a") + el.text = text + + el.set("href", href) + + if title is not None: + el.set("title", title) + + return el, m.start(0), index + + def getLink(self, data, index): + """Parse data between `()` of `[Text]()` allowing recursive `()`. """ + + href = '' + title = None + handled = False + + m = self.RE_LINK.match(data, pos=index) + if m and m.group(1): + # Matches [Text](<link> "title") + href = m.group(1)[1:-1].strip() + if m.group(2): + title = m.group(2)[1:-1] + index = m.end(0) + handled = True + elif m: + # Track bracket nesting and index in string + bracket_count = 1 + backtrack_count = 1 + start_index = m.end() + index = start_index + last_bracket = -1 + + # Primary (first found) quote tracking. + quote = None + start_quote = -1 + exit_quote = -1 + ignore_matches = False + + # Secondary (second found) quote tracking. + alt_quote = None + start_alt_quote = -1 + exit_alt_quote = -1 + + # Track last character + last = '' + + for pos in range(index, len(data)): + c = data[pos] + if c == '(': + # Count nested ( + # Don't increment the bracket count if we are sure we're in a title. + if not ignore_matches: + bracket_count += 1 + elif backtrack_count > 0: + backtrack_count -= 1 + elif c == ')': + # Match nested ) to ( + # Don't decrement if we are sure we are in a title that is unclosed. + if ((exit_quote != -1 and quote == last) or (exit_alt_quote != -1 and alt_quote == last)): + bracket_count = 0 + elif not ignore_matches: + bracket_count -= 1 + elif backtrack_count > 0: + backtrack_count -= 1 + # We've found our backup end location if the title doesn't resolve. + if backtrack_count == 0: + last_bracket = index + 1 + + elif c in ("'", '"'): + # Quote has started + if not quote: + # We'll assume we are now in a title. + # Brackets are quoted, so no need to match them (except for the final one). + ignore_matches = True + backtrack_count = bracket_count + bracket_count = 1 + start_quote = index + 1 + quote = c + # Secondary quote (in case the first doesn't resolve): [text](link'"title") + elif c != quote and not alt_quote: + start_alt_quote = index + 1 + alt_quote = c + # Update primary quote match + elif c == quote: + exit_quote = index + 1 + # Update secondary quote match + elif alt_quote and c == alt_quote: + exit_alt_quote = index + 1 + + index += 1 + + # Link is closed, so let's break out of the loop + if bracket_count == 0: + # Get the title if we closed a title string right before link closed + if exit_quote >= 0 and quote == last: + href = data[start_index:start_quote - 1] + title = ''.join(data[start_quote:exit_quote - 1]) + elif exit_alt_quote >= 0 and alt_quote == last: + href = data[start_index:start_alt_quote - 1] + title = ''.join(data[start_alt_quote:exit_alt_quote - 1]) + else: + href = data[start_index:index - 1] + break + + if c != ' ': + last = c + + # We have a scenario: `[test](link"notitle)` + # When we enter a string, we stop tracking bracket resolution in the main counter, + # but we do keep a backup counter up until we discover where we might resolve all brackets + # if the title string fails to resolve. + if bracket_count != 0 and backtrack_count == 0: + href = data[start_index:last_bracket - 1] + index = last_bracket + bracket_count = 0 + + handled = bracket_count == 0 + + if title is not None: + title = self.RE_TITLE_CLEAN.sub(' ', dequote(self.unescape(title.strip()))) + + href = self.unescape(href).strip() + + return href, title, index, handled + + def getText(self, data, index): + """Parse the content between `[]` of the start of an image or link + resolving nested square brackets. + + """ + bracket_count = 1 + text = [] + for pos in range(index, len(data)): + c = data[pos] + if c == ']': + bracket_count -= 1 + elif c == '[': + bracket_count += 1 + index += 1 + if bracket_count == 0: + break + text.append(c) + return ''.join(text), index, bracket_count == 0 + + +class ImageInlineProcessor(LinkInlineProcessor): + """ Return a `img` element from the given match. """ + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: + """ Return an `img` [`Element`][xml.etree.ElementTree.Element] or `(None, None, None)`. """ + text, index, handled = self.getText(data, m.end(0)) + if not handled: + return None, None, None + + src, title, index, handled = self.getLink(data, index) + if not handled: + return None, None, None + + el = etree.Element("img") + + el.set("src", src) + + if title is not None: + el.set("title", title) + + el.set('alt', self.unescape(text)) + return el, m.start(0), index + + +class ReferenceInlineProcessor(LinkInlineProcessor): + """ Match to a stored reference and return link element. """ + NEWLINE_CLEANUP_RE = re.compile(r'\s+', re.MULTILINE) + + RE_LINK = re.compile(r'\s?\[([^\]]*)\]', re.DOTALL | re.UNICODE) + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: + """ + Return [`Element`][xml.etree.ElementTree.Element] returned by `makeTag` method or `(None, None, None)`. + + """ + text, index, handled = self.getText(data, m.end(0)) + if not handled: + return None, None, None + + id, end, handled = self.evalId(data, index, text) + if not handled: + return None, None, None + + # Clean up line breaks in id + id = self.NEWLINE_CLEANUP_RE.sub(' ', id) + if id not in self.md.references: # ignore undefined refs + return None, m.start(0), end + + href, title = self.md.references[id] + + return self.makeTag(href, title, text), m.start(0), end + + def evalId(self, data, index, text): + """ + Evaluate the id portion of `[ref][id]`. + + If `[ref][]` use `[ref]`. + """ + m = self.RE_LINK.match(data, pos=index) + if not m: + return None, index, False + else: + id = m.group(1).lower() + end = m.end(0) + if not id: + id = text.lower() + return id, end, True + + def makeTag(self, href: str, title: str, text: str) -> etree.Element: + """ Return an `a` [`Element`][xml.etree.ElementTree.Element]. """ + el = etree.Element('a') + + el.set('href', href) + if title: + el.set('title', title) + + el.text = text + return el + + +class ShortReferenceInlineProcessor(ReferenceInlineProcessor): + """Short form of reference: `[google]`. """ + def evalId(self, data, index, text): + """Evaluate the id of `[ref]`. """ + + return text.lower(), index, True + + +class ImageReferenceInlineProcessor(ReferenceInlineProcessor): + """ Match to a stored reference and return `img` element. """ + def makeTag(self, href: str, title: str, text: str) -> etree.Element: + """ Return an `img` [`Element`][xml.etree.ElementTree.Element]. """ + el = etree.Element("img") + el.set("src", href) + if title: + el.set("title", title) + el.set("alt", self.unescape(text)) + return el + + +class ShortImageReferenceInlineProcessor(ImageReferenceInlineProcessor): + """ Short form of image reference: `![ref]`. """ + def evalId(self, data, index, text): + """Evaluate the id of `[ref]`. """ + + return text.lower(), index, True + + +class AutolinkInlineProcessor(InlineProcessor): + """ Return a link Element given an auto-link (`<http://example/com>`). """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: + """ Return an `a` [`Element`][xml.etree.ElementTree.Element] of `group(1)`. """ + el = etree.Element("a") + el.set('href', self.unescape(m.group(1))) + el.text = util.AtomicString(m.group(1)) + return el, m.start(0), m.end(0) + + +class AutomailInlineProcessor(InlineProcessor): + """ + Return a `mailto` link Element given an auto-mail link (`<foo@example.com>`). + """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: + """ Return an [`Element`][xml.etree.ElementTree.Element] containing a `mailto` link of `group(1)`. """ + el = etree.Element('a') + email = self.unescape(m.group(1)) + if email.startswith("mailto:"): + email = email[len("mailto:"):] + + def codepoint2name(code): + """Return entity definition by code, or the code if not defined.""" + entity = entities.codepoint2name.get(code) + if entity: + return "{}{};".format(util.AMP_SUBSTITUTE, entity) + else: + return "%s#%d;" % (util.AMP_SUBSTITUTE, code) + + letters = [codepoint2name(ord(letter)) for letter in email] + el.text = util.AtomicString(''.join(letters)) + + mailto = "mailto:" + email + mailto = "".join([util.AMP_SUBSTITUTE + '#%d;' % + ord(letter) for letter in mailto]) + el.set('href', mailto) + return el, m.start(0), m.end(0) diff --git a/plugins/markdown_preview/markdown/postprocessors.py b/plugins/markdown_preview/markdown/postprocessors.py new file mode 100644 index 0000000..3da5ee1 --- /dev/null +++ b/plugins/markdown_preview/markdown/postprocessors.py @@ -0,0 +1,143 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" + +Post-processors run on the text of the entire document after is has been serialized into a string. +Postprocessors should be used to work with the text just before output. Usually, they are used add +back sections that were extracted in a preprocessor, fix up outgoing encodings, or wrap the whole +document. + +""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import TYPE_CHECKING, Any +from . import util +import re + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +def build_postprocessors(md: Markdown, **kwargs: Any) -> util.Registry[Postprocessor]: + """ Build the default postprocessors for Markdown. """ + postprocessors = util.Registry() + postprocessors.register(RawHtmlPostprocessor(md), 'raw_html', 30) + postprocessors.register(AndSubstitutePostprocessor(), 'amp_substitute', 20) + return postprocessors + + +class Postprocessor(util.Processor): + """ + Postprocessors are run after the ElementTree it converted back into text. + + Each Postprocessor implements a `run` method that takes a pointer to a + text string, modifies it as necessary and returns a text string. + + Postprocessors must extend `Postprocessor`. + + """ + + def run(self, text: str) -> str: + """ + Subclasses of `Postprocessor` should implement a `run` method, which + takes the html document as a single text string and returns a + (possibly modified) string. + + """ + pass # pragma: no cover + + +class RawHtmlPostprocessor(Postprocessor): + """ Restore raw html to the document. """ + + BLOCK_LEVEL_REGEX = re.compile(r'^\<\/?([^ >]+)') + + def run(self, text: str): + """ Iterate over html stash and restore html. """ + replacements = OrderedDict() + for i in range(self.md.htmlStash.html_counter): + html = self.stash_to_string(self.md.htmlStash.rawHtmlBlocks[i]) + if self.isblocklevel(html): + replacements["<p>{}</p>".format( + self.md.htmlStash.get_placeholder(i))] = html + replacements[self.md.htmlStash.get_placeholder(i)] = html + + def substitute_match(m): + key = m.group(0) + + if key not in replacements: + if key[3:-4] in replacements: + return f'<p>{ replacements[key[3:-4]] }</p>' + else: + return key + + return replacements[key] + + if replacements: + base_placeholder = util.HTML_PLACEHOLDER % r'([0-9]+)' + pattern = re.compile(f'<p>{ base_placeholder }</p>|{ base_placeholder }') + processed_text = pattern.sub(substitute_match, text) + else: + return text + + if processed_text == text: + return processed_text + else: + return self.run(processed_text) + + def isblocklevel(self, html: str) -> bool: + """ Check is block of HTML is block-level. """ + m = self.BLOCK_LEVEL_REGEX.match(html) + if m: + if m.group(1)[0] in ('!', '?', '@', '%'): + # Comment, PHP etc... + return True + return self.md.is_block_level(m.group(1)) + return False + + def stash_to_string(self, text: str) -> str: + """ Convert a stashed object to a string. """ + return str(text) + + +class AndSubstitutePostprocessor(Postprocessor): + """ Restore valid entities """ + + def run(self, text): + text = text.replace(util.AMP_SUBSTITUTE, "&") + return text + + +@util.deprecated( + "This class is deprecated and will be removed in the future; " + "use [`UnescapeTreeprocessor`][markdown.treeprocessors.UnescapeTreeprocessor] instead." +) +class UnescapePostprocessor(Postprocessor): + """ Restore escaped chars. """ + + RE = re.compile(r'{}(\d+){}'.format(util.STX, util.ETX)) + + def unescape(self, m): + return chr(int(m.group(1))) + + def run(self, text): + return self.RE.sub(self.unescape, text) diff --git a/plugins/markdown_preview/markdown/preprocessors.py b/plugins/markdown_preview/markdown/preprocessors.py new file mode 100644 index 0000000..0f63cdd --- /dev/null +++ b/plugins/markdown_preview/markdown/preprocessors.py @@ -0,0 +1,91 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +Preprocessors work on source text before it is broken down into its individual parts. +This is an excellent place to clean up bad characters or to extract portions for later +processing that the parser may otherwise choke on. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from . import util +from .htmlparser import HTMLExtractor +import re + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +def build_preprocessors(md: Markdown, **kwargs: Any) -> util.Registry[Preprocessor]: + """ Build and return the default set of preprocessors used by Markdown. """ + preprocessors = util.Registry() + preprocessors.register(NormalizeWhitespace(md), 'normalize_whitespace', 30) + preprocessors.register(HtmlBlockPreprocessor(md), 'html_block', 20) + return preprocessors + + +class Preprocessor(util.Processor): + """ + Preprocessors are run after the text is broken into lines. + + Each preprocessor implements a `run` method that takes a pointer to a + list of lines of the document, modifies it as necessary and returns + either the same pointer or a pointer to a new list. + + Preprocessors must extend `Preprocessor`. + + """ + def run(self, lines: list[str]) -> list[str]: + """ + Each subclass of `Preprocessor` should override the `run` method, which + takes the document as a list of strings split by newlines and returns + the (possibly modified) list of lines. + + """ + pass # pragma: no cover + + +class NormalizeWhitespace(Preprocessor): + """ Normalize whitespace for consistent parsing. """ + + def run(self, lines: list[str]) -> list[str]: + source = '\n'.join(lines) + source = source.replace(util.STX, "").replace(util.ETX, "") + source = source.replace("\r\n", "\n").replace("\r", "\n") + "\n\n" + source = source.expandtabs(self.md.tab_length) + source = re.sub(r'(?<=\n) +\n', '\n', source) + return source.split('\n') + + +class HtmlBlockPreprocessor(Preprocessor): + """ + Remove html blocks from the text and store them for later retrieval. + + The raw HTML is stored in the [`htmlStash`][markdown.util.HtmlStash] of the + [`Markdown`][markdown.Markdown] instance. + """ + + def run(self, lines: list[str]) -> list[str]: + source = '\n'.join(lines) + parser = HTMLExtractor(self.md) + parser.feed(source) + parser.close() + return ''.join(parser.cleandoc).split('\n') diff --git a/plugins/markdown_preview/markdown/serializers.py b/plugins/markdown_preview/markdown/serializers.py new file mode 100644 index 0000000..5a8818e --- /dev/null +++ b/plugins/markdown_preview/markdown/serializers.py @@ -0,0 +1,193 @@ +# Add x/html serialization to `Elementree` +# Taken from ElementTree 1.3 preview with slight modifications +# +# Copyright (c) 1999-2007 by Fredrik Lundh. All rights reserved. +# +# fredrik@pythonware.com +# https://www.pythonware.com/ +# +# -------------------------------------------------------------------- +# The ElementTree toolkit is +# +# Copyright (c) 1999-2007 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + +""" +Python-Markdown provides two serializers which render [`ElementTree.Element`][xml.etree.ElementTree.Element] +objects to a string of HTML. Both functions wrap the same underlying code with only a few minor +differences as outlined below: + +1. Empty (self-closing) tags are rendered as `<tag>` for HTML and as `<tag />` for XHTML. +2. Boolean attributes are rendered as `attrname` for HTML and as `attrname="attrname"` for XHTML. +""" + +from __future__ import annotations + +from xml.etree.ElementTree import ProcessingInstruction +from xml.etree.ElementTree import Comment, ElementTree, Element, QName, HTML_EMPTY +import re + +__all__ = ['to_html_string', 'to_xhtml_string'] + +RE_AMP = re.compile(r'&(?!(?:\#[0-9]+|\#x[0-9a-f]+|[0-9a-z]+);)', re.I) + + +def _raise_serialization_error(text): # pragma: no cover + raise TypeError( + "cannot serialize {!r} (type {})".format(text, type(text).__name__) + ) + + +def _escape_cdata(text): + # escape character data + try: + # it's worth avoiding do-nothing calls for strings that are + # shorter than 500 character, or so. assume that's, by far, + # the most common case in most applications. + if "&" in text: + # Only replace & when not part of an entity + text = RE_AMP.sub('&', text) + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + return text + except (TypeError, AttributeError): # pragma: no cover + _raise_serialization_error(text) + + +def _escape_attrib(text): + # escape attribute value + try: + if "&" in text: + # Only replace & when not part of an entity + text = RE_AMP.sub('&', text) + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + if "\"" in text: + text = text.replace("\"", """) + if "\n" in text: + text = text.replace("\n", " ") + return text + except (TypeError, AttributeError): # pragma: no cover + _raise_serialization_error(text) + + +def _escape_attrib_html(text): + # escape attribute value + try: + if "&" in text: + # Only replace & when not part of an entity + text = RE_AMP.sub('&', text) + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + if "\"" in text: + text = text.replace("\"", """) + return text + except (TypeError, AttributeError): # pragma: no cover + _raise_serialization_error(text) + + +def _serialize_html(write, elem, format): + tag = elem.tag + text = elem.text + if tag is Comment: + write("<!--%s-->" % _escape_cdata(text)) + elif tag is ProcessingInstruction: + write("<?%s?>" % _escape_cdata(text)) + elif tag is None: + if text: + write(_escape_cdata(text)) + for e in elem: + _serialize_html(write, e, format) + else: + namespace_uri = None + if isinstance(tag, QName): + # `QNAME` objects store their data as a string: `{uri}tag` + if tag.text[:1] == "{": + namespace_uri, tag = tag.text[1:].split("}", 1) + else: + raise ValueError('QName objects must define a tag.') + write("<" + tag) + items = elem.items() + if items: + items = sorted(items) # lexical order + for k, v in items: + if isinstance(k, QName): + # Assume a text only `QName` + k = k.text + if isinstance(v, QName): + # Assume a text only `QName` + v = v.text + else: + v = _escape_attrib_html(v) + if k == v and format == 'html': + # handle boolean attributes + write(" %s" % v) + else: + write(' {}="{}"'.format(k, v)) + if namespace_uri: + write(' xmlns="%s"' % (_escape_attrib(namespace_uri))) + if format == "xhtml" and tag.lower() in HTML_EMPTY: + write(" />") + else: + write(">") + if text: + if tag.lower() in ["script", "style"]: + write(text) + else: + write(_escape_cdata(text)) + for e in elem: + _serialize_html(write, e, format) + if tag.lower() not in HTML_EMPTY: + write("</" + tag + ">") + if elem.tail: + write(_escape_cdata(elem.tail)) + + +def _write_html(root, format="html"): + assert root is not None + data = [] + write = data.append + _serialize_html(write, root, format) + return "".join(data) + + +# -------------------------------------------------------------------- +# public functions + + +def to_html_string(element: Element) -> str: + """ Serialize element and its children to a string of HTML5. """ + return _write_html(ElementTree(element).getroot(), format="html") + + +def to_xhtml_string(element: Element) -> str: + """ Serialize element and its children to a string of XHTML. """ + return _write_html(ElementTree(element).getroot(), format="xhtml") diff --git a/plugins/markdown_preview/markdown/test_tools.py b/plugins/markdown_preview/markdown/test_tools.py new file mode 100644 index 0000000..895e44e --- /dev/null +++ b/plugins/markdown_preview/markdown/test_tools.py @@ -0,0 +1,224 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" A collection of tools for testing the Markdown code base and extensions. """ + +from __future__ import annotations + +import os +import sys +import unittest +import textwrap +from typing import Any +from . import markdown, Markdown, util + +try: + import tidylib +except ImportError: + tidylib = None + +__all__ = ['TestCase', 'LegacyTestCase', 'Kwargs'] + + +class TestCase(unittest.TestCase): + """ + A [`unittest.TestCase`][] subclass with helpers for testing Markdown output. + + Define `default_kwargs` as a `dict` of keywords to pass to Markdown for each + test. The defaults can be overridden on individual tests. + + The `assertMarkdownRenders` method accepts the source text, the expected + output, and any keywords to pass to Markdown. The `default_kwargs` are used + except where overridden by `kwargs`. The output and expected output are passed + to `TestCase.assertMultiLineEqual`. An `AssertionError` is raised with a diff + if the actual output does not equal the expected output. + + The `dedent` method is available to dedent triple-quoted strings if + necessary. + + In all other respects, behaves as `unittest.TestCase`. + """ + + default_kwargs: dict[str, Any] = {} + """ Default options to pass to Markdown for each test. """ + + def assertMarkdownRenders(self, source, expected, expected_attrs=None, **kwargs): + """ + Test that source Markdown text renders to expected output with given keywords. + + `expected_attrs` accepts a `dict`. Each key should be the name of an attribute + on the `Markdown` instance and the value should be the expected value after + the source text is parsed by Markdown. After the expected output is tested, + the expected value for each attribute is compared against the actual + attribute of the `Markdown` instance using `TestCase.assertEqual`. + """ + + expected_attrs = expected_attrs or {} + kws = self.default_kwargs.copy() + kws.update(kwargs) + md = Markdown(**kws) + output = md.convert(source) + self.assertMultiLineEqual(output, expected) + for key, value in expected_attrs.items(): + self.assertEqual(getattr(md, key), value) + + def dedent(self, text): + """ + Dedent text. + """ + + # TODO: If/when actual output ends with a newline, then use: + # return textwrap.dedent(text.strip('/n')) + return textwrap.dedent(text).strip() + + +class recursionlimit: + """ + A context manager which temporarily modifies the Python recursion limit. + + The testing framework, coverage, etc. may add an arbitrary number of levels to the depth. To maintain consistency + in the tests, the current stack depth is determined when called, then added to the provided limit. + + Example usage: + + ``` python + with recursionlimit(20): + # test code here + ``` + + See <https://stackoverflow.com/a/50120316/866026>. + """ + + def __init__(self, limit): + self.limit = util._get_stack_depth() + limit + self.old_limit = sys.getrecursionlimit() + + def __enter__(self): + sys.setrecursionlimit(self.limit) + + def __exit__(self, type, value, tb): + sys.setrecursionlimit(self.old_limit) + + +######################### +# Legacy Test Framework # +######################### + + +class Kwargs(dict): + """ A `dict` like class for holding keyword arguments. """ + pass + + +def _normalize_whitespace(text): + """ Normalize whitespace for a string of HTML using `tidylib`. """ + output, errors = tidylib.tidy_fragment(text, options={ + 'drop_empty_paras': 0, + 'fix_backslash': 0, + 'fix_bad_comments': 0, + 'fix_uri': 0, + 'join_styles': 0, + 'lower_literals': 0, + 'merge_divs': 0, + 'output_xhtml': 1, + 'quote_ampersand': 0, + 'newline': 'LF' + }) + return output + + +class LegacyTestMeta(type): + def __new__(cls, name, bases, dct): + + def generate_test(infile, outfile, normalize, kwargs): + def test(self): + with open(infile, encoding="utf-8") as f: + input = f.read() + with open(outfile, encoding="utf-8") as f: + # Normalize line endings + # (on Windows, git may have altered line endings). + expected = f.read().replace("\r\n", "\n") + output = markdown(input, **kwargs) + if tidylib and normalize: + try: + expected = _normalize_whitespace(expected) + output = _normalize_whitespace(output) + except OSError: + self.skipTest("Tidylib's c library not available.") + elif normalize: + self.skipTest('Tidylib not available.') + self.assertMultiLineEqual(output, expected) + return test + + location = dct.get('location', '') + exclude = dct.get('exclude', []) + normalize = dct.get('normalize', False) + input_ext = dct.get('input_ext', '.txt') + output_ext = dct.get('output_ext', '.html') + kwargs = dct.get('default_kwargs', Kwargs()) + + if os.path.isdir(location): + for file in os.listdir(location): + infile = os.path.join(location, file) + if os.path.isfile(infile): + tname, ext = os.path.splitext(file) + if ext == input_ext: + outfile = os.path.join(location, tname + output_ext) + tname = tname.replace(' ', '_').replace('-', '_') + kws = kwargs.copy() + if tname in dct: + kws.update(dct[tname]) + test_name = 'test_%s' % tname + if tname not in exclude: + dct[test_name] = generate_test(infile, outfile, normalize, kws) + else: + dct[test_name] = unittest.skip('Excluded')(lambda: None) + + return type.__new__(cls, name, bases, dct) + + +class LegacyTestCase(unittest.TestCase, metaclass=LegacyTestMeta): + """ + A [`unittest.TestCase`][] subclass for running Markdown's legacy file-based tests. + + A subclass should define various properties which point to a directory of + text-based test files and define various behaviors/defaults for those tests. + The following properties are supported: + + Attributes: + location (str): A path to the directory of test files. An absolute path is preferred. + exclude (list[str]): A list of tests to exclude. Each test name should comprise the filename + without an extension. + normalize (bool): A boolean value indicating if the HTML should be normalized. Default: `False`. + input_ext (str): A string containing the file extension of input files. Default: `.txt`. + output_ext (str): A string containing the file extension of expected output files. Default: `html`. + default_kwargs (Kwargs[str, Any]): The default set of keyword arguments for all test files in the directory. + + In addition, properties can be defined for each individual set of test files within + the directory. The property should be given the name of the file without the file + extension. Any spaces and dashes in the filename should be replaced with + underscores. The value of the property should be a `Kwargs` instance which + contains the keyword arguments that should be passed to `Markdown` for that + test file. The keyword arguments will "update" the `default_kwargs`. + + When the class instance is created, it will walk the given directory and create + a separate `Unitttest` for each set of test files using the naming scheme: + `test_filename`. One `Unittest` will be run for each set of input and output files. + """ + pass diff --git a/plugins/markdown_preview/markdown/treeprocessors.py b/plugins/markdown_preview/markdown/treeprocessors.py new file mode 100644 index 0000000..59a3eb3 --- /dev/null +++ b/plugins/markdown_preview/markdown/treeprocessors.py @@ -0,0 +1,476 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +Tree processors manipulate the tree created by block processors. They can even create an entirely +new `ElementTree` object. This is an excellent place for creating summaries, adding collected +references, or last minute adjustments. + +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as etree +from typing import TYPE_CHECKING, Any +from . import util +from . import inlinepatterns + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +def build_treeprocessors(md: Markdown, **kwargs: Any) -> util.Registry[Treeprocessor]: + """ Build the default `treeprocessors` for Markdown. """ + treeprocessors = util.Registry() + treeprocessors.register(InlineProcessor(md), 'inline', 20) + treeprocessors.register(PrettifyTreeprocessor(md), 'prettify', 10) + treeprocessors.register(UnescapeTreeprocessor(md), 'unescape', 0) + return treeprocessors + + +def isString(s: Any) -> bool: + """ Return `True` if object is a string but not an [`AtomicString`][markdown.util.AtomicString]. """ + if not isinstance(s, util.AtomicString): + return isinstance(s, str) + return False + + +class Treeprocessor(util.Processor): + """ + `Treeprocessor`s are run on the `ElementTree` object before serialization. + + Each `Treeprocessor` implements a `run` method that takes a pointer to an + `Element` and modifies it as necessary. + + `Treeprocessors` must extend `markdown.Treeprocessor`. + + """ + def run(self, root: etree.Element) -> etree.Element | None: + """ + Subclasses of `Treeprocessor` should implement a `run` method, which + takes a root `Element`. This method can return another `Element` + object, and the existing root `Element` will be replaced, or it can + modify the current tree and return `None`. + """ + pass # pragma: no cover + + +class InlineProcessor(Treeprocessor): + """ + A `Treeprocessor` that traverses a tree, applying inline patterns. + """ + + def __init__(self, md): + self.__placeholder_prefix = util.INLINE_PLACEHOLDER_PREFIX + self.__placeholder_suffix = util.ETX + self.__placeholder_length = 4 + len(self.__placeholder_prefix) \ + + len(self.__placeholder_suffix) + self.__placeholder_re = util.INLINE_PLACEHOLDER_RE + self.md = md + self.inlinePatterns = md.inlinePatterns + self.ancestors = [] + + def __makePlaceholder(self, type) -> tuple[str, str]: + """ Generate a placeholder """ + id = "%04d" % len(self.stashed_nodes) + hash = util.INLINE_PLACEHOLDER % id + return hash, id + + def __findPlaceholder(self, data: str, index: int) -> tuple[str | None, int]: + """ + Extract id from data string, start from index. + + Arguments: + data: String. + index: Index, from which we start search. + + Returns: + Placeholder id and string index, after the found placeholder. + + """ + m = self.__placeholder_re.search(data, index) + if m: + return m.group(1), m.end() + else: + return None, index + 1 + + def __stashNode(self, node, type) -> str: + """ Add node to stash. """ + placeholder, id = self.__makePlaceholder(type) + self.stashed_nodes[id] = node + return placeholder + + def __handleInline(self, data: str, patternIndex: int = 0) -> str: + """ + Process string with inline patterns and replace it with placeholders. + + Arguments: + data: A line of Markdown text. + patternIndex: The index of the `inlinePattern` to start with. + + Returns: + String with placeholders. + + """ + if not isinstance(data, util.AtomicString): + startIndex = 0 + count = len(self.inlinePatterns) + while patternIndex < count: + data, matched, startIndex = self.__applyPattern( + self.inlinePatterns[patternIndex], data, patternIndex, startIndex + ) + if not matched: + patternIndex += 1 + return data + + def __processElementText(self, node: etree.Element, subnode: etree.Element, isText: bool = True): + """ + Process placeholders in `Element.text` or `Element.tail` + of Elements popped from `self.stashed_nodes`. + + Arguments: + node: Parent node. + subnode: Processing node. + isText: Boolean variable, True - it's text, False - it's a tail. + + """ + if isText: + text = subnode.text + subnode.text = None + else: + text = subnode.tail + subnode.tail = None + + childResult = self.__processPlaceholders(text, subnode, isText) + + if not isText and node is not subnode: + pos = list(node).index(subnode) + 1 + else: + pos = 0 + + childResult.reverse() + for newChild in childResult: + node.insert(pos, newChild[0]) + + def __processPlaceholders( + self, + data: str, + parent: etree.Element, + isText: bool = True + ) -> list[tuple[etree.Element, Any]]: + """ + Process string with placeholders and generate `ElementTree` tree. + + Arguments: + data: String with placeholders instead of `ElementTree` elements. + parent: Element, which contains processing inline data. + isText: Boolean variable, True - it's text, False - it's a tail. + + Returns: + List with `ElementTree` elements with applied inline patterns. + + """ + def linkText(text): + if text: + if result: + if result[-1][0].tail: + result[-1][0].tail += text + else: + result[-1][0].tail = text + elif not isText: + if parent.tail: + parent.tail += text + else: + parent.tail = text + else: + if parent.text: + parent.text += text + else: + parent.text = text + result = [] + strartIndex = 0 + while data: + index = data.find(self.__placeholder_prefix, strartIndex) + if index != -1: + id, phEndIndex = self.__findPlaceholder(data, index) + + if id in self.stashed_nodes: + node = self.stashed_nodes.get(id) + + if index > 0: + text = data[strartIndex:index] + linkText(text) + + if not isString(node): # it's Element + for child in [node] + list(node): + if child.tail: + if child.tail.strip(): + self.__processElementText( + node, child, False + ) + if child.text: + if child.text.strip(): + self.__processElementText(child, child) + else: # it's just a string + linkText(node) + strartIndex = phEndIndex + continue + + strartIndex = phEndIndex + result.append((node, self.ancestors[:])) + + else: # wrong placeholder + end = index + len(self.__placeholder_prefix) + linkText(data[strartIndex:end]) + strartIndex = end + else: + text = data[strartIndex:] + if isinstance(data, util.AtomicString): + # We don't want to loose the `AtomicString` + text = util.AtomicString(text) + linkText(text) + data = "" + + return result + + def __applyPattern( + self, + pattern: inlinepatterns.Pattern, + data: str, + patternIndex: int, + startIndex: int = 0 + ) -> tuple[str, bool, int]: + """ + Check if the line fits the pattern, create the necessary + elements, add it to `stashed_nodes`. + + Arguments: + data: The text to be processed. + pattern: The pattern to be checked. + patternIndex: Index of current pattern. + startIndex: String index, from which we start searching. + + Returns: + String with placeholders instead of `ElementTree` elements. + + """ + new_style = isinstance(pattern, inlinepatterns.InlineProcessor) + + for exclude in pattern.ANCESTOR_EXCLUDES: + if exclude.lower() in self.ancestors: + return data, False, 0 + + if new_style: + match = None + # Since `handleMatch` may reject our first match, + # we iterate over the buffer looking for matches + # until we can't find any more. + for match in pattern.getCompiledRegExp().finditer(data, startIndex): + node, start, end = pattern.handleMatch(match, data) + if start is None or end is None: + startIndex += match.end(0) + match = None + continue + break + else: # pragma: no cover + match = pattern.getCompiledRegExp().match(data[startIndex:]) + leftData = data[:startIndex] + + if not match: + return data, False, 0 + + if not new_style: # pragma: no cover + node = pattern.handleMatch(match) + start = match.start(0) + end = match.end(0) + + if node is None: + return data, True, end + + if not isString(node): + if not isinstance(node.text, util.AtomicString): + # We need to process current node too + for child in [node] + list(node): + if not isString(node): + if child.text: + self.ancestors.append(child.tag.lower()) + child.text = self.__handleInline( + child.text, patternIndex + 1 + ) + self.ancestors.pop() + if child.tail: + child.tail = self.__handleInline( + child.tail, patternIndex + ) + + placeholder = self.__stashNode(node, pattern.type()) + + if new_style: + return "{}{}{}".format(data[:start], + placeholder, data[end:]), True, 0 + else: # pragma: no cover + return "{}{}{}{}".format(leftData, + match.group(1), + placeholder, match.groups()[-1]), True, 0 + + def __build_ancestors(self, parent, parents): + """Build the ancestor list.""" + ancestors = [] + while parent is not None: + if parent is not None: + ancestors.append(parent.tag.lower()) + parent = self.parent_map.get(parent) + ancestors.reverse() + parents.extend(ancestors) + + def run(self, tree: etree.Element, ancestors: list[str] | None = None) -> etree.Element: + """Apply inline patterns to a parsed Markdown tree. + + Iterate over `Element`, find elements with inline tag, apply inline + patterns and append newly created Elements to tree. To avoid further + processing of string with inline patterns, instead of normal string, + use subclass [`AtomicString`][markdown.util.AtomicString]: + + node.text = markdown.util.AtomicString("This will not be processed.") + + Arguments: + tree: `Element` object, representing Markdown tree. + ancestors: List of parent tag names that precede the tree node (if needed). + + Returns: + An element tree object with applied inline patterns. + + """ + self.stashed_nodes: dict[str, etree.Element] = {} + + # Ensure a valid parent list, but copy passed in lists + # to ensure we don't have the user accidentally change it on us. + tree_parents = [] if ancestors is None else ancestors[:] + + self.parent_map = {c: p for p in tree.iter() for c in p} + stack = [(tree, tree_parents)] + + while stack: + currElement, parents = stack.pop() + + self.ancestors = parents + self.__build_ancestors(currElement, self.ancestors) + + insertQueue = [] + for child in currElement: + if child.text and not isinstance( + child.text, util.AtomicString + ): + self.ancestors.append(child.tag.lower()) + text = child.text + child.text = None + lst = self.__processPlaceholders( + self.__handleInline(text), child + ) + for item in lst: + self.parent_map[item[0]] = child + stack += lst + insertQueue.append((child, lst)) + self.ancestors.pop() + if child.tail: + tail = self.__handleInline(child.tail) + dumby = etree.Element('d') + child.tail = None + tailResult = self.__processPlaceholders(tail, dumby, False) + if dumby.tail: + child.tail = dumby.tail + pos = list(currElement).index(child) + 1 + tailResult.reverse() + for newChild in tailResult: + self.parent_map[newChild[0]] = currElement + currElement.insert(pos, newChild[0]) + if len(child): + self.parent_map[child] = currElement + stack.append((child, self.ancestors[:])) + + for element, lst in insertQueue: + for i, obj in enumerate(lst): + newChild = obj[0] + element.insert(i, newChild) + return tree + + +class PrettifyTreeprocessor(Treeprocessor): + """ Add line breaks to the html document. """ + + def _prettifyETree(self, elem): + """ Recursively add line breaks to `ElementTree` children. """ + + i = "\n" + if self.md.is_block_level(elem.tag) and elem.tag not in ['code', 'pre']: + if (not elem.text or not elem.text.strip()) \ + and len(elem) and self.md.is_block_level(elem[0].tag): + elem.text = i + for e in elem: + if self.md.is_block_level(e.tag): + self._prettifyETree(e) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + + def run(self, root: etree.Element) -> None: + """ Add line breaks to `Element` object and its children. """ + + self._prettifyETree(root) + # Do `<br />`'s separately as they are often in the middle of + # inline content and missed by `_prettifyETree`. + brs = root.iter('br') + for br in brs: + if not br.tail or not br.tail.strip(): + br.tail = '\n' + else: + br.tail = '\n%s' % br.tail + # Clean up extra empty lines at end of code blocks. + pres = root.iter('pre') + for pre in pres: + if len(pre) and pre[0].tag == 'code': + code = pre[0] + # Only prettify code containing text only + if not len(code) and code.text is not None: + code.text = util.AtomicString(code.text.rstrip() + '\n') + + +class UnescapeTreeprocessor(Treeprocessor): + """ Restore escaped chars """ + + RE = re.compile(r'{}(\d+){}'.format(util.STX, util.ETX)) + + def _unescape(self, m): + return chr(int(m.group(1))) + + def unescape(self, text: str) -> str: + return self.RE.sub(self._unescape, text) + + def run(self, root): + """ Loop over all elements and unescape all text. """ + for elem in root.iter(): + # Unescape text content + if elem.text and not elem.tag == 'code': + elem.text = self.unescape(elem.text) + # Unescape tail content + if elem.tail: + elem.tail = self.unescape(elem.tail) + # Unescape attribute values + for key, value in elem.items(): + elem.set(key, self.unescape(value)) diff --git a/plugins/markdown_preview/markdown/util.py b/plugins/markdown_preview/markdown/util.py new file mode 100644 index 0000000..827befd --- /dev/null +++ b/plugins/markdown_preview/markdown/util.py @@ -0,0 +1,399 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +This module contains various contacts, classes and functions which get referenced and used +throughout the code base. +""" + +from __future__ import annotations + +import re +import sys +import warnings +from functools import wraps, lru_cache +from itertools import count +from typing import TYPE_CHECKING, Generic, Iterator, NamedTuple, TypeVar, overload + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + +_T = TypeVar('_T') + + +""" +Constants you might want to modify +----------------------------------------------------------------------------- +""" + + +BLOCK_LEVEL_ELEMENTS: list[str] = [ + # Elements which are invalid to wrap in a `<p>` tag. + # See https://w3c.github.io/html/grouping-content.html#the-p-element + 'address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', + 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', + 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', + 'p', 'pre', 'section', 'table', 'ul', + # Other elements which Markdown should not be mucking up the contents of. + 'canvas', 'colgroup', 'dd', 'body', 'dt', 'group', 'html', 'iframe', 'li', 'legend', + 'math', 'map', 'noscript', 'output', 'object', 'option', 'progress', 'script', + 'style', 'summary', 'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'tr', 'video' +] +""" +List of HTML tags which get treated as block-level elements. Same as the `block_level_elements` +attribute of the [`Markdown`][markdown.Markdown] class. Generally one should use the +attribute on the class. This remains for compatibility with older extensions. +""" + +# Placeholders +STX = '\u0002' +""" "Start of Text" marker for placeholder templates. """ +ETX = '\u0003' +""" "End of Text" marker for placeholder templates. """ +INLINE_PLACEHOLDER_PREFIX = STX+"klzzwxh:" +""" Prefix for inline placeholder template. """ +INLINE_PLACEHOLDER = INLINE_PLACEHOLDER_PREFIX + "%s" + ETX +""" Placeholder template for stashed inline text. """ +INLINE_PLACEHOLDER_RE = re.compile(INLINE_PLACEHOLDER % r'([0-9]+)') +""" Regular Expression which matches inline placeholders. """ +AMP_SUBSTITUTE = STX+"amp"+ETX +""" Placeholder template for HTML entities. """ +HTML_PLACEHOLDER = STX + "wzxhzdk:%s" + ETX +""" Placeholder template for raw HTML. """ +HTML_PLACEHOLDER_RE = re.compile(HTML_PLACEHOLDER % r'([0-9]+)') +""" Regular expression which matches HTML placeholders. """ +TAG_PLACEHOLDER = STX + "hzzhzkh:%s" + ETX +""" Placeholder template for tags. """ + + +# Constants you probably do not need to change +# ----------------------------------------------------------------------------- + +RTL_BIDI_RANGES = ( + ('\u0590', '\u07FF'), + # Hebrew (0590-05FF), Arabic (0600-06FF), + # Syriac (0700-074F), Arabic supplement (0750-077F), + # Thaana (0780-07BF), Nko (07C0-07FF). + ('\u2D30', '\u2D7F') # Tifinagh +) + + +# AUXILIARY GLOBAL FUNCTIONS +# ============================================================================= + + +@lru_cache(maxsize=None) +def get_installed_extensions(): + """ Return all entry_points in the `markdown.extensions` group. """ + if sys.version_info >= (3, 10): + from importlib import metadata + else: # `<PY310` use backport + import importlib_metadata as metadata + # Only load extension entry_points once. + return metadata.entry_points(group='markdown.extensions') + + +def deprecated(message: str, stacklevel: int = 2): + """ + Raise a [`DeprecationWarning`][] when wrapped function/method is called. + + Usage: + + ```python + @deprecated("This method will be removed in version X; use Y instead.") + def some_method(): + pass + ``` + """ + def wrapper(func): + @wraps(func) + def deprecated_func(*args, **kwargs): + warnings.warn( + f"'{func.__name__}' is deprecated. {message}", + category=DeprecationWarning, + stacklevel=stacklevel + ) + return func(*args, **kwargs) + return deprecated_func + return wrapper + + +def parseBoolValue(value: str | None, fail_on_errors: bool = True, preserve_none: bool = False) -> bool | None: + """Parses a string representing a boolean value. If parsing was successful, + returns `True` or `False`. If `preserve_none=True`, returns `True`, `False`, + or `None`. If parsing was not successful, raises `ValueError`, or, if + `fail_on_errors=False`, returns `None`.""" + if not isinstance(value, str): + if preserve_none and value is None: + return value + return bool(value) + elif preserve_none and value.lower() == 'none': + return None + elif value.lower() in ('true', 'yes', 'y', 'on', '1'): + return True + elif value.lower() in ('false', 'no', 'n', 'off', '0', 'none'): + return False + elif fail_on_errors: + raise ValueError('Cannot parse bool value: %r' % value) + + +def code_escape(text: str) -> str: + """HTML escape a string of code.""" + if "&" in text: + text = text.replace("&", "&") + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + return text + + +def _get_stack_depth(size=2): + """Get current stack depth, performantly. + """ + frame = sys._getframe(size) + + for size in count(size): + frame = frame.f_back + if not frame: + return size + + +def nearing_recursion_limit() -> bool: + """Return true if current stack depth is within 100 of maximum limit.""" + return sys.getrecursionlimit() - _get_stack_depth() < 100 + + +# MISC AUXILIARY CLASSES +# ============================================================================= + + +class AtomicString(str): + """A string which should not be further processed.""" + pass + + +class Processor: + """ The base class for all processors. + + Attributes: + Processor.md: The `Markdown` instance passed in an initialization. + + Arguments: + md: The `Markdown` instance this processor is a part of. + + """ + def __init__(self, md: Markdown | None = None): + self.md = md + + +class HtmlStash: + """ + This class is used for stashing HTML objects that we extract + in the beginning and replace with place-holders. + """ + + def __init__(self): + """ Create an `HtmlStash`. """ + self.html_counter = 0 # for counting inline html segments + self.rawHtmlBlocks = [] + self.tag_counter = 0 + self.tag_data = [] # list of dictionaries in the order tags appear + + def store(self, html: str) -> str: + """ + Saves an HTML segment for later reinsertion. Returns a + placeholder string that needs to be inserted into the + document. + + Keyword arguments: + html: An html segment. + + Returns: + A placeholder string. + + """ + self.rawHtmlBlocks.append(html) + placeholder = self.get_placeholder(self.html_counter) + self.html_counter += 1 + return placeholder + + def reset(self) -> None: + """ Clear the stash. """ + self.html_counter = 0 + self.rawHtmlBlocks = [] + + def get_placeholder(self, key: int) -> str: + return HTML_PLACEHOLDER % key + + def store_tag(self, tag: str, attrs: list, left_index: int, right_index: int) -> str: + """Store tag data and return a placeholder.""" + self.tag_data.append({'tag': tag, 'attrs': attrs, + 'left_index': left_index, + 'right_index': right_index}) + placeholder = TAG_PLACEHOLDER % str(self.tag_counter) + self.tag_counter += 1 # equal to the tag's index in `self.tag_data` + return placeholder + + +# Used internally by `Registry` for each item in its sorted list. +# Provides an easier to read API when editing the code later. +# For example, `item.name` is more clear than `item[0]`. +class _PriorityItem(NamedTuple): + name: str + priority: float + + +class Registry(Generic[_T]): + """ + A priority sorted registry. + + A `Registry` instance provides two public methods to alter the data of the + registry: `register` and `deregister`. Use `register` to add items and + `deregister` to remove items. See each method for specifics. + + When registering an item, a "name" and a "priority" must be provided. All + items are automatically sorted by "priority" from highest to lowest. The + "name" is used to remove ("deregister") and get items. + + A `Registry` instance it like a list (which maintains order) when reading + data. You may iterate over the items, get an item and get a count (length) + of all items. You may also check that the registry contains an item. + + When getting an item you may use either the index of the item or the + string-based "name". For example: + + registry = Registry() + registry.register(SomeItem(), 'itemname', 20) + # Get the item by index + item = registry[0] + # Get the item by name + item = registry['itemname'] + + When checking that the registry contains an item, you may use either the + string-based "name", or a reference to the actual item. For example: + + someitem = SomeItem() + registry.register(someitem, 'itemname', 20) + # Contains the name + assert 'itemname' in registry + # Contains the item instance + assert someitem in registry + + The method `get_index_for_name` is also available to obtain the index of + an item using that item's assigned "name". + """ + + def __init__(self): + self._data: dict[str, _T] = {} + self._priority = [] + self._is_sorted = False + + def __contains__(self, item: str | _T) -> bool: + if isinstance(item, str): + # Check if an item exists by this name. + return item in self._data.keys() + # Check if this instance exists. + return item in self._data.values() + + def __iter__(self) -> Iterator[_T]: + self._sort() + return iter([self._data[k] for k, p in self._priority]) + + @overload + def __getitem__(self, key: str | int) -> _T: # pragma: no cover + ... + + @overload + def __getitem__(self, key: slice) -> Registry[_T]: # pragma: no cover + ... + + def __getitem__(self, key: str | int | slice) -> _T | Registry[_T]: + self._sort() + if isinstance(key, slice): + data: Registry[_T] = Registry() + for k, p in self._priority[key]: + data.register(self._data[k], k, p) + return data + if isinstance(key, int): + return self._data[self._priority[key].name] + return self._data[key] + + def __len__(self) -> int: + return len(self._priority) + + def __repr__(self): + return '<{}({})>'.format(self.__class__.__name__, list(self)) + + def get_index_for_name(self, name: str) -> int: + """ + Return the index of the given name. + """ + if name in self: + self._sort() + return self._priority.index( + [x for x in self._priority if x.name == name][0] + ) + raise ValueError('No item named "{}" exists.'.format(name)) + + def register(self, item: _T, name: str, priority: float) -> None: + """ + Add an item to the registry with the given name and priority. + + Arguments: + item: The item being registered. + name: A string used to reference the item. + priority: An integer or float used to sort against all items. + + If an item is registered with a "name" which already exists, the + existing item is replaced with the new item. Treat carefully as the + old item is lost with no way to recover it. The new item will be + sorted according to its priority and will **not** retain the position + of the old item. + """ + if name in self: + # Remove existing item of same name first + self.deregister(name) + self._is_sorted = False + self._data[name] = item + self._priority.append(_PriorityItem(name, priority)) + + def deregister(self, name: str, strict: bool = True) -> None: + """ + Remove an item from the registry. + + Set `strict=False` to fail silently. Otherwise a [`ValueError`][] is raised for an unknown `name`. + """ + try: + index = self.get_index_for_name(name) + del self._priority[index] + del self._data[name] + except ValueError: + if strict: + raise + + def _sort(self): + """ + Sort the registry by priority from highest to lowest. + + This method is called internally and should never be explicitly called. + """ + if not self._is_sorted: + self._priority.sort(key=lambda item: item.priority, reverse=True) + self._is_sorted = True diff --git a/plugins/markdown_preview/markdown_preview.glade b/plugins/markdown_preview/markdown_preview.glade new file mode 100644 index 0000000..68a45f0 --- /dev/null +++ b/plugins/markdown_preview/markdown_preview.glade @@ -0,0 +1,121 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generated with glade 3.40.0 --> +<interface> + <requires lib="gtk+" version="3.24"/> + <requires lib="webkit2gtk" version="2.28"/> + <object class="GtkImage" id="settings_img"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="stock">gtk-justify-fill</property> + </object> + <object class="WebKitSettings" type-func="webkit_settings_get_type" id="web_view_settings"> + <property name="enable-offline-web-application-cache">False</property> + <property name="enable-html5-local-storage">False</property> + <property name="enable-html5-database">False</property> + <property name="enable-xss-auditor">False</property> + <property name="enable-hyperlink-auditing">False</property> + <property name="enable-tabs-to-links">False</property> + <property name="enable-fullscreen">False</property> + <property name="print-backgrounds">False</property> + <property name="enable-webaudio">False</property> + <property name="enable-page-cache">False</property> + <property name="user-agent">Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15</property> + <property name="enable-accelerated-2d-canvas">True</property> + <property name="allow-file-access-from-file-urls">True</property> + <property name="allow-universal-access-from-file-urls">True</property> + <property name="enable-webrtc">True</property> + </object> + <object class="GtkPopover" id="markdown_preview_dialog"> + <property name="width-request">620</property> + <property name="height-request">480</property> + <property name="can-focus">False</property> + <property name="vexpand">True</property> + <property name="position">left</property> + <property name="modal">False</property> + <property name="transitions-enabled">False</property> + <property name="constrain-to">none</property> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkButtonBox"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="layout-style">end</property> + <child> + <object class="GtkToggleButton"> + <property name="label">gtk-media-pause</property> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="receives-default">True</property> + <property name="use-stock">True</property> + <property name="always-show-image">True</property> + <signal name="toggled" handler="_tggle_preview_updates" swapped="no"/> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkButton"> + <property name="visible">True</property> + <property name="sensitive">False</property> + <property name="can-focus">True</property> + <property name="receives-default">True</property> + <property name="image">settings_img</property> + <signal name="clicked" handler="_handle_settings" swapped="no"/> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkScrolledWindow"> + <property name="visible">True</property> + <property name="can-focus">True</property> + <property name="shadow-type">in</property> + <child> + <object class="GtkViewport"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <child> + <object class="WebKitWebView" type-func="webkit_web_view_get_type" id="markdown_view"> + <property name="visible">True</property> + <property name="can-focus">False</property> + <property name="settings">web_view_settings</property> + <property name="is-ephemeral">True</property> + <property name="is-muted">True</property> + <property name="default-content-security-policy">*</property> + <child> + <placeholder/> + </child> + </object> + </child> + </object> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + </child> + </object> +</interface> diff --git a/plugins/markdown_preview/markdown_template_mixin.py b/plugins/markdown_preview/markdown_template_mixin.py new file mode 100644 index 0000000..710db12 --- /dev/null +++ b/plugins/markdown_preview/markdown_template_mixin.py @@ -0,0 +1,42 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class MarkdownTemplateMixin: + def wrap_html_to_body(self, html): + return f"""\ +<!DOCTYPE html> +<html lang="en" dir="ltr"> +<head> + <meta charset="utf-8"> + <title>Markdown View + + + + {html} + + + +""" \ No newline at end of file diff --git a/plugins/markdown_preview/plugin.py b/plugins/markdown_preview/plugin.py new file mode 100644 index 0000000..b363acb --- /dev/null +++ b/plugins/markdown_preview/plugin.py @@ -0,0 +1,114 @@ +# Python imports +import os + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') +gi.require_version('WebKit2', '4.0') +from gi.repository import Gtk +from gi.repository import Gdk +from gi.repository import WebKit2 + +# Application imports +from . import markdown +from .markdown_template_mixin import MarkdownTemplateMixin +from plugins.plugin_base import PluginBase + + + +class Plugin(MarkdownTemplateMixin, PluginBase): + def __init__(self): + super().__init__() + + self.name = "Markdown Preview" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus + # where self.name should not be needed for message comms + self.path = os.path.dirname(os.path.realpath(__file__)) + self._GLADE_FILE = f"{self.path}/markdown_preview.glade" + + self.is_preview_paused = False + self.is_md_file = False + + + def run(self): + WebKit2.WebView() # Need one initialized for webview to work from glade file + + self._builder = Gtk.Builder() + self._builder.add_from_file(self._GLADE_FILE) + self._connect_builder_signals(self, self._builder) + + separator_right = self._ui_objects[0] + self._markdown_dialog = self._builder.get_object("markdown_preview_dialog") + self._markdown_view = self._builder.get_object("markdown_view") + self._web_view_settings = self._builder.get_object("web_view_settings") + + self._markdown_dialog.set_relative_to(separator_right) + self._markdown_view.set_settings(self._web_view_settings) + self._markdown_view.set_background_color(Gdk.RGBA(0, 0, 0, 0.0)) + + + def generate_reference_ui_element(self): + ... + + def subscribe_to_events(self): + self._event_system.subscribe("tggle_markdown_preview", self._tggle_markdown_preview) + self._event_system.subscribe("set_active_src_view", self._set_active_src_view) + self._event_system.subscribe("buffer_changed", self._do_markdown_translate) + + def _buffer_changed_first_load(self, buffer): + self._buffer = buffer + + self._do_markdown_translate(buffer) + + def _set_active_src_view(self, source_view): + self._active_src_view = source_view + self._buffer = self._active_src_view.get_buffer() + + self._do_markdown_translate(self._buffer) + + def _handle_settings(self, widget = None, eve = None): + ... + + def _tggle_preview_updates(self, widget = None, eve = None): + self.is_preview_paused = not self.is_preview_paused + widget.set_active(self.is_preview_paused) + + if not self.is_preview_paused: + self._do_markdown_translate(self._buffer) + + def _tggle_markdown_preview(self, widget = None, eve = None): + if not self._active_src_view: return + + is_visible = self._markdown_dialog.is_visible() + buffer = self._active_src_view.get_buffer() + data = None + + if not is_visible: + self._markdown_dialog.popup(); + self._do_markdown_translate(buffer) + elif not data and is_visible: + self._markdown_dialog.popdown() + + def _do_markdown_translate(self, buffer): + if self.is_preview_paused: return + + self.is_markdown_check() + is_visible = self._markdown_dialog.is_visible() + if not is_visible or not self.is_md_file: return + self.render_markdown(buffer) + + def render_markdown(self, buffer): + start_iter = buffer.get_start_iter() + end_iter = buffer.get_end_iter() + text = buffer.get_text(start_iter, end_iter, include_hidden_chars = False) + html = markdown.markdown(text) + + path = self._active_src_view.get_current_file().get_parent().get_path() + data = self.wrap_html_to_body(html) + self._markdown_view.load_html(content = data, base_uri = f"file://{path}/") + + def is_markdown_check(self): + self.is_md_file = self._active_src_view.get_filetype() == "markdown" + if not self.is_md_file: + data = self.wrap_html_to_body("

Not a Markdown file...

") + self._markdown_view.load_html(content = data, base_uri = None) \ No newline at end of file diff --git a/plugins/search_replace/__init__.py b/plugins/search_replace/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/search_replace/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/search_replace/__main__.py b/plugins/search_replace/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/search_replace/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/search_replace/manifest.json b/plugins/search_replace/manifest.json new file mode 100644 index 0000000..a98cc50 --- /dev/null +++ b/plugins/search_replace/manifest.json @@ -0,0 +1,14 @@ +{ + "manifest": { + "name": "Search/Replace", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": { + "pass_events": "true", + "pass_ui_objects": ["separator_botton"], + "bind_keys": ["Search/Replace||tggl_search_replace:f"] + + } + } +} diff --git a/plugins/search_replace/plugin.py b/plugins/search_replace/plugin.py new file mode 100644 index 0000000..b387f07 --- /dev/null +++ b/plugins/search_replace/plugin.py @@ -0,0 +1,221 @@ +# Python imports +import os +import re +import threading + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') +from gi.repository import Gtk +from gi.repository import Gdk +from gi.repository import GLib + +# Application imports +from plugins.plugin_base import PluginBase +from .styling_mixin import StylingMixin +from .replace_mixin import ReplaceMixin + + + +class Plugin(StylingMixin, ReplaceMixin, PluginBase): + def __init__(self): + super().__init__() + + self.name = "Search/Replace" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus + # where self.name should not be needed for message comms + self.path = os.path.dirname(os.path.realpath(__file__)) + self._GLADE_FILE = f"{self.path}/search_replace.glade" + + self._search_replace_dialog = None + self._find_entry = None + self._replace_entry = None + self._active_src_view = None + self._buffer = None + self._tag_table = None + + self.use_regex = False + self.use_case_sensitive = False + self.search_only_in_selection = False + self.use_whole_word_search = False + + self.timer = None + self.search_time = 0.35 + self.find_text = "" + self.search_tag = "search_tag" + self.highlight_color = "#FBF719" + self.text_color = "#000000" + self.alpha_num_under = re.compile(r"[a-zA-Z0-9_]") + + + def run(self): + self._builder = Gtk.Builder() + self._builder.add_from_file(self._GLADE_FILE) + self._connect_builder_signals(self, self._builder) + + separator_botton = self._ui_objects[0] + self._search_replace_dialog = self._builder.get_object("search_replace_dialog") + self._find_status_lbl = self._builder.get_object("find_status_lbl") + self._find_options_lbl = self._builder.get_object("find_options_lbl") + + self._find_entry = self._builder.get_object("find_entry") + self._replace_entry = self._builder.get_object("replace_entry") + + self._search_replace_dialog.set_relative_to(separator_botton) + self._search_replace_dialog.set_hexpand(True) + + def generate_reference_ui_element(self): + ... + + def subscribe_to_events(self): + self._event_system.subscribe("tggl_search_replace", self._tggl_search_replace) + self._event_system.subscribe("set_active_src_view", self._set_active_src_view) + + def _set_active_src_view(self, source_view): + self._active_src_view = source_view + self._buffer = self._active_src_view.get_buffer() + self._tag_table = self._buffer.get_tag_table() + self.search_for_string(self._find_entry) + + def _show_search_replace(self, widget = None, eve = None): + self._search_replace_dialog.popup() + + def _tggl_search_replace(self, widget = None, eve = None): + is_visible = self._search_replace_dialog.is_visible() + buffer = self._active_src_view.get_buffer() + data = None + + if buffer.get_has_selection(): + start, end = buffer.get_selection_bounds() + data = buffer.get_text(start, end, include_hidden_chars = False) + + if data: + self._find_entry.set_text(data) + + if not is_visible: + self._search_replace_dialog.popup(); + self._find_entry.grab_focus() + elif not data and is_visible: + self._search_replace_dialog.popdown() + self._find_entry.set_text("") + else: + self._find_entry.grab_focus() + + + def get_search_tag(self, buffer): + tag_table = buffer.get_tag_table() + search_tag = tag_table.lookup(self.search_tag) + if not search_tag: + search_tag = buffer.create_tag(self.search_tag, background = self.highlight_color, foreground = self.text_color) + + buffer.remove_tag_by_name(self.search_tag, buffer.get_start_iter(), buffer.get_end_iter()) + return search_tag + + + def cancel_timer(self): + if self.timer: + self.timer.cancel() + GLib.idle_remove_by_data(None) + + def delay_search_glib(self): + GLib.idle_add(self._do_highlight) + + def delay_search(self): + wait_time = self.search_time / len(self.find_text) + wait_time = max(wait_time, 0.05) + + self.timer = threading.Timer(wait_time, self.delay_search_glib) + self.timer.daemon = True + self.timer.start() + + + def on_enter_search(self, widget, eve): + text = widget.get_text() + if not text: return + + keyname = Gdk.keyval_name(eve.keyval) + if keyname == "Return": + self.find_next(widget) + + def search_for_string(self, widget): + self.cancel_timer() + + self.find_text = widget.get_text() + if len(self.find_text) > 0 and len(self.find_text) < 5: + self.delay_search() + else: + self._do_highlight(self.find_text) + + + def _do_highlight(self, query = None): + query = self.find_text if not query else query + buffer = self._active_src_view.get_buffer() + # Also clears tag from buffer so if no query we're clean in ui + search_tag = self.get_search_tag(buffer) + + self.update_style(1) + if not query: + self._find_status_lbl.set_label(f"Find in current buffer") + self.update_style(0) + return + + start_itr = buffer.get_start_iter() + end_itr = buffer.get_end_iter() + + results, total_count = self.search(start_itr, query) + self._update_status_lbl(total_count, query) + for start, end in results: + buffer.apply_tag(search_tag, start, end) + + def search(self, start_itr = None, query = None, limit = None): + if not start_itr or not query: return None, None + + flags = Gtk.TextSearchFlags.VISIBLE_ONLY | Gtk.TextSearchFlags.TEXT_ONLY + if not self.use_case_sensitive: + flags = flags | Gtk.TextSearchFlags.CASE_INSENSITIVE + + if self.search_only_in_selection and self._buffer.get_has_selection(): + start_itr, limit = self._buffer.get_selection_bounds() + + _results = [] + while True: + result = start_itr.forward_search(query, flags, limit) + if not result: break + + _results.append(result) + start_itr = result[1] + + results = self.apply_filters(_results, query) + return results, len(results) + + def apply_filters(self, _results, query): + results = [] + for start, end in _results: + text = self._buffer.get_slice(start, end, include_hidden_chars = False) + if self.use_whole_word_search: + if not self.is_whole_word(start, end): + continue + + results.append([start, end]) + + return results + + def find_next(self, widget, eve = None, use_data = None): + mark = self._buffer.get_insert() + iter = self._buffer.get_iter_at_mark(mark) + iter.forward_line() + + search_tag = self._tag_table.lookup(self.search_tag) + next_tag_found = iter.forward_to_tag_toggle(search_tag) + if not next_tag_found: + self._buffer.place_cursor( self._buffer.get_start_iter() ) + mark = self._buffer.get_insert() + iter = self._buffer.get_iter_at_mark(mark) + iter.forward_to_tag_toggle(search_tag) + + self._buffer.place_cursor(iter) + self._active_src_view.scroll_to_mark( self._buffer.get_insert(), 0.0, True, 0.0, 0.0 ) + + + def find_all(self, widget): + ... \ No newline at end of file diff --git a/plugins/search_replace/replace_mixin.py b/plugins/search_replace/replace_mixin.py new file mode 100644 index 0000000..9d5a2f0 --- /dev/null +++ b/plugins/search_replace/replace_mixin.py @@ -0,0 +1,94 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class ReplaceMixin: + def replace(self, widget): + replace_text = self._replace_entry.get_text() + if self.find_text and replace_text: + self._buffer.begin_user_action() + + iter = self._buffer.get_start_iter() + search_tag = self._tag_table.lookup(self.search_tag) + + iter.forward_to_tag_toggle(search_tag) + self._do_replace(iter, replace_text) + self._active_src_view.scroll_to_iter( iter, 0.0, True, 0.0, 0.0 ) + + self._buffer.end_user_action() + + def replace_all(self, widget): + replace_text = self._replace_entry.get_text() + if self.find_text: + self._buffer.begin_user_action() + + mark = self._buffer.get_insert() + iter = self._buffer.get_start_iter() + search_tag = self._tag_table.lookup(self.search_tag) + + while iter.forward_to_tag_toggle(search_tag): + self._do_replace(iter, replace_text) + iter = self._buffer.get_start_iter() + + self._buffer.end_user_action() + + + def _do_replace(self, iter, text): + start, end = self.get_start_end(iter) + self.replace_in_buffer(start, end, text) + + def replace_in_buffer(self, start, end, text): + pos_mark = self._buffer.create_mark("find-replace", end, True) + self._buffer.delete(start, end) + replace_iter = self._buffer.get_iter_at_mark(pos_mark) + self._buffer.insert(replace_iter, text) + + def get_start_end(self, iter): + start = iter.copy() + end = None + + while True: + iter.forward_char() + tags = iter.get_tags() + valid = False + for tag in tags: + if tag.props.name and self.search_tag in tag.props.name: + valid = True + break + + if valid: + continue + + end = iter.copy() + break + + return start, end + + # NOTE: Below, lovingly taken from Hamad Al Marri's Gamma text editor. + # Link: https://gitlab.com/hamadmarri/gamma-text-editor + def is_whole_word(self, match_start, match_end): + is_prev_a_char = True + is_next_a_char = True + + prev_iter = match_start.copy() + next_iter = match_end.copy() + if not prev_iter.backward_char(): + is_prev_a_char = False + else: + c = prev_iter.get_char() + is_prev_a_char = (c.isalpha() or c.isdigit()) + + if not next_iter: + is_next_a_char = False + else: + c = next_iter.get_char() + is_next_a_char = (c.isalpha() or c.isdigit()) + + is_word = (not is_prev_a_char and not is_next_a_char) + + # Note: Both must be false to be a word... + return is_word diff --git a/plugins/search_replace/search_replace.glade b/plugins/search_replace/search_replace.glade new file mode 100644 index 0000000..ccab6c8 --- /dev/null +++ b/plugins/search_replace/search_replace.glade @@ -0,0 +1,299 @@ + + + + + + True + False + gtk-close + + + True + False + ../../icons/only-in-selection.png + + + True + False + ../../icons/whole-word.png + + + False + False + False + none + + + True + False + vertical + + + True + False + + + True + False + 5 + Find in Current Buffer + 0 + + + True + True + 0 + + + + + True + False + + + True + False + 20 + Finding with Options: Case Insensitive + 0 + + + False + True + 0 + + + + + True + False + start + + + .* + True + False + True + False + True + Use Regex + + + + True + True + 0 + + + + + Aa + True + True + False + True + Match Case + + + + True + True + 1 + + + + + True + True + False + True + Only In Selection + only-in-selection + True + + + + True + True + 2 + + + + + True + True + False + True + Whole Word + whole-word + True + + + + True + True + 3 + + + + + True + True + False + True + Close Panel + close_img + True + + + + True + True + 4 + + + + + False + True + 1 + + + + + False + True + 1 + + + + + False + True + 0 + + + + + + True + False + True + + + Replace All + True + True + False + True + Replace All + 5 + 5 + 5 + 5 + + + + 9 + 1 + + + + + Replace + True + True + False + True + Replace Next + 5 + 10 + 5 + 5 + + + + 8 + 1 + + + + + Find All + True + True + False + True + 5 + 5 + 5 + 5 + + + + 9 + 0 + + + + + Find + True + True + False + True + 5 + 5 + 5 + 5 + + + + 8 + 0 + + + + + True + True + edit-find-symbolic + False + False + Find in current buffer + + + + + 0 + 0 + 8 + + + + + True + True + edit-find-symbolic + False + False + Replace in current buffer + + + 0 + 1 + 8 + + + + + False + True + 10 + 3 + + + + + + diff --git a/plugins/search_replace/styling_mixin.py b/plugins/search_replace/styling_mixin.py new file mode 100644 index 0000000..1baf8a5 --- /dev/null +++ b/plugins/search_replace/styling_mixin.py @@ -0,0 +1,66 @@ +# Python imports + +# Lib imports + +# Application imports + + + +class StylingMixin: + def tggle_regex(self, widget): + self.use_regex = not widget.get_active() + self._set_find_options_lbl() + self.search_for_string(self._find_entry) + + def tggle_case_sensitive(self, widget): + self.use_case_sensitive = widget.get_active() + self._set_find_options_lbl() + self.search_for_string(self._find_entry) + + def tggle_selection_only_scan(self, widget): + self.search_only_in_selection = widget.get_active() + self._set_find_options_lbl() + self.search_for_string(self._find_entry) + + def tggle_whole_word_search(self, widget): + self.use_whole_word_search = widget.get_active() + self._set_find_options_lbl() + self.search_for_string(self._find_entry) + + def _set_find_options_lbl(self): + find_options = "Finding with Options: " + + if self.use_regex: + find_options += "Regex" + + find_options += ", " if self.use_regex else "" + find_options += "Case Sensitive" if self.use_case_sensitive else "Case Inensitive" + + if self.search_only_in_selection: + find_options += ", Within Current Selection" + + if self.use_whole_word_search: + find_options += ", Whole Word" + + self._find_options_lbl.set_label(find_options) + + def update_style(self, state): + self._find_entry.get_style_context().remove_class("searching") + self._find_entry.get_style_context().remove_class("search_success") + self._find_entry.get_style_context().remove_class("search_fail") + + if state == 0: + self._find_entry.get_style_context().add_class("searching") + elif state == 1: + self._find_entry.get_style_context().add_class("search_success") + elif state == 2: + self._find_entry.get_style_context().add_class("search_fail") + + def _update_status_lbl(self, total_count: int = 0, query: str = None): + if not query: return + + count = total_count if total_count > 0 else "No" + plural = "s" if total_count > 1 else "" + + if total_count == 0: self.update_style(2) + self._find_status_lbl.set_label(f"{count} results{plural} found for '{query}'") diff --git a/plugins/snippets/__init__.py b/plugins/snippets/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/snippets/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/snippets/__main__.py b/plugins/snippets/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/snippets/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/snippets/cson/__init__.py b/plugins/snippets/cson/__init__.py new file mode 100644 index 0000000..0fda134 --- /dev/null +++ b/plugins/snippets/cson/__init__.py @@ -0,0 +1,8 @@ +""" +A Coffescript Object Notation (CSON) parser for Python 2 and Python 3. +See documentation at https://github.com/avaka/pycson +""" + +from .parser import load, loads +from .writer import dump, dumps +from .speg import ParseError \ No newline at end of file diff --git a/plugins/snippets/cson/parser.py b/plugins/snippets/cson/parser.py new file mode 100644 index 0000000..36dc594 --- /dev/null +++ b/plugins/snippets/cson/parser.py @@ -0,0 +1,295 @@ +from .speg import peg +import re, sys + +if sys.version_info[0] == 2: + _chr = unichr +else: + _chr = chr + +def load(fin): + return loads(fin.read()) + +def loads(s): + if isinstance(s, bytes): + s = s.decode('utf-8') + if s.startswith(u'\ufeff'): + s = s[1:] + return peg(s.replace('\r\n', '\n'), _p_root) + +def _p_ws(p): + p('[ \t]*') + +def _p_nl(p): + p(r'([ \t]*(?:#[^\n]*)?\r?\n)+') + +def _p_ews(p): + with p: + p(_p_nl) + p(_p_ws) + +def _p_id(p): + return p(r'[$a-zA-Z_][$0-9a-zA-Z_]*') + +_escape_table = { + 'r': '\r', + 'n': '\n', + 't': '\t', + 'f': '\f', + 'b': '\b', +} +def _p_unescape(p): + esc = p('\\\\(?:u[0-9a-fA-F]{4}|[^\n])') + if esc[1] == 'u': + return _chr(int(esc[2:], 16)) + return _escape_table.get(esc[1:], esc[1:]) + +_re_indent = re.compile(r'[ \t]*') +def _p_block_str(p, c): + p(r'{c}{c}{c}'.format(c=c)) + lines = [['']] + with p: + while True: + s = p(r'(?:{c}(?!{c}{c})|[^{c}\\])*'.format(c=c)) + l = s.split('\n') + lines[-1].append(l[0]) + lines.extend([x] for x in l[1:]) + if p(r'(?:\\\n[ \t]*)*'): + continue + p.commit() + lines[-1].append(p(_p_unescape)) + p(r'{c}{c}{c}'.format(c=c)) + + lines = [''.join(l) for l in lines] + strip_ws = len(lines) > 1 + if strip_ws and all(c in ' \t' for c in lines[-1]): + lines.pop() + + indent = None + for line in lines[1:]: + if not line: + continue + if indent is None: + indent = _re_indent.match(line).group(0) + continue + for i, (c1, c2) in enumerate(zip(indent, line)): + if c1 != c2: + indent = indent[:i] + break + + ind_len = len(indent or '') + if strip_ws and all(c in ' \t' for c in lines[0]): + lines = [line[ind_len:] for line in lines[1:]] + else: + lines[1:] = [line[ind_len:] for line in lines[1:]] + + return '\n'.join(lines) + +_re_mstr_nl = re.compile(r'(?:[ \t]*\n)+[ \t]*') +_re_mstr_trailing_nl = re.compile(_re_mstr_nl.pattern + r'\Z') +def _p_multiline_str(p, c): + p('{c}(?!{c}{c})(?:[ \t]*\n[ \t]*)?'.format(c=c)) + string_parts = [] + with p: + while True: + string_parts.append(p(r'[^{c}\\]*'.format(c=c))) + if p(r'(?:\\\n[ \t]*)*'): + string_parts.append('') + continue + p.commit() + string_parts.append(p(_p_unescape)) + p(c) + string_parts[-1] = _re_mstr_trailing_nl.sub('', string_parts[-1]) + string_parts[::2] = [_re_mstr_nl.sub(' ', part) for part in string_parts[::2]] + return ''.join(string_parts) + +def _p_string(p): + with p: + return p(_p_block_str, '"') + with p: + return p(_p_block_str, "'") + with p: + return p(_p_multiline_str, '"') + return p(_p_multiline_str, "'") + +def _p_array_value(p): + with p: + p(_p_nl) + return p(_p_object) + with p: + p(_p_ws) + return p(_p_line_object) + p(_p_ews) + return p(_p_simple_value) + +def _p_key(p): + with p: + return p(_p_id) + return p(_p_string) + +def _p_flow_kv(p): + k = p(_p_key) + p(_p_ews) + p(':') + with p: + p(_p_nl) + return k, p(_p_object) + with p: + p(_p_ws) + return k, p(_p_line_object) + p(_p_ews) + return k, p(_p_simple_value) + +def _p_flow_obj_sep(p): + with p: + p(_p_ews) + p(',') + p(_p_ews) + return + + p(_p_nl) + p(_p_ws) + +def _p_simple_value(p): + with p: + p('null') + return None + + with p: + p('false') + return False + with p: + p('true') + return True + + with p: + return int(p('0b[01]+')[2:], 2) + with p: + return int(p('0o[0-7]+')[2:], 8) + with p: + return int(p('0x[0-9a-fA-F]+')[2:], 16) + with p: + return float(p(r'-?(?:[1-9][0-9]*|0)?\.[0-9]+(?:[Ee][\+-]?[0-9]+)?|(?:[1-9][0-9]*|0)(?:\.[0-9]+)?[Ee][\+-]?[0-9]+')) + with p: + return int(p('-?[1-9][0-9]*|0'), 10) + + with p: + return p(_p_string) + + with p: + p(r'\[') + r = [] + with p: + p.set('I', '') + r.append(p(_p_array_value)) + with p: + while True: + with p: + p(_p_ews) + p(',') + rr = p(_p_array_value) + if not p: + p(_p_nl) + with p: + rr = p(_p_object) + if not p: + p(_p_ews) + rr = p(_p_simple_value) + r.append(rr) + p.commit() + with p: + p(_p_ews) + p(',') + p(_p_ews) + p(r'\]') + return r + + p(r'\{') + + r = {} + p(_p_ews) + with p: + p.set('I', '') + k, v = p(_p_flow_kv) + r[k] = v + with p: + while True: + p(_p_flow_obj_sep) + k, v = p(_p_flow_kv) + r[k] = v + p.commit() + p(_p_ews) + with p: + p(',') + p(_p_ews) + p(r'\}') + return r + +def _p_line_kv(p): + k = p(_p_key) + p(_p_ws) + p(':') + p(_p_ws) + with p: + p(_p_nl) + p(p.get('I')) + return k, p(_p_indented_object) + with p: + return k, p(_p_line_object) + with p: + return k, p(_p_simple_value) + p(_p_nl) + p(p.get('I')) + p('[ \t]') + p(_p_ws) + return k, p(_p_simple_value) + +def _p_line_object(p): + k, v = p(_p_line_kv) + r = { k: v } + with p: + while True: + p(_p_ws) + p(',') + p(_p_ws) + k, v = p(_p_line_kv) + r[k] = v # uniqueness + p.commit() + return r + +def _p_object(p): + p.set('I', p.get('I') + p('[ \t]*')) + r = p(_p_line_object) + with p: + while True: + p(_p_ws) + with p: + p(',') + p(_p_nl) + p(p.get('I')) + rr = p(_p_line_object) + r.update(rr) # unqueness + p.commit() + return r + +def _p_indented_object(p): + p.set('I', p.get('I') + p('[ \t]')) + return p(_p_object) + +def _p_root(p): + with p: + p(_p_nl) + + with p: + p.set('I', '') + r = p(_p_object) + p(_p_ws) + with p: + p(',') + + if not p: + p(_p_ws) + r = p(_p_simple_value) + + p(_p_ews) + p(p.eof) + return r \ No newline at end of file diff --git a/plugins/snippets/cson/speg/__init__.py b/plugins/snippets/cson/speg/__init__.py new file mode 100644 index 0000000..650c80f --- /dev/null +++ b/plugins/snippets/cson/speg/__init__.py @@ -0,0 +1,15 @@ +from .errors import ParseError, ExpectedExpr, UnexpectedExpr, SemanticFailure +from .peg import Parser +from .rules import eof + +def peg(text, root_rule): + p = Parser(text) + return p(root_rule) + +def parse(text, root_rule): + p = Parser(text) + return p.parse(root_rule) + +def hidden(fn): + fn._speg_hidden = True + return fn \ No newline at end of file diff --git a/plugins/snippets/cson/speg/errors.py b/plugins/snippets/cson/speg/errors.py new file mode 100644 index 0000000..994a91b --- /dev/null +++ b/plugins/snippets/cson/speg/errors.py @@ -0,0 +1,66 @@ +from .rules import rule_to_str + +class ExpectedExpr: + def __init__(self, expr, callstack): + self.expr = expr + self.callstack = callstack + +class UnexpectedExpr: + def __init__(self, end_pos, rule, callstack): + self.end_pos = end_pos + self.rule = rule + self.callstack = callstack + +class SemanticFailure: + def __init__(self, args, kw, callstack): + self.args = args + self.kw = kw + self.callstack = callstack + +class ParseError(RuntimeError): + def __init__(self, message, text, start_pos, end_pos, failures): + self.message = message + self.text = text + self.start_pos = start_pos + self.end_pos = end_pos + self.failures = failures + + def __str__(self): + return 'at {}:{}: {}'.format( + self.start_pos.line, self.start_pos.col, self.message) + +def _first(iterable): + return next(iterable, None) + +def raise_parsing_error(text, position, failures): + end_pos = position + msg = [] + + sema = _first(f for f in failures if isinstance(f, SemanticFailure)) + if sema is not None: + msg.append(sema.args[0]) + else: + unexps = [f for f in failures if isinstance(f, UnexpectedExpr)] + if unexps: + unexp = min(unexps, + key=lambda f: f.end_pos.offset - position.offset) + end_pos = unexp.end_pos + msg.append('unexpected {}'.format(rule_to_str(unexp.rule))) + + exps = [f for f in failures if isinstance(f, ExpectedExpr)] + if exps: + exp_syms = set() + for f in exps: + r = _first(se.fn for se in f.callstack + if se.position == position and not getattr(se.fn, '_speg_hidden', False)) + if r is None: + r = f.expr + exp_syms.add(rule_to_str(r)) + exp_strs = sorted(exp_syms) + + if len(exp_strs) == 1: + msg.append('expected {}'.format(exp_strs[0])) + else: + msg.append('expected one of {}'.format(', '.join(exp_strs))) + + raise ParseError('; '.join(msg), text, position, end_pos, failures) diff --git a/plugins/snippets/cson/speg/peg.py b/plugins/snippets/cson/speg/peg.py new file mode 100644 index 0000000..a355a1e --- /dev/null +++ b/plugins/snippets/cson/speg/peg.py @@ -0,0 +1,195 @@ +import re +import sys + +import six + +from .errors import ExpectedExpr, UnexpectedExpr, SemanticFailure, raise_parsing_error +from .position import Position, get_line_at_position +from .rules import eof + +class _ParseBacktrackError(BaseException): + pass + +class _PegState: + def __init__(self, position): + self.position = position + self.vars = {} + self.committed = False + self.failure_pos = None + self.failures = None + +class Node: + def __init__(self, value, start_pos, end_pos): + self.value = value + self.start_pos = start_pos + self.end_pos = end_pos + +class Parser: + def __init__(self, text): + self._text = text + + def __call__(self, r, *args, **kw): + return self._parse(lambda p: p(r, *args, **kw)) + + def parse(self, r, *args, **kw): + return self._parse(lambda p: p.consume(r, *args, **kw)) + + def _parse(self, fn): + p = ParsingState(self._text) + try: + return fn(p) + except _ParseBacktrackError: + assert len(p._states) == 1 + assert p._states[0].failure_pos is not None + raise_parsing_error(self._text, p._states[0].failure_pos, p._states[0].failures) + +class CallstackEntry: + def __init__(self, position, fn, args, kw): + self.position = position + self.fn = fn + self.args = args + self.kw = kw + + def __repr__(self): + return '{} at {}:{}'.format(self.fn.__name__, self.position.line, self.position.col) + +class ParsingState(object): + def __init__(self, s): + self._s = s + self._states = [_PegState(Position())] + self._re_cache = {} + self._callstack = [] + + def __call__(self, r, *args, **kw): + st = self._states[-1] + if r is eof: + if st.position.offset != len(self._s): + self._raise(ExpectedExpr, eof) + return '' + elif isinstance(r, six.string_types): + flags = args[0] if args else 0 + compiled = self._re_cache.get((r, flags)) + if not compiled: + compiled = re.compile(r, flags) + self._re_cache[r, flags] = compiled + m = compiled.match(self._s[st.position.offset:]) + if not m: + self._raise(ExpectedExpr, r) + + ms = m.group(0) + st.position = st.position.advanced_by(ms) + return ms + else: + kw.pop('err', None) + self._callstack.append(CallstackEntry(st.position, r, args, kw)) + try: + return r(self, *args, **kw) + finally: + self._callstack.pop() + + def consume(self, r, *args, **kw): + start_pos = self.position() + value = self(r, *args, **kw) + end_pos = self.position() + return Node(value, start_pos, end_pos) + + def position(self): + return self._states[-1].position + + def __repr__(self): + line, line_offs = get_line_at_position(self._s, self._states[-1].position) + return ''.format('{}*{}'.format(line[:line_offs], line[line_offs:])) + + @staticmethod + def eof(p): + return p(eof) + + def error(self, *args, **kw): + if not args: + args = ['semantic error'] + self._raise(SemanticFailure, args, kw) + + def _raise(self, failure_type, *args): + st = self._states[-1] + if st.failure_pos is None or st.failure_pos <= st.position: + failure = failure_type(*args, callstack=tuple(self._callstack)) + if st.failure_pos != st.position: + st.failure_pos = st.position + st.failures = set([failure]) + else: + st.failures.add(failure) + raise _ParseBacktrackError() + + def get(self, key, default=None): + for state in self._states[::-1]: + if key in state.vars: + return state.vars[key][0] + return default + + def set(self, key, value): + self._states[-1].vars[key] = value, False + + def set_global(self, key, value): + self._states[-1].vars[key] = value, True + + def opt(self, *args, **kw): + with self: + return self(*args, **kw) + + def not_(self, r, *args, **kw): + self._states.append(_PegState(self._states[-1].position)) + try: + n = self.consume(r) + except _ParseBacktrackError: + consumed = False + else: + consumed = True + finally: + self._states.pop() + + if consumed: + self._raise(UnexpectedExpr, n.end_pos, r) + + def __enter__(self): + self._states[-1].committed = False + self._states.append(_PegState(self._states[-1].position)) + + def __exit__(self, type, value, traceback): + if type is None: + self.commit() + else: + cur = self._states[-1] + prev = self._states[-2] + assert cur.failure_pos is not None + + if prev.failure_pos is None or prev.failure_pos < cur.failure_pos: + prev.failure_pos = cur.failure_pos + prev.failures = cur.failures + elif prev.failure_pos == cur.failure_pos: + prev.failures.update(cur.failures) + + self._states.pop() + return type is _ParseBacktrackError + + def commit(self): + cur = self._states[-1] + prev = self._states[-2] + + for key in cur.vars: + val, g = cur.vars[key] + if not g: + continue + if key in prev.vars: + prev.vars[key] = val, prev.vars[key][1] + else: + prev.vars[key] = val, True + + cur.failure_pos = None + + prev.position = cur.position + prev.committed = True + + def __nonzero__(self): + return self._states[-1].committed + + __bool__ = __nonzero__ diff --git a/plugins/snippets/cson/speg/position.py b/plugins/snippets/cson/speg/position.py new file mode 100644 index 0000000..f9222d7 --- /dev/null +++ b/plugins/snippets/cson/speg/position.py @@ -0,0 +1,48 @@ +from functools import total_ordering + +@total_ordering +class Position(object): + def __init__(self, offset=0, line=1, col=1): + self.offset = offset + self.line = line + self.col = col + + def advanced_by(self, text): + text_len = len(text) + offset = self.offset + text_len + nl_pos = text.rfind('\n') + if nl_pos < 0: + line = self.line + col = self.col + text_len + else: + line = self.line + text[:nl_pos].count('\n') + 1 + col = text_len - nl_pos + return Position(offset, line, col) + + def __eq__(self, other): + if not isinstance(other, Position): + return NotImplemented + return self.offset == other.offset + + def __ne__(self, other): + return not self == other + + def __hash__(self): + return hash(self.offset) + + def __lt__(self, other): + if not isinstance(other, Position): + return NotImplemented + return self.offset < other.offset + + def __repr__(self): + return '{}({!r}, {!r}, {!r})'.format(Position.__name__, + self.offset, self.line, self.col) + +def get_line_at_position(text, pos): + suffix = text[pos.offset - pos.col + 1:] + stop = suffix.find('\n') + if stop == -1: + return suffix, pos.col - 1 + else: + return suffix[:stop], pos.col - 1 diff --git a/plugins/snippets/cson/speg/rules.py b/plugins/snippets/cson/speg/rules.py new file mode 100644 index 0000000..9ce0aed --- /dev/null +++ b/plugins/snippets/cson/speg/rules.py @@ -0,0 +1,14 @@ +from six import string_types + +class Eof: pass +eof = Eof() + +def rule_to_str(rule): + if rule is eof: + return 'eof' + if isinstance(rule, string_types): + return repr(rule) + fn_name = rule.__name__ + if fn_name.startswith('_'): + fn_name = fn_name[1:] + return '<{}>'.format(fn_name.replace('_', ' ')) diff --git a/plugins/snippets/cson/writer.py b/plugins/snippets/cson/writer.py new file mode 100644 index 0000000..391bd44 --- /dev/null +++ b/plugins/snippets/cson/writer.py @@ -0,0 +1,191 @@ +import re, json, sys + +if sys.version_info[0] == 2: + def _is_num(o): + return isinstance(o, int) or isinstance(o, long) or isinstance(o, float) + def _stringify(o): + if isinstance(o, str): + return unicode(o) + if isinstance(o, unicode): + return o + return None +else: + def _is_num(o): + return isinstance(o, int) or isinstance(o, float) + def _stringify(o): + if isinstance(o, bytes): + return o.decode() + if isinstance(o, str): + return o + return None + +_id_re = re.compile(r'[$a-zA-Z_][$0-9a-zA-Z_]*\Z') + +class CSONEncoder: + def __init__(self, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, sort_keys=False, + indent=None, default=None): + self._skipkeys = skipkeys + self._ensure_ascii = ensure_ascii + self._allow_nan = allow_nan + self._sort_keys = sort_keys + self._indent = ' ' * (indent or 4) + self._default = default + if check_circular: + self._obj_stack = set() + else: + self._obj_stack = None + + def _format_simple_val(self, o): + if o is None: + return 'null' + if isinstance(o, bool): + return 'true' if o else 'false' + if _is_num(o): + return str(o) + s = _stringify(o) + if s is not None: + return self._escape_string(s) + return None + + def _escape_string(self, s): + r = json.dumps(s, ensure_ascii=self._ensure_ascii) + return u"'{}'".format(r[1:-1].replace("'", r"\'")) + + def _escape_key(self, s): + if s is None or isinstance(s, bool) or _is_num(s): + s = str(s) + s = _stringify(s) + if s is None: + if self._skipkeys: + return None + raise TypeError('keys must be a string') + if not _id_re.match(s): + return self._escape_string(s) + return s + + def _push_obj(self, o): + if self._obj_stack is not None: + if id(o) in self._obj_stack: + raise ValueError('Circular reference detected') + self._obj_stack.add(id(o)) + + def _pop_obj(self, o): + if self._obj_stack is not None: + self._obj_stack.remove(id(o)) + + def _encode(self, o, obj_val=False, indent='', force_flow=False): + if isinstance(o, list): + if not o: + if obj_val: + yield ' []\n' + else: + yield indent + yield '[]\n' + else: + if obj_val: + yield ' [\n' + else: + yield indent + yield '[\n' + indent = indent + self._indent + self._push_obj(o) + for v in o: + for chunk in self._encode(v, obj_val=False, indent=indent, force_flow=True): + yield chunk + self._pop_obj(o) + yield indent[:-len(self._indent)] + yield ']\n' + elif isinstance(o, dict): + items = [(self._escape_key(k), v) for k, v in o.items()] + if self._skipkeys: + items = [(k, v) for k, v in items if k is not None] + if self._sort_keys: + items.sort() + if force_flow or not items: + if not items: + if obj_val: + yield ' {}\n' + else: + yield indent + yield '{}\n' + else: + if obj_val: + yield ' {\n' + else: + yield indent + yield '{\n' + indent = indent + self._indent + self._push_obj(o) + for k, v in items: + yield indent + yield k + yield ':' + for chunk in self._encode(v, obj_val=True, indent=indent + self._indent, force_flow=False): + yield chunk + self._pop_obj(o) + yield indent[:-len(self._indent)] + yield '}\n' + else: + if obj_val: + yield '\n' + self._push_obj(o) + for k, v in items: + yield indent + yield k + yield ':' + for chunk in self._encode(v, obj_val=True, indent=indent + self._indent, force_flow=False): + yield chunk + self._pop_obj(o) + else: + v = self._format_simple_val(o) + if v is None: + self._push_obj(o) + v = self.default(o) + for chunk in self._encode(v, obj_val=obj_val, indent=indent, force_flow=force_flow): + yield chunk + self._pop_obj(o) + else: + if obj_val: + yield ' ' + else: + yield indent + yield v + yield '\n' + + def iterencode(self, o): + return self._encode(o) + + def encode(self, o): + return ''.join(self.iterencode(o)) + + def default(self, o): + if self._default is None: + raise TypeError('Cannot serialize an object of type {}'.format(type(o).__name__)) + return self._default(o) + +def dump(obj, fp, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, cls=None, + indent=None, default=None, sort_keys=False, **kw): + if indent is None and cls is None: + return json.dump(obj, fp, skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, + allow_nan=allow_nan, default=default, sort_keys=sort_keys, separators=(',', ':')) + + if cls is None: + cls = CSONEncoder + encoder = cls(skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, + allow_nan=allow_nan, sort_keys=sort_keys, indent=indent, default=default, **kw) + + for chunk in encoder.iterencode(obj): + fp.write(chunk) + +def dumps(obj, skipkeys=False, ensure_ascii=True, check_circular=True, allow_nan=True, cls=None, indent=None, + default=None, sort_keys=False, **kw): + if indent is None and cls is None: + return json.dumps(obj, skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, + allow_nan=allow_nan, default=default, sort_keys=sort_keys, separators=(',', ':')) + + if cls is None: + cls = CSONEncoder + encoder = cls(skipkeys=skipkeys, ensure_ascii=ensure_ascii, check_circular=check_circular, + allow_nan=allow_nan, sort_keys=sort_keys, indent=indent, default=default, **kw) + + return encoder.encode(obj) diff --git a/plugins/snippets/manifest.json b/plugins/snippets/manifest.json new file mode 100644 index 0000000..0cbf907 --- /dev/null +++ b/plugins/snippets/manifest.json @@ -0,0 +1,12 @@ +{ + "manifest": { + "name": "Snippets", + "author": "ITDominator", + "version": "0.0.1", + "support": "", + "requests": { + "pass_events": "true", + "bind_keys": ["Snippets||show_snippets_ui:i"] + } + } +} \ No newline at end of file diff --git a/plugins/snippets/plugin.py b/plugins/snippets/plugin.py new file mode 100644 index 0000000..9a5877b --- /dev/null +++ b/plugins/snippets/plugin.py @@ -0,0 +1,103 @@ +# Python imports + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from plugins.plugin_base import PluginBase +from . import cson + + + +class Plugin(PluginBase): + def __init__(self): + super().__init__() + + self.name = "Snippets" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus + # where self.name should not be needed for message comms + self.snippet_data = None + self._file_type = None + self.active_snippit_group = None + self.snippit_groups = [] + self.snippit_prefixes = [] + self.snippit_group_keys = [] + + + def generate_reference_ui_element(self): + ... + + def run(self): + with open('snippets.cson', 'rb') as f: + self.snippet_data = cson.load(f) + self.snippit_groups = self.snippet_data.keys() + + def subscribe_to_events(self): + self._event_system.subscribe("set_active_src_view", self._set_active_src_view) + self._event_system.subscribe("show_snippets_ui", self._show_snippets_ui) + self._event_system.subscribe("buffer_changed_first_load", self._buffer_changed_first_load) + self._event_system.subscribe("buffer_changed", self._buffer_changed) + + + def _set_active_src_view(self, source_view): + self._active_src_view = source_view + self._buffer = source_view.get_buffer() + self._file_type = source_view.get_filetype() + self._tag_table = self._buffer.get_tag_table() + + self.load_target_snippt_group() + + def load_target_snippt_group(self): + self.active_snippit_group = None + for group in self.snippit_groups: + if group in self._file_type: + self.active_snippit_group = group + break + + if self.active_snippit_group: + self.snippit_prefixes.clear() + keys = self.snippet_data[self.active_snippit_group].keys() + + self.snippit_group_keys.clear() + for key in keys: + self.snippit_group_keys.append(key) + prefix = self.snippet_data[self.active_snippit_group][key]["prefix"] + self.snippit_prefixes.append(prefix) + + def _buffer_changed_first_load(self, buffer): + self._buffer = buffer + self._handle_update(buffer) + + def _buffer_changed(self, buffer): + self._event_system.emit("pause_event_processing") + self._handle_update(buffer) + self._event_system.emit("resume_event_processing") + + + def _show_snippets_ui(self): + print(f"Data: {self.snippit_groups}") + + def _handle_update(self, buffer): + if not self.active_snippit_group: return + + end_iter = buffer.get_iter_at_mark( buffer.get_insert() ) + start_iter = end_iter.copy() + start_iter.backward_word_start() + + matches = [] + text = buffer.get_text(start_iter, end_iter, include_hidden_chars = False) + for prefix in self.snippit_prefixes: + if text in prefix: + matches.append(prefix) + + snippits = [] + for _match in matches: + for key in self.snippit_group_keys: + prefix = self.snippet_data[self.active_snippit_group][key]["prefix"] + if prefix == _match: + body = self.snippet_data[self.active_snippit_group][key]["body"] + snippits.append(body) + + # print(snippits) + print("Snippits Plugin: _handle_update > results > stub...") \ No newline at end of file diff --git a/plugins/snippets/snippets.cson b/plugins/snippets/snippets.cson new file mode 100644 index 0000000..cf61d58 --- /dev/null +++ b/plugins/snippets/snippets.cson @@ -0,0 +1,614 @@ +# Your snippets +# +# Atom snippets allow you to enter a simple prefix in the editor and hit tab to +# expand the prefix into a larger code block with templated values. +# +# You can create a new snippet in this file by typing "snip" and then hitting +# tab. +# +# An example CoffeeScript snippet to expand log to console.log: +# +# '.source.coffee': +# 'Console log': +# 'prefix': 'log' +# 'body': 'console.log $1' +# +# Each scope (e.g. '.source.coffee' above) can only be declared once. +# +# This file uses CoffeeScript Object Notation (CSON). +# If you are unfamiliar with CSON, you can read more about it in the +# Atom Flight Manual: +# http://flight-manual.atom.io/using-atom/sections/basic-customization/#_cson + + +### HTML SNIPPETS ### +'html': + + 'HTML Template': + 'prefix': 'html' + 'body': """ + + + + + + + + + + + + + + + + + + + +""" + + 'Canvas Tag': + 'prefix': 'canvas' + 'body': """""" + + 'Img Tag': + 'prefix': 'img' + 'body': """""" + + 'Br Tag': + 'prefix': 'br' + 'body': """
""" + + 'Hr Tag': + 'prefix': 'hr' + 'body': """
""" + + 'Server Side Events': + 'prefix': 'sse' + 'body': """// SSE events if supported +if(typeof(EventSource) !== "undefined") { + let source = new EventSource("resources/php/sse.php"); + source.onmessage = (event) => { + if (event.data === "") { + // code here + } + }; +} else { + console.log("SSE Not Supported In Browser..."); +} +""" + + 'AJAX Template Function': + 'prefix': 'ajax template' + 'body': """const doAjax = async (actionPath, data) => { + let xhttp = new XMLHttpRequest(); + + xhttp.onreadystatechange = function() { + if (this.readyState === 4 && this.status === 200) { + if (this.responseText != null) { // this.responseXML if getting XML fata + handleReturnData(JSON.parse(this.responseText)); + } else { + console.log("No content returned. Check the file path."); + } + } + }; + + xhttp.open("POST", actionPath, true); + xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + // Force return to be JSON NOTE: Use application/xml to force XML + xhttp.overrideMimeType('application/json'); + xhttp.send(data); +} +""" + + 'CSS Message Colors': + 'prefix': 'css colors' + 'body': """.error { color: rgb(255, 0, 0); } + .warning { color: rgb(255, 168, 0); } + .success { color: rgb(136, 204, 39); } + """ + + +### JS SNIPPETS ### +'js': + + 'Server Side Events': + 'prefix': 'sse' + 'body': """// SSE events if supported +if(typeof(EventSource) !== "undefined") { + let source = new EventSource("resources/php/sse.php"); + source.onmessage = (event) => { + if (event.data === "") { + // code here + } + }; +} else { + console.log("SSE Not Supported In Browser..."); +} +""" + + 'AJAX Template Function': + 'prefix': 'ajax template' + 'body': """const doAjax = async (actionPath, data) => { + let xhttp = new XMLHttpRequest(); + + xhttp.onreadystatechange = function() { + if (this.readyState === 4 && this.status === 200) { + if (this.responseText != null) { // this.responseXML if getting XML fata + handleReturnData(JSON.parse(this.responseText)); + } else { + console.log("No content returned. Check the file path."); + } + } + }; + + xhttp.open("POST", actionPath, true); + xhttp.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); + // Force return to be JSON NOTE: Use application/xml to force XML + xhttp.overrideMimeType('application/json'); + xhttp.send(data); +} +""" + + 'SE6 Function': + 'prefix': 'function se6' + 'body': """const funcName = (arg = "") => { + + } +""" + +### CSS SNIPPETS ### +'css': + + 'CSS Message Colors': + 'prefix': 'css colors' + 'body': """.error { color: rgb(255, 0, 0); } + .warning { color: rgb(255, 168, 0); } + .success { color: rgb(136, 204, 39); } + """ + +### PHP SNIPPETS ### +'php': + + 'SSE PHP': + 'prefix': 'sse php' + 'body': """ + """ + + 'PHP Template': + 'prefix': 'php' + 'body': """ Illegal Access Method!"; + serverMessage("error", $message); +} +?> +""" + 'HTML Template': + 'prefix': 'html' + 'body': """ + + + + + + + + + + + + + + + + + + + +""" + + +### BASH SNIPPETS ### +'bash': + + 'Bash or Shell Template': + 'prefix': 'bash template' + 'body': """#!/bin/bash + +# . CONFIG.sh + +# set -o xtrace ## To debug scripts +# set -o errexit ## To exit on error +# set -o errunset ## To exit if a variable is referenced but not set + + +function main() { + cd "$(dirname "$0")" + echo "Working Dir: " $(pwd) + + file="$1" + if [ -z "${file}" ]; then + echo "ERROR: No file argument supplied..." + exit + fi + + if [[ -f "${file}" ]]; then + echo "SUCCESS: The path and file exists!" + else + echo "ERROR: The path or file '${file}' does NOT exist..." + fi +} +main "$@"; + """ + + + 'Bash or Shell Config': + 'prefix': 'bash config' + 'body': """#!/bin/bash + + shopt -s expand_aliases + + alias echo="echo -e" + """ + + +### PYTHON SNIPPETS ### +'python': + + 'Glade __main__ Class Template': + 'prefix': 'glade __main__ class' + 'body': """#!/usr/bin/python3 + + +# Python imports +import argparse +import faulthandler +import traceback +import signal +from setproctitle import setproctitle + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from gi.repository import GLib + +# Application imports +from app import Application + + +def run(): + try: + setproctitle('') + faulthandler.enable() # For better debug info + + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, Gtk.main_quit) + + parser = argparse.ArgumentParser() + # Add long and short arguments + parser.add_argument("--debug", "-d", default="false", help="Do extra console messaging.") + parser.add_argument("--trace-debug", "-td", default="false", help="Disable saves, ignore IPC lock, do extra console messaging.") + parser.add_argument("--no-plugins", "-np", default="false", help="Do not load plugins.") + + parser.add_argument("--new-tab", "-t", default="", help="Open a file into new tab.") + parser.add_argument("--new-window", "-w", default="", help="Open a file into a new window.") + + # Read arguments (If any...) + args, unknownargs = parser.parse_known_args() + + main = Application(args, unknownargs) + Gtk.main() + except Exception as e: + traceback.print_exc() + quit() + + +if __name__ == "__main__": + ''' Set process title, get arguments, and create GTK main thread. ''' + run() + + + """ + + + 'Glade __main__ Testing Template': + 'prefix': 'glade testing class' + 'body': """#!/usr/bin/python3 + + +# Python imports +import traceback +import faulthandler +import signal + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from gi.repository import GLib + +# Application imports + + +app_name = "Gtk Quick Test" + + +class Application(Gtk.ApplicationWindow): + def __init__(self): + super(Application, self).__init__() + self._setup_styling() + self._setup_signals() + self._load_widgets() + + self.add(Gtk.Box()) + + self.show_all() + + + def _setup_styling(self): + self.set_default_size(1670, 830) + self.set_title(f"{app_name}") + # self.set_icon_from_file( settings.get_window_icon() ) + self.set_gravity(5) # 5 = CENTER + self.set_position(1) # 1 = CENTER, 4 = CENTER_ALWAYS + + def _setup_signals(self): + self.connect("delete-event", Gtk.main_quit) + + + def _load_widgets(self): + ... + + + + +def run(): + try: + faulthandler.enable() # For better debug info + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, Gtk.main_quit) + + main = Application() + Gtk.main() + except Exception as e: + traceback.print_exc() + quit() + + +if __name__ == "__main__": + ''' Set process title, get arguments, and create GTK main thread. ''' + run() + + + """ + + + 'Glade _init_ Class Template': + 'prefix': 'glade __init__ class' + 'body': """# Python imports +import inspect + + +# Lib imports + + +# Application imports +from utils import Settings +from signal_classes import CrossClassSignals + + +class Main: + def __init__(self, args): + settings = Settings() + builder = settings.returnBuilder() + + # Gets the methods from the classes and sets to handler. + # Then, builder connects to any signals it needs. + classes = [CrossClassSignals(settings)] + + handlers = {} + for c in classes: + methods = inspect.getmembers(c, predicate=inspect.ismethod) + handlers.update(methods) + + builder.connect_signals(handlers) + window = settings.createWindow() + window.show() + + """ + + 'Class Method': + 'prefix': 'def1' + 'body': """ + def fname(self): + ... + """ + + 'Gtk Class Method': + 'prefix': 'def2' + 'body': """ + def fname(self, widget, eve): + ... + """ + + + 'Python Glade Settings Template': + 'prefix': 'glade settings class' + 'body': """# Python imports + import os + + # Lib imports + import gi, cairo + gi.require_version('Gtk', '3.0') + gi.require_version('Gdk', '3.0') + + from gi.repository import Gtk + from gi.repository import Gdk + + + # Application imports + + + class Settings: + def __init__(self): + self.SCRIPT_PTH = os.path.dirname(os.path.realpath(__file__)) + "/" + self.builder = Gtk.Builder() + self.builder.add_from_file(self.SCRIPT_PTH + "../resources/Main_Window.glade") + + # 'Filters' + self.office = ('.doc', '.docx', '.xls', '.xlsx', '.xlt', '.xltx', '.xlm', + '.ppt', 'pptx', '.pps', '.ppsx', '.odt', '.rtf') + self.vids = ('.mkv', '.avi', '.flv', '.mov', '.m4v', '.mpg', '.wmv', + '.mpeg', '.mp4', '.webm') + self.txt = ('.txt', '.text', '.sh', '.cfg', '.conf') + self.music = ('.psf', '.mp3', '.ogg' , '.flac') + self.images = ('.png', '.jpg', '.jpeg', '.gif') + self.pdf = ('.pdf') + + + def createWindow(self): + # Get window and connect signals + window = self.builder.get_object("Main_Window") + window.connect("delete-event", gtk.main_quit) + self.setWindowData(window, False) + return window + + def setWindowData(self, window, paintable): + screen = window.get_screen() + visual = screen.get_rgba_visual() + + if visual != None and screen.is_composited(): + window.set_visual(visual) + + # bind css file + cssProvider = gtk.CssProvider() + cssProvider.load_from_path(self.SCRIPT_PTH + '../resources/stylesheet.css') + screen = Gdk.Screen.get_default() + styleContext = Gtk.StyleContext() + styleContext.add_provider_for_screen(screen, cssProvider, gtk.STYLE_PROVIDER_PRIORITY_USER) + + window.set_app_paintable(paintable) + if paintable: + window.connect("draw", self.area_draw) + + def getMonitorData(self): + screen = self.builder.get_object("Main_Window").get_screen() + monitors = [] + for m in range(screen.get_n_monitors()): + monitors.append(screen.get_monitor_geometry(m)) + + for monitor in monitors: + print(str(monitor.width) + "x" + str(monitor.height) + "+" + str(monitor.x) + "+" + str(monitor.y)) + + return monitors + + def area_draw(self, widget, cr): + cr.set_source_rgba(0, 0, 0, 0.54) + cr.set_operator(cairo.OPERATOR_SOURCE) + cr.paint() + cr.set_operator(cairo.OPERATOR_OVER) + + + def returnBuilder(self): return self.builder + + # Filter returns + def returnOfficeFilter(self): return self.office + def returnVidsFilter(self): return self.vids + def returnTextFilter(self): return self.txt + def returnMusicFilter(self): return self.music + def returnImagesFilter(self): return self.images + def returnPdfFilter(self): return self.pdf + + """ + + 'Python Glade CrossClassSignals Template': + 'prefix': 'glade crossClassSignals class' + 'body': """# Python imports + import threading + import subprocess + import os + + # Lib imports + + # Application imports + + + def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs).start() + + return wrapper + + + class CrossClassSignals: + def __init__(self, settings): + self.settings = settings + self.builder = self.settings.returnBuilder() + + + def getClipboardData(self): + proc = subprocess.Popen(['xclip','-selection', 'clipboard', '-o'], stdout=subprocess.PIPE) + retcode = proc.wait() + data = proc.stdout.read() + return data.decode("utf-8").strip() + + def setClipboardData(self, data): + proc = subprocess.Popen(['xclip','-selection','clipboard'], stdin=subprocess.PIPE) + proc.stdin.write(data) + proc.stdin.close() + retcode = proc.wait() + + """ + + + 'Python Glade Generic Template': + 'prefix': 'glade generic class' + 'body': """# Python imports + + # Lib imports + + # Application imports + + + class GenericClass: + def __init__(self): + super(GenericClass, self).__init__() + + self._setup_styling() + self._setup_signals() + self._subscribe_to_events() + self._load_widgets() + + + def _setup_styling(self): + ... + + def _setup_signals(self): + ... + + def _subscribe_to_events(self): + event_system.subscribe("handle_file_from_ipc", self.handle_file_from_ipc) + + def _load_widgets(self): + ... + + """ \ No newline at end of file diff --git a/plugins/template/__init__.py b/plugins/template/__init__.py new file mode 100644 index 0000000..d36fa8c --- /dev/null +++ b/plugins/template/__init__.py @@ -0,0 +1,3 @@ +""" + Pligin Module +""" diff --git a/plugins/template/__main__.py b/plugins/template/__main__.py new file mode 100644 index 0000000..a576329 --- /dev/null +++ b/plugins/template/__main__.py @@ -0,0 +1,3 @@ +""" + Pligin Package +""" diff --git a/plugins/template/manifest.json b/plugins/template/manifest.json new file mode 100644 index 0000000..1f8c8a5 --- /dev/null +++ b/plugins/template/manifest.json @@ -0,0 +1,13 @@ +{ + "manifest": { + "name": "Example Plugin", + "author": "John Doe", + "version": "0.0.1", + "support": "", + "requests": { + "ui_target": "plugin_control_list", + "pass_events": "true", + "bind_keys": ["Example Plugin||send_message:f"] + } + } +} diff --git a/plugins/template/plugin.py b/plugins/template/plugin.py new file mode 100644 index 0000000..c52c0ff --- /dev/null +++ b/plugins/template/plugin.py @@ -0,0 +1,51 @@ +# Python imports +import os +import threading +import subprocess +import time + +# Lib imports +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk + +# Application imports +from plugins.plugin_base import PluginBase + + + + +# NOTE: Threads WILL NOT die with parent's destruction. +def threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=False).start() + return wrapper + +# NOTE: Threads WILL die with parent's destruction. +def daemon_threaded(fn): + def wrapper(*args, **kwargs): + threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True).start() + return wrapper + + + + +class Plugin(PluginBase): + def __init__(self): + super().__init__() + + self.name = "Example Plugin" # NOTE: Need to remove after establishing private bidirectional 1-1 message bus + # where self.name should not be needed for message comms + + + def generate_reference_ui_element(self): + button = Gtk.Button(label=self.name) + button.connect("button-release-event", self.send_message) + return button + + def run(self): + ... + + def send_message(self, widget=None, eve=None): + message = "Hello, World!" + event_system.emit("display_message", ("warning", message, None))