LazyCompletions.Native.cs

// LazyCompletions.Native.cs - C# hot-path implementation for LazyCompletions (six methods)
//
// Compiled to a DLL by Initialize-NativeCode / Start-NativeCompileJob in
// LazyCompletions.psm1 (<cache dir>\Native\LazyCompletions.Native.<module version>.dll).
// Six methods: command-name parsing (ParseNames) / cache encode-decode (ReadCache+WriteCache) /
// Tab system-file filtering (FilterSystem) / dir incremental comparison (CompareDir) /
// dir reconcile (Reconcile) / cache dir incremental maintenance (UpdateCacheForDir).
//
// ⚠ Must stay logically equivalent to the PS implementations in the psm1
// (ParseNamesScriptBlock / Read-CacheFile / Write-CacheFile / wrapper filter /
// Register-FromCacheWithDelta comparison / Reconcile-DirectoryState / Update-CacheForDir) —
// changing either side requires syncing both!
// ⚠ No RegexOptions.Compiled (measured): a Compiled regex JIT-compiles at its first match
// in the process, which measured ~4s slower on an 800-file cold scan (paid once per
// session); interpreted mode first match ~50µs, long-term difference is microseconds.
// ⚠ Compiled via Add-Type -CompilerOptions '/optimize+' (Release IL): the default Debug compile
// would emit a DebuggableAttribute with DisableOptimizations — neither the C# compiler nor
// the JIT would optimize or inline (measured: a CPU-bound loop ran 7x slower; AggressiveInlining
// was ignored). With /optimize+ the JIT optimizes and auto-inlines small methods.
// ⚠ C# interop scalar pitfall: a PS function returning a single-element array is unwrapped
// into a scalar string; iterating it as IEnumerable would split the string into single
// chars — WriteCache must honor the PS @($e.Names) array-ization semantics (add scalar
// strings as a whole).
 
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
 
public static class LazyNative
{
    // Integrity anchor: the source SHA256 is injected at compile time (replaces the
    // __SOURCE_HASH__ placeholder) and compared by Test-NativeSourceHash at load time —
    // protects against a DLL replaced (injected) in the cache dir.
    // Note: this placeholder must stay as-is; the compile flow depends on string replacement!
    public const string SourceHash = "__SOURCE_HASH__";
 
    private static readonly Regex BlockCommentRegex = new Regex("(?s)<#.*?#>");
    private static readonly Regex NamePartRegex = new Regex("['\"]([^'\"]+)['\"]");
    private static readonly Regex CommandNameRegex = new Regex("-CommandName\\s+(.+?)(?=\\s+-[A-Za-z]|$)", RegexOptions.IgnoreCase);
    private static readonly Regex PlainNameRegex = new Regex("^[\\w.-]+$");
 
