mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
1336 lines
28 KiB
TypeScript
1336 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
HighlightStyle,
|
|
StreamLanguage,
|
|
type StringStream,
|
|
syntaxHighlighting,
|
|
} from "@codemirror/language";
|
|
import { EditorState } from "@codemirror/state";
|
|
import { tags } from "@lezer/highlight";
|
|
import CodeMirror, {
|
|
EditorView,
|
|
placeholder as codeEditorPlaceholder,
|
|
} from "@uiw/react-codemirror";
|
|
import { Check, Copy } from "lucide-react";
|
|
import { useTheme } from "next-themes";
|
|
import { type HTMLAttributes, useState } from "react";
|
|
|
|
import { Badge } from "@/components/shadcn";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export const QUERY_EDITOR_LANGUAGE = {
|
|
OPEN_CYPHER: "openCypher",
|
|
PLAIN_TEXT: "plainText",
|
|
JSON: "json",
|
|
SHELL: "shell",
|
|
HCL: "hcl",
|
|
BICEP: "bicep",
|
|
YAML: "yaml",
|
|
} as const;
|
|
|
|
export type QueryEditorLanguage =
|
|
(typeof QUERY_EDITOR_LANGUAGE)[keyof typeof QUERY_EDITOR_LANGUAGE];
|
|
|
|
const OPEN_CYPHER_KEYWORDS = new Set([
|
|
"all",
|
|
"and",
|
|
"as",
|
|
"asc",
|
|
"ascending",
|
|
"by",
|
|
"call",
|
|
"case",
|
|
"contains",
|
|
"create",
|
|
"delete",
|
|
"desc",
|
|
"descending",
|
|
"detach",
|
|
"distinct",
|
|
"else",
|
|
"end",
|
|
"exists",
|
|
"false",
|
|
"in",
|
|
"is",
|
|
"limit",
|
|
"match",
|
|
"merge",
|
|
"not",
|
|
"null",
|
|
"optional",
|
|
"or",
|
|
"order",
|
|
"remove",
|
|
"return",
|
|
"set",
|
|
"skip",
|
|
"then",
|
|
"true",
|
|
"unwind",
|
|
"where",
|
|
"with",
|
|
"xor",
|
|
"yield",
|
|
]);
|
|
|
|
const OPEN_CYPHER_FUNCTIONS = new Set([
|
|
"collect",
|
|
"coalesce",
|
|
"count",
|
|
"exists",
|
|
"head",
|
|
"id",
|
|
"keys",
|
|
"labels",
|
|
"last",
|
|
"length",
|
|
"nodes",
|
|
"properties",
|
|
"range",
|
|
"reduce",
|
|
"relationships",
|
|
"size",
|
|
"startnode",
|
|
"sum",
|
|
"tail",
|
|
"timestamp",
|
|
"tolower",
|
|
"toupper",
|
|
"trim",
|
|
"type",
|
|
]);
|
|
|
|
const SHELL_KEYWORDS = new Set([
|
|
"if",
|
|
"then",
|
|
"else",
|
|
"elif",
|
|
"fi",
|
|
"for",
|
|
"in",
|
|
"do",
|
|
"done",
|
|
"while",
|
|
"until",
|
|
"case",
|
|
"esac",
|
|
"function",
|
|
"return",
|
|
"export",
|
|
"local",
|
|
"readonly",
|
|
"declare",
|
|
"typeset",
|
|
"unset",
|
|
"shift",
|
|
"break",
|
|
"continue",
|
|
"select",
|
|
"time",
|
|
"trap",
|
|
]);
|
|
|
|
const SHELL_COMMANDS = new Set([
|
|
"aws",
|
|
"az",
|
|
"gcloud",
|
|
"kubectl",
|
|
"terraform",
|
|
"echo",
|
|
"grep",
|
|
"sed",
|
|
"awk",
|
|
"curl",
|
|
"wget",
|
|
"chmod",
|
|
"chown",
|
|
"mkdir",
|
|
"rm",
|
|
"cp",
|
|
"mv",
|
|
"cat",
|
|
"ls",
|
|
]);
|
|
|
|
const HCL_KEYWORDS = new Set([
|
|
"resource",
|
|
"data",
|
|
"variable",
|
|
"output",
|
|
"locals",
|
|
"module",
|
|
"provider",
|
|
"terraform",
|
|
"backend",
|
|
"required_providers",
|
|
"dynamic",
|
|
"for_each",
|
|
"count",
|
|
"depends_on",
|
|
"lifecycle",
|
|
"provisioner",
|
|
"connection",
|
|
]);
|
|
|
|
const HCL_FUNCTIONS = new Set([
|
|
"lookup",
|
|
"merge",
|
|
"join",
|
|
"split",
|
|
"length",
|
|
"element",
|
|
"concat",
|
|
"format",
|
|
"replace",
|
|
"regex",
|
|
"tolist",
|
|
"tomap",
|
|
"toset",
|
|
"try",
|
|
"can",
|
|
"file",
|
|
"templatefile",
|
|
"jsonencode",
|
|
"jsondecode",
|
|
"yamlencode",
|
|
"yamldecode",
|
|
"base64encode",
|
|
"base64decode",
|
|
"md5",
|
|
"sha256",
|
|
"cidrsubnet",
|
|
"cidrhost",
|
|
]);
|
|
|
|
interface JsonParserState {
|
|
inString: boolean;
|
|
stringIsProperty: boolean;
|
|
escapeNext: boolean;
|
|
}
|
|
|
|
const jsonLanguage = StreamLanguage.define<JsonParserState>({
|
|
startState() {
|
|
return {
|
|
inString: false,
|
|
stringIsProperty: false,
|
|
escapeNext: false,
|
|
};
|
|
},
|
|
token(stream, state) {
|
|
if (state.inString) {
|
|
while (!stream.eol()) {
|
|
const next = stream.next();
|
|
|
|
if (state.escapeNext) {
|
|
state.escapeNext = false;
|
|
continue;
|
|
}
|
|
|
|
if (next === "\\") {
|
|
state.escapeNext = true;
|
|
continue;
|
|
}
|
|
|
|
if (next === '"') {
|
|
state.inString = false;
|
|
return state.stringIsProperty ? "propertyName" : "string";
|
|
}
|
|
}
|
|
|
|
return state.stringIsProperty ? "propertyName" : "string";
|
|
}
|
|
|
|
if (stream.eatSpace()) {
|
|
return null;
|
|
}
|
|
|
|
if (stream.peek() === '"') {
|
|
const restOfLine = stream.string.slice(stream.pos);
|
|
state.inString = true;
|
|
state.escapeNext = false;
|
|
state.stringIsProperty = /^\s*"([^"\\]|\\.)*"\s*:/.test(restOfLine);
|
|
stream.next();
|
|
return state.stringIsProperty ? "propertyName" : "string";
|
|
}
|
|
|
|
if (stream.match(/[{}\[\],:]/)) {
|
|
return "punctuation";
|
|
}
|
|
|
|
if (stream.match(/-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/)) {
|
|
return "number";
|
|
}
|
|
|
|
if (stream.match(/\b(?:true|false|null)\b/)) {
|
|
return "keyword";
|
|
}
|
|
|
|
stream.next();
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const BICEP_KEYWORDS = new Set([
|
|
"resource",
|
|
"module",
|
|
"param",
|
|
"var",
|
|
"output",
|
|
"type",
|
|
"metadata",
|
|
"import",
|
|
"using",
|
|
"extension",
|
|
"targetScope",
|
|
"existing",
|
|
"if",
|
|
"for",
|
|
"in",
|
|
"true",
|
|
"false",
|
|
"null",
|
|
]);
|
|
|
|
const BICEP_DECORATORS = new Set([
|
|
"description",
|
|
"secure",
|
|
"minLength",
|
|
"maxLength",
|
|
"minValue",
|
|
"maxValue",
|
|
"allowed",
|
|
"metadata",
|
|
"batchSize",
|
|
"sys",
|
|
]);
|
|
|
|
const BICEP_FUNCTIONS = new Set([
|
|
"concat",
|
|
"format",
|
|
"toLower",
|
|
"toUpper",
|
|
"substring",
|
|
"replace",
|
|
"split",
|
|
"join",
|
|
"length",
|
|
"contains",
|
|
"empty",
|
|
"first",
|
|
"last",
|
|
"indexOf",
|
|
"array",
|
|
"union",
|
|
"intersection",
|
|
"resourceId",
|
|
"subscriptionResourceId",
|
|
"tenantResourceId",
|
|
"reference",
|
|
"listKeys",
|
|
"listAccountSas",
|
|
"uniqueString",
|
|
"guid",
|
|
"base64",
|
|
"uri",
|
|
"environment",
|
|
"subscription",
|
|
"resourceGroup",
|
|
"tenant",
|
|
]);
|
|
|
|
interface OpenCypherParserState {
|
|
inBlockComment: boolean;
|
|
inString: "'" | '"' | null;
|
|
}
|
|
|
|
interface ShellParserState {
|
|
inString: "'" | '"' | null;
|
|
}
|
|
|
|
interface HclParserState {
|
|
inBlockComment: boolean;
|
|
inString: '"' | null;
|
|
expectBlockType: boolean;
|
|
heredocTerminator: string | null;
|
|
}
|
|
|
|
interface BicepParserState {
|
|
inBlockComment: boolean;
|
|
inString: "'" | null;
|
|
expectResourceType: boolean;
|
|
}
|
|
|
|
interface YamlParserState {
|
|
inString: "'" | '"' | null;
|
|
inBlockScalar: boolean;
|
|
blockScalarIndent: number;
|
|
}
|
|
|
|
const openCypherLanguage = StreamLanguage.define<OpenCypherParserState>({
|
|
startState() {
|
|
return {
|
|
inBlockComment: false,
|
|
inString: null,
|
|
};
|
|
},
|
|
token(stream, state) {
|
|
if (state.inBlockComment) {
|
|
while (!stream.eol()) {
|
|
if (stream.match("*/")) {
|
|
state.inBlockComment = false;
|
|
break;
|
|
}
|
|
stream.next();
|
|
}
|
|
|
|
return "comment";
|
|
}
|
|
|
|
if (state.inString) {
|
|
let escaped = false;
|
|
|
|
while (!stream.eol()) {
|
|
const next = stream.next();
|
|
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (next === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (next === state.inString) {
|
|
state.inString = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (stream.eol()) {
|
|
state.inString = null;
|
|
}
|
|
|
|
return "string";
|
|
}
|
|
|
|
if (stream.eatSpace()) {
|
|
return null;
|
|
}
|
|
|
|
if (stream.match("//")) {
|
|
stream.skipToEnd();
|
|
return "comment";
|
|
}
|
|
|
|
if (stream.match("/*")) {
|
|
state.inBlockComment = true;
|
|
return "comment";
|
|
}
|
|
|
|
const quote = stream.peek();
|
|
if (quote === "'" || quote === '"') {
|
|
state.inString = quote;
|
|
stream.next();
|
|
return "string";
|
|
}
|
|
|
|
if (stream.match(/\$[A-Za-z_][\w]*/)) {
|
|
return "variableName";
|
|
}
|
|
|
|
if (stream.match(/:[A-Za-z_][\w]*/)) {
|
|
return "typeName";
|
|
}
|
|
|
|
if (stream.match(/[()[\]{},.;]/)) {
|
|
return "punctuation";
|
|
}
|
|
|
|
if (stream.match(/[<>!=~|&+\-/*%^]+/)) {
|
|
return "operator";
|
|
}
|
|
|
|
if (stream.match(/\d+(?:\.\d+)?/)) {
|
|
return "number";
|
|
}
|
|
|
|
if (stream.match(/[A-Za-z_][\w]*/)) {
|
|
const currentValue = stream.current();
|
|
const normalizedValue = currentValue.toLowerCase();
|
|
|
|
if (OPEN_CYPHER_KEYWORDS.has(normalizedValue)) {
|
|
return "keyword";
|
|
}
|
|
|
|
if (
|
|
OPEN_CYPHER_FUNCTIONS.has(normalizedValue) &&
|
|
stream.match(/\s*(?=\()/, false)
|
|
) {
|
|
return "function";
|
|
}
|
|
|
|
return "variableName";
|
|
}
|
|
|
|
stream.next();
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const shellLanguage = StreamLanguage.define<ShellParserState>({
|
|
startState() {
|
|
return {
|
|
inString: null,
|
|
};
|
|
},
|
|
token(stream, state) {
|
|
if (state.inString) {
|
|
let escaped = false;
|
|
|
|
while (!stream.eol()) {
|
|
const next = stream.next();
|
|
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (next === "\\" && state.inString === '"') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (next === state.inString) {
|
|
state.inString = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (stream.eol()) {
|
|
state.inString = null;
|
|
}
|
|
|
|
return "string";
|
|
}
|
|
|
|
if (stream.eatSpace()) {
|
|
return null;
|
|
}
|
|
|
|
if (stream.peek() === "#") {
|
|
stream.skipToEnd();
|
|
return "comment";
|
|
}
|
|
|
|
if (stream.match(/\$\([^)]+\)/)) {
|
|
return "variableName";
|
|
}
|
|
|
|
if (stream.match(/\$\{[A-Za-z_][\w]*\}/)) {
|
|
return "variableName";
|
|
}
|
|
|
|
if (stream.match(/\$[A-Za-z_][\w]*/)) {
|
|
return "variableName";
|
|
}
|
|
|
|
const quote = stream.peek();
|
|
if (quote === "'" || quote === '"') {
|
|
state.inString = quote;
|
|
stream.next();
|
|
return "string";
|
|
}
|
|
|
|
if (stream.match(/--[A-Za-z0-9][\w-]*/)) {
|
|
return "operator";
|
|
}
|
|
|
|
if (stream.match(/-[A-Za-z0-9]+/)) {
|
|
return "operator";
|
|
}
|
|
|
|
if (stream.match(/\|\||&&|>>|[|><;]/)) {
|
|
return "operator";
|
|
}
|
|
|
|
if (stream.match(/\d+(?:\.\d+)?/)) {
|
|
return "number";
|
|
}
|
|
|
|
if (stream.match(/[A-Za-z_][\w-]*/)) {
|
|
const currentValue = stream.current();
|
|
const normalizedValue = currentValue.toLowerCase();
|
|
|
|
if (SHELL_KEYWORDS.has(normalizedValue)) {
|
|
return "keyword";
|
|
}
|
|
|
|
if (SHELL_COMMANDS.has(normalizedValue)) {
|
|
return "function";
|
|
}
|
|
|
|
return "variableName";
|
|
}
|
|
|
|
stream.next();
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const hclLanguage = StreamLanguage.define<HclParserState>({
|
|
startState() {
|
|
return {
|
|
inBlockComment: false,
|
|
inString: null,
|
|
expectBlockType: false,
|
|
heredocTerminator: null,
|
|
};
|
|
},
|
|
token(stream, state) {
|
|
if (state.heredocTerminator) {
|
|
// Match the closing terminator on its own line, including indented <<-EOF forms.
|
|
if (
|
|
stream.sol() &&
|
|
stream.match(new RegExp(`^\\s*${state.heredocTerminator}\\s*$`))
|
|
) {
|
|
state.heredocTerminator = null;
|
|
return "keyword";
|
|
}
|
|
|
|
stream.skipToEnd();
|
|
return "string";
|
|
}
|
|
|
|
if (state.inBlockComment) {
|
|
while (!stream.eol()) {
|
|
if (stream.match("*/")) {
|
|
state.inBlockComment = false;
|
|
break;
|
|
}
|
|
|
|
stream.next();
|
|
}
|
|
|
|
return "comment";
|
|
}
|
|
|
|
if (state.inString) {
|
|
let escaped = false;
|
|
|
|
while (!stream.eol()) {
|
|
const next = stream.next();
|
|
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (next === "\\") {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (next === state.inString) {
|
|
state.inString = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (stream.eol()) {
|
|
state.inString = null;
|
|
}
|
|
|
|
return "string";
|
|
}
|
|
|
|
if (stream.eatSpace()) {
|
|
return null;
|
|
}
|
|
|
|
if (stream.match("#") || stream.match("//")) {
|
|
stream.skipToEnd();
|
|
return "comment";
|
|
}
|
|
|
|
if (stream.match("/*")) {
|
|
state.inBlockComment = true;
|
|
return "comment";
|
|
}
|
|
|
|
if (stream.match(/\$\{[^}]+\}/)) {
|
|
return "variableName";
|
|
}
|
|
|
|
if (stream.peek() === '"') {
|
|
if (state.expectBlockType) {
|
|
state.expectBlockType = false;
|
|
stream.next(); // opening "
|
|
while (!stream.eol()) {
|
|
const ch = stream.next();
|
|
if (ch === "\\") {
|
|
stream.next(); // skip escaped char
|
|
} else if (ch === '"') {
|
|
break;
|
|
}
|
|
}
|
|
return "typeName";
|
|
}
|
|
|
|
state.inString = '"';
|
|
stream.next();
|
|
return "string";
|
|
}
|
|
|
|
if (stream.match(/[{}\[\]()]/)) {
|
|
return "punctuation";
|
|
}
|
|
|
|
// Heredoc (<<EOF, <<-EOF) — must be before generic operator matcher
|
|
if (stream.match(/<<-?([A-Za-z_][\w]*)/)) {
|
|
state.heredocTerminator = stream.current().replace(/^<<-?/, "");
|
|
return "keyword";
|
|
}
|
|
|
|
if (stream.match(/=>|\.\.\.|==|!=|>=|<=|[=><?:]/)) {
|
|
return "operator";
|
|
}
|
|
|
|
if (stream.match(/\b(?:true|false)\b/)) {
|
|
return "keyword";
|
|
}
|
|
|
|
if (stream.match(/\d+(?:\.\d+)?/)) {
|
|
return "number";
|
|
}
|
|
|
|
if (stream.match(/[A-Za-z_][\w-]*/)) {
|
|
const currentValue = stream.current();
|
|
const normalizedValue = currentValue.toLowerCase();
|
|
|
|
if (HCL_KEYWORDS.has(normalizedValue)) {
|
|
state.expectBlockType =
|
|
normalizedValue === "resource" || normalizedValue === "data";
|
|
return "keyword";
|
|
}
|
|
|
|
if (
|
|
HCL_FUNCTIONS.has(normalizedValue) &&
|
|
stream.match(/\s*(?=\()/, false)
|
|
) {
|
|
return "function";
|
|
}
|
|
|
|
if (state.expectBlockType) {
|
|
state.expectBlockType = false;
|
|
return "typeName";
|
|
}
|
|
|
|
if (stream.match(/\s*(?==)/, false)) {
|
|
return "propertyName";
|
|
}
|
|
|
|
return "variableName";
|
|
}
|
|
|
|
stream.next();
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const yamlLanguage = StreamLanguage.define<YamlParserState>({
|
|
startState() {
|
|
return {
|
|
inString: null,
|
|
inBlockScalar: false,
|
|
blockScalarIndent: 0,
|
|
};
|
|
},
|
|
token(stream, state) {
|
|
// Block scalar continuation (| or > multiline strings)
|
|
if (state.inBlockScalar) {
|
|
// Blank lines are always part of a block scalar.
|
|
if (stream.match(/^\s*$/)) {
|
|
stream.skipToEnd();
|
|
return "string";
|
|
}
|
|
|
|
const indent = stream.indentation();
|
|
|
|
if (indent > state.blockScalarIndent) {
|
|
stream.skipToEnd();
|
|
return "string";
|
|
}
|
|
|
|
state.inBlockScalar = false;
|
|
state.blockScalarIndent = 0;
|
|
}
|
|
|
|
// Continue quoted strings across tokens
|
|
if (state.inString) {
|
|
let escaped = false;
|
|
|
|
while (!stream.eol()) {
|
|
const next = stream.next();
|
|
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (next === "\\" && state.inString === '"') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (next === state.inString) {
|
|
state.inString = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return "string";
|
|
}
|
|
|
|
if (stream.eatSpace()) {
|
|
return null;
|
|
}
|
|
|
|
// Comments
|
|
if (stream.peek() === "#") {
|
|
stream.skipToEnd();
|
|
return "comment";
|
|
}
|
|
|
|
// Document markers
|
|
if (stream.sol() && (stream.match("---") || stream.match("..."))) {
|
|
return "keyword";
|
|
}
|
|
|
|
// Anchors & aliases
|
|
if (stream.match(/[&*][A-Za-z_][\w]*/)) {
|
|
return "variableName";
|
|
}
|
|
|
|
// CloudFormation intrinsic tags (!Ref, !Sub, !GetAtt, etc.)
|
|
if (stream.match(/![A-Za-z][A-Za-z0-9]*/)) {
|
|
return "typeName";
|
|
}
|
|
|
|
// Tags (!!str, !!map, etc.)
|
|
if (stream.match(/!![A-Za-z]+/)) {
|
|
return "typeName";
|
|
}
|
|
|
|
// Quoted strings
|
|
const quote = stream.peek();
|
|
if (quote === "'" || quote === '"') {
|
|
state.inString = quote;
|
|
stream.next();
|
|
return "string";
|
|
}
|
|
|
|
// Block scalar indicators (| or >)
|
|
if (
|
|
stream.sol() === false &&
|
|
(stream.peek() === "|" || stream.peek() === ">")
|
|
) {
|
|
const prevChar = stream.string.charAt(stream.pos - 1);
|
|
|
|
if (prevChar === " " || prevChar === ":") {
|
|
stream.next();
|
|
// Eat optional modifiers like |-, |+, |2
|
|
stream.match(/[-+]?\d?/);
|
|
state.inBlockScalar = true;
|
|
state.blockScalarIndent = stream.indentation();
|
|
return "operator";
|
|
}
|
|
}
|
|
|
|
// List item marker
|
|
if (stream.match(/^-(?=\s)/)) {
|
|
return "punctuation";
|
|
}
|
|
|
|
// Key: value pattern — supports CloudFormation long-form intrinsics (Fn::Sub:)
|
|
if (stream.match(/[A-Za-z_][\w./-]*(?:::[A-Za-z_][\w]*)*(?=\s*:(?!:))/)) {
|
|
return "propertyName";
|
|
}
|
|
|
|
// Booleans & null (YAML spec values)
|
|
if (stream.match(/\b(?:true|false|yes|no|on|off|null)\b/i)) {
|
|
return "keyword";
|
|
}
|
|
|
|
// Numbers (integers, floats, hex, octal)
|
|
if (
|
|
stream.match(
|
|
/^[-+]?(?:0x[0-9a-fA-F]+|0o[0-7]+|0b[01]+|\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)/,
|
|
)
|
|
) {
|
|
return "number";
|
|
}
|
|
|
|
// Colon separator
|
|
if (stream.match(":")) {
|
|
return "punctuation";
|
|
}
|
|
|
|
// Braces/brackets (flow style)
|
|
if (stream.match(/[{}\[\],]/)) {
|
|
return "punctuation";
|
|
}
|
|
|
|
// Tilde (null alias)
|
|
if (stream.match("~")) {
|
|
return "keyword";
|
|
}
|
|
|
|
// Unquoted strings / values — consume word
|
|
if (stream.match(/[^\s#:,\[\]{}]+/)) {
|
|
return "string";
|
|
}
|
|
|
|
stream.next();
|
|
return null;
|
|
},
|
|
});
|
|
|
|
function readBicepStringSegment(
|
|
stream: StringStream,
|
|
includeOpeningQuote = false,
|
|
) {
|
|
if (includeOpeningQuote) {
|
|
stream.next();
|
|
}
|
|
|
|
while (!stream.eol()) {
|
|
if (stream.match("${")) {
|
|
stream.backUp(2);
|
|
break;
|
|
}
|
|
|
|
const next = stream.next();
|
|
|
|
if (next === "'" && stream.peek() === "'") {
|
|
stream.next();
|
|
continue;
|
|
}
|
|
|
|
if (next === "'") {
|
|
stream.backUp(1);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const bicepLanguage = StreamLanguage.define<BicepParserState>({
|
|
startState() {
|
|
return {
|
|
inBlockComment: false,
|
|
inString: null,
|
|
expectResourceType: false,
|
|
};
|
|
},
|
|
token(stream, state) {
|
|
if (state.inBlockComment) {
|
|
while (!stream.eol()) {
|
|
if (stream.match("*/")) {
|
|
state.inBlockComment = false;
|
|
break;
|
|
}
|
|
|
|
stream.next();
|
|
}
|
|
|
|
return "comment";
|
|
}
|
|
|
|
if (state.inString) {
|
|
if (stream.match("${")) {
|
|
let depth = 1;
|
|
|
|
while (!stream.eol() && depth > 0) {
|
|
const next = stream.next();
|
|
|
|
if (next === "{") {
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
|
|
if (next === "}") {
|
|
depth -= 1;
|
|
}
|
|
}
|
|
|
|
return "variableName";
|
|
}
|
|
|
|
readBicepStringSegment(stream);
|
|
|
|
if (stream.peek() !== "'") {
|
|
return "string";
|
|
}
|
|
|
|
if (stream.peek() === "'") {
|
|
state.inString = null;
|
|
stream.next();
|
|
}
|
|
|
|
return "string";
|
|
}
|
|
|
|
if (stream.eatSpace()) {
|
|
return null;
|
|
}
|
|
|
|
if (stream.match("//")) {
|
|
stream.skipToEnd();
|
|
return "comment";
|
|
}
|
|
|
|
if (stream.match("/*")) {
|
|
state.inBlockComment = true;
|
|
return "comment";
|
|
}
|
|
|
|
if (stream.match(/@[A-Za-z_][\w]*/)) {
|
|
const decorator = stream.current().slice(1);
|
|
|
|
if (BICEP_DECORATORS.has(decorator)) {
|
|
return "keyword";
|
|
}
|
|
|
|
return "keyword";
|
|
}
|
|
|
|
if (stream.peek() === "'") {
|
|
if (state.expectResourceType) {
|
|
state.expectResourceType = false;
|
|
// Consume the full quoted resource type including both quotes
|
|
stream.next(); // opening '
|
|
while (!stream.eol()) {
|
|
const ch = stream.next();
|
|
if (ch === "'" && stream.peek() === "'") {
|
|
stream.next(); // escaped ''
|
|
continue;
|
|
}
|
|
if (ch === "'") {
|
|
break; // closing '
|
|
}
|
|
}
|
|
return "typeName";
|
|
}
|
|
|
|
stream.next(); // consume opening '
|
|
state.inString = "'";
|
|
return "string";
|
|
}
|
|
|
|
if (stream.match(/[A-Za-z_][\w-]*(?=\s*:)/)) {
|
|
return "propertyName";
|
|
}
|
|
|
|
if (stream.match(/\?\?|==|!=|>=|<=|=|>|<|\?|:|!/)) {
|
|
return "operator";
|
|
}
|
|
|
|
if (stream.match(/[{}\[\]()]/)) {
|
|
return "punctuation";
|
|
}
|
|
|
|
if (stream.match(/-?\d+(?:\.\d+)?/)) {
|
|
return "number";
|
|
}
|
|
|
|
if (stream.match(/[A-Za-z_][\w]*/)) {
|
|
const currentValue = stream.current();
|
|
|
|
if (BICEP_KEYWORDS.has(currentValue)) {
|
|
state.expectResourceType =
|
|
currentValue === "resource" || currentValue === "module";
|
|
return "keyword";
|
|
}
|
|
|
|
if (
|
|
BICEP_FUNCTIONS.has(currentValue) &&
|
|
stream.match(/\s*(?=\()/, false)
|
|
) {
|
|
return "function";
|
|
}
|
|
|
|
return "variableName";
|
|
}
|
|
|
|
stream.next();
|
|
return null;
|
|
},
|
|
});
|
|
|
|
const lightHighlightStyle = HighlightStyle.define([
|
|
{ tag: tags.keyword, color: "#0550ae", fontWeight: "600" },
|
|
{ tag: tags.string, color: "#0a3069" },
|
|
{ tag: tags.number, color: "#8250df" },
|
|
{ tag: [tags.typeName, tags.className], color: "#953800" },
|
|
{ tag: [tags.variableName, tags.propertyName], color: "#24292f" },
|
|
{ tag: tags.function(tags.variableName), color: "#8250df" },
|
|
{ tag: tags.operator, color: "#57606a" },
|
|
{ tag: tags.comment, color: "#6e7781", fontStyle: "italic" },
|
|
{ tag: tags.punctuation, color: "#57606a" },
|
|
]);
|
|
|
|
const darkHighlightStyle = HighlightStyle.define([
|
|
{ tag: tags.keyword, color: "#79c0ff", fontWeight: "600" },
|
|
{ tag: tags.string, color: "#a5d6ff" },
|
|
{ tag: tags.number, color: "#d2a8ff" },
|
|
{ tag: [tags.typeName, tags.className], color: "#ffa657" },
|
|
{ tag: [tags.variableName, tags.propertyName], color: "#e6edf3" },
|
|
{ tag: tags.function(tags.variableName), color: "#d2a8ff" },
|
|
{ tag: tags.operator, color: "#8b949e" },
|
|
{ tag: tags.comment, color: "#8b949e", fontStyle: "italic" },
|
|
{ tag: tags.punctuation, color: "#8b949e" },
|
|
]);
|
|
|
|
const MONO_FONT =
|
|
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace';
|
|
|
|
const LIGHT_SELECTION_BG = "rgba(9, 105, 218, 0.18)";
|
|
const DARK_SELECTION_BG = "rgba(121, 192, 255, 0.18)";
|
|
|
|
function createEditorTheme({
|
|
isDarkMode,
|
|
minHeight,
|
|
fill,
|
|
}: {
|
|
isDarkMode: boolean;
|
|
minHeight: number;
|
|
fill?: boolean;
|
|
}) {
|
|
return EditorView.theme(
|
|
{
|
|
"&": {
|
|
backgroundColor: "transparent",
|
|
color: "var(--text-neutral-primary)",
|
|
fontFamily: MONO_FONT,
|
|
fontSize: "12px",
|
|
// When filling, the editor takes the full height of its (bounded)
|
|
// wrapper so the scroller below can scroll instead of growing.
|
|
...(fill && { height: "100%" }),
|
|
},
|
|
"&.cm-focused": {
|
|
outline: "none",
|
|
},
|
|
".cm-scroller": {
|
|
// A fixed min-height would force the editor to overflow a smaller
|
|
// container; when filling we let flexbox size it instead.
|
|
...(fill ? {} : { minHeight: `${minHeight}px` }),
|
|
overflow: "auto",
|
|
fontFamily: MONO_FONT,
|
|
lineHeight: "1.5rem",
|
|
},
|
|
".cm-content": {
|
|
padding: "16px",
|
|
caretColor: "var(--text-neutral-primary)",
|
|
},
|
|
".cm-line": {
|
|
padding: "0 0 0 8px",
|
|
},
|
|
".cm-gutters": {
|
|
backgroundColor: "var(--bg-neutral-secondary)",
|
|
color: "var(--text-neutral-tertiary)",
|
|
borderRight: "1px solid var(--border-neutral-secondary)",
|
|
minWidth: "44px",
|
|
},
|
|
".cm-lineNumbers .cm-gutterElement": {
|
|
padding: "0 10px 0 12px",
|
|
},
|
|
".cm-activeLineGutter": {
|
|
backgroundColor: "var(--bg-neutral-secondary)",
|
|
color: "var(--text-neutral-secondary)",
|
|
},
|
|
".cm-activeLine": {
|
|
backgroundColor: "transparent",
|
|
},
|
|
".cm-cursor, .cm-dropCursor": {
|
|
borderLeftColor: "var(--text-neutral-primary)",
|
|
},
|
|
".cm-placeholder": {
|
|
color: "var(--text-neutral-tertiary)",
|
|
},
|
|
".cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection":
|
|
{
|
|
backgroundColor: isDarkMode ? DARK_SELECTION_BG : LIGHT_SELECTION_BG,
|
|
},
|
|
},
|
|
{ dark: isDarkMode },
|
|
);
|
|
}
|
|
|
|
interface QueryCodeEditorProps
|
|
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
ariaLabel: string;
|
|
visibleLabel?: string | null;
|
|
language?: QueryEditorLanguage;
|
|
value: string;
|
|
copyValue?: string;
|
|
placeholder?: string;
|
|
invalid?: boolean;
|
|
requirementBadge?: string;
|
|
editable?: boolean;
|
|
minHeight?: number;
|
|
/**
|
|
* When true the editor fills the height of its parent (which must be a
|
|
* bounded flex container) and scrolls internally instead of growing with
|
|
* its content.
|
|
*/
|
|
fill?: boolean;
|
|
showCopyButton?: boolean;
|
|
showLineNumbers?: boolean;
|
|
onChange: (value: string) => void;
|
|
onBlur?: () => void;
|
|
}
|
|
|
|
export const QueryCodeEditor = ({
|
|
id,
|
|
className,
|
|
ariaLabel,
|
|
visibleLabel = ariaLabel,
|
|
language = QUERY_EDITOR_LANGUAGE.OPEN_CYPHER,
|
|
value,
|
|
copyValue,
|
|
placeholder,
|
|
invalid = false,
|
|
requirementBadge,
|
|
editable = true,
|
|
minHeight = 320,
|
|
fill = false,
|
|
showCopyButton = false,
|
|
showLineNumbers = true,
|
|
onChange,
|
|
onBlur,
|
|
...props
|
|
}: QueryCodeEditorProps) => {
|
|
const { resolvedTheme } = useTheme();
|
|
const [copied, setCopied] = useState(false);
|
|
const isDarkMode = resolvedTheme === "dark";
|
|
const editorTheme = createEditorTheme({ isDarkMode, minHeight, fill });
|
|
const editorHighlightStyle = isDarkMode
|
|
? darkHighlightStyle
|
|
: lightHighlightStyle;
|
|
|
|
const extensions = [
|
|
EditorView.lineWrapping,
|
|
codeEditorPlaceholder(placeholder ?? ""),
|
|
EditorView.contentAttributes.of({
|
|
id: id ?? "",
|
|
"aria-label": ariaLabel,
|
|
"aria-invalid": invalid ? "true" : "false",
|
|
"aria-readonly": editable ? "false" : "true",
|
|
}),
|
|
EditorView.editorAttributes.of({
|
|
class: cn("minimal-scrollbar", !editable && "cursor-text"),
|
|
}),
|
|
EditorView.domEventHandlers({
|
|
blur: () => {
|
|
onBlur?.();
|
|
return false;
|
|
},
|
|
}),
|
|
];
|
|
|
|
if (!editable) {
|
|
extensions.push(EditorState.readOnly.of(true));
|
|
}
|
|
|
|
if (language === QUERY_EDITOR_LANGUAGE.OPEN_CYPHER) {
|
|
extensions.push(
|
|
openCypherLanguage,
|
|
syntaxHighlighting(editorHighlightStyle),
|
|
);
|
|
} else if (language === QUERY_EDITOR_LANGUAGE.SHELL) {
|
|
extensions.push(shellLanguage, syntaxHighlighting(editorHighlightStyle));
|
|
} else if (language === QUERY_EDITOR_LANGUAGE.HCL) {
|
|
extensions.push(hclLanguage, syntaxHighlighting(editorHighlightStyle));
|
|
} else if (language === QUERY_EDITOR_LANGUAGE.JSON) {
|
|
extensions.push(jsonLanguage, syntaxHighlighting(editorHighlightStyle));
|
|
} else if (language === QUERY_EDITOR_LANGUAGE.BICEP) {
|
|
extensions.push(bicepLanguage, syntaxHighlighting(editorHighlightStyle));
|
|
} else if (language === QUERY_EDITOR_LANGUAGE.YAML) {
|
|
extensions.push(yamlLanguage, syntaxHighlighting(editorHighlightStyle));
|
|
}
|
|
|
|
const handleCopy = async () => {
|
|
await navigator.clipboard.writeText(copyValue ?? value);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
data-testid="query-code-editor"
|
|
data-language={language}
|
|
data-show-line-numbers={String(showLineNumbers)}
|
|
className={cn(
|
|
"border-border-neutral-secondary bg-bg-neutral-primary overflow-hidden rounded-xl border",
|
|
fill && "flex min-h-0 flex-1 flex-col",
|
|
invalid && "border-border-error-primary",
|
|
className,
|
|
)}
|
|
{...props}
|
|
>
|
|
<div className="border-border-neutral-secondary bg-bg-neutral-secondary flex shrink-0 items-center justify-between border-b px-4 py-2">
|
|
{visibleLabel ? (
|
|
<span className="text-text-neutral-secondary text-xs font-medium">
|
|
{visibleLabel}
|
|
</span>
|
|
) : (
|
|
<span aria-hidden="true" />
|
|
)}
|
|
<div className="flex items-center gap-2">
|
|
{requirementBadge ? (
|
|
<Badge
|
|
variant="tag"
|
|
className="text-text-neutral-secondary border-border-neutral-secondary bg-bg-neutral-primary px-2 py-0 text-[11px]"
|
|
>
|
|
{requirementBadge}
|
|
</Badge>
|
|
) : null}
|
|
{showCopyButton ? (
|
|
<button
|
|
type="button"
|
|
aria-label={`Copy ${ariaLabel}`}
|
|
onClick={() => void handleCopy()}
|
|
className="text-text-neutral-secondary hover:text-text-neutral-primary shrink-0 cursor-pointer transition-colors"
|
|
>
|
|
{copied ? (
|
|
<Check className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Copy className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<CodeMirror
|
|
value={value}
|
|
theme={editorTheme}
|
|
className={cn(fill && "min-h-0 flex-1")}
|
|
basicSetup={{
|
|
foldGutter: false,
|
|
highlightActiveLine: false,
|
|
highlightActiveLineGutter: showLineNumbers,
|
|
searchKeymap: false,
|
|
lineNumbers: showLineNumbers,
|
|
}}
|
|
editable={editable}
|
|
onChange={onChange}
|
|
extensions={extensions}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|