vibefpmgui.gremlins

Gremlins: characters in a file that aren't plain printable ASCII.

Non-ASCII letters and symbols (é, °, smart quotes), invisible ones (non-breaking and zero-width spaces, a byte-order mark) and control characters. Compilers are often fussy about them (gfortran rejects most outside comments and strings), and the invisible ones can't be seen in the editor. Tabs and line ends are not gremlins.

The editor draws a gremlin in the gutter of each line that has one (paint_gremlin), tints the characters, and lists them in the Problems tab (View → Show Gremlins turns it off).

  1"""*Gremlins*: characters in a file that aren't plain printable ASCII.
  2
  3Non-ASCII letters and symbols (``é``, ``°``, smart quotes), invisible ones
  4(non-breaking and zero-width spaces, a byte-order mark) and control
  5characters. Compilers are often fussy about them (gfortran rejects most
  6outside comments and strings), and the invisible ones can't be seen in the
  7editor. Tabs and line ends are not gremlins.
  8
  9The editor draws a gremlin in the gutter of each line that has one
 10(`paint_gremlin`), tints the characters, and lists them in the *Problems*
 11tab (View → Show Gremlins turns it off).
 12"""
 13
 14from __future__ import annotations
 15
 16import re
 17import unicodedata
 18from typing import NamedTuple
 19
 20from qtpy.QtCore import QPointF, QRectF, Qt
 21from qtpy.QtGui import QColor, QPainter, QPainterPath, QPen
 22
 23MAX_GREMLINS = 2000  # per file (a file in another language would have one per accented letter)
 24GREMLIN_RE = re.compile(r"[^\t\n\x20-\x7e]")
 25COLOR = "#e8590c"  # the tint, badge and Problems color
 26
 27# Names for the ones that have none, or a long one.
 28NAMES = {"\u00a0": "non-breaking space", "\u200b": "zero-width space", "\u200c": "zero-width non-joiner",
 29         "\u200d": "zero-width joiner", "\u2060": "word joiner", "\ufeff": "byte-order mark (zero-width)",
 30         "\u00ad": "soft hyphen", "\r": "carriage return", "\x0c": "form feed", "\x00": "null",
 31         "\x1b": "escape", "\x7f": "delete"}
 32
 33
 34class Gremlin(NamedTuple):
 35    line: int        # 1-based
 36    column: int      # 0-based
 37    char: str
 38
 39    @property
 40    def code(self) -> str:
 41        return f"U+{ord(self.char):04X}"
 42
 43    @property
 44    def message(self) -> str:
 45        return describe(self.char)
 46
 47
 48def describe(char: str) -> str:
 49    """What a gremlin is, e.g. "non-breaking space, not ASCII" or "latin
 50    small letter e with acute (é), not ASCII"."""
 51    name = NAMES.get(char)
 52    if name is None:
 53        if unicodedata.category(char) in ("Cc", "Cf", "Zs", "Zl", "Zp"):  # (can't be seen: just the name)
 54            name = unicodedata.name(char, "control character").lower()
 55        else:
 56            return f"{unicodedata.name(char, 'unnamed character').lower()} ({char}), not ASCII"
 57    return f"{name}, not ASCII" if ord(char) > 0x7F else f"{name} (a control character)"
 58
 59
 60def find(text: str, limit: int = MAX_GREMLINS) -> list[Gremlin]:
 61    """Every gremlin in a text ("\\n" line ends), in order."""
 62    out: list[Gremlin] = []
 63    line, line_start, last = 1, 0, 0
 64    for match in GREMLIN_RE.finditer(text):
 65        start = match.start()
 66        newlines = text.count("\n", last, start)
 67        if newlines:
 68            line += newlines
 69            line_start = text.rfind("\n", 0, start) + 1
 70        last = start
 71        out.append(Gremlin(line, start - line_start, match.group()))
 72        if len(out) >= limit:
 73            break
 74    return out
 75
 76
 77def paint_gremlin(painter: QPainter, rect: QRectF):
 78    """A little gremlin (big ears, round eyes, a grin) filling ``rect``."""
 79    painter.save()
 80    painter.setRenderHint(QPainter.Antialiasing)
 81    painter.translate(rect.topLeft())
 82    painter.scale(rect.width() / 24, rect.height() / 24)  # (drawn on a 24 × 24 grid)
 83    green, dark = QColor("#5fb04a"), QColor("#2b5a22")
 84    painter.setPen(QPen(dark, 1.2))
 85    painter.setBrush(green)
 86    for side in (-1, 1):  # ears: long and pointed, out to the sides
 87        ear = QPainterPath(QPointF(12 + side * 4, 9))
 88        ear.lineTo(QPointF(12 + side * 11.5, 4))
 89        ear.lineTo(QPointF(12 + side * 6, 14))
 90        ear.closeSubpath()
 91        painter.drawPath(ear)
 92    painter.drawEllipse(QRectF(5, 5.5, 14, 15))  # the head
 93    painter.setPen(Qt.NoPen)
 94    painter.setBrush(QColor("#fff7d6"))
 95    for x in (9, 15):
 96        painter.drawEllipse(QPointF(x, 11.5), 2.6, 2.9)  # eyes
 97    painter.setBrush(QColor("#c92a2a"))
 98    for x in (9.5, 14.5):
 99        painter.drawEllipse(QPointF(x, 12), 1.2, 1.4)  # (red pupils)