    // ---- Command-name parsing (equivalent to PS $script:ParseNamesScriptBlock) ----
    // Returns string[]; empty array = no results (caller treats it as AutomationNull)
    public static string[] ParseNames(string path)
    {
        string text;
        try { text = System.IO.File.ReadAllText(path); }
        catch { return Array.Empty<string>(); }
        text = BlockCommentRegex.Replace(text, "");
        var found = new List<string>();
        bool hadRac = false;
        int idx = 0;
        const string pattern = "Register-ArgumentCompleter";
        while ((idx = text.IndexOf(pattern, idx, StringComparison.OrdinalIgnoreCase)) >= 0)
        {
            hadRac = true;
            int lineStart = text.LastIndexOf('\n', idx) + 1;
            int lineEnd = text.IndexOf('\n', idx);
            if (lineEnd < 0) lineEnd = text.Length;
            string line = text.Substring(lineStart, lineEnd - lineStart);
            idx += pattern.Length;
            string trimmed = line.TrimStart();
            if (trimmed.Length > 0 && trimmed[0] == '#') continue;
            Match m = CommandNameRegex.Match(line);
            if (!m.Success) continue;
            string[] names = ParseNamePart(m.Groups[1].Value.Trim());
            if (names != null)
                foreach (string n in names)
                    if (!found.Contains(n)) found.Add(n); // same as PS List.Contains (case-sensitive)
        }
        if (found.Count > 0) return found.ToArray();
        if (!hadRac) return Array.Empty<string>();
 
        // AST fallback (multi-line/colon/dynamic names), equivalent to the PS version
        try
        {
            System.Management.Automation.Language.Token[] tokens = null;
            System.Management.Automation.Language.ParseError[] errors = null;
            var ast = System.Management.Automation.Language.Parser.ParseFile(path, out tokens, out errors);
            var registers = new List<System.Management.Automation.Language.CommandAst>();
            foreach (var s in ast.EndBlock.Statements)
            {
                var cmd = s as System.Management.Automation.Language.CommandAst;
                if (cmd != null && string.Equals(cmd.GetCommandName(), "Register-ArgumentCompleter", StringComparison.OrdinalIgnoreCase))
                    registers.Add(cmd);
            }
            if (registers.Count == 0)
            {
                foreach (var n in ast.FindAll(
                    x => x is System.Management.Automation.Language.CommandAst c &&
                         string.Equals(c.GetCommandName(), "Register-ArgumentCompleter", StringComparison.OrdinalIgnoreCase), true))
                    registers.Add((System.Management.Automation.Language.CommandAst)n);
            }
            var all = new List<string>();
            foreach (var cmd in registers)
            {
                var elements = cmd.CommandElements;
                for (int i = 1; i < elements.Count; i++)
                {
                    var el = elements[i] as System.Management.Automation.Language.CommandParameterAst;
                    if (el == null) continue;
                    if (!string.Equals(el.ParameterName, "CommandName", StringComparison.OrdinalIgnoreCase)) continue;
                    System.Management.Automation.Language.CommandElementAst val = el.Argument; // ExpressionAst subclass assignable
                    if (val == null && i + 1 < elements.Count) val = elements[i + 1];
                    var str = val as System.Management.Automation.Language.StringConstantExpressionAst;
                    if (str != null) { all.Add(str.Value); continue; }
                    var arr = val as System.Management.Automation.Language.ArrayLiteralAst;
                    if (arr != null)
                        foreach (var e in arr.Elements)
                        {
                            var es = e as System.Management.Automation.Language.StringConstantExpressionAst;
                            if (es != null) all.Add(es.Value);
                        }
                }
            }
            if (all.Count > 0) return all.ToArray();
        }
        catch { }
        return Array.Empty<string>();
    }
 
    // Equivalent to PS Parse-NamePart: quoted extraction / unquoted legal names / multi-element case-insensitive dedupe+sort
    private static string[] ParseNamePart(string namePart)
    {
        MatchCollection mc = NamePartRegex.Matches(namePart);
        if (mc.Count > 0)
        {
            if (mc.Count == 1) return new string[] { mc[0].Groups[1].Value };
            var list = new List<string>();
            foreach (Match m in mc)
            {
                string v = m.Groups[1].Value;
                if (!list.Contains(v, StringComparer.OrdinalIgnoreCase)) list.Add(v); // Sort-Object -Unique semantics
            }
            list.Sort(StringComparer.OrdinalIgnoreCase);
            return list.ToArray();
        }
        return PlainNameRegex.IsMatch(namePart) ? new string[] { namePart } : null;
    }
 
