Files
Jean-Pierre MartineauandClaude Opus 5.5 ffe31dac36 Docstrings and comments of meshterm/core are in ASD-STE100
The prose of each docstring and comment in the core package now follows
ASD-STE100 Simplified Technical English, with Canadian spelling: short
sentences, simple verb tenses, no dashes or semicolons, and one word for
one concept (docs/development/writing-style.md has the rules and the
glossary). The meaning and each reason stay. The code does not change:
the AST of each file, without its docstrings, is the same as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:29:03 -04:00

116 lines
4.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Find the links in a line of text: with a scheme, or without one where the meaning is clear.
A link with a scheme is any text in the form ``scheme://…``. Any scheme is accepted, not
only the schemes in a list. Thus a ``meshcore://`` share in a chat is found, the same as a
web link.
A link without a scheme is accepted only when it can have no other meaning. It is a host
that starts with ``www.``, or a host whose last label is a top-level domain (TLD) that
IANA delegated (``meshterm.net``, ``github.com/…``). Thus ``file.txt`` is not a link,
because ``txt`` is not a TLD. Also, ``3.14``, ``e.g.``, and an address such as
``jp@meshterm.net`` are not links. A host counts only at the start of a word, never in an
address, a URL path, or another host.
The list of TLDs is the IANA list, copied exactly into ``assets/tlds.txt`` with its
version line at the top. To update it, download the file again
(https://data.iana.org/TLD/tlds-alpha-by-domain.txt). Some delegated TLDs are also file
extensions (``.py``, ``.md``, ``.zip``), so a name that ends with one of them is a link
here. This result comes from the registry, not from a guess. When it is wrong, MeshTerm
offers a QR code for a link that nobody wanted. That error costs less than a link that
MeshTerm does not find.
Each link is returned as the URL that it opens. A link without a scheme gets ``https://``
at its start, because a phone reads a bare host in a QR code as text.
"""
from __future__ import annotations
import re
from functools import cache
from pathlib import Path
#: The IANA list of the delegated top-level domains: one domain on each line, in upper
#: case, with ``#`` comments.
TLDS_FILE = Path(__file__).resolve().parent.parent / "assets" / "tlds.txt"
#: One host label: letters, digits, and hyphens that are not at an end. The maximum
#: length is 63.
_LABEL = r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
#: A link with a scheme: the scheme, ``://``, and all the text up to the next space.
_SCHEMED = r"\b[a-z][a-z0-9+.-]*://[^\s<>\"]+"
#: A host without a scheme, then an optional port and URL path. The host must be at the
#: start of a word. It must not come after a letter, a digit, ``@`` (an address), ``/`` or
#: ``:`` (a URL path), or ``.`` or ``-`` (the middle of a longer name). Its last label must
#: have only letters, because each TLD has only letters. Thus a version number or a
#: decimal number does not match.
_BARE = (
r"(?<![\w@/:.-])"
rf"(?P<host>(?:{_LABEL}\.)+(?P<tld>[a-z]{{2,63}}))(?![\w-])"
r"(?::\d{1,5})?"
r"(?:[/?#][^\s<>\"]*)?"
)
#: Either type of link. The pattern with a scheme comes first. Thus a host in a link with a
#: scheme is part of that match, and the pattern does not find it a second time.
_LINK = re.compile(rf"(?P<scheme>{_SCHEMED})|(?P<bare>{_BARE})", re.IGNORECASE)
#: Punctuation that follows a link in a sentence and is not part of the link.
_TRAILING = ".,;:!?'\""
#: Each closing bracket and its opening bracket. A closing bracket ends a link only when
#: the link has no opening bracket for it. Thus ``(see https://example.com/a_(b))`` keeps
#: the ``)`` that is part of the link.
_CLOSERS = {")": "(", "]": "[", "}": "{"}
@cache
def tlds() -> frozenset[str]:
"""All the delegated top-level domains, in lower case.
The file is read one time, at the first search for a link.
"""
lines = TLDS_FILE.read_text(encoding="ascii").splitlines()
return frozenset(line.strip().lower() for line in lines if line.strip()[:1] not in ("", "#"))
def _trimmed(link: str) -> str:
"""``link`` without the sentence punctuation after it, or a bracket that it did not open."""
while link:
last = link[-1]
if last in _TRAILING or (
last in _CLOSERS and link.count(last) > link.count(_CLOSERS[last])
):
link = link[:-1]
else:
break
return link
def urls(text: str) -> list[str]:
"""All the links in ``text`` as the URLs that they open, in order, and each link once.
Punctuation after a link is part of the sentence, not of the link. A link without a
scheme is returned with ``https://`` at its start. Thus ``meshterm.net`` and
``https://meshterm.net`` in one message are the same link, and the list has it once.
"""
found: list[str] = []
for match in _LINK.finditer(text):
link = _trimmed(match.group())
if match.group("scheme"):
if link.endswith("://"):
continue # only a scheme, with no text after it
else:
host = match.group("host").lower()
if not host.startswith("www.") and match.group("tld").lower() not in tlds():
continue
link = "https://" + link
if link not in found:
found.append(link)
return found
__all__ = ["TLDS_FILE", "tlds", "urls"]