100    painter.setPen(QPen(dark, 1.1))
101    painter.setBrush(Qt.NoBrush)
102    grin = QPainterPath(QPointF(8.5, 16))
103    grin.quadTo(QPointF(12, 19), QPointF(15.5, 16))
104    painter.drawPath(grin)
105    painter.restore()
MAX_GREMLINS = 2000
GREMLIN_RE = re.compile('[^\\t\\n\\x20-\\x7e]')
COLOR = '#e8590c'
NAMES = {'\xa0': 'non-breaking space', '\u200b': 'zero-width space', '\u200c': 'zero-width non-joiner', '\u200d': 'zero-width joiner', '\u2060': 'word joiner', '\ufeff': 'byte-order mark (zero-width)', '\xad': 'soft hyphen', '\r': 'carriage return', '\x0c': 'form feed', '\x00': 'null', '\x1b': 'escape', '\x7f': 'delete'}
class Gremlin(typing.NamedTuple):
35class Gremlin(NamedTuple):
36    line: int        # 1-based
37    column: int      # 0-based
38    char: str
39
40    @property
41    def code(self) -> str:
42        return f"U+{ord(self.char):04X}"
43
44    @property
45    def message(self) -> str:
46        return describe(self.char)

Gremlin(line, column, char)

Gremlin(line: int, column: int, char: str)

Create new instance of Gremlin(line, column, char)

line: int

Alias for field number 0

column: int

Alias for field number 1

char: str

Alias for field number 2

code: str
40    @property
41    def code(self) -> str:
42        return f"U+{ord(self.char):04X}"
message: str
44    @property
45    def message(self) -> str:
46        return describe(self.char)
def describe(char: str) -> str:
49def describe(char: str) -> str:
50    """What a gremlin is, e.g. "non-breaking space, not ASCII" or "latin
51    small letter e with acute (é), not ASCII"."""
52    name = NAMES.get(char)
53    if name is None:
54        if unicodedata.category(char) in ("Cc", "Cf", "Zs", "Zl", "Zp"):  # (can't be seen: just the name)
55            name = unicodedata.name(char, "control character").lower()
56        else:
57            return f"{unicodedata.name(char, 'unnamed character').lower()} ({char}), not ASCII"
58    return f"{name}, not ASCII" if ord(char) > 0x7F else f"{name} (a control character)"

What a gremlin is, e.g. "non-breaking space, not ASCII" or "latin small letter e with acute (é), not ASCII".

def find(text: str, limit: int = 2000) -> list[Gremlin]:
61def find(text: str, limit: int = MAX_GREMLINS) -> list[Gremlin]:
62    """Every gremlin in a text ("\\n" line ends), in order."""
63    out: list[Gremlin] = []
64    line, line_start, last = 1, 0, 0
65    for match in GREMLIN_RE.finditer(text):
66        start = match.start()
67        newlines = text.count("\n", last, start)
68        if newlines:
69            line += newlines
70            line_start = text.rfind("\n", 0, start) + 1
71        last = start
72        out.append(Gremlin(line, start - line_start, match.group()))
73        if len(out) >= limit:
74            break
75    return out

Every gremlin in a text ("\n" line ends), in order.

def paint_gremlin(painter: PySide6.QtGui.QPainter, rect: PySide6.QtCore.QRectF):
 78def paint_gremlin(painter: QPainter, rect: QRectF):
 79    """A little gremlin (big ears, round eyes, a grin) filling ``rect``."""
 80    painter.save()
 81    painter.setRenderHint(QPainter.Antialiasing)
 82    painter.translate(rect.topLeft())
 83    painter.scale(rect.width() / 24, rect.height() / 24)  # (drawn on a 24 × 24 grid)
 84    green, dark = QColor("#5fb04a"), QColor("#2b5a22")
 85    painter.setPen(QPen(dark, 1.2))
 86    painter.setBrush(green)
 87    for side in (-1, 1):  # ears: long and pointed, out to the sides
 88        ear = QPainterPath(QPointF(12 + side * 4, 9))
 89        ear.lineTo(QPointF(12 + side * 11.5, 4))
 90        ear.lineTo(QPointF(12 + side * 6, 14))
 91        ear.closeSubpath()
 92        painter.drawPath(ear)
 93    painter.drawEllipse(QRectF(5, 5.5, 14, 15))  # the head
 94    painter.setPen(Qt.NoPen)
 95    painter.setBrush(QColor("#fff7d6"))
 96    for x in (9, 15):
 97        painter.drawEllipse(QPointF(x, 11.5), 2.6, 2.9)  # eyes
 98    painter.setBrush(QColor("#c92a2a"))
 99    for x in (9.5, 14.5):
100        painter.drawEllipse(QPointF(x, 12), 1.2, 1.4)  # (red pupils)
101    painter.setPen(QPen(dark, 1.1))
102    painter.setBrush(Qt.NoBrush)
103    grin = QPainterPath(QPointF(8.5, 16))
104    grin.quadTo(QPointF(12, 19), QPointF(15.5, 16))
105    painter.drawPath(grin)
106    painter.restore()

A little gremlin (big ears, round eyes, a grin) filling rect.