    // ---- Cache read (equivalent to PS Read-CacheFile: full boundary validation) ----
    // Returns object[]{ long DirTicks, Dictionary<string,object> Entries } — keys are
    // OrdinalIgnoreCase, matching PS @{} case-insensitive semantics (the legacy non-generic
    // Hashtable was culture-sensitive and boxed every lookup); Entries[name] =
    // @{Ticks; Names=List<string>} (values stay Hashtables, isomorphic with the PS version,
    // call sites unchanged); or null (no cache/corrupt/version mismatch). String reads rent
    // ArrayPool buffers instead of allocating one byte[] per entry.
    public static object ReadCache(string file, byte formatVer, string moduleVer)
    {
        try
        {
            if (!System.IO.File.Exists(file)) return null;
            using (var fs = System.IO.File.OpenRead(file))
            using (var br = new System.IO.BinaryReader(fs))
            {
                var magic = new string(br.ReadChars(2));
                if (magic != "LC") return null;
                if (br.ReadByte() != formatVer) return null;
                if (br.ReadString() != moduleVer) return null;
                long dirTicks = br.ReadInt64();
                int count = br.ReadInt32();
                if (count < 0 || count > 100000) return null;
                var entries = new Dictionary<string, object>(count, StringComparer.OrdinalIgnoreCase);
                for (int i = 0; i < count; i++)
                {
                    string name = ReadString(br, 65536);
                    if (name == null) return null;
                    long ticks = br.ReadInt64();
                    int cmdCount = br.ReadInt32();
                    if (cmdCount < 0 || cmdCount > 1000) return null;
                    var cmds = new List<string>();
                    for (int j = 0; j < cmdCount; j++)
                    {
                        string c = ReadString(br, 65536);
                        if (c == null) return null;
                        cmds.Add(c);
                    }
                    var e = new System.Collections.Hashtable(2);
                    e["Ticks"] = ticks;
                    e["Names"] = cmds;
                    entries[name] = e;
                }
                if (br.BaseStream.Position != fs.Length) return null;
                return new object[] { dirTicks, entries };
            }
        }
        catch { return null; }
    }
 
    // Rented-buffer string read: length-prefixed UTF8; returns null on corrupt length or short read
    private static string ReadString(System.IO.BinaryReader br, int maxLen)
    {
        int len = br.ReadInt32();
        if (len <= 0 || len > maxLen) return null;
        byte[] rented = System.Buffers.ArrayPool<byte>.Shared.Rent(len);
        try
        {
            if (br.Read(rented, 0, len) != len) return null;
            return System.Text.Encoding.UTF8.GetString(rented, 0, len);
        }
        finally { System.Buffers.ArrayPool<byte>.Shared.Return(rented); }
    }
 
    // ---- Cache write (equivalent to PS Write-CacheFile: empty-name filtering + atomic write + in-use retry) ----
    // entries: IDictionary name -> @{Ticks; Names} (Names may be List/object[]/scalar string/AutomationNull).
    // String writes rent ArrayPool buffers instead of allocating one byte[] per string.
    public static void WriteCache(string file, long dirTicks, System.Collections.IDictionary entries, byte formatVer, string moduleVer, int pid)
    {
        string tmp = file + "." + pid + "." + Guid.NewGuid().ToString("N") + ".tmp";
        using (var fs = System.IO.File.Create(tmp))
        using (var bw = new System.IO.BinaryWriter(fs))
        {
            bw.Write('L'); bw.Write('C');
            bw.Write(formatVer);
            bw.Write(moduleVer);
            bw.Write(dirTicks);
            bw.Write(entries.Count);
            foreach (System.Collections.DictionaryEntry de in entries)
            {
                var e = (System.Collections.IDictionary)de.Value;
                WriteString(bw, (string)de.Key);
                bw.Write(Convert.ToInt64(e["Ticks"]));
                // array-ize and filter empty values (equivalent to the PS @($e.Names) array-ization
                // semantics — measured pitfall: a PS function returning a single-element array is
                // unwrapped into a scalar string; iterating it as IEnumerable would split the
                // string into single chars! scalar strings must be added as a whole)
                var namesObj = e["Names"];
                var list = new List<string>();
                if (namesObj is string single) { if (single.Length > 0) list.Add(single); }
                else if (namesObj is char ch) { list.Add(ch.ToString()); }
                else if (namesObj is System.Collections.IEnumerable names)
                {
                    foreach (var n in names)
                    {
                        if (n == null) continue;
                        string s = n as string;
                        if (s == null) s = n.ToString(); // e.g. PSObject wrappers
                        if (!string.IsNullOrEmpty(s)) list.Add(s);
                    }
                }
                bw.Write(list.Count);
                foreach (var c in list) WriteString(bw, c);
            }
            bw.Flush();
        }
        // .NET atomic replace + in-use retry (same as PS)
        bool moved = false;
        for (int attempt = 0; attempt < 3 && !moved; attempt++)
        {
            try { System.IO.File.Move(tmp, file, true); moved = true; }
            catch { if (attempt >= 2) throw; System.Threading.Thread.Sleep(50); }
        }
    }
 
