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