module

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

ensure_present(lines : Array(String), line : String, regexp : String | Nil, search_string : String | Nil, insertafter : String | Nil, insertbefore : String | Nil, backrefs : Bool, firstmatch : Bool) : Tuple(Array(String), Bool, String)

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.

Source
expand_backref_template(template : String, match : Regex::MatchData) : String

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).

Source
insertion_index(lines : Array(String), insertafter : String | Nil, insertbefore : String | Nil, firstmatch : Bool = false) : Int32

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.

Source
match_index(lines : Array(String), pattern : String, firstmatch : Bool, literal : Bool = false) : Int32 | Nil

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.

Source
matches_regexp?(line : String, pattern : String | Nil) : Bool
Source
remove_matching(lines : Array(String), line : String | Nil, regexp : String | Nil, search_string : String | Nil = nil) : Tuple(Array(String), Bool, Int32)

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}.

Source
split_lines(content : String) : Array(String)

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()).

Source