diff --git a/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/data/el-schema.json b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/data/el-schema.json new file mode 100644 index 000000000..16750be0f --- /dev/null +++ b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/data/el-schema.json @@ -0,0 +1,428 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "SSLPrincipal": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "businessCategory": { + "type": "string" + }, + "c": { + "type": "string" + }, + "cn": { + "type": "string" + }, + "countryOfCitizenship": { + "type": "string" + }, + "countryOfResidence": { + "type": "string" + }, + "dateOfBirth": { + "type": "string" + }, + "dc": { + "type": "string" + }, + "description": { + "type": "string" + }, + "dmdName": { + "type": "string" + }, + "dn": { + "type": "string" + }, + "dnQualifier": { + "type": "string" + }, + "e": { + "type": "string" + }, + "emailAddress": { + "type": "string" + }, + "gender": { + "type": "string" + }, + "generation": { + "type": "string" + }, + "givenname": { + "type": "string" + }, + "initials": { + "type": "string" + }, + "l": { + "type": "string" + }, + "name": { + "type": "string" + }, + "nameAtBirth": { + "type": "string" + }, + "o": { + "type": "string" + }, + "organizationIdentifier": { + "type": "string" + }, + "ou": { + "type": "string" + }, + "placeOfBirth": { + "type": "string" + }, + "postalAddress": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "pseudonym": { + "type": "string" + }, + "role": { + "type": "string" + }, + "serialnumber": { + "type": "string" + }, + "st": { + "type": "string" + }, + "street": { + "type": "string" + }, + "surname": { + "type": "string" + }, + "t": { + "type": "string" + }, + "telephoneNumber": { + "type": "string" + }, + "uid": { + "type": "string" + }, + "uniqueIdentifier": { + "type": "string" + }, + "unstructuredAddress": { + "type": "string" + } + } + }, + "HttpHeaders": { + "type": "object", + "additionalProperties": { + "type": "string", + "enum": [ + "Accept", + "Accept-Charset", + "Accept-Encoding", + "Accept-Language", + "Accept-Ranges", + "Access-Control-Allow-Credentials", + "Access-Control-Allow-Headers", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Origin", + "Access-Control-Expose-Headers", + "Access-Control-Max-Age", + "Access-Control-Request-Headers", + "Access-Control-Request-Method", + "Age", + "Allow", + "Authorization", + "Cache-Control", + "Connection", + "Content-Disposition", + "Content-Encoding", + "Content-ID", + "Content-Language", + "Content-Length", + "Content-Location", + "Content-MD5", + "Content-Range", + "Content-Type", + "Cookie", + "Date", + "ETag", + "Expires", + "Expect", + "Forwarded", + "From", + "Host", + "If-Match", + "If-Modified-Since", + "If-None-Match", + "If-Unmodified-Since", + "Keep-Alive", + "Last-Modified", + "Location", + "Link", + "Max-Forwards", + "MIME-Version", + "Origin", + "Pragma", + "Proxy-Authenticate", + "Proxy-Authorization", + "Proxy-Connection", + "Range", + "Referer", + "Retry-After", + "Server", + "Set-Cookie", + "Set-Cookie2", + "TE", + "Trailer", + "Transfer-Encoding", + "Upgrade", + "User-Agent", + "Vary", + "Via", + "Warning", + "WWW-Authenticate", + "X-Forwarded-For", + "X-Forwarded-Proto", + "X-Forwarded-Server", + "X-Forwarded-Host", + "X-Forwarded-Port", + "X-Forwarded-Prefix" + ] + } + } + }, + "type": "object", + "properties": { + "api": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "version": { + "type": "string" + } + } + }, + "context": { + "type": "object", + "properties": { + "attributes": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "dictionaries": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "node": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "shardingTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "tenant": { + "type": "string" + }, + "version": { + "type": "string" + }, + "zone": { + "type": "string" + } + } + }, + "request": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "contextPath": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HttpHeaders" + }, + "host": { + "type": "string" + }, + "id": { + "type": "string" + }, + "jsonContent": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "localAddress": { + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "path": { + "type": "string" + }, + "pathInfo": { + "type": "string" + }, + "pathInfos": { + "type": "array", + "items": { + "type": "string" + } + }, + "pathParams": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "remoteAddress": { + "type": "string" + }, + "scheme": { + "type": "string" + }, + "ssl": { + "type": "object", + "properties": { + "client": { + "$ref": "#/$defs/SSLPrincipal" + }, + "clientHost": { + "type": "string" + }, + "clientPort": { + "type": "integer" + }, + "server": { + "$ref": "#/$defs/SSLPrincipal" + } + } + }, + "timestamp": { + "type": "integer" + }, + "transactionId": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "version": { + "type": "string" + }, + "xmlContent": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "response": { + "type": "object", + "properties": { + "content": { + "type": "string" + }, + "headers": { + "$ref": "#/$defs/HttpHeaders" + }, + "jsonContent": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "status": { + "type": "integer" + }, + "xmlContent": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "subscription": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "type": { + "type": "string", + "enum": ["STANDARD", "PUSH"] + } + } + } + } +} diff --git a/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/gio-monaco-editor.component.ts b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/gio-monaco-editor.component.ts index 33cd7d44b..2fb07e617 100644 --- a/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/gio-monaco-editor.component.ts +++ b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/gio-monaco-editor.component.ts @@ -35,6 +35,8 @@ import { takeUntil } from 'rxjs/operators'; import { GioMonacoEditorConfig, GIO_MONACO_EDITOR_CONFIG } from './models/GioMonacoEditorConfig'; import { GioLanguageJsonService } from './services/gio-language-json.service'; import { GioMonacoEditorService } from './services/gio-monaco-editor.service'; +import { GioLanguageElService } from './services/gio-language-el.service'; +import { JSONSchema } from './models/JSONSchemaAutoComplete'; export type MonacoEditorLanguageConfig = | { @@ -46,6 +48,10 @@ export type MonacoEditorLanguageConfig = } | { language: 'html'; + } + | { + language: 'spel'; + schema?: JSONSchema; }; @Component({ @@ -91,6 +97,7 @@ export class GioMonacoEditorComponent implements ControlValueAccessor, AfterView @Inject(GIO_MONACO_EDITOR_CONFIG) private readonly config: GioMonacoEditorConfig, private readonly monacoEditorService: GioMonacoEditorService, private readonly languageJsonService: GioLanguageJsonService, + private readonly languageSpelService: GioLanguageElService, private readonly changeDetectorRef: ChangeDetectorRef, private readonly ngZone: NgZone, @Optional() @Self() public readonly ngControl: NgControl, @@ -224,6 +231,11 @@ export class GioMonacoEditorComponent implements ControlValueAccessor, AfterView this.languageJsonService.addSchemas(uri, languageConfig.schemas); } break; + case 'spel': + if (languageConfig.schema) { + this.languageSpelService.setSchema(languageConfig.schema); + } + break; default: break; } diff --git a/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/gio-monaco-editor.stories.ts b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/gio-monaco-editor.stories.ts index 9b0baab00..37b7606c0 100644 --- a/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/gio-monaco-editor.stories.ts +++ b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/gio-monaco-editor.stories.ts @@ -20,6 +20,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import GioJsonSchema from '../gio-form-json-schema/model/GioJsonSchema.json'; +import ElSchema from './data/el-schema.json'; import { GioMonacoEditorComponent } from './gio-monaco-editor.component'; import { GioMonacoEditorModule } from './gio-monaco-editor.module'; @@ -95,6 +96,16 @@ export const LanguageJson: StoryObj = { }, }; +export const LanguageEL: StoryObj = { + args: { + languageConfig: { + language: 'spel', + schema: ElSchema, + }, + value: `{#request.headers['x-story'] == 'true'}`, + }, +}; + export const Disabled: StoryObj = { args: { disabled: true, diff --git a/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/models/JSONSchemaAutoComplete.ts b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/models/JSONSchemaAutoComplete.ts new file mode 100644 index 000000000..d17dcc3a3 --- /dev/null +++ b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/models/JSONSchemaAutoComplete.ts @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2024 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export interface PropertySuggestion { + type: string; + name: string; + enum?: string[]; +} + +export interface IndexableSuggestion { + type?: string; + enum?: string[]; + additionalProperties?: IndexableSuggestion; + items?: IndexableSuggestion; +} + +export interface Suggestions { + properties: PropertySuggestion[]; + additionalProperties: IndexableSuggestion; + items: IndexableSuggestion; +} + +export interface JSONSchema { + properties?: JSONSchemaProperties; + $defs?: JSONSchemaProperties; +} + +export interface JSONSchemaProperty { + type?: string; + $ref?: string; + additionalProperties?: JSONSchemaProperty; + items?: JSONSchemaProperty; + properties?: JSONSchemaProperties; +} + +export type JSONSchemaPropertyWithDefs = JSONSchema & JSONSchemaProperty; + +export interface JSONSchemaProperties { + [key: string]: JSONSchemaProperty; +} + +const SCHEMA_REF_BASE = '#/$defs/'; + +export function getKeywords(schema: JSONSchemaPropertyWithDefs, keywords = new Set()): string[] { + const properties = schema.properties || {}; + const defs = schema.$defs || {}; + for (const property of Object.values(defs)) { + if (property.properties) { + getKeywords(property, keywords); + } + } + for (const [name, property] of Object.entries(properties)) { + keywords.add(name); + if (property.properties) { + getKeywords(property, keywords); + } + } + return Array.from(keywords); +} +/** + * Build suggestions for autocompletion based on a JSON schema definition and a property path + * + * @param schema an object model defined as a JSON schema following the 2020-12 specification + * @param path the property path to inspect. If the property path contains '.', then root properties will be returned + */ +export function getSuggestions(schema: JSONSchema, path: string[]): Suggestions { + const propertyNames = [...path]; + const propertyName = propertyNames.shift(); + + if (propertyName === '.') { + return buildSuggestions(schema, schema); + } + + const property = getProperty(schema, propertyName); + if (!property) { + return { + properties: [], + additionalProperties: {}, + items: {}, + }; + } + + const newSchema = mergeSchemaWithProperties(schema, property); + if (propertyNames.length > 0) { + return getSuggestions(newSchema, propertyNames); + } + + return buildSuggestions(newSchema, property); +} + +function resolveReference(ref: string, defs: JSONSchemaProperties = {}): JSONSchemaProperty { + const refName = ref.substring(SCHEMA_REF_BASE.length); + const resolved = defs[refName]; + if (resolved && resolved.$ref) { + return resolveReference(resolved.$ref, defs); + } + return resolved || {}; +} + +function resolveType(property: JSONSchemaProperty, defs?: JSONSchemaProperties): string { + if (property.$ref) { + const resolved = resolveReference(property.$ref, defs); + return resolveType(resolved, defs); + } + if (property.additionalProperties || property.items) { + const stringMap = 'map`; + } + return property.type as string; +} + +function getProperty(schema: JSONSchemaPropertyWithDefs, propertyName = String()): JSONSchemaProperty { + if (schema.properties && schema.properties[propertyName]) { + return schema.properties[propertyName]; + } + + if (schema.$ref && schema.$defs) { + const property = resolveReference(schema.$ref, schema.$defs); + if (property && property.properties) { + return property.properties[propertyName]; + } + } + return {}; +} + +function buildIndexableSuggestion(property: JSONSchemaProperty): Suggestions { + return { + properties: [], + additionalProperties: property.additionalProperties || {}, + items: property.items || {}, + }; +} + +function buildSuggestions(schema: JSONSchemaPropertyWithDefs, property: JSONSchemaProperty): Suggestions { + if (property.$ref && schema.$defs) { + const ref = resolveReference(property.$ref, schema.$defs); + return buildSuggestions(schema, ref); + } + + if (property.additionalProperties || property.items) { + return buildIndexableSuggestion(property); + } + + const properties = property.properties || {}; + return { + properties: mapSuggestions(properties, schema.$defs), + additionalProperties: {}, + items: {}, + }; +} + +function mapSuggestions(properties: JSONSchemaProperties, defs?: JSONSchemaProperties): PropertySuggestion[] { + return Object.entries(properties).map(([name, property]) => { + return { + name, + type: resolveType(property, defs), + }; + }); +} + +function mergeSchemaWithProperties(schema: JSONSchema, property: JSONSchemaProperty): JSONSchemaPropertyWithDefs { + return { ...schema, ...property }; +} diff --git a/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/services/gio-language-el.service.ts b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/services/gio-language-el.service.ts new file mode 100644 index 000000000..ea1a47144 --- /dev/null +++ b/ui-particles-angular/projects/ui-particles-angular/src/lib/gio-monaco-editor/services/gio-language-el.service.ts @@ -0,0 +1,262 @@ +/* + * Copyright (C) 2024 The Gravitee team (http://gravitee.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Injectable, OnDestroy } from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { editor, languages, Position } from 'monaco-editor'; + +import CompletionItem = languages.CompletionItem; + +import type Monaco from 'monaco-editor'; + +import { getKeywords, getSuggestions, IndexableSuggestion, JSONSchema, PropertySuggestion } from '../models/JSONSchemaAutoComplete'; + +import { GioMonacoEditorService } from './gio-monaco-editor.service'; + +export const SPEL_LANG_ID = 'spel'; + +function getPropertyPath(model: editor.ITextModel, position: Position): string[] { + const line = model.getValueInRange({ + startLineNumber: position.lineNumber, + startColumn: 0, + endLineNumber: position.lineNumber, + endColumn: position.column, + }); + + // get last valid el expression (most right hand side of the current line) + const lastExpression = line.split('#').filter(Boolean).pop(); + if (!lastExpression) { + return []; + } + + // get expression left hand side + const lastExpressionLHS = lastExpression.split(/\s+/).filter(Boolean).shift(); + if (!lastExpressionLHS) { + return []; + } + + return ( + lastExpressionLHS + // remove special characters + .replace(/\W/g, ' ') + // normalize spaces + .replace(/\s+/g, ' ') + .trim() + .split(/\s/) + ); +} + +@Injectable({ + providedIn: 'root', +}) +export class GioLanguageElService implements OnDestroy { + private unsubscribe$ = new Subject(); + + private context: { schema: JSONSchema; keywords: string[] } = { + schema: {}, + keywords: [], + }; + + private schema: JSONSchema = {}; + private keywords: string[] = []; + + public ngOnDestroy(): void { + this.unsubscribe$.next(); + this.unsubscribe$.complete(); + } + + constructor(codeEditorService: GioMonacoEditorService) { + codeEditorService.loaded$.pipe(takeUntil(this.unsubscribe$)).subscribe(event => { + this.setup(event.monaco); + }); + } + + public setup(monaco: typeof Monaco): void { + if (!monaco) { + throw new Error('Monaco is not loaded'); + } + + monaco.languages.register({ id: SPEL_LANG_ID }); + + monaco.languages.setLanguageConfiguration(SPEL_LANG_ID, { + brackets: [ + ['{', '}'], + ['(', ')'], + ['[', ']'], + ], + autoClosingPairs: [ + { + open: "'", + close: "'", + }, + { + open: '"', + close: '"', + }, + { + open: '(', + close: ')', + }, + { + open: '{', + close: '}', + }, + { + open: '[', + close: ']', + }, + ], + surroundingPairs: [ + { + open: "'", + close: "'", + }, + { + open: '"', + close: '"', + }, + { + open: '(', + close: ')', + }, + { + open: '{', + close: '}', + }, + ], + }); + + const schema = this.schema; + const keywords = this.keywords; + + monaco.languages.setMonarchTokensProvider(SPEL_LANG_ID, { + keywords, + operators: ['=', '>', '<', '!', '?', ':', '==', '<=', '>=', '!=', '&&', '||', '++', '--', '+', '-', '*', '/', '^', '%'], + + tokenizer: { + root: [ + [ + /@?[a-zA-Z][\w$]*/, + { + cases: { + '@keywords': 'keyword', + '@operators': 'operator', + '@default': 'variable', + }, + }, + ], + + [/["'].*?["']/, 'string'], + [/"([^"\\]|\\.)*$/, 'string.invalid'], + + [/#/, 'number'], + + // eslint-disable-next-line no-useless-escape + [/\d*\.\d+([eE][\-+]?\d+)?/, 'number.float'], + [/0[xX][0-9a-fA-F]+/, 'number.hex'], + [/\d+/, 'number'], + ], + }, + }); + + monaco.languages.registerCompletionItemProvider(SPEL_LANG_ID, { + provideCompletionItems( + model: editor.ITextModel, + position: Position, + context: languages.CompletionContext, + ): languages.ProviderResult { + const word = model.getWordUntilPosition(position); + + const mapPropertyKind = (propertySuggestion: PropertySuggestion): number => { + switch (propertySuggestion.type) { + case 'object': + return monaco.languages.CompletionItemKind.Struct; + case 'string': + return monaco.languages.CompletionItemKind.Text; + default: + return monaco.languages.CompletionItemKind.Field; + } + }; + + const mapPropertySuggestion = (propertySuggestion: PropertySuggestion): CompletionItem => { + return { + label: propertySuggestion.name, + kind: mapPropertyKind(propertySuggestion), + insertText: propertySuggestion.name, + detail: propertySuggestion.type, + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }, + }; + }; + + const mapIndexableSuggestions = (suggestion: IndexableSuggestion): CompletionItem[] => { + return ( + suggestion.enum?.map(name => { + return { + label: name, + kind: monaco.languages.CompletionItemKind.Enum, + insertText: `"${name}"`, + detail: 'enum', + range: { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }, + }; + }) || [] + ); + }; + + if (context.triggerCharacter === '#') { + return { + suggestions: getSuggestions(schema, ['.']).properties.map(mapPropertySuggestion).sort(), + }; + } + + if (context.triggerCharacter === '[') { + const path = getPropertyPath(model, position); + const suggestions = getSuggestions(schema, path); + if (suggestions.additionalProperties && suggestions.additionalProperties.enum) { + return { + suggestions: mapIndexableSuggestions(suggestions.additionalProperties), + }; + } + } + + if (context.triggerCharacter === '.') { + const path = getPropertyPath(model, position); + const suggestions = getSuggestions(schema, path).properties.map(mapPropertySuggestion).sort(); + return { + suggestions, + }; + } + + return { suggestions: [] }; + }, + triggerCharacters: ['#', '.', '['], + }); + } + + public setSchema(schema: JSONSchema): void { + Object.assign(this.schema, schema); + this.keywords.push(...getKeywords(schema)); + } +}