Krikri::PluginHelpers::LineEditor
LineEditor - pure line-matching/insertion logic for the lineinfile plugin, factored out so it can be unit tested without touching the filesystem or going through the plugin's stdin/stdout protocol.
The lineinfile entry points (ensure_present/remove_matching/split_lines) work on lines WITH their terminator still attached - the same shape Ansible's own lineinfile uses, because it reads the file in binary mode (open(b_dest, 'rb') + f.readlines()), so a "\r\n"-terminated file yields lines like "en\r\n" and the trailing "\r" is part of the line the regexp and the replacement comparison see. SoftEther's own lang.config (written BOM + CRLF by the vpnserver/vpncmd binaries on every startup - found via softasap.sa-vpn-softether round 2600001, where Ansible reported changed on both cold and warm runs while crystal reported ok) is the live case: regexp "^(en|ja|cn)$" cannot match "en\r\n" ($ has nothing to anchor to before the "\r"), and Ansible then replaces the whole "en\r\n" line with "en\n" - changed
- while a stripped-lines comparison calls it already correct. The terminator-less view used here previously made lines_equal? strip() the "\r" away and report ok forever.
Class methods
state: present - a 1:1 port of Ansible's lineinfile present() (ansible-core 2.19 modules/lineinfile.py) over terminator-attached lines. Returns {new_lines, changed, msg} with Ansible's own msg strings ('line added' / 'line replaced' / '').
Search order mirrors Ansible: regexp match wins (LAST match by
default, first with firstmatch - live-verified benchmarking
geerlingguy.phpmyadmin, whose regexp matches both the populated
config line and a commented template near EOF, and Ansible rewrites
only the final occurrence); then search_string the same way; only
when neither matched does the exact-line scan run (line compared
to each file line stripped of trailing \r\n) and, failing that,
the insertafter/insertbefore anchor scan.
Expands a backrefs replacement template the way Python's
re.Match.expand does - the exact function Ansible's own
lineinfile hands line: to (its match.expand(line)), so the
semantics have to match it, not just the numeric group refs:
Python templates also interpret standard backslash escapes, so
line: 'MaxAuthTriesProbe \1\nMaxAuthTriesProbeBench \1' (real
sshd-hardening-style playbooks do this to write two lines in one
task) expands to TWO physical lines - a literal backslash-n
written into the file diverges from Ansible byte-for-byte.
Covered: \1-\99 positional refs (up to two digits; a group that
didn't participate expands to the empty string, like Python),
\g<n>/\g<name> refs, and Python's ESCAPES table
(\a \b \f \n \r \t \v); any other backslash escape is kept
literally (real Python raises "bad escape" there - krikri stays
lenient rather than failing the task on escape spellings Python
accepts nowhere but errors on).
Shared with BlockEditor, so this is public rather than private.
Ansible anchors the insertion at the LAST line matching the insertafter/insertbefore pattern (its own loop keeps scanning and only stops early under firstmatch), not the first - live-verified against ansible-core 2.19.4 for both lineinfile and blockinfile (blockinfile's marker-placement loop also has no break). The previous first-match-always behavior diverged on any file where the anchor pattern occurs more than once.
Position of the line matching pattern: the LAST match by default
(Ansible's lineinfile/blockinfile both scan the whole file
without breaking), or the FIRST when firstmatch is set. With
literal: true the pattern is a plain substring to CONTAIN
(Ansible's search_string), not a regex.
state: absent - drop every line matching regexp, containing
search_string (literal substring - Ansible's own matcher for
state=absent), or - failing both - an exact match against line
(Ansible compares line to the file line with only its trailing
\r\n stripped - no whitespace normalization). firstmatch does NOT
apply here: Ansible removes ALL matching lines regardless
(live-verified against ansible-core 2.19.4). Kept lines keep their
original terminators. Returns {new_lines, changed, found_count}.
Binary-mode readlines: split on "\n" only, keeping the "\n" on every line it terminates. A trailing "\r" (CRLF files) stays in the line body; a final line without a newline stays unterminated; empty content is zero lines (Python's b"".readlines()).