Files
2026-02-04 14:20:08 +01:00

513 lines
19 KiB
C#

using System.Text.Json;
using BlazorMonaco;
using BlazorMonaco.Languages;
namespace Inspectron.PrintServer.TemplateEditor.Services;
public class TemplateCompletionProvider
{
private List<JsonPropertyInfo> _jsonPaths = [];
public void SetJsonPaths(List<JsonPropertyInfo> paths) => _jsonPaths = paths;
public Task<CompletionList> ProvideCompletionItems(string modelUri, Position position, CompletionContext context)
{
var items = new List<CompletionItem>();
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<CompletionItem> ResolveCompletionItem(CompletionItem item) => Task.FromResult(item);
private Func<string>? _editorContentAccessor;
public void SetEditorContentAccessor(Func<string> 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 </...
if (afterLt.StartsWith("/"))
{
var prefix = afterLt[1..];
return new CompletionContextInfo(ContextKind.ElementName, prefix);
}
// Check if we're still on the element name or moved to attributes
var spaceIdx = afterLt.IndexOf(' ');
if (spaceIdx < 0)
{
// Still typing element name
return new CompletionContextInfo(ContextKind.ElementName, afterLt);
}
// After element name + space = attribute context
var elemName = afterLt[..spaceIdx].Trim();
var attrText = afterLt[spaceIdx..].TrimStart();
// Get the last partial attribute name being typed
var lastSpace = attrText.LastIndexOfAny([' ', '"', '\'']);
var attrPrefix = lastSpace >= 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<string, ElementSchema> 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 <col> 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 <row> or <table>", 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 <if>)", []),
};
private static AttributeSchema BoolAttr(string desc) => new(["true", "false"], desc);
private record ElementSchema(string Description, Dictionary<string, AttributeSchema> Attributes);
private record AttributeSchema(List<string> Values, string Description);
#endregion
#region Completion Generators
private static List<CompletionItem> GetElementCompletions(string prefix)
{
var items = new List<CompletionItem>();
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}</line>",
"columns" => "columns left=\"${1}\" right=\"${2}\" />",
"row" => "row>\n\t<col align=\"${1:left}\">${2}</col>\n\t<col align=\"${3:right}\">${4}</col>\n</row>",
"col" => "col${1: align=\"${2:left}\"}>${3:$0}</col>",
"separator" => "separator />",
"cut" => "cut />",
"feed" => "feed${1: lines=\"${2:1}\"} />",
"foreach" => "foreach items=\"${1}\" var=\"${2:item}\">\n\t$0\n</foreach>",
"if" => "if test=\"${1}\">\n\t$0\n</if>",
"else" => "else>\n\t$0\n</else>",
"table" => "table items=\"${1}\" var=\"${2:item}\">\n\t<col width=\"${3}\" align=\"${4:left}\">${5:Header}</col>\n</table>",
"receipt" => "receipt>\n\t$0\n</receipt>",
_ => $"{name}>$0</{name}>"
};
}
private static List<CompletionItem> GetAttributeCompletions(string elementName, string prefix)
{
var items = new List<CompletionItem>();
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<CompletionItem> GetAttributeValueCompletions(string elementName, string attributeName)
{
var items = new List<CompletionItem>();
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<CompletionItem> GetExpressionCompletions(string prefix, string[] lines, int lineNumber)
{
var items = new List<CompletionItem>();
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<LoopVarInfo> FindLoopVariables(string[] lines, int lineNumber)
{
var vars = new List<LoopVarInfo>();
// 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("</foreach>"))
depth++;
// Check for opening foreach tags
var foreachMatch = System.Text.RegularExpressions.Regex.Match(line,
@"<foreach\s+items=""([^""]+)""\s+var=""([^""]+)""");
if (!foreachMatch.Success)
foreachMatch = System.Text.RegularExpressions.Regex.Match(line,
@"<foreach\s+var=""([^""]+)""\s+items=""([^""]+)""");
if (foreachMatch.Success)
{
if (depth > 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<JsonPropertyInfo> 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
}