using System.Text.Json; using BlazorMonaco; using BlazorMonaco.Languages; namespace Inspectron.PrintServer.TemplateEditor.Services; public class TemplateCompletionProvider { private List _jsonPaths = []; public void SetJsonPaths(List paths) => _jsonPaths = paths; public Task ProvideCompletionItems(string modelUri, Position position, CompletionContext context) { var items = new List(); var editorContent = _editorContentAccessor?.Invoke(); if (string.IsNullOrEmpty(editorContent)) return Task.FromResult(new CompletionList { Suggestions = items }); var lines = editorContent.Split('\n'); if (position.LineNumber < 1 || position.LineNumber > lines.Length) return Task.FromResult(new CompletionList { Suggestions = items }); var line = lines[position.LineNumber - 1]; var col = Math.Min(position.Column - 1, line.Length); var textBefore = line[..col]; var completionContext = DetectContext(textBefore, lines, position.LineNumber); switch (completionContext.Kind) { case ContextKind.ElementName: items = GetElementCompletions(completionContext.Prefix); break; case ContextKind.AttributeName: items = GetAttributeCompletions(completionContext.ElementName, completionContext.Prefix); break; case ContextKind.AttributeValue: items = GetAttributeValueCompletions(completionContext.ElementName, completionContext.AttributeName); break; case ContextKind.Expression: items = GetExpressionCompletions(completionContext.Prefix, lines, position.LineNumber); break; } return Task.FromResult(new CompletionList { Suggestions = items }); } public Task ResolveCompletionItem(CompletionItem item) => Task.FromResult(item); private Func? _editorContentAccessor; public void SetEditorContentAccessor(Func accessor) => _editorContentAccessor = accessor; #region Context Detection private enum ContextKind { None, ElementName, AttributeName, AttributeValue, Expression } private record CompletionContextInfo(ContextKind Kind, string Prefix = "", string ElementName = "", string AttributeName = ""); private static CompletionContextInfo DetectContext(string textBefore, string[] lines, int lineNumber) { // Inside {{ }} expression? var lastOpen = textBefore.LastIndexOf("{{", StringComparison.Ordinal); var lastClose = textBefore.LastIndexOf("}}", StringComparison.Ordinal); if (lastOpen >= 0 && lastOpen > lastClose) { var exprText = textBefore[(lastOpen + 2)..]; return new CompletionContextInfo(ContextKind.Expression, exprText); } // Inside attribute value (="...")? var inAttrValue = IsInsideAttributeValue(textBefore); if (inAttrValue.Inside) return new CompletionContextInfo(ContextKind.AttributeValue, ElementName: inAttrValue.ElementName, AttributeName: inAttrValue.AttributeName); // After < for element name? var lastLt = textBefore.LastIndexOf('<'); var lastGt = textBefore.LastIndexOf('>'); if (lastLt >= 0 && lastLt > lastGt) { var afterLt = textBefore[(lastLt + 1)..]; // Closing tag = 0 ? attrText[(lastSpace + 1)..] : attrText; // Filter out if we're right after = or inside a value if (!attrPrefix.Contains('=')) return new CompletionContextInfo(ContextKind.AttributeName, attrPrefix, elemName); } return new CompletionContextInfo(ContextKind.None); } private static (bool Inside, string ElementName, string AttributeName) IsInsideAttributeValue(string text) { // Find the last opening tag context var lastLt = text.LastIndexOf('<'); if (lastLt < 0) return (false, "", ""); var tagContent = text[(lastLt + 1)..]; var lastGt = tagContent.LastIndexOf('>'); if (lastGt >= 0) return (false, "", ""); // Extract element name var spaceIdx = tagContent.IndexOf(' '); if (spaceIdx < 0) return (false, "", ""); var elemName = tagContent[..spaceIdx]; if (elemName.StartsWith("/")) return (false, "", ""); // Check if cursor is inside a quoted attribute value var attrPart = tagContent[spaceIdx..]; var inDoubleQuote = false; var inSingleQuote = false; var lastAttrName = ""; var i = 0; while (i < attrPart.Length) { if (attrPart[i] == '"' && !inSingleQuote) { inDoubleQuote = !inDoubleQuote; } else if (attrPart[i] == '\'' && !inDoubleQuote) { inSingleQuote = !inSingleQuote; } else if (!inDoubleQuote && !inSingleQuote && char.IsLetter(attrPart[i])) { // Capture attribute name var start = i; while (i < attrPart.Length && (char.IsLetterOrDigit(attrPart[i]) || attrPart[i] == '-' || attrPart[i] == '_')) i++; lastAttrName = attrPart[start..i]; continue; } i++; } if (inDoubleQuote || inSingleQuote) return (true, elemName, lastAttrName); return (false, "", ""); } #endregion #region Element Schema private static readonly Dictionary Schema = new(StringComparer.OrdinalIgnoreCase) { ["receipt"] = new("Required root element", []), ["line"] = new("Single line of text", new() { ["align"] = new(["left", "center", "right"], "Text alignment"), ["bold"] = BoolAttr("Bold text"), ["big"] = BoolAttr("Double-width font"), ["tall"] = BoolAttr("Double-height font"), ["red"] = BoolAttr("Red text (supported printers)"), ["lineSpacing"] = new([], "Line spacing in dots"), ["wrap"] = BoolAttr("Word-wrap text"), ["wrapIndent"] = new([], "Indent for wrapped lines"), }), ["columns"] = new("Two-column layout", new() { ["left"] = new([], "Left column content"), ["right"] = new([], "Right column content"), ["bold"] = BoolAttr("Bold text"), ["big"] = BoolAttr("Double-width font"), ["tall"] = BoolAttr("Double-height font"), ["red"] = BoolAttr("Red text"), ["lineSpacing"] = new([], "Line spacing in dots"), ["wrap"] = BoolAttr("Wrap left column"), ["wrapIndent"] = new([], "Indent for wrapped lines"), }), ["row"] = new("Multi-column layout with children", new() { ["bold"] = BoolAttr("Bold text"), ["big"] = BoolAttr("Double-width font"), ["tall"] = BoolAttr("Double-height font"), ["red"] = BoolAttr("Red text"), ["lineSpacing"] = new([], "Line spacing in dots"), }), ["col"] = new("Column inside or ", new() { ["width"] = new([], "Column width (char count or percentage)"), ["align"] = new(["left", "center", "right"], "Cell alignment"), ["wrap"] = BoolAttr("Word-wrap cell text"), }), ["separator"] = new("Repeated character line", new() { ["char"] = new([], "Character to repeat (default: -)"), }), ["cut"] = new("Paper cut command", []), ["feed"] = new("Empty lines", new() { ["lines"] = new([], "Number of empty lines"), }), ["table"] = new("ASCII box table", new() { ["items"] = new([], "JSON array path for data rows"), ["var"] = new([], "Loop variable name"), ["headerItems"] = new([], "JSON array path for header rows"), }), ["foreach"] = new("Loop over array", new() { ["items"] = new([], "Path to JSON array (required)"), ["var"] = new([], "Loop variable name (required)"), }), ["if"] = new("Conditional block", new() { ["test"] = new([], "Condition expression (required)"), }), ["else"] = new("Else branch (must follow )", []), }; private static AttributeSchema BoolAttr(string desc) => new(["true", "false"], desc); private record ElementSchema(string Description, Dictionary Attributes); private record AttributeSchema(List Values, string Description); #endregion #region Completion Generators private static List GetElementCompletions(string prefix) { var items = new List(); var sortIndex = 0; foreach (var (name, schema) in Schema) { items.Add(new CompletionItem { LabelAsString = name, Kind = CompletionItemKind.Keyword, Detail = schema.Description, InsertText = GetElementSnippet(name, schema), InsertTextRules = CompletionItemInsertTextRule.InsertAsSnippet, SortText = sortIndex++.ToString("D3"), }); } return items; } private static string GetElementSnippet(string name, ElementSchema schema) { return name switch { "line" => "line${1: align=\"${2:left}\"}>${3:$0}", "columns" => "columns left=\"${1}\" right=\"${2}\" />", "row" => "row>\n\t${2}\n\t${4}\n", "col" => "col${1: align=\"${2:left}\"}>${3:$0}", "separator" => "separator />", "cut" => "cut />", "feed" => "feed${1: lines=\"${2:1}\"} />", "foreach" => "foreach items=\"${1}\" var=\"${2:item}\">\n\t$0\n", "if" => "if test=\"${1}\">\n\t$0\n", "else" => "else>\n\t$0\n", "table" => "table items=\"${1}\" var=\"${2:item}\">\n\t${5:Header}\n
", "receipt" => "receipt>\n\t$0\n", _ => $"{name}>$0" }; } private static List GetAttributeCompletions(string elementName, string prefix) { var items = new List(); if (!Schema.TryGetValue(elementName, out var schema)) return items; var sortIndex = 0; foreach (var (attrName, attrSchema) in schema.Attributes) { var insertText = attrSchema.Values.Count > 0 ? $"{attrName}=\"${{1|{string.Join(",", attrSchema.Values)}|}}\"" : $"{attrName}=\"$1\""; items.Add(new CompletionItem { LabelAsString = attrName, Kind = CompletionItemKind.Property, Detail = attrSchema.Description, InsertText = insertText, InsertTextRules = CompletionItemInsertTextRule.InsertAsSnippet, SortText = sortIndex++.ToString("D3"), }); } return items; } private static List GetAttributeValueCompletions(string elementName, string attributeName) { var items = new List(); if (!Schema.TryGetValue(elementName, out var schema)) return items; if (!schema.Attributes.TryGetValue(attributeName, out var attrSchema)) return items; foreach (var value in attrSchema.Values) { items.Add(new CompletionItem { LabelAsString = value, Kind = CompletionItemKind.Value, InsertText = value, }); } return items; } private List GetExpressionCompletions(string prefix, string[] lines, int lineNumber) { var items = new List(); var sortIndex = 0; // Loop variables var loopVars = FindLoopVariables(lines, lineNumber); // Special loop variables foreach (var special in new[] { "$index", "$first", "$last" }) { if (loopVars.Count > 0) // Only suggest if inside a foreach { items.Add(new CompletionItem { LabelAsString = special, Kind = CompletionItemKind.Variable, Detail = special switch { "$index" => "Zero-based loop index", "$first" => "True on first iteration", "$last" => "True on last iteration", _ => "" }, InsertText = special, SortText = sortIndex++.ToString("D3"), }); } } // Loop variable property paths from JSON foreach (var loopVar in loopVars) { // Find array in JSON paths and suggest its item properties var arrayPaths = FindMatchingArrayPaths(loopVar.ItemsPath); foreach (var prop in arrayPaths) { // prop.Path is like "Items[].Description" - we want "item.Description" var suffix = prop.Path; var bracketIdx = suffix.IndexOf("[].", StringComparison.Ordinal); if (bracketIdx >= 0) suffix = suffix[(bracketIdx + 3)..]; else continue; var label = $"{loopVar.VarName}.{suffix}"; items.Add(new CompletionItem { LabelAsString = label, Kind = CompletionItemKind.Field, Detail = $"{prop.Type} (from {loopVar.ItemsPath})", InsertText = label, SortText = sortIndex++.ToString("D3"), }); } } // Top-level JSON paths foreach (var prop in _jsonPaths.Where(p => !p.Path.Contains("[]."))) { var kind = prop.IsArray ? CompletionItemKind.Enum : CompletionItemKind.Field; items.Add(new CompletionItem { LabelAsString = prop.Path, Kind = kind, Detail = prop.Type, InsertText = prop.Path, SortText = sortIndex++.ToString("D3"), }); } // Format specifiers if (prefix.Contains(':')) { items.Clear(); foreach (var (fmt, desc) in FormatSpecifiers) { items.Add(new CompletionItem { LabelAsString = fmt, Kind = CompletionItemKind.Unit, Detail = desc, InsertText = fmt, }); } } return items; } private static readonly (string Format, string Description)[] FormatSpecifiers = [ ("F2", "Fixed-point 2 decimals (9.50)"), ("F0", "Fixed-point no decimals (10)"), ("N0", "Number with separators (1,234)"), ("N2", "Number with separators + decimals (1,234.56)"), ("dd.MM.yyyy", "Date (15.03.2024)"), ("HH:mm", "Time (14:30)"), ("HH:mm:ss", "Time with seconds (14:30:00)"), ("dd-MMM-yy HH:mm", "Date + time (15-Mar-24 14:30)"), ("dd.MM.yyyy HH:mm", "Date + time (15.03.2024 14:30)"), ]; #endregion #region Loop Variable Detection private record LoopVarInfo(string VarName, string ItemsPath); private static List FindLoopVariables(string[] lines, int lineNumber) { var vars = new List(); // Walk backwards from current line to find enclosing foreach elements var depth = 0; for (var i = lineNumber - 1; i >= 0; i--) { var line = lines[i].Trim(); // Count closing foreach tags (increase depth needed) if (line.Contains("")) depth++; // Check for opening foreach tags var foreachMatch = System.Text.RegularExpressions.Regex.Match(line, @" 0) { depth--; continue; } string itemsPath, varName; if (foreachMatch.Groups.Count >= 3) { // Determine which capture is items vs var based on pattern if (line.IndexOf("items=", StringComparison.Ordinal) < line.IndexOf("var=", StringComparison.Ordinal)) { itemsPath = foreachMatch.Groups[1].Value; varName = foreachMatch.Groups[2].Value; } else { varName = foreachMatch.Groups[1].Value; itemsPath = foreachMatch.Groups[2].Value; } vars.Add(new LoopVarInfo(varName, itemsPath)); } } } return vars; } private List FindMatchingArrayPaths(string itemsPath) { // itemsPath could be "Items" or "gang.Dishes" etc. // We need to find JSON paths that start with the matching array path + "[]." return _jsonPaths .Where(p => p.Path.StartsWith(itemsPath + "[].", StringComparison.OrdinalIgnoreCase)) .ToList(); } #endregion }