513 lines
19 KiB
C#
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
|
|
}
|