    // Rented-buffer string write: length-prefixed UTF8 bytes
    private static void WriteString(System.IO.BinaryWriter bw, string value)
    {
        int byteCount = System.Text.Encoding.UTF8.GetByteCount(value);
        byte[] rented = System.Buffers.ArrayPool<byte>.Shared.Rent(byteCount);
        try
        {
            System.Text.Encoding.UTF8.GetBytes(value.AsSpan(), rented.AsSpan(0, byteCount));
            bw.Write(byteCount);
            bw.Write(rented, 0, byteCount);
        }
        finally { System.Buffers.ArrayPool<byte>.Shared.Return(rented); }
    }
 
    // ---- Tab system-file filtering (equivalent to the PS wrapper filter: strip quotes + relative-to-absolute + System-bit check) ----
    // Span-based stripping: the per-candidate slicing runs on a ReadOnlySpan and the path string
    // is materialized only once (the previous version allocated ~6 strings per candidate), then
    // relative paths are combined with pwd. Returns the filtered CompletionResult[] (original
    // objects preserved, semantics identical to PS).
    public static System.Management.Automation.CompletionResult[] FilterSystem(
        System.Collections.Generic.IList<System.Management.Automation.CompletionResult> results, string pwd)
    {
        var result = new List<System.Management.Automation.CompletionResult>(results.Count);
        foreach (var m in results)
        {
            var rt = m.ResultType;
            if (rt == System.Management.Automation.CompletionResultType.ProviderItem ||
                rt == System.Management.Automation.CompletionResultType.ProviderContainer)
            {
                // strip the & prefix and quotes on a span (equivalent to -replace '^&\s*' /
                // "^'|'$" / '^"|"$'; exact same order: & first, then ', then ")
                ReadOnlySpan<char> span = m.CompletionText.AsSpan();
                if (span.Length > 0 && span[0] == '&') span = span.Slice(1).TrimStart();
                if (span.Length > 0 && span[0] == '\'') span = span.Slice(1);
                if (span.Length > 0 && span[span.Length - 1] == '\'') span = span.Slice(0, span.Length - 1);
                if (span.Length > 0 && span[0] == '"') span = span.Slice(1);
                if (span.Length > 0 && span[span.Length - 1] == '"') span = span.Slice(0, span.Length - 1);
                // relative paths are converted to absolute per the PS current dir (measured):
                // [IO.File]::GetAttributes resolves relative paths with the .NET CWD, which is
                // out of sync with the PS location; drive-qualified (C:foo) is extremely rare,
                // kept as-is -> throw -> treated as kept
                string path;
                if (System.IO.Path.IsPathRooted(span) || (span.Length >= 2 && span[1] == ':'))
                {
                    path = new string(span);
                }
                else
                {
                    path = System.IO.Path.Combine(pwd, new string(span));
                }
                try
                {
                    var attr = System.IO.File.GetAttributes(path);
                    if ((attr & System.IO.FileAttributes.System) == 0) result.Add(m);
                }
                catch { result.Add(m); }
            }
            else result.Add(m);
        }
        return result.ToArray();
    }
 
