1262 lines
36 KiB
TypeScript
1262 lines
36 KiB
TypeScript
"use client";
|
|
|
|
import type { Monaco } from "@monaco-editor/react";
|
|
import {
|
|
MonacoEditor,
|
|
type MonacoEditorProps,
|
|
} from "@workspace/ui/composed/editor/monaco-editor";
|
|
import type * as monaco from "monaco-editor";
|
|
import DraculaTheme from "monaco-themes/themes/Dracula.json" with {
|
|
type: "json",
|
|
};
|
|
import { useEffect, useRef } from "react";
|
|
|
|
type SchemaType = "string" | "number" | "boolean" | "object" | "array" | "null";
|
|
|
|
interface SchemaProperty {
|
|
type: SchemaType;
|
|
items?: SchemaProperty;
|
|
properties?: Record<string, SchemaProperty>;
|
|
description?: string;
|
|
}
|
|
|
|
export interface GoTemplateEditorProps
|
|
extends Omit<MonacoEditorProps, "language"> {
|
|
schema?: Record<string, SchemaProperty> | Record<string, unknown>;
|
|
enableSprig?: boolean;
|
|
}
|
|
|
|
interface CompletionItem {
|
|
label: string;
|
|
kind: number;
|
|
insertText: string;
|
|
documentation: string;
|
|
sortText: string;
|
|
}
|
|
|
|
const SORT_PREFIXES = {
|
|
RANGE_VAR: "aa_var_",
|
|
NESTED: "a_nested_",
|
|
CURRENT: "a_current_",
|
|
VAR_FIELD: "b_var_",
|
|
ROOT_FIELD: "c_field_",
|
|
RANGE_OP: "d_range_",
|
|
WITH_OP: "d_with_",
|
|
ROOT_IN_RANGE: "y_root_",
|
|
KEYWORD: "z_keyword_",
|
|
SPRIG: "zz_sprig_",
|
|
} as const;
|
|
|
|
const COMPLETION_KINDS = {
|
|
VARIABLE: 6,
|
|
PROPERTY: 10,
|
|
FUNCTION: 3,
|
|
KEYWORD: 14,
|
|
} as const;
|
|
|
|
const MATCH_SCORES = {
|
|
EXACT: 1000,
|
|
DOT_FIELD: 1000,
|
|
PREFIX_BASE: 800,
|
|
DOT_VARIABLE: 500,
|
|
INCLUDE_BASE: 500,
|
|
FUZZY_BASE: 100,
|
|
OTHER: 100,
|
|
} as const;
|
|
|
|
const REGEX_PATTERNS = {
|
|
RANGE_ASSIGNMENT: /(?:\$\w+\s*,\s*)?(\$\w+)\s*:=\s*(\.\w+(?:\.\w+)*)/,
|
|
FIELD_PATH: /^(\.\w+(?:\.\w+)*)$/,
|
|
NESTED_DOT: /(\.\w+(?:\.\w+)*)\./,
|
|
NESTED_VAR: /(\$\w+\.\w+(?:\.\w+)*)\.$/,
|
|
VAR_DOT: /(\$\w+)\.$/,
|
|
NESTED_GENERAL: /([\w.]+)\.$/,
|
|
WORD_WITH_SPACES: /(\s*)(\S*)$/,
|
|
LEADING_SPACES: /^(\s+)/,
|
|
DOT_VAR_CLEAN: /^[.$]/,
|
|
} as const;
|
|
|
|
const COMPLETION_SNIPPETS = {
|
|
IF_BLOCK: {
|
|
label: "if...end",
|
|
kind: 15,
|
|
insertText: "if ${1:condition}\n\t$0\nend",
|
|
insertTextRules: 4,
|
|
documentation: "Create an if block",
|
|
sortText: `${SORT_PREFIXES.KEYWORD}if_block`,
|
|
},
|
|
RANGE_BLOCK: {
|
|
label: "range...end",
|
|
kind: 15,
|
|
insertText: "range ${1:.items}\n\t$0\nend",
|
|
insertTextRules: 4,
|
|
documentation: "Create a range loop block",
|
|
sortText: `${SORT_PREFIXES.KEYWORD}range_block`,
|
|
},
|
|
WITH_BLOCK: {
|
|
label: "with...end",
|
|
kind: 15,
|
|
insertText: "with ${1:.field}\n\t$0\nend",
|
|
insertTextRules: 4,
|
|
documentation: "Create a with block",
|
|
sortText: `${SORT_PREFIXES.KEYWORD}with_block`,
|
|
},
|
|
} as const;
|
|
|
|
const calculateMatchScore = (label: string, searchText: string): number => {
|
|
if (!searchText) return 0;
|
|
|
|
if (searchText === ".") {
|
|
if (label.startsWith(".")) return MATCH_SCORES.DOT_FIELD;
|
|
if (label.startsWith("$")) return MATCH_SCORES.DOT_VARIABLE;
|
|
return MATCH_SCORES.OTHER;
|
|
}
|
|
|
|
const labelLower = label.toLowerCase();
|
|
const searchLower = searchText.toLowerCase();
|
|
const cleanLabel = labelLower.replace(REGEX_PATTERNS.DOT_VAR_CLEAN, "");
|
|
const cleanSearch = searchLower.replace(REGEX_PATTERNS.DOT_VAR_CLEAN, "");
|
|
|
|
if (cleanLabel === cleanSearch || labelLower === searchLower) {
|
|
return MATCH_SCORES.EXACT;
|
|
}
|
|
|
|
if (
|
|
cleanLabel.startsWith(cleanSearch) ||
|
|
labelLower.startsWith(searchLower)
|
|
) {
|
|
const matchLength = Math.max(cleanSearch.length, searchLower.length);
|
|
const totalLength = Math.max(cleanLabel.length, labelLower.length);
|
|
return MATCH_SCORES.PREFIX_BASE + (matchLength / totalLength) * 100;
|
|
}
|
|
|
|
let includeIndex = cleanLabel.indexOf(cleanSearch);
|
|
if (includeIndex === -1) {
|
|
includeIndex = labelLower.indexOf(searchLower);
|
|
}
|
|
|
|
if (includeIndex !== -1) {
|
|
const totalLength = Math.max(cleanLabel.length, labelLower.length);
|
|
const positionScore = ((totalLength - includeIndex) / totalLength) * 50;
|
|
const matchLength = Math.max(cleanSearch.length, searchLower.length);
|
|
const lengthScore = (matchLength / totalLength) * 100;
|
|
return MATCH_SCORES.INCLUDE_BASE + positionScore + lengthScore;
|
|
}
|
|
|
|
let fuzzyScore = 0;
|
|
let searchIndex = 0;
|
|
const targetText = cleanSearch ? cleanLabel : labelLower;
|
|
const searchQuery = cleanSearch || searchLower;
|
|
|
|
for (
|
|
let i = 0;
|
|
i < targetText.length && searchIndex < searchQuery.length;
|
|
i++
|
|
) {
|
|
if (targetText[i] === searchQuery[searchIndex]) {
|
|
fuzzyScore += 10;
|
|
searchIndex++;
|
|
}
|
|
}
|
|
|
|
return searchIndex === searchQuery.length
|
|
? MATCH_SCORES.FUZZY_BASE + fuzzyScore
|
|
: 0;
|
|
};
|
|
|
|
const generateDynamicSortText = (
|
|
item: CompletionItem,
|
|
searchText: string,
|
|
baseCategory: string
|
|
): string => {
|
|
const matchScore = calculateMatchScore(item.label, searchText);
|
|
const scorePrefix = (10_000 - matchScore).toString().padStart(5, "0");
|
|
return `${scorePrefix}_${baseCategory}_${item.label}`;
|
|
};
|
|
|
|
const GO_TEMPLATE_KEYWORDS = [
|
|
"if",
|
|
"else",
|
|
"end",
|
|
"with",
|
|
"range",
|
|
"template",
|
|
"define",
|
|
"block",
|
|
"include",
|
|
"not",
|
|
"and",
|
|
"or",
|
|
"eq",
|
|
"ne",
|
|
"lt",
|
|
"le",
|
|
"gt",
|
|
"ge",
|
|
"len",
|
|
"index",
|
|
"slice",
|
|
"printf",
|
|
"print",
|
|
"println",
|
|
];
|
|
|
|
const SPRIG_FUNCTIONS = [
|
|
"trim",
|
|
"trimAll",
|
|
"trimSuffix",
|
|
"trimPrefix",
|
|
"upper",
|
|
"lower",
|
|
"title",
|
|
"untitle",
|
|
"repeat",
|
|
"substr",
|
|
"nospace",
|
|
"trunc",
|
|
"abbrev",
|
|
"abbrevboth",
|
|
"initials",
|
|
"randAlphaNum",
|
|
"randAlpha",
|
|
"randNumeric",
|
|
"randAscii",
|
|
"wrap",
|
|
"wrapWith",
|
|
"contains",
|
|
"hasPrefix",
|
|
"hasSuffix",
|
|
"quote",
|
|
"squote",
|
|
"cat",
|
|
"indent",
|
|
"nindent",
|
|
"replace",
|
|
"plural",
|
|
"snakecase",
|
|
"camelcase",
|
|
"kebabcase",
|
|
"swapcase",
|
|
"shuffle",
|
|
|
|
"splitList",
|
|
"split",
|
|
"join",
|
|
"sortAlpha",
|
|
|
|
"add",
|
|
"add1",
|
|
"sub",
|
|
"div",
|
|
"mod",
|
|
"mul",
|
|
"max",
|
|
"min",
|
|
"floor",
|
|
"ceil",
|
|
"round",
|
|
"randInt",
|
|
|
|
"until",
|
|
"untilStep",
|
|
"seq",
|
|
|
|
"addf",
|
|
"add1f",
|
|
"subf",
|
|
"divf",
|
|
"mulf",
|
|
"maxf",
|
|
"minf",
|
|
|
|
"now",
|
|
"ago",
|
|
"date",
|
|
"dateInZone",
|
|
"duration",
|
|
"durationRound",
|
|
"unixEpoch",
|
|
"dateModify",
|
|
"mustDateModify",
|
|
"htmlDate",
|
|
"htmlDateInZone",
|
|
"toDate",
|
|
"mustToDate",
|
|
|
|
"default",
|
|
"empty",
|
|
"coalesce",
|
|
"fromJson",
|
|
"toJson",
|
|
"toPrettyJson",
|
|
"toRawJson",
|
|
"ternary",
|
|
|
|
"b64enc",
|
|
"b64dec",
|
|
"base32enc",
|
|
"base32dec",
|
|
|
|
"list",
|
|
"first",
|
|
"rest",
|
|
"last",
|
|
"initial",
|
|
"append",
|
|
"prepend",
|
|
"concat",
|
|
"reverse",
|
|
"uniq",
|
|
"without",
|
|
"has",
|
|
"compact",
|
|
"slice",
|
|
|
|
"get",
|
|
"set",
|
|
"dict",
|
|
"hasKey",
|
|
"pluck",
|
|
"dig",
|
|
"deepCopy",
|
|
"keys",
|
|
"pick",
|
|
"omit",
|
|
"merge",
|
|
"mergeOverwrite",
|
|
"values",
|
|
|
|
"atoi",
|
|
"int",
|
|
"int64",
|
|
"float64",
|
|
"toDecimal",
|
|
"toString",
|
|
"toStrings",
|
|
|
|
"regexMatch",
|
|
"mustRegexMatch",
|
|
"regexFindAll",
|
|
"mustRegexFindAll",
|
|
"regexFind",
|
|
"mustRegexFind",
|
|
"regexReplaceAll",
|
|
"mustRegexReplaceAll",
|
|
"regexReplaceAllLiteral",
|
|
"mustRegexReplaceAllLiteral",
|
|
"regexSplit",
|
|
"mustRegexSplit",
|
|
"regexQuoteMeta",
|
|
|
|
"sha1sum",
|
|
"sha256sum",
|
|
"adler32sum",
|
|
"htpasswd",
|
|
"derivePassword",
|
|
"buildCustomCert",
|
|
"genCA",
|
|
"genCAWithKey",
|
|
"genSelfSignedCert",
|
|
"genSelfSignedCertWithKey",
|
|
"genSignedCert",
|
|
"genSignedCertWithKey",
|
|
"encryptAES",
|
|
"decryptAES",
|
|
"genPrivateKey",
|
|
"genPublicKey",
|
|
|
|
"base",
|
|
"dir",
|
|
"ext",
|
|
"clean",
|
|
"isAbs",
|
|
"osBase",
|
|
"osDir",
|
|
"osExt",
|
|
"osClean",
|
|
"osIsAbs",
|
|
|
|
"fail",
|
|
|
|
"uuidv4",
|
|
|
|
"env",
|
|
"expandenv",
|
|
|
|
"semver",
|
|
"semverCompare",
|
|
|
|
"typeOf",
|
|
"typeIs",
|
|
"typeIsLike",
|
|
"kindOf",
|
|
"kindIs",
|
|
"deepEqual",
|
|
|
|
"getHostByName",
|
|
|
|
"urlParse",
|
|
"urlJoin",
|
|
"urlquery",
|
|
];
|
|
|
|
export function GoTemplateEditor({
|
|
schema,
|
|
enableSprig = true,
|
|
...props
|
|
}: GoTemplateEditorProps) {
|
|
const providersRef = useRef<{
|
|
completionProvider?: monaco.IDisposable;
|
|
semanticTokensProvider?: monaco.IDisposable;
|
|
}>({});
|
|
|
|
const cleanup = () => {
|
|
if (providersRef.current.completionProvider) {
|
|
providersRef.current.completionProvider.dispose();
|
|
}
|
|
if (providersRef.current.semanticTokensProvider) {
|
|
providersRef.current.semanticTokensProvider.dispose();
|
|
}
|
|
providersRef.current = {};
|
|
};
|
|
|
|
useEffect(() => cleanup, []);
|
|
|
|
const generateSmartCompletions = (
|
|
schema:
|
|
| Record<string, SchemaProperty>
|
|
| Record<string, unknown>
|
|
| undefined,
|
|
activeRangeField?: string | null,
|
|
rangeVariable?: string | null
|
|
): CompletionItem[] => {
|
|
if (!schema) return [];
|
|
|
|
const isSchemaProperty = (obj: unknown): obj is SchemaProperty =>
|
|
obj !== null && typeof obj === "object" && "type" in obj;
|
|
|
|
if (activeRangeField) {
|
|
const rangeCompletions = getRangeCompletions(
|
|
schema,
|
|
activeRangeField,
|
|
rangeVariable || null,
|
|
isSchemaProperty
|
|
);
|
|
|
|
const hasAlias = rangeVariable !== null;
|
|
|
|
if (hasAlias) {
|
|
const rootCompletions = getRootCompletions(
|
|
schema,
|
|
isSchemaProperty,
|
|
true
|
|
);
|
|
return [...rangeCompletions, ...rootCompletions];
|
|
}
|
|
return rangeCompletions;
|
|
}
|
|
|
|
return getRootCompletions(schema, isSchemaProperty);
|
|
};
|
|
|
|
const getRootCompletions = (
|
|
schema: Record<string, unknown>,
|
|
isSchemaProperty: (obj: unknown) => obj is SchemaProperty,
|
|
isInRangeContext = false
|
|
): CompletionItem[] => {
|
|
const items: CompletionItem[] = [];
|
|
const sortPrefix = isInRangeContext
|
|
? SORT_PREFIXES.ROOT_IN_RANGE
|
|
: SORT_PREFIXES.ROOT_FIELD;
|
|
|
|
Object.keys(schema).forEach((key) => {
|
|
const value = schema[key];
|
|
|
|
if (isSchemaProperty(value)) {
|
|
const prop = value as SchemaProperty;
|
|
addFieldCompletions(items, key, prop, "", false, sortPrefix);
|
|
} else {
|
|
items.push({
|
|
label: `.${key}`,
|
|
kind: COMPLETION_KINDS.PROPERTY,
|
|
insertText: `.${key}`,
|
|
documentation: `${isInRangeContext ? "(root) " : ""}Field: ${key}`,
|
|
sortText: `${sortPrefix}${key}`,
|
|
});
|
|
}
|
|
});
|
|
|
|
return items;
|
|
};
|
|
|
|
const getRangeCompletions = (
|
|
schema: Record<string, unknown>,
|
|
rangeField: string,
|
|
rangeVariable: string | null,
|
|
isSchemaProperty: (obj: unknown) => obj is SchemaProperty
|
|
): CompletionItem[] => {
|
|
const items: CompletionItem[] = [];
|
|
if (rangeVariable) {
|
|
items.push({
|
|
label: rangeVariable,
|
|
kind: COMPLETION_KINDS.VARIABLE,
|
|
insertText: rangeVariable,
|
|
documentation: `Range variable for current item in ${rangeField}`,
|
|
sortText: `${SORT_PREFIXES.RANGE_VAR}${rangeVariable}`,
|
|
});
|
|
}
|
|
|
|
const fieldPath = rangeField.startsWith(".")
|
|
? rangeField.slice(1)
|
|
: rangeField;
|
|
const pathParts = fieldPath.split(".");
|
|
|
|
let currentSchema: Record<string, unknown> | SchemaProperty | null = schema;
|
|
for (const part of pathParts) {
|
|
if (
|
|
currentSchema &&
|
|
typeof currentSchema === "object" &&
|
|
!isSchemaProperty(currentSchema)
|
|
) {
|
|
const schemaObj = currentSchema as Record<string, unknown>;
|
|
if (schemaObj[part]) {
|
|
currentSchema = schemaObj[part] as
|
|
| Record<string, unknown>
|
|
| SchemaProperty;
|
|
if (
|
|
isSchemaProperty(currentSchema) &&
|
|
currentSchema.type === "object" &&
|
|
currentSchema.properties
|
|
) {
|
|
currentSchema = currentSchema.properties;
|
|
}
|
|
} else {
|
|
return items;
|
|
}
|
|
} else {
|
|
return items;
|
|
}
|
|
}
|
|
|
|
if (
|
|
isSchemaProperty(currentSchema) &&
|
|
currentSchema.type === "array" &&
|
|
currentSchema.items
|
|
) {
|
|
const itemSchema = currentSchema.items;
|
|
if (itemSchema.type === "object" && itemSchema.properties) {
|
|
Object.keys(itemSchema.properties).forEach((key) => {
|
|
const prop = itemSchema.properties![key];
|
|
|
|
if (isSchemaProperty(prop)) {
|
|
if (!rangeVariable) {
|
|
addFieldCompletions(items, key, prop, "", true);
|
|
}
|
|
} else if (!rangeVariable) {
|
|
items.push({
|
|
label: `.${key}`,
|
|
kind: COMPLETION_KINDS.PROPERTY,
|
|
insertText: `.${key}`,
|
|
documentation: `Field in current item: ${key}`,
|
|
sortText: `${SORT_PREFIXES.CURRENT}${key}`,
|
|
});
|
|
}
|
|
|
|
if (rangeVariable) {
|
|
if (isSchemaProperty(prop)) {
|
|
items.push({
|
|
label: `${rangeVariable}.${key}`,
|
|
kind: COMPLETION_KINDS.PROPERTY,
|
|
insertText: `${rangeVariable}.${key}`,
|
|
documentation: `${prop.description || key} (${prop.type}) via range variable`,
|
|
sortText: `${SORT_PREFIXES.VAR_FIELD}${rangeVariable}_${key}`,
|
|
});
|
|
} else {
|
|
items.push({
|
|
label: `${rangeVariable}.${key}`,
|
|
kind: COMPLETION_KINDS.PROPERTY,
|
|
insertText: `${rangeVariable}.${key}`,
|
|
documentation: `Field ${key} via range variable ${rangeVariable}`,
|
|
sortText: `${SORT_PREFIXES.VAR_FIELD}${rangeVariable}_${key}`,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
return items;
|
|
};
|
|
|
|
const addFieldCompletions = (
|
|
items: CompletionItem[],
|
|
key: string,
|
|
prop: SchemaProperty,
|
|
prefix: string,
|
|
isRangeContext = false,
|
|
customSortPrefix?: string
|
|
) => {
|
|
const fieldPath = prefix ? `${prefix}.${key}` : `.${key}`;
|
|
const contextPrefix = isRangeContext ? "(current item) " : "";
|
|
const sortPrefix =
|
|
customSortPrefix ||
|
|
(isRangeContext ? SORT_PREFIXES.CURRENT : SORT_PREFIXES.ROOT_FIELD);
|
|
|
|
items.push({
|
|
label: fieldPath,
|
|
kind: COMPLETION_KINDS.PROPERTY,
|
|
insertText: fieldPath,
|
|
documentation: `${contextPrefix}${prop.description || key} (${prop.type})`,
|
|
sortText: `${sortPrefix}${fieldPath}`,
|
|
});
|
|
|
|
if (prop.type === "array" && !isRangeContext) {
|
|
items.push({
|
|
label: `range ${fieldPath}`,
|
|
kind: COMPLETION_KINDS.KEYWORD,
|
|
insertText: `range ${fieldPath}`,
|
|
documentation: `Loop through ${fieldPath} array`,
|
|
sortText: `${SORT_PREFIXES.RANGE_OP}${fieldPath}`,
|
|
});
|
|
}
|
|
|
|
if (prop.type === "object" && !isRangeContext) {
|
|
items.push({
|
|
label: `with ${fieldPath}`,
|
|
kind: COMPLETION_KINDS.KEYWORD,
|
|
insertText: `with ${fieldPath}`,
|
|
documentation: `Set context to ${fieldPath} object`,
|
|
sortText: `${SORT_PREFIXES.WITH_OP}${fieldPath}`,
|
|
});
|
|
}
|
|
};
|
|
|
|
const getNestedFieldCompletions = (
|
|
schema:
|
|
| Record<string, SchemaProperty>
|
|
| Record<string, unknown>
|
|
| undefined,
|
|
fieldPath: string,
|
|
isSchemaProperty: (obj: unknown) => obj is SchemaProperty
|
|
): CompletionItem[] => {
|
|
if (!schema) return [];
|
|
|
|
const items: CompletionItem[] = [];
|
|
let cleanPath = fieldPath;
|
|
|
|
if (cleanPath.startsWith("$")) {
|
|
cleanPath = cleanPath.replace(/^\$\w+\./, "");
|
|
} else if (cleanPath.startsWith(".")) {
|
|
cleanPath = cleanPath.slice(1);
|
|
}
|
|
|
|
if (!cleanPath || cleanPath === "") {
|
|
Object.keys(schema).forEach((key) => {
|
|
const value = schema[key];
|
|
if (isSchemaProperty(value)) {
|
|
items.push({
|
|
label: key,
|
|
kind: 10,
|
|
insertText: key,
|
|
documentation: `${value.description || key} (${value.type})`,
|
|
sortText: `a_nested_${key}`,
|
|
});
|
|
} else {
|
|
items.push({
|
|
label: key,
|
|
kind: 10,
|
|
insertText: key,
|
|
documentation: `Field: ${key}`,
|
|
sortText: `a_nested_${key}`,
|
|
});
|
|
}
|
|
});
|
|
return items;
|
|
}
|
|
|
|
const pathParts = cleanPath
|
|
.split(".")
|
|
.filter((part) => part && part.length > 0);
|
|
|
|
let currentSchema: Record<string, unknown> | SchemaProperty | null = schema;
|
|
|
|
for (const part of pathParts) {
|
|
if (
|
|
currentSchema &&
|
|
typeof currentSchema === "object" &&
|
|
!isSchemaProperty(currentSchema)
|
|
) {
|
|
const schemaObj = currentSchema as Record<string, unknown>;
|
|
if (schemaObj[part]) {
|
|
currentSchema = schemaObj[part] as
|
|
| Record<string, unknown>
|
|
| SchemaProperty;
|
|
} else {
|
|
return items;
|
|
}
|
|
} else if (isSchemaProperty(currentSchema)) {
|
|
if (currentSchema.type === "object" && currentSchema.properties) {
|
|
const prop = currentSchema.properties[part] as
|
|
| SchemaProperty
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
if (prop) {
|
|
currentSchema = prop;
|
|
} else {
|
|
return items;
|
|
}
|
|
} else {
|
|
return items;
|
|
}
|
|
} else {
|
|
return items;
|
|
}
|
|
}
|
|
|
|
if (
|
|
isSchemaProperty(currentSchema) &&
|
|
currentSchema.type === "object" &&
|
|
currentSchema.properties
|
|
) {
|
|
Object.keys(currentSchema.properties).forEach((key) => {
|
|
const prop = currentSchema.properties![key];
|
|
if (isSchemaProperty(prop)) {
|
|
items.push({
|
|
label: key,
|
|
kind: COMPLETION_KINDS.PROPERTY,
|
|
insertText: key,
|
|
documentation: `${prop.description || key} (${prop.type})`,
|
|
sortText: `${SORT_PREFIXES.NESTED}${key}`,
|
|
});
|
|
} else {
|
|
items.push({
|
|
label: key,
|
|
kind: COMPLETION_KINDS.PROPERTY,
|
|
insertText: key,
|
|
documentation: `Field: ${key}`,
|
|
sortText: `${SORT_PREFIXES.NESTED}${key}`,
|
|
});
|
|
}
|
|
});
|
|
} else if (
|
|
isSchemaProperty(currentSchema) &&
|
|
currentSchema.type === "array" &&
|
|
currentSchema.items &&
|
|
currentSchema.items.type === "object" &&
|
|
currentSchema.items.properties
|
|
) {
|
|
Object.keys(currentSchema.items.properties).forEach((key) => {
|
|
const prop = currentSchema.items!.properties![key];
|
|
if (isSchemaProperty(prop)) {
|
|
items.push({
|
|
label: key,
|
|
kind: COMPLETION_KINDS.PROPERTY,
|
|
insertText: key,
|
|
documentation: `${prop.description || key} (${prop.type}) - from array item`,
|
|
sortText: `${SORT_PREFIXES.NESTED}${key}`,
|
|
});
|
|
} else {
|
|
items.push({
|
|
label: key,
|
|
kind: COMPLETION_KINDS.PROPERTY,
|
|
insertText: key,
|
|
documentation: `Field: ${key} - from array item`,
|
|
sortText: `${SORT_PREFIXES.NESTED}${key}`,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return items;
|
|
};
|
|
|
|
const handleBeforeMount = (monaco: Monaco) => {
|
|
cleanup();
|
|
|
|
monaco.languages.register({ id: "go-template" });
|
|
|
|
monaco.languages.setMonarchTokensProvider("go-template", {
|
|
tokenizer: {
|
|
root: [
|
|
[/\{\{\/\*/, "comment", "@comment"],
|
|
[/\{\{-/, "template-tag", "@template"],
|
|
[/\{\{/, "template-tag", "@template"],
|
|
[/./, "text"],
|
|
],
|
|
|
|
template: [
|
|
[/\/\*/, "comment", "@comment"],
|
|
[/-\}\}/, "template-tag", "@pop"],
|
|
[/\}\}/, "template-tag", "@pop"],
|
|
[/"([^"\\]|\\.)*$/, "string.invalid"],
|
|
[/"/, "string", "@string"],
|
|
[
|
|
/\b(if|else|end|with|range|template|define|block|include|not|and|or|eq|ne|lt|le|gt|ge|len|index|slice|printf|print|println)\b/,
|
|
"keyword",
|
|
],
|
|
[/\.[a-zA-Z_][a-zA-Z0-9_]*/, "variable"],
|
|
[/\$[a-zA-Z_][a-zA-Z0-9_]*/, "variable"],
|
|
[/\d*\.\d+([eE][-+]?\d+)?/, "number.float"],
|
|
[/\d+/, "number"],
|
|
[/[|:]/, "operator"],
|
|
[/[a-zA-Z_][a-zA-Z0-9_]*/, "function"],
|
|
[/\s+/, "white"],
|
|
],
|
|
|
|
comment: [
|
|
[/\*\//, "comment", "@pop"],
|
|
[/./, "comment"],
|
|
],
|
|
|
|
string: [
|
|
[/[^\\"]+/, "string"],
|
|
[/\\./, "string.escape"],
|
|
[/"/, "string", "@pop"],
|
|
],
|
|
},
|
|
});
|
|
|
|
providersRef.current.completionProvider =
|
|
monaco.languages.registerCompletionItemProvider("go-template", {
|
|
triggerCharacters: [".", " "],
|
|
provideCompletionItems: (model: any, position: any) => {
|
|
try {
|
|
const textUntilPosition = model.getValueInRange({
|
|
startLineNumber: position.lineNumber,
|
|
startColumn: 1,
|
|
endLineNumber: position.lineNumber,
|
|
endColumn: position.column,
|
|
});
|
|
|
|
const lastOpenBrace = Math.max(
|
|
textUntilPosition.lastIndexOf("{{"),
|
|
textUntilPosition.lastIndexOf("{{-")
|
|
);
|
|
const lastCloseBrace = Math.max(
|
|
textUntilPosition.lastIndexOf("}}"),
|
|
textUntilPosition.lastIndexOf("-}}")
|
|
);
|
|
const insideTemplate =
|
|
lastOpenBrace > lastCloseBrace && lastOpenBrace !== -1;
|
|
|
|
if (!insideTemplate) {
|
|
return { suggestions: [] };
|
|
}
|
|
|
|
const fullText = model.getValue();
|
|
const currentPosition = model.getOffsetAt(position);
|
|
const textBeforePosition = fullText.substring(0, currentPosition);
|
|
|
|
const rangeMatches = [
|
|
...textBeforePosition.matchAll(
|
|
/\{\{-?\s*range\s+([^}]+)\s*-?\}\}/g
|
|
),
|
|
];
|
|
const endMatches = [
|
|
...textBeforePosition.matchAll(/\{\{-?\s*end\s*-?\}\}/g),
|
|
];
|
|
|
|
let activeRangeField: string | null = null;
|
|
let rangeVariable: string | null = null;
|
|
if (rangeMatches.length > endMatches.length) {
|
|
const lastRange = rangeMatches.at(-1);
|
|
if (lastRange?.[1]) {
|
|
const rangeField = lastRange[1].trim();
|
|
|
|
const patterns = [
|
|
REGEX_PATTERNS.RANGE_ASSIGNMENT,
|
|
REGEX_PATTERNS.FIELD_PATH,
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
const match = rangeField.match(pattern);
|
|
if (match) {
|
|
if (pattern.source.includes(":=")) {
|
|
rangeVariable = match[1] || null;
|
|
activeRangeField = match[2] || null;
|
|
} else {
|
|
activeRangeField = match[1] || null;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const templateFunctions: CompletionItem[] =
|
|
GO_TEMPLATE_KEYWORDS.map((keyword) => ({
|
|
label: keyword,
|
|
kind: COMPLETION_KINDS.KEYWORD,
|
|
insertText: keyword,
|
|
documentation: `Go template keyword: ${keyword}`,
|
|
sortText: `${SORT_PREFIXES.KEYWORD}${keyword}`,
|
|
}));
|
|
|
|
const snippetCompletions: CompletionItem[] =
|
|
Object.values(COMPLETION_SNIPPETS);
|
|
|
|
const sprigFunctions: CompletionItem[] = enableSprig
|
|
? SPRIG_FUNCTIONS.map((fn) => ({
|
|
label: fn,
|
|
kind: COMPLETION_KINDS.FUNCTION,
|
|
insertText: fn,
|
|
documentation: `Sprig function: ${fn}`,
|
|
sortText: `${SORT_PREFIXES.SPRIG}${fn}`,
|
|
}))
|
|
: [];
|
|
|
|
const baseCompletionItems: CompletionItem[] = [
|
|
...templateFunctions,
|
|
...snippetCompletions,
|
|
...sprigFunctions,
|
|
];
|
|
let allCompletions: CompletionItem[] = [...baseCompletionItems];
|
|
|
|
if (schema) {
|
|
const schemaCompletions = generateSmartCompletions(
|
|
schema,
|
|
activeRangeField,
|
|
rangeVariable
|
|
);
|
|
allCompletions = [...allCompletions, ...schemaCompletions];
|
|
}
|
|
|
|
const wordStart = textUntilPosition.lastIndexOf(" ") + 1;
|
|
const templateStartNormal = textUntilPosition.lastIndexOf("{{");
|
|
const templateStartTrim = textUntilPosition.lastIndexOf("{{-");
|
|
const templateStart =
|
|
Math.max(templateStartNormal, templateStartTrim) +
|
|
(templateStartTrim > templateStartNormal ? 3 : 2);
|
|
const actualStart = Math.max(wordStart, templateStart);
|
|
const currentWord = textUntilPosition.substring(actualStart);
|
|
const currentWordTrimmed = currentWord.trim();
|
|
|
|
let dotMatches = currentWordTrimmed.match(
|
|
REGEX_PATTERNS.NESTED_DOT
|
|
);
|
|
if (!dotMatches) {
|
|
const beforeCursor = textUntilPosition.substring(
|
|
Math.max(templateStart, 0)
|
|
);
|
|
dotMatches =
|
|
beforeCursor.match(REGEX_PATTERNS.NESTED_DOT) ||
|
|
beforeCursor.match(REGEX_PATTERNS.NESTED_VAR) ||
|
|
beforeCursor.match(REGEX_PATTERNS.VAR_DOT) ||
|
|
beforeCursor.match(REGEX_PATTERNS.NESTED_GENERAL);
|
|
}
|
|
|
|
const varDotMatch = textUntilPosition.match(REGEX_PATTERNS.VAR_DOT);
|
|
const isVarDot =
|
|
varDotMatch &&
|
|
textUntilPosition.endsWith(".") &&
|
|
schema &&
|
|
activeRangeField &&
|
|
rangeVariable;
|
|
|
|
const isNestedField =
|
|
(dotMatches && textUntilPosition.endsWith(".") && schema) ||
|
|
isVarDot;
|
|
|
|
const justTypedDot =
|
|
currentWordTrimmed.endsWith(".") ||
|
|
textUntilPosition.endsWith(".");
|
|
const justTypedSpace = textUntilPosition.endsWith(" ");
|
|
let wordForFiltering = currentWordTrimmed;
|
|
if (justTypedDot) {
|
|
wordForFiltering = currentWordTrimmed.slice(0, -1);
|
|
}
|
|
|
|
if ((justTypedDot && activeRangeField) || isVarDot) {
|
|
console.log("Go Template Debug:", {
|
|
justTypedDot,
|
|
isVarDot,
|
|
varDotMatch,
|
|
activeRangeField,
|
|
rangeVariable,
|
|
currentWord,
|
|
currentWordTrimmed,
|
|
wordForFiltering,
|
|
textUntilPosition,
|
|
allCompletionsCount: allCompletions.length,
|
|
});
|
|
}
|
|
|
|
if (isNestedField && schema) {
|
|
let fieldPath = "";
|
|
|
|
if (isVarDot && varDotMatch) {
|
|
const variableName = varDotMatch[1];
|
|
if (variableName === rangeVariable && activeRangeField) {
|
|
fieldPath = activeRangeField;
|
|
}
|
|
} else if (dotMatches?.[1]) {
|
|
fieldPath = dotMatches[1];
|
|
}
|
|
|
|
if (fieldPath) {
|
|
const nestedCompletions = getNestedFieldCompletions(
|
|
schema,
|
|
fieldPath,
|
|
(obj: unknown): obj is SchemaProperty =>
|
|
obj !== null && typeof obj === "object" && "type" in obj
|
|
);
|
|
allCompletions = [...allCompletions, ...nestedCompletions];
|
|
}
|
|
}
|
|
|
|
const isVariableOrFieldItem = (item: CompletionItem): boolean =>
|
|
item.label.startsWith("$") ||
|
|
item.label.startsWith(".") ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.RANGE_VAR) ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.VAR_FIELD) ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.ROOT_FIELD) ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.CURRENT);
|
|
|
|
const filteredCompletions = allCompletions.filter((item) => {
|
|
if (isNestedField) {
|
|
return item.sortText?.startsWith(SORT_PREFIXES.NESTED);
|
|
}
|
|
|
|
if (justTypedDot && activeRangeField) {
|
|
return (
|
|
item.label.startsWith(".") ||
|
|
item.label.startsWith("$") ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.RANGE_VAR) ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.VAR_FIELD) ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.CURRENT) ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.ROOT_FIELD) ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.ROOT_IN_RANGE)
|
|
);
|
|
}
|
|
|
|
if (justTypedDot) {
|
|
return isVariableOrFieldItem(item);
|
|
}
|
|
|
|
if (justTypedSpace) {
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
activeRangeField &&
|
|
(item.sortText?.startsWith(SORT_PREFIXES.RANGE_VAR) ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.VAR_FIELD) ||
|
|
item.sortText?.startsWith(SORT_PREFIXES.CURRENT))
|
|
) {
|
|
if (!wordForFiltering) {
|
|
return true;
|
|
}
|
|
const label = item.label.toLowerCase();
|
|
const word = wordForFiltering.toLowerCase();
|
|
return (
|
|
label.includes(word) || label.startsWith(word) || word === ""
|
|
);
|
|
}
|
|
|
|
if (!wordForFiltering) {
|
|
return true;
|
|
}
|
|
|
|
const label = item.label.toLowerCase();
|
|
const word = wordForFiltering.toLowerCase();
|
|
|
|
if (word.startsWith("$")) {
|
|
return label.startsWith("$") || label.includes(word);
|
|
}
|
|
|
|
return (
|
|
label.includes(word) ||
|
|
label.startsWith(word) ||
|
|
label
|
|
.replace(/[^a-zA-Z0-9]/g, "")
|
|
.includes(word.replace(/[^a-zA-Z0-9]/g, ""))
|
|
);
|
|
});
|
|
|
|
const createUniqueCompletions = (
|
|
completions: CompletionItem[]
|
|
): CompletionItem[] => {
|
|
const seen = new Set<string>();
|
|
return completions.filter((item) => {
|
|
const key = `${item.label}:${item.insertText}`;
|
|
if (seen.has(key)) {
|
|
return false;
|
|
}
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const uniqueFilteredCompletions =
|
|
createUniqueCompletions(filteredCompletions);
|
|
|
|
const getCategoryFromSortText = (sortText: string): string => {
|
|
if (sortText.startsWith(SORT_PREFIXES.RANGE_VAR)) return "var";
|
|
if (sortText.startsWith(SORT_PREFIXES.NESTED)) return "nested";
|
|
if (sortText.startsWith(SORT_PREFIXES.CURRENT)) return "current";
|
|
if (sortText.startsWith(SORT_PREFIXES.VAR_FIELD))
|
|
return "range_var";
|
|
if (sortText.startsWith(SORT_PREFIXES.ROOT_FIELD)) return "field";
|
|
if (sortText.startsWith(SORT_PREFIXES.RANGE_OP))
|
|
return "range_op";
|
|
if (sortText.startsWith(SORT_PREFIXES.WITH_OP)) return "with_op";
|
|
if (sortText.startsWith(SORT_PREFIXES.ROOT_IN_RANGE))
|
|
return "root";
|
|
if (sortText.startsWith(SORT_PREFIXES.KEYWORD)) return "keyword";
|
|
if (sortText.startsWith(SORT_PREFIXES.SPRIG)) return "sprig";
|
|
return "other";
|
|
};
|
|
|
|
const getSearchText = (): string => {
|
|
if (isNestedField) {
|
|
return currentWord.split(".").pop() || "";
|
|
}
|
|
if (justTypedDot) {
|
|
const afterDot = currentWord.replace(/.*\./, "");
|
|
return afterDot === "" && activeRangeField ? "." : afterDot;
|
|
}
|
|
if (justTypedSpace) {
|
|
return "";
|
|
}
|
|
if (wordForFiltering.startsWith(".")) {
|
|
return wordForFiltering.slice(1);
|
|
}
|
|
return wordForFiltering;
|
|
};
|
|
|
|
const searchText = getSearchText();
|
|
|
|
const dynamicallySortedCompletions = uniqueFilteredCompletions
|
|
.map((item: CompletionItem) => ({
|
|
...item,
|
|
sortText: generateDynamicSortText(
|
|
item,
|
|
searchText,
|
|
getCategoryFromSortText(item.sortText)
|
|
),
|
|
}))
|
|
.sort((a: CompletionItem, b: CompletionItem) =>
|
|
a.sortText.localeCompare(b.sortText)
|
|
);
|
|
|
|
return {
|
|
suggestions: dynamicallySortedCompletions.map(
|
|
(item: CompletionItem) => {
|
|
let insertText = item.insertText;
|
|
let startColumn = actualStart;
|
|
|
|
const templateContent =
|
|
textUntilPosition.substring(templateStart);
|
|
|
|
if (isNestedField) {
|
|
startColumn = position.column;
|
|
} else if (justTypedDot && item.insertText.startsWith(".")) {
|
|
insertText = item.insertText.slice(1);
|
|
startColumn = position.column;
|
|
} else if (justTypedDot) {
|
|
startColumn = position.column;
|
|
} else if (justTypedSpace) {
|
|
insertText = `${item.insertText} `;
|
|
startColumn = position.column;
|
|
} else {
|
|
const wordMatch = templateContent.match(
|
|
REGEX_PATTERNS.WORD_WITH_SPACES
|
|
);
|
|
if (wordMatch) {
|
|
const [, leadingSpaces, currentWordInTemplate] =
|
|
wordMatch;
|
|
if (currentWordInTemplate) {
|
|
startColumn =
|
|
templateStart +
|
|
templateContent.length -
|
|
currentWordInTemplate.length;
|
|
} else if (leadingSpaces) {
|
|
startColumn = position.column;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
insertText,
|
|
range: {
|
|
startLineNumber: position.lineNumber,
|
|
endLineNumber: position.lineNumber,
|
|
startColumn,
|
|
endColumn: position.column,
|
|
},
|
|
};
|
|
}
|
|
),
|
|
};
|
|
} catch (error) {
|
|
console.error("Go template completion error:", error);
|
|
return { suggestions: [] };
|
|
}
|
|
},
|
|
});
|
|
|
|
providersRef.current.semanticTokensProvider =
|
|
monaco.languages.registerDocumentSemanticTokensProvider("go-template", {
|
|
getLegend: () => ({
|
|
tokenTypes: ["variable", "function", "keyword", "string", "number"],
|
|
tokenModifiers: [],
|
|
}),
|
|
provideDocumentSemanticTokens: (model: any) => {
|
|
const tokens: number[] = [];
|
|
const text = model.getValue();
|
|
const lines = text.split("\n");
|
|
|
|
lines.forEach((line: any, lineIndex: any) => {
|
|
const templateMatches = [...line.matchAll(/\{\{([^}]+)\}\}/g)];
|
|
templateMatches.forEach((match) => {
|
|
if (match.index !== undefined && match[1]) {
|
|
const content = match[1].trim();
|
|
const startCol = match.index + 2;
|
|
const length = content.length;
|
|
|
|
if (content.startsWith(".") || content.startsWith("$")) {
|
|
tokens.push(lineIndex, startCol, length, 0, 0);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
return { data: new Uint32Array(tokens) };
|
|
},
|
|
releaseDocumentSemanticTokens: () => {
|
|
/* no-op */
|
|
},
|
|
});
|
|
|
|
monaco.editor.defineTheme("transparentTheme", {
|
|
base: DraculaTheme.base as "vs" | "vs-dark" | "hc-black",
|
|
inherit: DraculaTheme.inherit,
|
|
rules: [
|
|
...DraculaTheme.rules,
|
|
{ token: "template-tag", foreground: "FFB86C", fontStyle: "bold" },
|
|
{ token: "template-keyword", foreground: "BD93F9", fontStyle: "bold" },
|
|
{ token: "template-string", foreground: "F1FA8C" },
|
|
{ token: "template-function", foreground: "50FA7B" },
|
|
{ token: "template-variable", foreground: "F8F8F2" },
|
|
{ token: "keyword", foreground: "FF79C6" },
|
|
],
|
|
colors: {
|
|
...DraculaTheme.colors,
|
|
"editor.background": "#00000000",
|
|
},
|
|
});
|
|
};
|
|
|
|
return (
|
|
<MonacoEditor
|
|
description={`Go text/template syntax${enableSprig ? " with Sprig functions" : ""}`}
|
|
title="Go Template Editor"
|
|
{...props}
|
|
beforeMount={handleBeforeMount}
|
|
language="go-template"
|
|
placeholder="Enter your Go template here..."
|
|
/>
|
|
);
|
|
}
|