|
|
@@ -20,9 +20,11 @@ |
|
|
|
|
|
|
|
|
from __future__ import annotations |
|
|
from __future__ import annotations |
|
|
|
|
|
|
|
|
import html.entities as htmlentities |
|
|
|
|
|
|
|
|
import html.entities |
|
|
|
|
|
import math |
|
|
import re |
|
|
import re |
|
|
from math import log |
|
|
|
|
|
|
|
|
from enum import Enum |
|
|
|
|
|
from typing import Literal, cast, overload |
|
|
|
|
|
|
|
|
from ..definitions import ( |
|
|
from ..definitions import ( |
|
|
get_html_tag, |
|
|
get_html_tag, |
|
|
@@ -64,13 +66,20 @@ class _TagOpenData: |
|
|
self.reset = 0 |
|
|
self.reset = 0 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Sentinel(Enum): |
|
|
|
|
|
START = 0 |
|
|
|
|
|
END = 1 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
START = Sentinel.START |
|
|
|
|
|
END = Sentinel.END |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Tokenizer: |
|
|
class Tokenizer: |
|
|
"""Creates a list of tokens from a string of wikicode.""" |
|
|
"""Creates a list of tokens from a string of wikicode.""" |
|
|
|
|
|
|
|
|
USES_C = False |
|
|
USES_C = False |
|
|
START = object() |
|
|
|
|
|
END = object() |
|
|
|
|
|
MARKERS = [ |
|
|
|
|
|
|
|
|
MARKERS: list[str | Sentinel] = [ |
|
|
"{", |
|
|
"{", |
|
|
"}", |
|
|
"}", |
|
|
"[", |
|
|
"[", |
|
|
@@ -99,8 +108,8 @@ class Tokenizer: |
|
|
tag_splitter = re.compile(r"([\s\"\'\\]+)") |
|
|
tag_splitter = re.compile(r"([\s\"\'\\]+)") |
|
|
|
|
|
|
|
|
def __init__(self): |
|
|
def __init__(self): |
|
|
self._text = None |
|
|
|
|
|
self._head = 0 |
|
|
|
|
|
|
|
|
self._text: list[str] = [] |
|
|
|
|
|
self._head: int = 0 |
|
|
self._stacks = [] |
|
|
self._stacks = [] |
|
|
self._global = 0 |
|
|
self._global = 0 |
|
|
self._depth = 0 |
|
|
self._depth = 0 |
|
|
@@ -222,27 +231,44 @@ class Tokenizer: |
|
|
self._emit_all(stack) |
|
|
self._emit_all(stack) |
|
|
self._head -= 1 |
|
|
self._head -= 1 |
|
|
|
|
|
|
|
|
def _read(self, delta=0, wrap=False, strict=False): |
|
|
|
|
|
|
|
|
@overload |
|
|
|
|
|
def _read( |
|
|
|
|
|
self, *, strict: Literal[False] = False |
|
|
|
|
|
) -> str | Literal[Sentinel.END]: ... |
|
|
|
|
|
|
|
|
|
|
|
@overload |
|
|
|
|
|
def _read(self, *, strict: Literal[True]) -> str: ... |
|
|
|
|
|
|
|
|
|
|
|
@overload |
|
|
|
|
|
def _read( |
|
|
|
|
|
self, delta: int = 0, *, strict: Literal[False] = False |
|
|
|
|
|
) -> str | Literal[Sentinel.START, Sentinel.END]: ... |
|
|
|
|
|
|
|
|
|
|
|
@overload |
|
|
|
|
|
def _read( |
|
|
|
|
|
self, delta: int = 0, *, strict: Literal[True] |
|
|
|
|
|
) -> str | Literal[Sentinel.START]: ... |
|
|
|
|
|
|
|
|
|
|
|
def _read( |
|
|
|
|
|
self, delta: int = 0, *, strict: bool = False |
|
|
|
|
|
) -> str | Literal[Sentinel.START, Sentinel.END]: |
|
|
"""Read the value at a relative point in the wikicode. |
|
|
"""Read the value at a relative point in the wikicode. |
|
|
|
|
|
|
|
|
The value is read from :attr:`self._head <_head>` plus the value of |
|
|
The value is read from :attr:`self._head <_head>` plus the value of |
|
|
*delta* (which can be negative). If *wrap* is ``False``, we will not |
|
|
|
|
|
allow attempts to read from the end of the string if ``self._head + |
|
|
|
|
|
delta`` is negative. If *strict* is ``True``, the route will be failed |
|
|
|
|
|
(with :meth:`_fail_route`) if we try to read from past the end of the |
|
|
|
|
|
string; otherwise, :attr:`self.END <END>` is returned. If we try to |
|
|
|
|
|
read from before the start of the string, :attr:`self.START <START>` is |
|
|
|
|
|
returned. |
|
|
|
|
|
|
|
|
*delta* (which can be negative). If *strict* is ``True``, the route |
|
|
|
|
|
will be failed (with :meth:`_fail_route`) if we try to read from past |
|
|
|
|
|
the end of the string; otherwise, ``END`` is returned. If we try to |
|
|
|
|
|
read from before the start of the string, ``START`` is returned. |
|
|
""" |
|
|
""" |
|
|
index = self._head + delta |
|
|
index = self._head + delta |
|
|
if index < 0 and (not wrap or abs(index) > len(self._text)): |
|
|
|
|
|
return self.START |
|
|
|
|
|
|
|
|
if index < 0: |
|
|
|
|
|
return START |
|
|
try: |
|
|
try: |
|
|
return self._text[index] |
|
|
return self._text[index] |
|
|
except IndexError: |
|
|
except IndexError: |
|
|
if strict: |
|
|
if strict: |
|
|
self._fail_route() |
|
|
self._fail_route() |
|
|
return self.END |
|
|
|
|
|
|
|
|
return END |
|
|
|
|
|
|
|
|
def _parse_template(self, has_content): |
|
|
def _parse_template(self, has_content): |
|
|
"""Parse a template at the head of the wikicode string.""" |
|
|
"""Parse a template at the head of the wikicode string.""" |
|
|
@@ -399,12 +425,12 @@ class Tokenizer: |
|
|
self._head += 2 |
|
|
self._head += 2 |
|
|
else: |
|
|
else: |
|
|
|
|
|
|
|
|
def all_valid(): |
|
|
|
|
|
return all(char in self.URISCHEME for char in self._read()) |
|
|
|
|
|
|
|
|
def all_valid(this: str): |
|
|
|
|
|
return all(char in self.URISCHEME for char in this) |
|
|
|
|
|
|
|
|
scheme = "" |
|
|
scheme = "" |
|
|
while self._read() is not self.END and all_valid(): |
|
|
|
|
|
scheme += self._read() |
|
|
|
|
|
|
|
|
while (this := self._read()) is not END and all_valid(this): |
|
|
|
|
|
scheme += this |
|
|
self._emit_text(self._read()) |
|
|
self._emit_text(self._read()) |
|
|
self._head += 1 |
|
|
self._head += 1 |
|
|
if self._read() != ":": |
|
|
if self._read() != ":": |
|
|
@@ -472,7 +498,7 @@ class Tokenizer: |
|
|
# Built from _parse()'s end sentinels: |
|
|
# Built from _parse()'s end sentinels: |
|
|
after, ctx = self._read(2), self._context |
|
|
after, ctx = self._read(2), self._context |
|
|
return ( |
|
|
return ( |
|
|
this in (self.END, "\n", "[", "]", "<", ">", '"') |
|
|
|
|
|
|
|
|
this in (END, "\n", "[", "]", "<", ">", '"') |
|
|
or " " in this |
|
|
or " " in this |
|
|
or this == nxt == "'" |
|
|
or this == nxt == "'" |
|
|
or (this == "|" and ctx & contexts.TEMPLATE) |
|
|
or (this == "|" and ctx & contexts.TEMPLATE) |
|
|
@@ -491,7 +517,7 @@ class Tokenizer: |
|
|
self._parse_free_uri_scheme() |
|
|
self._parse_free_uri_scheme() |
|
|
invalid = ("\n", " ", "[", "]") |
|
|
invalid = ("\n", " ", "[", "]") |
|
|
punct = tuple(",;\\.:!?)") |
|
|
punct = tuple(",;\\.:!?)") |
|
|
if self._read() is self.END or self._read()[0] in invalid: |
|
|
|
|
|
|
|
|
if (this := self._read()) is END or this[0] in invalid: |
|
|
self._fail_route() |
|
|
self._fail_route() |
|
|
tail = "" |
|
|
tail = "" |
|
|
while True: |
|
|
while True: |
|
|
@@ -512,7 +538,7 @@ class Tokenizer: |
|
|
tail = "" |
|
|
tail = "" |
|
|
self._parse_template_or_argument() |
|
|
self._parse_template_or_argument() |
|
|
elif brackets: |
|
|
elif brackets: |
|
|
if this is self.END or this == "\n": |
|
|
|
|
|
|
|
|
if this is END or this == "\n": |
|
|
self._fail_route() |
|
|
self._fail_route() |
|
|
if this == "]": |
|
|
if this == "]": |
|
|
return self._pop(), None |
|
|
return self._pop(), None |
|
|
@@ -534,7 +560,7 @@ class Tokenizer: |
|
|
self._emit_text(this) |
|
|
self._emit_text(this) |
|
|
else: |
|
|
else: |
|
|
if self._is_uri_end(this, nxt): |
|
|
if self._is_uri_end(this, nxt): |
|
|
if this is not self.END and " " in this: |
|
|
|
|
|
|
|
|
if this is not END and " " in this: |
|
|
before, after = this.split(" ", 1) |
|
|
before, after = this.split(" ", 1) |
|
|
punct, tail = self._handle_free_link_text(punct, tail, before) |
|
|
punct, tail = self._handle_free_link_text(punct, tail, before) |
|
|
tail += " " + after |
|
|
tail += " " + after |
|
|
@@ -616,7 +642,7 @@ class Tokenizer: |
|
|
while self._read() == "=": |
|
|
while self._read() == "=": |
|
|
best += 1 |
|
|
best += 1 |
|
|
self._head += 1 |
|
|
self._head += 1 |
|
|
current = int(log(self._context / contexts.HEADING_LEVEL_1, 2)) + 1 |
|
|
|
|
|
|
|
|
current = int(math.log(self._context / contexts.HEADING_LEVEL_1, 2)) + 1 |
|
|
level = min(current, min(best, 6)) |
|
|
level = min(current, min(best, 6)) |
|
|
|
|
|
|
|
|
try: # Try to check for a heading closure after this one |
|
|
try: # Try to check for a heading closure after this one |
|
|
@@ -667,7 +693,7 @@ class Tokenizer: |
|
|
if test < 1 or test > 0x10FFFF: |
|
|
if test < 1 or test > 0x10FFFF: |
|
|
self._fail_route() |
|
|
self._fail_route() |
|
|
else: |
|
|
else: |
|
|
if this not in htmlentities.entitydefs: |
|
|
|
|
|
|
|
|
if this not in html.entities.entitydefs: |
|
|
self._fail_route() |
|
|
self._fail_route() |
|
|
|
|
|
|
|
|
self._emit(tokens.Text(text=this)) |
|
|
self._emit(tokens.Text(text=this)) |
|
|
@@ -692,7 +718,7 @@ class Tokenizer: |
|
|
self._push() |
|
|
self._push() |
|
|
while True: |
|
|
while True: |
|
|
this = self._read() |
|
|
this = self._read() |
|
|
if this == self.END: |
|
|
|
|
|
|
|
|
if this == END: |
|
|
self._pop() |
|
|
self._pop() |
|
|
self._head = reset |
|
|
self._head = reset |
|
|
self._emit_text("<!--") |
|
|
self._emit_text("<!--") |
|
|
@@ -854,7 +880,7 @@ class Tokenizer: |
|
|
|
|
|
|
|
|
while True: |
|
|
while True: |
|
|
this, nxt = self._read(), self._read(1) |
|
|
this, nxt = self._read(), self._read(1) |
|
|
if this is self.END: |
|
|
|
|
|
|
|
|
if this is END: |
|
|
self._fail_route() |
|
|
self._fail_route() |
|
|
elif this == "<" and nxt == "/": |
|
|
elif this == "<" and nxt == "/": |
|
|
self._head += 3 |
|
|
self._head += 3 |
|
|
@@ -917,7 +943,7 @@ class Tokenizer: |
|
|
not data.context & (data.CX_QUOTED | data.CX_NAME) |
|
|
not data.context & (data.CX_QUOTED | data.CX_NAME) |
|
|
or data.context & data.CX_NOTE_SPACE |
|
|
or data.context & data.CX_NOTE_SPACE |
|
|
) |
|
|
) |
|
|
if this is self.END: |
|
|
|
|
|
|
|
|
if this is END: |
|
|
if self._context & contexts.TAG_ATTR: |
|
|
if self._context & contexts.TAG_ATTR: |
|
|
if data.context & data.CX_QUOTED: |
|
|
if data.context & data.CX_QUOTED: |
|
|
# Unclosed attribute quote: reset, don't die |
|
|
# Unclosed attribute quote: reset, don't die |
|
|
@@ -948,7 +974,8 @@ class Tokenizer: |
|
|
reset = self._head + 1 |
|
|
reset = self._head + 1 |
|
|
self._head += 2 |
|
|
self._head += 2 |
|
|
try: |
|
|
try: |
|
|
if not is_single_only(self.tag_splitter.split(self._read())[0]): |
|
|
|
|
|
|
|
|
assert (this := self._read()) is not END |
|
|
|
|
|
if not is_single_only(self.tag_splitter.split(this)[0]): |
|
|
raise BadRoute() |
|
|
raise BadRoute() |
|
|
tag = self._really_parse_tag() |
|
|
tag = self._really_parse_tag() |
|
|
except BadRoute: |
|
|
except BadRoute: |
|
|
@@ -1100,6 +1127,7 @@ class Tokenizer: |
|
|
def _handle_list_marker(self): |
|
|
def _handle_list_marker(self): |
|
|
"""Handle a list marker at the head (``#``, ``*``, ``;``, ``:``).""" |
|
|
"""Handle a list marker at the head (``#``, ``*``, ``;``, ``:``).""" |
|
|
markup = self._read() |
|
|
markup = self._read() |
|
|
|
|
|
assert markup is not END |
|
|
if markup == ";": |
|
|
if markup == ";": |
|
|
self._context |= contexts.DL_TERM |
|
|
self._context |= contexts.DL_TERM |
|
|
self._emit(tokens.TagOpenOpen(wiki_markup=markup)) |
|
|
self._emit(tokens.TagOpenOpen(wiki_markup=markup)) |
|
|
@@ -1159,7 +1187,7 @@ class Tokenizer: |
|
|
self._emit_text(tag) |
|
|
self._emit_text(tag) |
|
|
self._emit(tokens.TagCloseClose()) |
|
|
self._emit(tokens.TagCloseClose()) |
|
|
|
|
|
|
|
|
def _handle_table_style(self, end_token): |
|
|
|
|
|
|
|
|
def _handle_table_style(self, end_token: str): |
|
|
"""Handle style attributes for a table until ``end_token``.""" |
|
|
"""Handle style attributes for a table until ``end_token``.""" |
|
|
data = _TagOpenData() |
|
|
data = _TagOpenData() |
|
|
data.context = _TagOpenData.CX_ATTR_READY |
|
|
data.context = _TagOpenData.CX_ATTR_READY |
|
|
@@ -1169,12 +1197,13 @@ class Tokenizer: |
|
|
not data.context & data.CX_QUOTED or data.context & data.CX_NOTE_SPACE |
|
|
not data.context & data.CX_QUOTED or data.context & data.CX_NOTE_SPACE |
|
|
) |
|
|
) |
|
|
if this == end_token and can_exit: |
|
|
if this == end_token and can_exit: |
|
|
|
|
|
assert this is not END |
|
|
if data.context & (data.CX_ATTR_NAME | data.CX_ATTR_VALUE): |
|
|
if data.context & (data.CX_ATTR_NAME | data.CX_ATTR_VALUE): |
|
|
self._push_tag_buffer(data) |
|
|
self._push_tag_buffer(data) |
|
|
if this.isspace(): |
|
|
if this.isspace(): |
|
|
data.padding_buffer["first"] += this |
|
|
data.padding_buffer["first"] += this |
|
|
return data.padding_buffer["first"] |
|
|
return data.padding_buffer["first"] |
|
|
if this is self.END or this == end_token: |
|
|
|
|
|
|
|
|
if this is END or this == end_token: |
|
|
if self._context & contexts.TAG_ATTR: |
|
|
if self._context & contexts.TAG_ATTR: |
|
|
if data.context & data.CX_QUOTED: |
|
|
if data.context & data.CX_QUOTED: |
|
|
# Unclosed attribute quote: reset, don't die |
|
|
# Unclosed attribute quote: reset, don't die |
|
|
@@ -1343,11 +1372,11 @@ class Tokenizer: |
|
|
return True |
|
|
return True |
|
|
if context & contexts.HAS_TEXT: |
|
|
if context & contexts.HAS_TEXT: |
|
|
if context & contexts.FAIL_ON_TEXT: |
|
|
if context & contexts.FAIL_ON_TEXT: |
|
|
if this is self.END or not this.isspace(): |
|
|
|
|
|
|
|
|
if this is END or not this.isspace(): |
|
|
return False |
|
|
return False |
|
|
elif this == "\n": |
|
|
elif this == "\n": |
|
|
self._context |= contexts.FAIL_ON_TEXT |
|
|
self._context |= contexts.FAIL_ON_TEXT |
|
|
elif this is self.END or not this.isspace(): |
|
|
|
|
|
|
|
|
elif this is END or not this.isspace(): |
|
|
self._context |= contexts.HAS_TEXT |
|
|
self._context |= contexts.HAS_TEXT |
|
|
return True |
|
|
return True |
|
|
if context & contexts.TAG_CLOSE: |
|
|
if context & contexts.TAG_CLOSE: |
|
|
@@ -1389,7 +1418,7 @@ class Tokenizer: |
|
|
self._emit_text(this) |
|
|
self._emit_text(this) |
|
|
self._head += 1 |
|
|
self._head += 1 |
|
|
continue |
|
|
continue |
|
|
if this is self.END: |
|
|
|
|
|
|
|
|
if this is END: |
|
|
return self._handle_end() |
|
|
return self._handle_end() |
|
|
nxt = self._read(1) |
|
|
nxt = self._read(1) |
|
|
if this == nxt == "{": |
|
|
if this == nxt == "{": |
|
|
@@ -1402,7 +1431,7 @@ class Tokenizer: |
|
|
elif this == "=" and self._context & contexts.TEMPLATE_PARAM_KEY: |
|
|
elif this == "=" and self._context & contexts.TEMPLATE_PARAM_KEY: |
|
|
if ( |
|
|
if ( |
|
|
not self._global & contexts.GL_HEADING |
|
|
not self._global & contexts.GL_HEADING |
|
|
and self._read(-1) in ("\n", self.START) |
|
|
|
|
|
|
|
|
and self._read(-1) in ("\n", START) |
|
|
and nxt == "=" |
|
|
and nxt == "=" |
|
|
): |
|
|
): |
|
|
self._parse_heading() |
|
|
self._parse_heading() |
|
|
@@ -1439,7 +1468,7 @@ class Tokenizer: |
|
|
and not self._global & contexts.GL_HEADING |
|
|
and not self._global & contexts.GL_HEADING |
|
|
and not self._context & contexts.TEMPLATE |
|
|
and not self._context & contexts.TEMPLATE |
|
|
): |
|
|
): |
|
|
if self._read(-1) in ("\n", self.START): |
|
|
|
|
|
|
|
|
if self._read(-1) in ("\n", START): |
|
|
self._parse_heading() |
|
|
self._parse_heading() |
|
|
else: |
|
|
else: |
|
|
self._emit_text("=") |
|
|
self._emit_text("=") |
|
|
@@ -1454,7 +1483,7 @@ class Tokenizer: |
|
|
self._parse_comment() |
|
|
self._parse_comment() |
|
|
else: |
|
|
else: |
|
|
self._emit_text(this) |
|
|
self._emit_text(this) |
|
|
elif this == "<" and nxt == "/" and self._read(2) is not self.END: |
|
|
|
|
|
|
|
|
elif this == "<" and nxt == "/" and self._read(2) is not END: |
|
|
if self._context & contexts.TAG_BODY: |
|
|
if self._context & contexts.TAG_BODY: |
|
|
self._handle_tag_open_close() |
|
|
self._handle_tag_open_close() |
|
|
else: |
|
|
else: |
|
|
@@ -1470,9 +1499,9 @@ class Tokenizer: |
|
|
result = self._parse_style() |
|
|
result = self._parse_style() |
|
|
if result is not None: |
|
|
if result is not None: |
|
|
return result |
|
|
return result |
|
|
elif self._read(-1) in ("\n", self.START) and this in ("#", "*", ";", ":"): |
|
|
|
|
|
|
|
|
elif self._read(-1) in ("\n", START) and this in ("#", "*", ";", ":"): |
|
|
self._handle_list() |
|
|
self._handle_list() |
|
|
elif self._read(-1) in ("\n", self.START) and ( |
|
|
|
|
|
|
|
|
elif self._read(-1) in ("\n", START) and ( |
|
|
this == nxt == self._read(2) == self._read(3) == "-" |
|
|
this == nxt == self._read(2) == self._read(3) == "-" |
|
|
): |
|
|
): |
|
|
self._handle_hr() |
|
|
self._handle_hr() |
|
|
@@ -1486,10 +1515,10 @@ class Tokenizer: |
|
|
this == "{" |
|
|
this == "{" |
|
|
and nxt == "|" |
|
|
and nxt == "|" |
|
|
and ( |
|
|
and ( |
|
|
self._read(-1) in ("\n", self.START) |
|
|
|
|
|
|
|
|
self._read(-1) in ("\n", START) |
|
|
or ( |
|
|
or ( |
|
|
self._read(-2) in ("\n", self.START) |
|
|
|
|
|
and self._read(-1).isspace() |
|
|
|
|
|
|
|
|
self._read(-2) in ("\n", START) |
|
|
|
|
|
and cast(str, self._read(-1)).isspace() |
|
|
) |
|
|
) |
|
|
) |
|
|
) |
|
|
): |
|
|
): |
|
|
@@ -1516,8 +1545,9 @@ class Tokenizer: |
|
|
elif this == "\n" and self._context & contexts.TABLE_CELL_LINE_CONTEXTS: |
|
|
elif this == "\n" and self._context & contexts.TABLE_CELL_LINE_CONTEXTS: |
|
|
self._context &= ~contexts.TABLE_CELL_LINE_CONTEXTS |
|
|
self._context &= ~contexts.TABLE_CELL_LINE_CONTEXTS |
|
|
self._emit_text(this) |
|
|
self._emit_text(this) |
|
|
elif self._read(-1) in ("\n", self.START) or ( |
|
|
|
|
|
self._read(-2) in ("\n", self.START) and self._read(-1).isspace() |
|
|
|
|
|
|
|
|
elif self._read(-1) in ("\n", START) or ( |
|
|
|
|
|
self._read(-2) in ("\n", START) |
|
|
|
|
|
and cast(str, self._read(-1)).isspace() |
|
|
): |
|
|
): |
|
|
if this == "|" and nxt == "}": |
|
|
if this == "|" and nxt == "}": |
|
|
if self._context & contexts.TABLE_CELL_OPEN: |
|
|
if self._context & contexts.TABLE_CELL_OPEN: |
|
|
@@ -1548,7 +1578,7 @@ class Tokenizer: |
|
|
self._emit_text(this) |
|
|
self._emit_text(this) |
|
|
self._head += 1 |
|
|
self._head += 1 |
|
|
|
|
|
|
|
|
def tokenize(self, text, context=0, skip_style_tags=False): |
|
|
|
|
|
|
|
|
def tokenize(self, text: str, context=0, skip_style_tags=False): |
|
|
"""Build a list of tokens from a string of wikicode and return it.""" |
|
|
"""Build a list of tokens from a string of wikicode and return it.""" |
|
|
split = self.regex.split(text) |
|
|
split = self.regex.split(text) |
|
|
self._text = [segment for segment in split if segment] |
|
|
self._text = [segment for segment in split if segment] |
|
|
|