@finos/legend-application-pure-ide
Version:
Legend Pure IDE application core
745 lines (702 loc) • 23.1 kB
text/typescript
/**
* Copyright (c) 2020-present, Goldman Sachs
*
* 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 {
type PureGrammarTextSuggestion,
CODE_EDITOR_LANGUAGE,
isTokenOneOf,
PURE_GRAMMAR_TOKEN,
} from '@finos/legend-code-editor';
import {
ELEMENT_PATH_DELIMITER,
extractElementNameFromPath,
PARSER_SECTION_MARKER,
PURE_ELEMENT_NAME,
PURE_PARSER,
} from '@finos/legend-graph';
import {
guaranteeNonNullable,
isNonNullable,
returnUndefOnError,
} from '@finos/legend-shared';
import {
languages as monacoLanguagesAPI,
type IPosition,
editor as monacoEditorAPI,
} from 'monaco-editor';
import { deserialize } from 'serializr';
import { ConceptType } from '../server/models/ConceptTree.js';
import {
AttributeSuggestion,
ClassSuggestion,
ElementSuggestion,
VariableSuggestion,
} from '../server/models/Suggestion.js';
import type { PureIDEStore } from './PureIDEStore.js';
import {
BLANK_CLASS_SNIPPET,
BLANK_FUNCTION_SNIPPET,
BLANK_MAPPING_SNIPPET,
BLANK_RELATIONAL_DATABASE_SNIPPET,
CLASS_WITH_CONSTRAINT_SNIPPET,
CLASS_WITH_INHERITANCE_SNIPPET,
CLASS_WITH_PROPERTY_SNIPPET,
MAPPING_WITH_ENUMERATION_MAPPING_SNIPPET,
MAPPING_WITH_M2M_CLASS_MAPPING_SNIPPET,
MAPPING_WITH_RELATIONAL_CLASS_MAPPING_SNIPPET,
SIMPLE_ASSOCIATION_SNIPPET,
SIMPLE_ENUMERATION_SNIPPET,
SIMPLE_FUNCTION_SNIPPET,
SIMPLE_PROFILE_SNIPPET,
BLANK_DIAGRAM_SNIPPET,
getCopyrightHeaderSnippet,
} from '../__lib__/LegendPureIDECodeSnippet.js';
// NOTE: these are technically different parsers compared to the ones we have in `Legend Engine` so we will
// not try to reuse the constants from DSL diagram
export const PURE_GRAMMAR_DIAGRAM_PARSER_NAME = 'Diagram';
export const PURE_GRAMMAR_DIAGRAM_ELEMENT_TYPE_LABEL = 'Diagram';
export const collectParserKeywordSuggestions =
(): PureGrammarTextSuggestion[] => [
{
text: PURE_PARSER.PURE,
description: `(core Pure)`,
insertText: PURE_PARSER.PURE,
},
{
text: PURE_PARSER.MAPPING,
description: `(dsl)`,
insertText: PURE_PARSER.MAPPING,
},
{
text: PURE_PARSER.CONNECTION,
description: `(dsl)`,
insertText: PURE_PARSER.CONNECTION,
},
{
text: PURE_PARSER.RUNTIME,
description: `(dsl)`,
insertText: PURE_PARSER.RUNTIME,
},
{
text: PURE_PARSER.RELATIONAL,
description: `(external store)`,
insertText: PURE_PARSER.RELATIONAL,
},
// NOTE: these are technically different parsers compared to the ones we have in `Legend Engine` so we will
// not try to reuse the constants from DSL diagram
{
text: PURE_GRAMMAR_DIAGRAM_PARSER_NAME,
description: `(dsl)`,
insertText: PURE_GRAMMAR_DIAGRAM_ELEMENT_TYPE_LABEL,
},
];
export const collectParserElementSnippetSuggestions = (
parserKeyword: string,
): PureGrammarTextSuggestion[] => {
switch (parserKeyword) {
case PURE_PARSER.PURE: {
return [
// class
{
text: PURE_ELEMENT_NAME.CLASS,
description: '(blank)',
insertText: BLANK_CLASS_SNIPPET,
},
{
text: PURE_ELEMENT_NAME.CLASS,
description: 'with property',
insertText: CLASS_WITH_PROPERTY_SNIPPET,
},
{
text: PURE_ELEMENT_NAME.CLASS,
description: 'with inheritance',
insertText: CLASS_WITH_INHERITANCE_SNIPPET,
},
{
text: PURE_ELEMENT_NAME.CLASS,
description: 'with constraint',
insertText: CLASS_WITH_CONSTRAINT_SNIPPET,
},
// profile
{
text: PURE_ELEMENT_NAME.PROFILE,
insertText: SIMPLE_PROFILE_SNIPPET,
},
// enumeration
{
text: PURE_ELEMENT_NAME.ENUMERATION,
insertText: SIMPLE_ENUMERATION_SNIPPET,
},
// association
{
text: PURE_ELEMENT_NAME.ASSOCIATION,
insertText: SIMPLE_ASSOCIATION_SNIPPET,
},
// function
{
text: PURE_ELEMENT_NAME.FUNCTION,
description: '(blank)',
insertText: BLANK_FUNCTION_SNIPPET,
},
{
text: PURE_ELEMENT_NAME.FUNCTION,
insertText: SIMPLE_FUNCTION_SNIPPET,
},
];
}
case PURE_PARSER.MAPPING: {
return [
{
text: PURE_ELEMENT_NAME.MAPPING,
description: '(blank)',
insertText: BLANK_MAPPING_SNIPPET,
},
{
text: PURE_ELEMENT_NAME.MAPPING,
description: 'with model-to-model mapping',
insertText: MAPPING_WITH_M2M_CLASS_MAPPING_SNIPPET,
},
{
text: PURE_ELEMENT_NAME.MAPPING,
description: 'with relational mapping',
insertText: MAPPING_WITH_RELATIONAL_CLASS_MAPPING_SNIPPET,
},
{
text: PURE_ELEMENT_NAME.MAPPING,
description: 'with enumeration mapping',
insertText: MAPPING_WITH_ENUMERATION_MAPPING_SNIPPET,
},
];
}
case PURE_PARSER.RELATIONAL: {
return [
{
text: PURE_ELEMENT_NAME.DATABASE,
description: '(blank)',
insertText: BLANK_RELATIONAL_DATABASE_SNIPPET,
},
];
}
case PURE_GRAMMAR_DIAGRAM_PARSER_NAME: {
return [
{
text: PURE_GRAMMAR_DIAGRAM_ELEMENT_TYPE_LABEL,
description: '(blank)',
insertText: BLANK_DIAGRAM_SNIPPET,
},
];
}
default: {
return [];
}
}
};
export const collectExtraInlineSnippetSuggestions =
(): PureGrammarTextSuggestion[] => [
{
text: 'print',
description: '(io)',
insertText: `print(\${1:})`,
},
{
text: 'println',
description: '(io)',
insertText: `println(\${1:})`,
},
{
text: 'NULL',
description: '(nullish value)',
insertText: `[]`,
},
];
export const getCopyrightHeaderSuggestions = (
position: IPosition,
model: monacoEditorAPI.ITextModel,
): monacoLanguagesAPI.CompletionItem[] => {
const results: monacoLanguagesAPI.CompletionItem[] = [];
const textUntilPosition = model
.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
})
.trimStart();
if (['', '/'].includes(textUntilPosition)) {
results.push({
label: {
label: `/copyright`,
description: `(copyright header)`,
},
kind: monacoLanguagesAPI.CompletionItemKind.Snippet,
insertTextRules:
monacoLanguagesAPI.CompletionItemInsertTextRule.InsertAsSnippet,
insertText: getCopyrightHeaderSnippet(),
// NOTE: only show this suggestion when the cursor is on the first line of the file
range: {
startLineNumber: 1,
startColumn: 1,
endLineNumber: 1,
endColumn: 1000,
},
} as monacoLanguagesAPI.CompletionItem);
}
return results;
};
const constructorClassSuggestionToCompletionItem = (suggestion: {
pureId: string;
pureName: string;
requiredClassProperties: string[];
}): monacoLanguagesAPI.CompletionItem =>
({
label: {
label: suggestion.pureName,
description: suggestion.pureId,
},
kind: monacoLanguagesAPI.CompletionItemKind.Class,
filterText: suggestion.pureName,
insertTextRules:
monacoLanguagesAPI.CompletionItemInsertTextRule.InsertAsSnippet,
insertText: `${suggestion.pureName}(${suggestion.requiredClassProperties
.map((property, idx) => `${property}=\${${idx + 1}:}`)
.join(',')})`,
}) as monacoLanguagesAPI.CompletionItem;
const createFunctionInvocationSnippet = (
functionName: string,
functionPureId: string,
useArrowForm: boolean,
): string => {
const fn = extractElementNameFromPath(functionPureId);
const functionType = returnUndefOnError(() =>
fn.substring(fn.indexOf('_'), fn.length - 1),
);
// NOTE: remove the return type and if use arrow function form, remove the first parameter
const parameters = functionType?.split('__') ?? [];
parameters.pop();
if (useArrowForm) {
parameters.shift();
}
return `${functionName}(${parameters
.map((param, idx) => `\${${idx + 1}:}`)
.join(',')})`;
};
const elementSuggestionToCompletionItem = (
suggestion: ElementSuggestion,
options?: {
preferArrowFunctionForm?: boolean;
},
): monacoLanguagesAPI.CompletionItem => {
const type = suggestion.pureType;
const insertText =
type === ConceptType.FUNCTION || type === ConceptType.NATIVE_FUNCTION
? createFunctionInvocationSnippet(
suggestion.pureName,
suggestion.pureId,
Boolean(options?.preferArrowFunctionForm),
)
: suggestion.pureName;
const kind =
type === ConceptType.PACKAGE
? monacoLanguagesAPI.CompletionItemKind.Folder
: type === ConceptType.CLASS
? monacoLanguagesAPI.CompletionItemKind.Class
: type === ConceptType.FUNCTION
? monacoLanguagesAPI.CompletionItemKind.Function
: type === ConceptType.ENUMERATION
? monacoLanguagesAPI.CompletionItemKind.Enum
: type === ConceptType.PROFILE
? monacoLanguagesAPI.CompletionItemKind.Module
: type === ConceptType.ASSOCIATION
? monacoLanguagesAPI.CompletionItemKind.Interface
: monacoLanguagesAPI.CompletionItemKind.Value;
return {
label: {
label: suggestion.pureName,
description: suggestion.text,
},
kind,
filterText: suggestion.pureName,
insertTextRules:
monacoLanguagesAPI.CompletionItemInsertTextRule.InsertAsSnippet,
insertText,
// attempt to push package suggestions to the bottom of the list
sortText:
type === ConceptType.PACKAGE
? `zzzz_${suggestion.text}`
: suggestion.text,
} as monacoLanguagesAPI.CompletionItem;
};
const INCOMPLETE_PATH_PATTERN = /(?<incompletePath>(?:\w[\w$]*::)+$)/;
const ARROW_FUNCTION_USAGE_WITH_INCOMPLETE_PATH_PATTERN =
/->\s*(?:\w[\w$]*::)+$/;
const CONSTRUCTOR_USAGE_WITH_INCOMPLETE_PATH_PATTERN = /\^\s*(?:\w[\w$]*::)+$/;
export const getIncompletePathSuggestions = async (
position: IPosition,
model: monacoEditorAPI.ITextModel,
ideStore: PureIDEStore,
): Promise<monacoLanguagesAPI.CompletionItem[]> => {
const incompletePathMatch = model
.getLineContent(position.lineNumber)
.substring(0, position.column - 1)
.match(INCOMPLETE_PATH_PATTERN);
if (incompletePathMatch?.groups?.incompletePath) {
const isUsingArrowFunction = Boolean(
model
.getLineContent(position.lineNumber)
.substring(0, position.column - 1)
.match(ARROW_FUNCTION_USAGE_WITH_INCOMPLETE_PATH_PATTERN),
);
const isUsingConstructor = Boolean(
model
.getLineContent(position.lineNumber)
.substring(0, position.column - 1)
.match(CONSTRUCTOR_USAGE_WITH_INCOMPLETE_PATH_PATTERN),
);
let suggestions: ElementSuggestion[] = [];
try {
suggestions = (
await ideStore.client.getSuggestionsForIncompletePath(
incompletePathMatch.groups.incompletePath.substring(
0,
incompletePathMatch.groups.incompletePath.length -
ELEMENT_PATH_DELIMITER.length,
),
isUsingConstructor
? [ConceptType.CLASS]
: isUsingArrowFunction
? [ConceptType.FUNCTION, ConceptType.NATIVE_FUNCTION]
: [],
)
).map((child) => deserialize(ElementSuggestion, child));
} catch {
// do nothing: provide no suggestions when error ocurred
}
return suggestions.map((suggestion) =>
isUsingConstructor
? constructorClassSuggestionToCompletionItem(suggestion)
: elementSuggestionToCompletionItem(suggestion, {
preferArrowFunctionForm: isUsingArrowFunction,
}),
);
}
return [];
};
const IMPORT_STATEMENT_PATTERN =
/^\s*import\s+(?:(?<importPath>(?:(?:\w[\w$]*)::)*\w[\w$]*)::*)/;
const getCurrentSectionImportPaths = (
position: IPosition,
model: monacoEditorAPI.ITextModel,
): string[] => {
const textUntilPosition = model.getValueInRange({
startLineNumber: 1,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
const lines =
// NOTE: since `###Pure` is implicitly considered as the first section, we prepend it to the text
`${PARSER_SECTION_MARKER}${PURE_PARSER.PURE}\n${textUntilPosition}`.split(
'\n',
);
return lines
.slice(
lines
.map((line) => line.startsWith(PARSER_SECTION_MARKER))
.lastIndexOf(true),
)
.map((line) => line.match(IMPORT_STATEMENT_PATTERN)?.groups?.importPath)
.filter(isNonNullable);
};
const ARROW_FUNCTION_USAGE_PATTERN = /->\s*(?:\w[\w$]*)?$/;
const CONSTRUCTOR_USAGE_PATTERN = /\^\s*(?:\w[\w$]*)?$/;
export const getIdentifierSuggestions = async (
position: IPosition,
model: monacoEditorAPI.ITextModel,
ideStore: PureIDEStore,
): Promise<monacoLanguagesAPI.CompletionItem[]> => {
const importPaths = getCurrentSectionImportPaths(position, model);
const isUsingArrowFunction = Boolean(
model
.getLineContent(position.lineNumber)
.substring(0, position.column - 1)
.match(ARROW_FUNCTION_USAGE_PATTERN),
);
const isUsingConstructor = Boolean(
model
.getLineContent(position.lineNumber)
.substring(0, position.column - 1)
.match(CONSTRUCTOR_USAGE_PATTERN),
);
let suggestions: ElementSuggestion[] = [];
try {
suggestions = (
await ideStore.client.getSuggestionsForIdentifier(
importPaths,
isUsingConstructor
? [ConceptType.CLASS]
: isUsingArrowFunction
? [ConceptType.FUNCTION, ConceptType.NATIVE_FUNCTION]
: [],
)
).map((child) => deserialize(ElementSuggestion, child));
} catch {
// do nothing: provide no suggestions when error ocurred
}
return suggestions.map((suggestion) =>
isUsingConstructor
? constructorClassSuggestionToCompletionItem(suggestion)
: elementSuggestionToCompletionItem(suggestion, {
preferArrowFunctionForm: isUsingArrowFunction,
}),
);
};
export const getArrowFunctionSuggestions = async (
position: IPosition,
model: monacoEditorAPI.ITextModel,
ideStore: PureIDEStore,
): Promise<monacoLanguagesAPI.CompletionItem[]> => {
const importPaths = getCurrentSectionImportPaths(position, model);
let suggestions: ElementSuggestion[] = [];
try {
suggestions = (
await ideStore.client.getSuggestionsForIdentifier(importPaths, [
ConceptType.FUNCTION,
ConceptType.NATIVE_FUNCTION,
])
).map((child) => deserialize(ElementSuggestion, child));
} catch {
// do nothing: provide no suggestions when error ocurred
}
return suggestions.map((suggestion) =>
elementSuggestionToCompletionItem(suggestion, {
preferArrowFunctionForm: true,
}),
);
};
const attributeSuggestionToCompletionItem = (
suggestion: AttributeSuggestion,
): monacoLanguagesAPI.CompletionItem => {
const type = suggestion.pureType;
const insertText =
type === ConceptType.PROPERTY || type === ConceptType.QUALIFIED_PROPERTY
? `${suggestion.pureName}(\${1:})`
: suggestion.pureName;
const kind =
type === ConceptType.PROPERTY
? monacoLanguagesAPI.CompletionItemKind.Property
: type === ConceptType.QUALIFIED_PROPERTY
? monacoLanguagesAPI.CompletionItemKind.Method
: type === ConceptType.TAG
? monacoLanguagesAPI.CompletionItemKind.Constant
: type === ConceptType.STEREOTYPE
? monacoLanguagesAPI.CompletionItemKind.Value
: type === ConceptType.ENUM_VALUE
? monacoLanguagesAPI.CompletionItemKind.Enum
: monacoLanguagesAPI.CompletionItemKind.Value;
return {
label: {
label: suggestion.pureName,
description: `${suggestion.owner}.${suggestion.pureName}`,
},
kind,
filterText: suggestion.pureName,
insertTextRules:
monacoLanguagesAPI.CompletionItemInsertTextRule.InsertAsSnippet,
insertText,
} as monacoLanguagesAPI.CompletionItem;
};
const ATTRIBUTE_ACCESSOR_PATTERN =
/^(?<owner>(?:(?:\w[\w$]*)::)*\w[\w$]*)\s*.$/;
export const getAttributeSuggestions = async (
position: IPosition,
model: monacoEditorAPI.ITextModel,
ideStore: PureIDEStore,
): Promise<monacoLanguagesAPI.CompletionItem[]> => {
const attributeAccessorMatch = model
.getLineContent(position.lineNumber)
.substring(0, position.column - 1)
.match(ATTRIBUTE_ACCESSOR_PATTERN);
const importPaths = getCurrentSectionImportPaths(position, model);
if (attributeAccessorMatch?.groups?.owner) {
let suggestions: AttributeSuggestion[] = [];
try {
suggestions = (
await ideStore.client.getSuggestionsForAttribute(
importPaths,
attributeAccessorMatch.groups.owner,
)
).map((child) => deserialize(AttributeSuggestion, child));
} catch {
// do nothing: provide no suggestions when error ocurred
}
return suggestions.map((suggestion) =>
attributeSuggestionToCompletionItem(suggestion),
);
}
return [];
};
export const getConstructorClassSuggestions = async (
position: IPosition,
model: monacoEditorAPI.ITextModel,
ideStore: PureIDEStore,
): Promise<monacoLanguagesAPI.CompletionItem[]> => {
const importPaths = getCurrentSectionImportPaths(position, model);
let suggestions: ClassSuggestion[] = [];
try {
suggestions = (
await ideStore.client.getSuggestionsForClass(importPaths)
).map((child) => deserialize(ClassSuggestion, child));
} catch {
// do nothing: provide no suggestions when error ocurred
}
return suggestions.map((suggestion) =>
constructorClassSuggestionToCompletionItem(suggestion),
);
};
const castingClassSuggestionToCompletionItem = (
suggestion: ClassSuggestion,
): monacoLanguagesAPI.CompletionItem =>
({
label: {
label: suggestion.pureName,
description: suggestion.pureId,
},
kind: monacoLanguagesAPI.CompletionItemKind.Class,
filterText: suggestion.pureName,
insertTextRules:
monacoLanguagesAPI.CompletionItemInsertTextRule.InsertAsSnippet,
insertText: suggestion.pureName,
}) as monacoLanguagesAPI.CompletionItem;
export const getCastingClassSuggestions = async (
position: IPosition,
model: monacoEditorAPI.ITextModel,
ideStore: PureIDEStore,
): Promise<monacoLanguagesAPI.CompletionItem[]> => {
const importPaths = getCurrentSectionImportPaths(position, model);
let suggestions: ClassSuggestion[] = [];
try {
suggestions = (
await ideStore.client.getSuggestionsForClass(importPaths)
).map((child) => deserialize(ClassSuggestion, child));
} catch {
// do nothing: provide no suggestions when error ocurred
}
return suggestions.map((suggestion) =>
castingClassSuggestionToCompletionItem(suggestion),
);
};
const variableSuggestionToCompletionItem = (
suggestion: VariableSuggestion,
isFromCompiledSource: boolean,
): monacoLanguagesAPI.CompletionItem =>
({
label: suggestion.name,
kind: monacoLanguagesAPI.CompletionItemKind.Variable,
insertTextRules:
monacoLanguagesAPI.CompletionItemInsertTextRule.InsertAsSnippet,
// if suggestions coming from compiled source, they are ranked higher
sortText: !isFromCompiledSource
? `zzzz_${suggestion.name}`
: suggestion.name,
insertText: suggestion.name,
}) as monacoLanguagesAPI.CompletionItem;
const VARIABLE_SUGGESTION_SCANNING_RANGE = 10;
export const getVariableSuggestions = async (
position: IPosition,
model: monacoEditorAPI.ITextModel,
filePath: string,
ideStore: PureIDEStore,
): Promise<monacoLanguagesAPI.CompletionItem[]> => {
let suggestions: VariableSuggestion[] = [];
// get suggestions from compiled source
try {
suggestions = (
await ideStore.client.getSuggestionsForVariable(
filePath,
position.lineNumber,
position.column,
)
).map((child) => deserialize(VariableSuggestion, child));
} catch {
// do nothing: provide no suggestions when error ocurred
}
// NOTE: potentially, we could scan for all tokens that come before the current position
// and filter out variable suggestions that nolonger available
// get suggestions from current (potentially non-compiled) source
const varNames = new Set<string>();
let stopSearching = false;
for (
let i = position.lineNumber - 1;
i >
Math.max(0, position.lineNumber - 1 - VARIABLE_SUGGESTION_SCANNING_RANGE);
--i
) {
// NOTE: stop searching after reaching function definition or section marker
if (stopSearching) {
break;
}
const line = model.getLineContent(i + 1);
if (line.match(/^\s*function\s+/) || line.match(/^\s*###\w+/)) {
stopSearching = true;
}
// scan for potential variable/parameter declarations
const lineTokens = guaranteeNonNullable(
monacoEditorAPI.tokenize(
model.getLineContent(i + 1),
CODE_EDITOR_LANGUAGE.PURE,
)[0],
);
lineTokens.forEach((token, lineIndex) => {
if (
// must come before the current position
(i !== position.lineNumber - 1 || token.offset < position.column) &&
isTokenOneOf(
token.type,
[PURE_GRAMMAR_TOKEN.VARIABLE, PURE_GRAMMAR_TOKEN.PARAMETER],
true,
)
) {
varNames.add(
model.getValueInRange({
startLineNumber: i + 1,
startColumn: token.offset + 1,
endLineNumber: i + 1,
endColumn:
lineIndex === lineTokens.length - 1
? Number.MAX_SAFE_INTEGER
: guaranteeNonNullable(lineTokens[lineIndex + 1]).offset + 1,
}),
);
}
});
}
const variablesFoundFromSuggestions = suggestions.map(
(suggestion) => suggestion.name,
);
return suggestions
.map((suggestion) => variableSuggestionToCompletionItem(suggestion, true))
.concat(
Array.from(varNames)
.filter((varName) => !variablesFoundFromSuggestions.includes(varName))
.map((varName) => {
const suggestion = new VariableSuggestion();
suggestion.name = varName;
return suggestion;
})
.map((suggestion) =>
variableSuggestionToCompletionItem(suggestion, false),
),
);
};