    // ---- Dir incremental comparison (equivalent to the enumeration+comparison part of PS Register-FromCacheWithDelta) ----
    // entries: Hashtable name -> @{Ticks; Names} (PS/C# cache entries are isomorphic; unwrap
    // PSObject wrappers). Returns object[]{ string[] fresh, string[] unchanged } (file names):
    // fresh = no cache entry (added) or mtime differs (content edited) -> caller must parse
    // unchanged = cache entry mtime matches -> caller registers with cached Names directly (zero parsing)
    // Enumeration+comparison fully in C# (measured: PS loop interpretation ~28ms/800 -> C# a few ms);
    // on failure (dir missing/IO error) -> all fresh (semantics = no cache, full; caller fallback).
    public static object[] CompareDir(string dir, System.Collections.IDictionary entries)
    {
        var fresh = new List<string>();
        var unchanged = new List<string>();
        System.IO.FileInfo[] files;
        try { files = new System.IO.DirectoryInfo(dir).GetFiles("*.ps1"); }
        catch { return new object[] { fresh.ToArray(), unchanged.ToArray() }; }
        foreach (var f in files)
        {
            bool same = false;
            if (entries != null)
            {
                object raw = entries[f.Name];
                var ps = raw as System.Management.Automation.PSObject;
                if (ps != null) raw = ps.BaseObject;
                var e = raw as System.Collections.IDictionary;
                if (e != null && e.Contains("Ticks"))
                {
                    try { same = Convert.ToInt64(e["Ticks"]) == f.LastWriteTimeUtc.Ticks; }
                    catch { same = false; }
                }
            }
            if (same) unchanged.Add(f.Name);
            else fresh.Add(f.Name);
        }
        return new object[] { fresh.ToArray(), unchanged.ToArray() };
    }
 
    // ---- Dir reconcile (equivalent to PS Reconcile-DirectoryState) ----
    // Operates directly on the session state tables (in-place via references, zero write-back):
    // cleanup for deleted/renamed scripts + command-set diff; when the command set changed and
    // the script is loaded, resets Loaded/Reals. Returns the number of scripts removed.
    // Params: statesByDir (lowercased dir -> List[script path]) / states (path -> state PSObject
    // {Loaded; Reals; LastError; Commands=List<string>}) / cmdToScript (command -> path) /
    // foundScripts (file name -> command set array/List, or $true for existence-only, or {Ticks,Names})
    // Semantics vs PS: Hashtable indexing is case-sensitive; value comparisons (-eq/-in/-ieq) are
    // case-insensitive; Commands removal uses value matching (List.Remove is case-sensitive, same as PS).
    public static int Reconcile(string dir, System.Collections.IDictionary statesByDir,
        System.Collections.IDictionary states, System.Collections.IDictionary cmdToScript,
        System.Collections.IDictionary foundScripts)
    {
        int removed = 0;
        string dirKey = dir.ToLowerInvariant();
        var paths = new List<string>();
        if (statesByDir != null && statesByDir.Contains(dirKey))
        {
            object raw = statesByDir[dirKey];
            var ps = raw as System.Management.Automation.PSObject;
            if (ps != null) raw = ps.BaseObject;
            var list = raw as System.Collections.IEnumerable;
            if (list != null) { foreach (object p in list) { if (p != null) paths.Add(p.ToString()); } }
        }
        foreach (string path in paths)
        {
            var state = states[path] as System.Management.Automation.PSObject;
            if (state == null) continue;
            string baseName = System.IO.Path.GetFileName(path);
            if (foundScripts == null || !foundScripts.Contains(baseName))
            {
                // file no longer exists (deleted/renamed): clean up all command mappings and the state
                var cmds = GetCommands(state);
                if (cmds != null)
                {
                    foreach (string n in cmds)
                    {
                        object cur = cmdToScript[n];
                        if (cur != null && string.Equals(cur.ToString(), path, StringComparison.OrdinalIgnoreCase))
                            cmdToScript.Remove(n);
                    }
                }
                states.Remove(path);
                var dirList = statesByDir[dirKey] as List<string>;
                if (dirList != null) dirList.Remove(path);
                removed++;
                continue;
            }
            // file still exists: command-set diff (incl. per-file command changes = content rename)
            object found = foundScripts[baseName];
            var fps = found as System.Management.Automation.PSObject;
            if (fps != null) found = fps.BaseObject;
            if (found is bool) continue; // existence-only info, skip the command diff
            if (found is System.Collections.IDictionary fd && fd.Contains("Names")) found = fd["Names"];
            var newCmds = new List<string>();
            var fe = found as System.Collections.IEnumerable;
            if (fe != null) { foreach (object x in fe) { if (x != null) newCmds.Add(x.ToString()); } }
            var cmds2 = GetCommands(state);
            if (cmds2 == null) continue;
            bool changed = false;
            for (int i = cmds2.Count - 1; i >= 0; i--)
            {
                string n = cmds2[i];
                if (!newCmds.Contains(n, StringComparer.OrdinalIgnoreCase))
                {
                    object cur = cmdToScript[n];
                    if (cur != null && string.Equals(cur.ToString(), path, StringComparison.OrdinalIgnoreCase))
                        cmdToScript.Remove(n);
                    cmds2.RemoveAt(i);
                    changed = true;
                }
            }
            foreach (string n in newCmds)
            {
                if (n == null) continue; // defensive: same as PS, skip only null (empty strings are allowed)
                bool exists = false;
                foreach (string c in cmds2) { if (string.Equals(c, n, StringComparison.OrdinalIgnoreCase)) { exists = true; break; } }
                if (!exists) { cmds2.Add(n); changed = true; }
            }
            if (changed)
            {
                var loaded = state.Properties["Loaded"];
                if (loaded != null && Convert.ToBoolean(loaded.Value))
                {
                    loaded.Value = false; // the loaded old completer no longer matches; next Tab reloads
                    var reals = state.Properties["Reals"];
                    if (reals != null) reals.Value = new System.Collections.Hashtable();
                }
            }
        }
        return removed;
    }
 
