diff --git a/docs/plugin-api-additions/names.md b/docs/plugin-api-additions/names.md new file mode 100644 index 00000000..5f0fa731 --- /dev/null +++ b/docs/plugin-api-additions/names.md @@ -0,0 +1,21 @@ +# Explicit IRC comparison keys + +`IRCName(text, context=None, casemapping=None, connection_id=None)` is an +immutable helper, not a str subclass. Construct it explicitly for dictionary/set +keys; existing get_info/list/event values remain normal strings. +Equality and hashing include the connection ID and captured casemapping, so +identical-looking names on different connections are different keys. An IRCName +never compares equal to a plain str. `str(key)` / `key.text` recover the spelling. + +`irc_casefold(text, casemapping='rfc1459')` supports ascii, strict-rfc1459 and +rfc1459. Only ASCII IRC characters fold; Unicode casefold would be incorrect. +The new `get_info('casemapping')` reports the comparator actually used by core. +Current master handles ASCII and RFC1459; this patch does not change its handling +of advertised strict-rfc1459. An explicit mapping is available for scripts that +need strict-rfc1459 independently. Keys capture their mapping so hashes stay +stable; rebuild a collection if the connection's mapping changes. + +```python +seen = {zoitechat.IRCName('[SomeNick]')} +assert zoitechat.IRCName('{somenick}') in seen # RFC1459 connection +``` diff --git a/plugins/python/_zoitechat_names.py b/plugins/python/_zoitechat_names.py new file mode 100644 index 00000000..d6af80d1 --- /dev/null +++ b/plugins/python/_zoitechat_names.py @@ -0,0 +1,74 @@ +"""Explicit immutable IRC names; existing string-returning APIs are unchanged.""" +import _zoitechat as api + +__all__ = ['IRCName', 'irc_casefold'] + + +def irc_casefold(text, casemapping='rfc1459'): + """Fold ASCII IRC characters only; never apply Unicode lower()/casefold().""" + if casemapping not in ('ascii', 'strict-rfc1459', 'rfc1459'): + raise ValueError('unsupported IRC casemapping') + extra = {'[': '{', ']': '}', '\\': '|'} if casemapping != 'ascii' else {} + if casemapping == 'rfc1459': + extra['^'] = '~' + return ''.join(chr(ord(c) + 32) if 'A' <= c <= 'Z' else extra.get(c, c) + for c in text) + + +class IRCName: + """Equality/hash include connection identity and the captured casemapping.""" + __slots__ = ('_text', '_casemapping', '_connection_id', '_key') + + def __init__(self, text, context=None, casemapping=None, connection_id=None): + context = context or api.get_context() + if casemapping is None: + casemapping = context.get_info('casemapping') + if casemapping is None: + raise RuntimeError('host does not expose its comparison casemapping') + if connection_id is None: + previous = api.get_context() + if not context.set(): + raise ValueError('context has closed') + try: + connection_id = api.get_prefs('id') + finally: + previous.set() + object.__setattr__(self, '_text', text) + object.__setattr__(self, '_casemapping', casemapping) + object.__setattr__(self, '_connection_id', connection_id) + object.__setattr__(self, '_key', (connection_id, casemapping, + irc_casefold(text, casemapping))) + + def __setattr__(self, name, value): + raise AttributeError('IRCName is immutable') + + @property + def text(self): + return self._text + + @property + def casemapping(self): + return self._casemapping + + @property + def connection_id(self): + return self._connection_id + + def __str__(self): + return self._text + + def __repr__(self): + return 'IRCName({!r}, casemapping={!r}, connection_id={!r})'.format( + self._text, self._casemapping, self._connection_id) + + def __eq__(self, other): + if not isinstance(other, IRCName): + return NotImplemented + return self._key == other._key + + def __ne__(self, other): + result = self.__eq__(other) + return NotImplemented if result is NotImplemented else not result + + def __hash__(self): + return hash(self._key) diff --git a/src/common/plugin.c b/src/common/plugin.c index 07c08a58..6a48a3d7 100644 --- a/src/common/plugin.c +++ b/src/common/plugin.c @@ -1187,6 +1187,16 @@ zoitechat_get_info (zoitechat_plugin *ph, const char *id) return text_find_format_string (e); } + /* Report the comparison actually used by this connection, not an + * advertised mapping that the current core does not implement. */ + if (strcmp (id, "casemapping") == 0) + { + if (!is_session (ph->context)) + return NULL; + return ph->context->server->p_cmp == g_ascii_strcasecmp + ? "ascii" : "rfc1459"; + } + hash = str_hash (id); /* do the session independant ones first */ switch (hash)