    private static List<string> GetCommands(System.Management.Automation.PSObject state)
    {
        var p = state.Properties["Commands"];
        if (p == null) return null;
        object v = p.Value;
        var ps = v as System.Management.Automation.PSObject;
        if (ps != null) v = ps.BaseObject;
        return v as List<string>;
    }
 
    // ---- Cache dir incremental maintenance (equivalent to the enumeration+comparison+parsing part of PS Update-CacheForDir) ----
    // entries: old cache entries (name -> @{Ticks; Names}) or null (full); force: ignore entries and parse everything.
    // Returns object[]{ int count, Hashtable newEntries } — new entries are isomorphic:
    // name -> @{ Ticks = long; Names = List<string> } (unchanged entries reuse the original objects, same as PS)
    // Parsing reuses ParseNames (C# main path); on failure (dir missing/IO) -> empty entries (caller fallback writes the cache).
    public static object[] UpdateCacheForDir(string dir, System.Collections.IDictionary entries, bool force)
    {
        var newEntries = new System.Collections.Hashtable();
        System.IO.FileInfo[] files;
        try { files = new System.IO.DirectoryInfo(dir).GetFiles("*.ps1"); }
        catch { return new object[] { newEntries.Count, newEntries }; }
        foreach (var f in files)
        {
            object e = null;
            if (entries != null) e = entries[f.Name];
            if (e != null && !force)
            {
                var ps = e as System.Management.Automation.PSObject;
                if (ps != null) e = ps.BaseObject;
                var ed = e as System.Collections.IDictionary;
                if (ed != null && ed.Contains("Ticks"))
                {
                    try
                    {
                        object t = ed["Ticks"];
                        var tps = t as System.Management.Automation.PSObject;
                        if (tps != null) t = tps.BaseObject;
                        if (Convert.ToInt64(t) == f.LastWriteTimeUtc.Ticks)
                        {
                            newEntries[f.Name] = e; // unchanged: reuse the cache entry (zero parsing)
                            continue;
                        }
                    }
                    catch { }
                }
            }
            var names = ParseNames(f.FullName);
            var entry = new System.Collections.Hashtable(2);
            entry["Ticks"] = f.LastWriteTimeUtc.Ticks;
            entry["Names"] = names != null ? new List<string>(names) : new List<string>();
            newEntries[f.Name] = entry;
        }
        return new object[] { newEntries.Count, newEntries };
    }
}