From 734a72c00a9132604f0f46f27a9f23662337d825 Mon Sep 17 00:00:00 2001 From: Alexander Vogt Date: Tue, 29 Jul 2025 12:27:41 +0200 Subject: [PATCH 1/5] move to framework --- src/features/constraintMenu/AutoCompletion.ts | 8 +- .../dfdElements/AssignmentLanguage.ts | 410 ++++++++++++++++++ src/features/dfdElements/outputPortEditUi.ts | 357 +++------------ 3 files changed, 467 insertions(+), 308 deletions(-) create mode 100644 src/features/dfdElements/AssignmentLanguage.ts diff --git a/src/features/constraintMenu/AutoCompletion.ts b/src/features/constraintMenu/AutoCompletion.ts index 7118870..9752652 100644 --- a/src/features/constraintMenu/AutoCompletion.ts +++ b/src/features/constraintMenu/AutoCompletion.ts @@ -96,7 +96,7 @@ export class NegatableWord implements AbstractWord { } export class AutoCompleteTree { - constructor(private roots: AutoCompleteNode[]) {} + constructor(protected roots: AutoCompleteNode[]) {} private tokenize(text: string[]): Token[] { if (!text || text.length == 0) { @@ -283,9 +283,9 @@ function deduplicateErrors(errors: ValidationError[]): ValidationError[] { }); } -export interface AutoCompleteNode { - word: AbstractWord; - children: AutoCompleteNode[]; +export interface AutoCompleteNode { + word: W; + children: AutoCompleteNode[]; canBeFinal?: boolean; viewAsLeaf?: boolean; } diff --git a/src/features/dfdElements/AssignmentLanguage.ts b/src/features/dfdElements/AssignmentLanguage.ts new file mode 100644 index 0000000..26ccc41 --- /dev/null +++ b/src/features/dfdElements/AssignmentLanguage.ts @@ -0,0 +1,410 @@ +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import { + AbstractWord, + AutoCompleteNode, + AutoCompleteTree, + ConstantWord, + WordCompletion, +} from "../constraintMenu/AutoCompletion"; +import { SModelElementImpl, SModelRootImpl, SParentElementImpl, SPortImpl } from "sprotty"; +import { LabelTypeRegistry } from "../labels/labelTypeRegistry"; +import { DfdNodeImpl } from "./nodes"; + +export class MonacoEditorAssignmentLanguageCompletionProvider implements monaco.languages.CompletionItemProvider { + constructor(private tree: AutoCompleteTree) {} + + triggerCharacters = [".", ";", " ", ",", "("]; + + provideCompletionItems( + model: monaco.editor.ITextModel, + position: monaco.Position, + ): monaco.languages.ProviderResult { + const allLines = model.getLinesContent(); + const includedLines: string[] = []; + for (let i = 0; i < position.lineNumber - 1; i++) { + includedLines.push(allLines[i]); + } + const currentLine = allLines[position.lineNumber - 1].substring(0, position.column - 1); + includedLines.push(currentLine); + + const r = this.tree.getCompletion(includedLines); + return { + suggestions: r, + }; + } +} + +const startOfLineKeywords = ["forward", "assign", "set", "unset"]; +const statementKeywords = [...startOfLineKeywords, "if", "from"]; +const constantsKeywords = ["TRUE", "FALSE"]; +export const assignemntLanguageMonarchDefinition: monaco.languages.IMonarchLanguage = { + keywords: [...statementKeywords, ...constantsKeywords], + + operators: ["=", "||", "&&", "!"], + + symbols: /[=> string; +} + +type WordOrReplacableWord = ReplaceableAbstractWord | AbstractWord; + +export class ReplaceAutoCompleteTree extends AutoCompleteTree { + constructor(protected roots: AutoCompleteNode[]) { + super(roots); + } +} + +export namespace TreeBuilder { + export function buildTree( + model: SModelRootImpl, + labelTypeRegistry: LabelTypeRegistry, + ): AutoCompleteNode[] { + return [ + buildSetOrUnsetStatement(labelTypeRegistry, "set"), + buildSetOrUnsetStatement(labelTypeRegistry, "unset"), + buildForwardStatement(model), + buildAssignStatement(labelTypeRegistry, model), + ]; + } + + function buildSetOrUnsetStatement( + labelTypeRegistry: LabelTypeRegistry, + keyword: string, + ): AutoCompleteNode { + const labelNode: AutoCompleteNode = { + word: new LabelListWord(labelTypeRegistry), + children: [], + }; + return { + word: new ConstantWord(keyword), + children: [labelNode], + }; + } + + function buildForwardStatement(model: SModelRootImpl) { + const inputNode: AutoCompleteNode = { + word: new InputListWord(model), + children: [], + }; + return { + word: new ConstantWord("forward"), + children: [inputNode], + }; + } + + function buildAssignStatement( + labelTypeRegistry: LabelTypeRegistry, + model: SModelRootImpl, + ): AutoCompleteNode { + const fromNode: AutoCompleteNode = { + word: new ConstantWord("from"), + children: [ + { + word: new InputWord(model), + children: [], + }, + ], + }; + const ifNode: AutoCompleteNode = { + word: new ConstantWord("if"), + children: buildCondition(model, labelTypeRegistry, fromNode), + }; + return { + word: new ConstantWord("assign"), + children: [ + { + word: new LabelWord(labelTypeRegistry), + children: [ifNode], + }, + ], + }; + } + + function buildCondition(model: SModelRootImpl, labelTypeRegistry: LabelTypeRegistry, nextNode: AutoCompleteNode) { + const connectors: AutoCompleteNode[] = ["&&", "||"].map((o) => ({ + word: new ConstantWord(o), + children: [], + })); + + const expressors: AutoCompleteNode[] = [ + new ConstantWord("TRUE"), + new ConstantWord("FALSE"), + new InputLabelWord(model, labelTypeRegistry), + ].map((e) => ({ + word: e, + children: [...connectors, nextNode], + canBeFinal: true, + })); + + connectors.forEach((c) => { + c.children = expressors; + }); + return expressors; + } +} + +abstract class InputAwareWord { + constructor(private model: SModelRootImpl) {} + + protected getAvailableInputs(): string[] { + const selectedPorts = this.getSelectedPorts(this.model); + if (selectedPorts.length === 0) { + return []; + } + return selectedPorts.flatMap((port) => { + const parent = port.parent; + if (!(parent instanceof DfdNodeImpl)) { + return []; + } + return parent.getAvailableInputs().filter((input) => input !== undefined) as string[]; + }); + } + + private getSelectedPorts(node: SModelElementImpl): SPortImpl[] { + if (node instanceof SPortImpl && node.selected) { + return [node]; + } + if (node instanceof SParentElementImpl) { + return node.children.flatMap((child) => this.getSelectedPorts(child)); + } + return []; + } +} + +class LabelWord implements ReplaceableAbstractWord { + constructor(private readonly labelTypeRegistry: LabelTypeRegistry) {} + + completionOptions(word: string): WordCompletion[] { + const parts = word.split("."); + + if (parts.length == 1) { + return this.labelTypeRegistry.getLabelTypes().map((l) => ({ + insertText: l.name, + kind: monaco.languages.CompletionItemKind.Class, + })); + } else if (parts.length == 2) { + const type = this.labelTypeRegistry.getLabelTypes().find((l) => l.name === parts[0]); + if (!type) { + return []; + } + + const possibleValues: WordCompletion[] = type.values.map((l) => ({ + insertText: l.text, + kind: monaco.languages.CompletionItemKind.Enum, + startOffset: parts[0].length + 1, + })); + possibleValues.push({ + insertText: "$" + type.name, + kind: monaco.languages.CompletionItemKind.Variable, + startOffset: parts[0].length + 1, + }); + return possibleValues; + } + + return []; + } + + verifyWord(word: string): string[] { + const parts = word.split("."); + + if (parts.length > 2) { + return ["Expected at most 2 parts in characteristic selector"]; + } + + const type = this.labelTypeRegistry.getLabelTypes().find((l) => l.name === parts[0]); + if (!type) { + return ['Unknown label type "' + parts[0] + '"']; + } + + if (parts.length < 2) { + return ["Expected characteristic to have value"]; + } + + if (parts[1].startsWith("$") && parts[1].length >= 2) { + return []; + } + + const label = type.values.find((l) => l.text === parts[1]); + if (!label) { + return ['Unknown label value "' + parts[1] + '" for type "' + parts[0] + '"']; + } + + return []; + } + + replaceWord(text: string, old: string, replacement: string) { + if (text == old) { + return replacement; + } + return text; + } +} + +class LabelListWord implements ReplaceableAbstractWord { + labelWord: LabelWord; + + constructor(labelTypeRegistry: LabelTypeRegistry) { + this.labelWord = new LabelWord(labelTypeRegistry); + } + + completionOptions(word: string): WordCompletion[] { + const parts = word.split(","); + const lastPart = parts[parts.length - 1]; + return this.labelWord.completionOptions(lastPart); + } + + verifyWord(word: string): string[] { + const parts = word.split(","); + const errors: string[] = []; + for (const part of parts) { + errors.push(...this.labelWord.verifyWord(part)); + } + return errors; + } + + replaceWord(text: string, old: string, replacement: string) { + const parts = text.split(","); + const newParts = parts.map((part) => this.labelWord.replaceWord(part, old, replacement)); + return newParts.join(","); + } +} + +class InputWord extends InputAwareWord implements ReplaceableAbstractWord { + completionOptions(): WordCompletion[] { + return this.getAvailableInputs().map((input) => ({ + insertText: input, + kind: monaco.languages.CompletionItemKind.Variable, + })); + } + + verifyWord(word: string): string[] { + const availableInputs = this.getAvailableInputs(); + if (availableInputs.includes(word)) { + return []; + } + return [`Unknown input "${word}"`]; + } + + replaceWord(text: string, old: string, replacement: string) { + const availableInputs = this.getAvailableInputs(); + if (availableInputs.includes(old)) { + return text.replace(old, replacement); + } + return text; + } +} + +class InputListWord implements ReplaceableAbstractWord { + private inputWord: InputWord; + constructor(model: SModelRootImpl) { + this.inputWord = new InputWord(model); + } + + completionOptions(): WordCompletion[] { + return this.inputWord.completionOptions(); + } + + verifyWord(word: string): string[] { + const parts = word.split(","); + const errors: string[] = []; + for (const part of parts) { + errors.push(...this.inputWord.verifyWord(part)); + } + return errors; + } + + replaceWord(text: string, old: string, replacement: string) { + const parts = text.split(","); + const newParts = parts.map((part) => this.inputWord.replaceWord(part, old, replacement)); + return newParts.join(","); + } +} + +class InputLabelWord implements ReplaceableAbstractWord { + private inputWord: InputWord; + private labelWord: LabelWord; + + constructor(model: SModelRootImpl, labelTypeRegistry: LabelTypeRegistry) { + this.inputWord = new InputWord(model); + this.labelWord = new LabelWord(labelTypeRegistry); + } + + completionOptions(word: string): WordCompletion[] { + const parts = word.split("."); + if (parts[1] === undefined) { + return this.inputWord.completionOptions(); + } else if (parts.length === 2) { + return this.labelWord.completionOptions(parts[1]).map((c) => ({ + ...c, + insertText: parts[0] + "." + c.insertText, + })); + } + return []; + } + + verifyWord(word: string): string[] { + const parts = this.getParts(word); + const inputErrors = this.inputWord.verifyWord(parts[0]); + if (inputErrors.length > 0) { + return inputErrors; + } + if (parts[1] === undefined) { + return ["Expected input and label separated by a dot"]; + } + const labelErrors = this.labelWord.verifyWord(parts[1]); + return [...inputErrors, ...labelErrors]; + } + + replaceWord(text: string, old: string, replacement: string) { + const [input, label] = this.getParts(text); + if (input === old) { + return replacement + (label ? "." + label : ""); + } else if (label === old) { + return input + "." + replacement; + } + return text; + } + + private getParts(text: string): [string, string] | [string, undefined] { + if (text.includes(".")) { + const index = text.indexOf("."); + const input = text.substring(0, index); + const label = text.substring(index + 1); + return [input, label]; + } + return [text, undefined]; + } +} diff --git a/src/features/dfdElements/outputPortEditUi.ts b/src/features/dfdElements/outputPortEditUi.ts index fab91cf..5cd0dd3 100644 --- a/src/features/dfdElements/outputPortEditUi.ts +++ b/src/features/dfdElements/outputPortEditUi.ts @@ -21,7 +21,6 @@ import { matchesKeystroke } from "sprotty/lib/utils/keyboard"; import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; import { DfdOutputPortImpl } from "./ports"; import { DfdNodeImpl } from "./nodes"; -import { PortBehaviorValidator } from "./outputPortBehaviorValidation"; import { LabelTypeRegistry } from "../labels/labelTypeRegistry"; import { EditorModeController } from "../editorMode/editorModeController"; import { DFDBehaviorRefactorer } from "./behaviorRefactorer"; @@ -33,6 +32,12 @@ import "monaco-editor/esm/vs/editor/contrib/inlineCompletions/browser/inlineComp import "./outputPortEditUi.css"; import { ThemeManager, Switchable } from "../settingsMenu/themeManager"; +import { + assignemntLanguageMonarchDefinition, + MonacoEditorAssignmentLanguageCompletionProvider, + ReplaceAutoCompleteTree, + TreeBuilder, +} from "./AssignmentLanguage"; /** * Detects when a dfd output port is double clicked and shows the OutputPortEditUI @@ -82,276 +87,6 @@ export class OutputPortEditUIMouseListener extends MouseListener { } } -// More information and playground website for testing: https://microsoft.github.io/monaco-editor/monarch.html -const startOfLineKeywords = ["forward", "assign", "set", "unset"]; -const statementKeywords = ["forward", "assign", "set", "unset", "if", "from"]; -const constantsKeywords = ["TRUE", "FALSE"]; -const dfdBehaviorLanguageMonarchDefinition: monaco.languages.IMonarchLanguage = { - keywords: [...statementKeywords, ...constantsKeywords], - - operators: ["=", "||", "&&", "!"], - - symbols: /[=> { - // The first word of each line/statement is the statement type keyword - const statementType = model.getWordAtPosition({ column: 1, lineNumber: position.lineNumber }); - - // If we're currently at the first word of the statement, suggest the statement start keywords - // This also the case when the current line is empty. - const isAtFirstWord = - position.column >= (statementType?.startColumn ?? 1) && position.column <= (statementType?.endColumn ?? 1); - if (isAtFirstWord) { - // Start of line: suggest statement start keywords - return { - suggestions: this.getKeywordCompletions(model, position, startOfLineKeywords, true), - }; - } - - const parent = this.ui.getCurrentEditingPort()?.parent; - if (!(parent instanceof DfdNodeImpl)) { - return { - suggestions: [], - }; - } - - const lastWord = - model.getLineContent(position.lineNumber).substring(0, position.column).trimEnd().split(" ").pop() || ""; - const availableInputs = parent.getAvailableInputs().filter((input) => input !== undefined) as string[]; - if (lastWord.endsWith(",") || lastWord.endsWith(".") || lastWord == statementType?.word) { - // Suggestions per statement type - switch (statementType?.word) { - case "assign": - return { - suggestions: this.getOutLabelCompletions(model, position), - }; - case "forward": - return { - suggestions: this.getInputCompletions(model, position, availableInputs), - }; - case "set": - return { - suggestions: this.getOutLabelCompletions(model, position), - }; - case "unset": - return { - suggestions: this.getOutLabelCompletions(model, position), - }; - } - } else if (statementType?.word === "assign") { - const line = model.getLineContent(position.lineNumber).substring(0, position.column); - const hasFromKeyword = line.includes("from"); - const hasIfKeyword = line.includes("if"); - if (lastWord == "from") { - return { - suggestions: this.getInputCompletions(model, position, availableInputs), - }; - } else if (lastWord == "if" || ["|", "&", "(", "!"].includes(lastWord[lastWord.length - 1])) { - return { - suggestions: [ - ...this.getConstantsCompletions(model, position), - ...this.getOutLabelCompletions(model, position), - ], - }; - } else if (!hasIfKeyword) { - return { - suggestions: this.getKeywordCompletions(model, position, ["if"]), - }; - } else if (!hasFromKeyword && hasIfKeyword) { - return { - suggestions: this.getKeywordCompletions(model, position, ["from"]), - }; - } - } - - // Unknown statement type, cannot suggest anything - return { - suggestions: [], - }; - } - - private getKeywordCompletions( - model: monaco.editor.ITextModel, - position: monaco.Position, - keywords: string[], - replaceLine: boolean = false, - ): monaco.languages.CompletionItem[] { - const range = replaceLine - ? new monaco.Range(position.lineNumber, 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)) - : new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column); - return keywords.map((keyword) => ({ - label: keyword, - kind: monaco.languages.CompletionItemKind.Keyword, - insertText: keyword, - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, // Treat insertText as a snippet - range: range, - })); - } - - private getOutLabelCompletions( - model: monaco.editor.ITextModel, - position: monaco.Position, - ): monaco.languages.CompletionItem[] { - const line = model.getLineContent(position.lineNumber); - - // Find the start of the current expression (Type or value) - let currentExpressionStart = position.column - 1; - while (currentExpressionStart > 0) { - const currentChar = line[currentExpressionStart - 1]; // column is 1-based, array is 0-based - if (currentChar !== "." && !currentChar.match(/[A-Za-z0-9_]/)) { - break; - } - currentExpressionStart--; - } - - const currentExpression = line.substring(currentExpressionStart, position.column); - const expressionParts = currentExpression.split("."); - - switch (expressionParts.length) { - case 1: - // If there's only one part, we're completing the `Type` - return this.getLabelTypeCompletions(model, position); - case 2: { - // If there's already a dot, we complete the `value` for the specific `Type` - const labelTypeName = expressionParts[0]; - return this.getLabelValueCompletions(model, position, labelTypeName); - } - } - - return []; - } - - private getInputCompletions( - model: monaco.editor.ITextModel, - position: monaco.Position, - availableInputs: string[], - ): monaco.languages.CompletionItem[] { - const currentWord = model.getWordUntilPosition(position); - - return availableInputs.map((input) => ({ - label: input, - kind: monaco.languages.CompletionItemKind.Variable, - insertText: input, - range: new monaco.Range( - position.lineNumber, - currentWord.startColumn, - position.lineNumber, - currentWord.endColumn, - ), - })); - } - - private getConstantsCompletions( - model: monaco.editor.ITextModel, - position: monaco.Position, - ): monaco.languages.CompletionItem[] { - const currentWord = model.getWordUntilPosition(position); - - return constantsKeywords.map((constant) => ({ - label: constant, - kind: monaco.languages.CompletionItemKind.Constant, - insertText: constant, - range: new monaco.Range( - position.lineNumber, - currentWord.startColumn, - position.lineNumber, - currentWord.endColumn, - ), - })); - } - - private getLabelTypeCompletions( - model: monaco.editor.ITextModel, - position: monaco.Position, - ): monaco.languages.CompletionItem[] { - const availableLabelTypes = this.labelTypeRegistry?.getLabelTypes() ?? []; - const currentWord = model.getWordUntilPosition(position); - - return availableLabelTypes.map((labelType) => ({ - label: labelType.name, - kind: monaco.languages.CompletionItemKind.Class, - insertText: labelType.name, - range: new monaco.Range( - position.lineNumber, - currentWord.startColumn, - position.lineNumber, - currentWord.endColumn, - ), - })); - } - - private getLabelValueCompletions( - model: monaco.editor.ITextModel, - position: monaco.Position, - labelTypeName: string, - ): monaco.languages.CompletionItem[] { - const labelType = this.labelTypeRegistry - ?.getLabelTypes() - .find((labelType) => labelType.name === labelTypeName.trim()); - if (!labelType) { - return []; - } - - const currentWord = model.getWordUntilPosition(position); - - return labelType.values.map((labelValue) => ({ - label: labelValue.text, - kind: monaco.languages.CompletionItemKind.Enum, - insertText: labelValue.text, - range: new monaco.Range( - position.lineNumber, - currentWord.startColumn, - position.lineNumber, - currentWord.endColumn, - ), - })); - } -} - /** * UI that allows editing the behavior text of a dfd output port (DfdOutputPortImpl). */ @@ -365,14 +100,13 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable private port: DfdOutputPortImpl | undefined; private editor?: monaco.editor.IStandaloneCodeEditor; + private tree?: ReplaceAutoCompleteTree; constructor( @inject(TYPES.IActionDispatcher) private actionDispatcher: ActionDispatcher, @inject(TYPES.ViewerOptions) private viewerOptions: ViewerOptions, @inject(TYPES.DOMHelper) private domHelper: DOMHelper, @inject(MouseTool) private mouseTool: MouseTool, - @inject(PortBehaviorValidator) private validator: PortBehaviorValidator, - // Load label type registry watcher that handles changes to the behavior of // output ports when label types are changed. // It has to be loaded somewhere for inversify to create it and start watching. @@ -380,7 +114,7 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable // @ts-expect-error TS6133: 'labelTypeRegistry' is declared but its value is never read. @inject(DFDBehaviorRefactorer) private readonly _labelTypeChangeWatcher: DFDBehaviorRefactorer, - @inject(LabelTypeRegistry) @optional() private readonly labelTypeRegistry?: LabelTypeRegistry, + @inject(LabelTypeRegistry) private readonly labelTypeRegistry: LabelTypeRegistry, @inject(EditorModeController) @optional() private editorModeController?: EditorModeController, @@ -413,11 +147,13 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable // Initialize the monaco editor and setup the language for highlighting and autocomplete. const dfdLanguageName = "dfd-behavior"; monaco.languages.register({ id: dfdLanguageName }); - monaco.languages.setMonarchTokensProvider(dfdLanguageName, dfdBehaviorLanguageMonarchDefinition); - monaco.languages.registerCompletionItemProvider( - dfdLanguageName, - new MonacoEditorDfdBehaviorCompletionProvider(this, this.labelTypeRegistry), - ); + monaco.languages.setMonarchTokensProvider(dfdLanguageName, assignemntLanguageMonarchDefinition); + if (this.tree) { + monaco.languages.registerCompletionItemProvider( + dfdLanguageName, + new MonacoEditorAssignmentLanguageCompletionProvider(this.tree), + ); + } const monacoTheme = (ThemeManager?.useDarkMode ?? true) ? "vs-dark" : "vs"; this.editor = monaco.editor.create(this.editorContainer, { @@ -477,9 +213,7 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable }); // Run behavior validation when the behavior text changes. - this.editor?.onDidChangeModelContent(() => { - this.validateBehavior(); - }); + this.editor?.onDidChangeModelContent(() => {}); // When the content size of the editor changes, resize the editor accordingly. this.editor?.onDidContentSizeChange(() => { @@ -581,6 +315,8 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable readOnly: this.editorModeController?.isReadOnly() ?? false, }); + this.tree = new ReplaceAutoCompleteTree(TreeBuilder.buildTree(root, this.labelTypeRegistry)); + // Validation of loaded behavior text. this.validateBehavior(); @@ -612,36 +348,49 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable return; } - const behaviorText = this.editor?.getValue() ?? ""; - const results = this.validator.validate(behaviorText, this.port); - if (results.length === 0) { - // Everything fine - this.validationLabel.innerText = "Behavior is valid"; + if (!this.editor) { + return; + } + if (!this.tree) { + return; + } + + const model = this.editor?.getModel(); + if (!model) { + return; + } + + const content = model.getLinesContent(); + const marker: monaco.editor.IMarkerData[] = []; + const emptyContent = content.length == 0 || (content.length == 1 && content[0] === ""); + // empty content gets accepted as valid as it represents no constraints + if (!emptyContent) { + const errors = this.tree.verify(content); + marker.push( + ...errors.map((e) => ({ + severity: monaco.MarkerSeverity.Error, + startLineNumber: e.line, + startColumn: e.startColumn, + endLineNumber: e.line, + endColumn: e.endColumn, + message: e.message, + })), + ); + } + + if (marker.length == 0) { + this.validationLabel.innerText = "Assignments are valid"; this.validationLabel.classList.remove("validation-error"); this.validationLabel.classList.add("validation-success"); } else { - // Some error - this.validationLabel.innerText = `Behavior is invalid: ${results.length} error${ - results.length === 1 ? "" : "s" + this.validationLabel.innerText = `Assignments are invalid: ${marker.length} error${ + marker.length === 1 ? "" : "s" }.`; this.validationLabel.classList.remove("validation-success"); this.validationLabel.classList.add("validation-error"); } - // Add markers for each error to monaco (if any) - const markers: monaco.editor.IMarkerData[] = results.map((result) => ({ - severity: monaco.MarkerSeverity.Error, - message: result.message, - startLineNumber: result.line + 1, - startColumn: (result.colStart ?? 0) + 1, - endLineNumber: result.line + 1, - endColumn: (result.colEnd ?? 0) + 1, - })); - - const model = this.editor?.getModel(); - if (model) { - monaco.editor.setModelMarkers(model, "owner", markers); - } + monaco.editor.setModelMarkers(model, "assignment", marker); } /** From 7b95065ce2d0d07ae659b4e7d00a500e563a25ec Mon Sep 17 00:00:00 2001 From: Alexander Vogt Date: Tue, 29 Jul 2025 18:34:45 +0200 Subject: [PATCH 2/5] fix completion and verification --- src/features/constraintMenu/AutoCompletion.ts | 2 +- .../dfdElements/AssignmentLanguage.ts | 38 ++++++++++++------- src/features/dfdElements/outputPortEditUi.ts | 38 +++++++++++++------ 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/features/constraintMenu/AutoCompletion.ts b/src/features/constraintMenu/AutoCompletion.ts index 9752652..b28d667 100644 --- a/src/features/constraintMenu/AutoCompletion.ts +++ b/src/features/constraintMenu/AutoCompletion.ts @@ -233,7 +233,7 @@ export class AutoCompleteTree { return result; } for (const n of nodes) { - if (!n.word.verifyWord(tokens[index].text)) { + if (n.word.verifyWord(tokens[index].text).length > 0) { continue; } result = result.concat(this.completeNode(n.children, tokens, index + 1)); diff --git a/src/features/dfdElements/AssignmentLanguage.ts b/src/features/dfdElements/AssignmentLanguage.ts index 26ccc41..05c1ee2 100644 --- a/src/features/dfdElements/AssignmentLanguage.ts +++ b/src/features/dfdElements/AssignmentLanguage.ts @@ -133,7 +133,7 @@ export namespace TreeBuilder { word: new ConstantWord("from"), children: [ { - word: new InputWord(model), + word: new InputListWord(model), children: [], }, ], @@ -221,17 +221,11 @@ class LabelWord implements ReplaceableAbstractWord { return []; } - const possibleValues: WordCompletion[] = type.values.map((l) => ({ + return type.values.map((l) => ({ insertText: l.text, kind: monaco.languages.CompletionItemKind.Enum, startOffset: parts[0].length + 1, })); - possibleValues.push({ - insertText: "$" + type.name, - kind: monaco.languages.CompletionItemKind.Variable, - startOffset: parts[0].length + 1, - }); - return possibleValues; } return []; @@ -333,8 +327,20 @@ class InputListWord implements ReplaceableAbstractWord { this.inputWord = new InputWord(model); } - completionOptions(): WordCompletion[] { - return this.inputWord.completionOptions(); + completionOptions(word: string): WordCompletion[] { + const parts = word.split(","); + // remove last one as we are completing that one + if (parts.length > 1) { + parts.pop(); + } + const startOffset = parts.reduce((acc, part) => acc + part.length + 1, 0); // +1 for the commas + return this.inputWord + .completionOptions() + .filter((c) => !parts.includes(c.insertText)) + .map((c) => ({ + ...c, + startOffset: startOffset + (c.startOffset ?? 0), + })); } verifyWord(word: string): string[] { @@ -363,13 +369,17 @@ class InputLabelWord implements ReplaceableAbstractWord { } completionOptions(word: string): WordCompletion[] { - const parts = word.split("."); + const parts = this.getParts(word); if (parts[1] === undefined) { - return this.inputWord.completionOptions(); - } else if (parts.length === 2) { + return this.inputWord.completionOptions().map((c) => ({ + ...c, + insertText: c.insertText, + })); + } else if (parts.length >= 2) { return this.labelWord.completionOptions(parts[1]).map((c) => ({ ...c, - insertText: parts[0] + "." + c.insertText, + insertText: c.insertText, + startOffset: (c.startOffset ?? 0) + parts[0].length + 1, // +1 for the dot })); } return []; diff --git a/src/features/dfdElements/outputPortEditUi.ts b/src/features/dfdElements/outputPortEditUi.ts index 5cd0dd3..8fc4ea4 100644 --- a/src/features/dfdElements/outputPortEditUi.ts +++ b/src/features/dfdElements/outputPortEditUi.ts @@ -101,6 +101,9 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable private port: DfdOutputPortImpl | undefined; private editor?: monaco.editor.IStandaloneCodeEditor; private tree?: ReplaceAutoCompleteTree; + private completionProvider?: monaco.IDisposable; + + private static readonly DFD_LANGUAGE_NAME = "dfd-behavior"; constructor( @inject(TYPES.IActionDispatcher) private actionDispatcher: ActionDispatcher, @@ -145,15 +148,13 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable this.validationLabel.classList.add("validation-label"); // Initialize the monaco editor and setup the language for highlighting and autocomplete. - const dfdLanguageName = "dfd-behavior"; - monaco.languages.register({ id: dfdLanguageName }); - monaco.languages.setMonarchTokensProvider(dfdLanguageName, assignemntLanguageMonarchDefinition); - if (this.tree) { - monaco.languages.registerCompletionItemProvider( - dfdLanguageName, - new MonacoEditorAssignmentLanguageCompletionProvider(this.tree), - ); - } + + monaco.languages.register({ id: OutputPortEditUI.DFD_LANGUAGE_NAME }); + monaco.languages.setMonarchTokensProvider( + OutputPortEditUI.DFD_LANGUAGE_NAME, + assignemntLanguageMonarchDefinition, + ); + this.registerCompletionProvider(); const monacoTheme = (ThemeManager?.useDarkMode ?? true) ? "vs-dark" : "vs"; this.editor = monaco.editor.create(this.editorContainer, { @@ -166,7 +167,7 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable wordBasedSuggestions: "off", // Does not really work for our use case scrollBeyondLastLine: false, // Not needed theme: monacoTheme, - language: dfdLanguageName, + language: OutputPortEditUI.DFD_LANGUAGE_NAME, }); this.configureHandlers(containerElement); @@ -213,7 +214,9 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable }); // Run behavior validation when the behavior text changes. - this.editor?.onDidChangeModelContent(() => {}); + this.editor?.onDidChangeModelContent(() => { + this.validateBehavior(); + }); // When the content size of the editor changes, resize the editor accordingly. this.editor?.onDidContentSizeChange(() => { @@ -320,6 +323,8 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable // Validation of loaded behavior text. this.validateBehavior(); + this.registerCompletionProvider(); + // Wait for the next event loop tick to focus the port edit UI. // The user may have clicked more times before the show click was processed // (showing the UI takes some time due to finding the element in the graph, etc.). @@ -330,6 +335,17 @@ export class OutputPortEditUI extends AbstractUIExtension implements Switchable }, 0); // 0ms => next event loop tick } + private registerCompletionProvider() { + if (!this.tree) { + return; + } + this.completionProvider?.dispose(); + this.completionProvider = monaco.languages.registerCompletionItemProvider( + OutputPortEditUI.DFD_LANGUAGE_NAME, + new MonacoEditorAssignmentLanguageCompletionProvider(this.tree), + ); + } + /** * Sets the position of the UI to the position of the port that is currently edited. */ From fe5cac37abf5c7c9562f6213035b99c8bc5d9502 Mon Sep 17 00:00:00 2001 From: Alexander Vogt Date: Wed, 30 Jul 2025 11:53:48 +0200 Subject: [PATCH 3/5] implement replacement --- src/features/constraintMenu/AutoCompletion.ts | 27 +-- .../dfdElements/AssignmentLanguage.ts | 71 ++++++- .../dfdElements/behaviorRefactorer.ts | 180 ++++++------------ 3 files changed, 142 insertions(+), 136 deletions(-) diff --git a/src/features/constraintMenu/AutoCompletion.ts b/src/features/constraintMenu/AutoCompletion.ts index b28d667..cd01594 100644 --- a/src/features/constraintMenu/AutoCompletion.ts +++ b/src/features/constraintMenu/AutoCompletion.ts @@ -13,10 +13,11 @@ export interface ValidationError { endColumn: number; } -interface Token { +export interface Token { text: string; line: number; column: number; + whiteSpaceAfter?: string; } export type WordCompletion = RequiredCompletionParts & Partial; @@ -98,22 +99,27 @@ export class NegatableWord implements AbstractWord { export class AutoCompleteTree { constructor(protected roots: AutoCompleteNode[]) {} - private tokenize(text: string[]): Token[] { + protected tokenize(text: string[]): Token[] { if (!text || text.length == 0) { return []; } const tokens: Token[] = []; for (const [lineNumber, line] of text.entries()) { - const lineTokens = line.split(/\s+/).filter((t) => t.length > 0); + const lineTokens = line.split(/(\s+)/); let column = 0; - for (const token of lineTokens) { - column = line.indexOf(token, column); - tokens.push({ - text: token, - line: lineNumber + 1, - column: column + 1, - }); + for (let i = 0; i < lineTokens.length; i += 2) { + const token = lineTokens[i]; + if (token.length > 0) { + tokens.push({ + text: token, + line: lineNumber + 1, + column: column + 1, + whiteSpaceAfter: lineTokens[i + 1], + }); + } + column += token.length; + column += lineTokens[i + 1] ? lineTokens[i + 1].length : 0; // Add whitespace length } } @@ -217,7 +223,6 @@ export class AutoCompleteTree { skipStartCheck = false, ): WordCompletion[] { // check for new start - if (!skipStartCheck && tokens[index].column == 1) { const matchesAnyRoot = this.roots.some((n) => n.word.verifyWord(tokens[index].text).length === 0); if (matchesAnyRoot) { diff --git a/src/features/dfdElements/AssignmentLanguage.ts b/src/features/dfdElements/AssignmentLanguage.ts index 05c1ee2..d0c3050 100644 --- a/src/features/dfdElements/AssignmentLanguage.ts +++ b/src/features/dfdElements/AssignmentLanguage.ts @@ -4,6 +4,7 @@ import { AutoCompleteNode, AutoCompleteTree, ConstantWord, + Token, WordCompletion, } from "../constraintMenu/AutoCompletion"; import { SModelElementImpl, SModelRootImpl, SParentElementImpl, SPortImpl } from "sprotty"; @@ -76,7 +77,7 @@ export const assignemntLanguageMonarchDefinition: monaco.languages.IMonarchLangu }; interface ReplaceableAbstractWord extends AbstractWord { - replaceWord: (text: string, old: string, replacement: string) => string; + replaceWord(text: string, old: string, replacement: string): string; } type WordOrReplacableWord = ReplaceableAbstractWord | AbstractWord; @@ -85,6 +86,60 @@ export class ReplaceAutoCompleteTree extends AutoCompleteTree { constructor(protected roots: AutoCompleteNode[]) { super(roots); } + + public replace(lines: string[], old: string, replacement: string): string[] { + const tokens = this.tokenize(lines); + const replaced = this.replaceToken(this.roots, tokens, 0, old, replacement); + const newLines: string[] = []; + let currentLine = ""; + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + const newText = replaced[i]; + currentLine += newText; + currentLine += token.whiteSpaceAfter || ""; + if (i == tokens.length - 1 || tokens[i + 1].line !== token.line) { + newLines.push(currentLine); + currentLine = ""; + } + } + return newLines; + } + + private replaceToken( + nodes: AutoCompleteNode[], + tokens: Token[], + index: number, + old: string, + replacement: string, + skipStartCheck = false, + ): string[] { + if (index >= tokens.length) { + return []; + } + // check for new start + if (!skipStartCheck && tokens[index].column == 1) { + const matchesAnyRoot = this.roots.some((n) => n.word.verifyWord(tokens[index].text).length === 0); + if (matchesAnyRoot) { + return this.replaceToken(this.roots, tokens, index, old, replacement, true); + } + } + let text = tokens[index].text; + for (const n of nodes) { + if ((n.word as ReplaceableAbstractWord).replaceWord) { + text = (n.word as ReplaceableAbstractWord).replaceWord(text, old, replacement); + } + } + return [ + text, + ...this.replaceToken( + nodes.flatMap((n) => n.children), + tokens, + index + 1, + old, + replacement, + ), + ]; + } } export namespace TreeBuilder { @@ -277,7 +332,11 @@ class LabelListWord implements ReplaceableAbstractWord { completionOptions(word: string): WordCompletion[] { const parts = word.split(","); const lastPart = parts[parts.length - 1]; - return this.labelWord.completionOptions(lastPart); + const prefixLength = parts.slice(0, -1).reduce((acc, part) => acc + part.length + 1, 0); // +1 for the commas + return this.labelWord.completionOptions(lastPart).map((c) => ({ + ...c, + startOffset: prefixLength + (c.startOffset ?? 0), + })); } verifyWord(word: string): string[] { @@ -298,7 +357,8 @@ class LabelListWord implements ReplaceableAbstractWord { class InputWord extends InputAwareWord implements ReplaceableAbstractWord { completionOptions(): WordCompletion[] { - return this.getAvailableInputs().map((input) => ({ + const inputs = this.getAvailableInputs(); + return inputs.map((input) => ({ insertText: input, kind: monaco.languages.CompletionItemKind.Variable, })); @@ -313,9 +373,8 @@ class InputWord extends InputAwareWord implements ReplaceableAbstractWord { } replaceWord(text: string, old: string, replacement: string) { - const availableInputs = this.getAvailableInputs(); - if (availableInputs.includes(old)) { - return text.replace(old, replacement); + if (text == old) { + return replacement; } return text; } diff --git a/src/features/dfdElements/behaviorRefactorer.ts b/src/features/dfdElements/behaviorRefactorer.ts index 46c9f21..c70d31a 100644 --- a/src/features/dfdElements/behaviorRefactorer.ts +++ b/src/features/dfdElements/behaviorRefactorer.ts @@ -1,5 +1,5 @@ -import { inject, injectable, optional } from "inversify"; -import { LabelType, LabelTypeRegistry, LabelTypeValue } from "../labels/labelTypeRegistry"; +import { inject, injectable } from "inversify"; +import { LabelType, LabelTypeRegistry } from "../labels/labelTypeRegistry"; import { Command, CommandExecutionContext, @@ -10,22 +10,18 @@ import { SEdgeImpl, SLabelImpl, SModelElementImpl, + SModelRootImpl, SParentElementImpl, TYPES, } from "sprotty"; import { DfdInputPortImpl, DfdOutputPortImpl } from "./ports"; import { ApplyLabelEditAction } from "sprotty-protocol"; import { DfdNodeImpl } from "./nodes"; +import { ReplaceAutoCompleteTree, TreeBuilder } from "./AssignmentLanguage"; -interface LabelTypeChange { - oldLabelType: LabelType; - newLabelType: LabelType; -} - -interface LabelTypeValueChange { - labelType: LabelType; - oldLabelValue: LabelTypeValue; - newLabelValue: LabelTypeValue; +interface LabelChange { + oldLabel: string; + newLabel: string; } /** @@ -38,7 +34,7 @@ export class DFDBehaviorRefactorer { private previousLabelTypes: LabelType[] = []; constructor( - @optional() @inject(LabelTypeRegistry) private readonly registry: LabelTypeRegistry | undefined, + @inject(LabelTypeRegistry) private readonly registry: LabelTypeRegistry, @inject(TYPES.ILogger) private readonly logger: ILogger, @inject(TYPES.ICommandStack) private readonly commandStack: ICommandStack, ) { @@ -54,56 +50,59 @@ export class DFDBehaviorRefactorer { private async handleLabelUpdate(): Promise { this.logger.log(this, "Handling label type registry update"); - const currentLabelTypes = this.registry?.getLabelTypes() ?? []; - - const changedLabelTypes: LabelTypeChange[] = currentLabelTypes.flatMap((currentLabelType) => { - const previousLabelType = this.previousLabelTypes.find( - (previousLabelType) => previousLabelType.id === currentLabelType.id, - ); - if (previousLabelType && previousLabelType.name !== currentLabelType.name) { - return [{ oldLabelType: previousLabelType, newLabelType: currentLabelType }]; - } - return []; - }); + const currentLabelTypes = this.registry.getLabelTypes() ?? []; - const changedLabelValues: LabelTypeValueChange[] = currentLabelTypes.flatMap((currentLabelType) => { - const previousLabelType = this.previousLabelTypes.find( - (previousLabelType) => previousLabelType.id === currentLabelType.id, - ); - if (!previousLabelType) { - return []; + const changedLabels: LabelChange[] = []; + for (const newLabel of currentLabelTypes) { + const oldLabel = this.previousLabelTypes.find((label) => label.id === newLabel.id); + if (!oldLabel) { + continue; } - - return currentLabelType.values - .flatMap((newLabelValue) => { - const oldLabelValue = previousLabelType.values.find( - (oldLabelValue) => oldLabelValue.id === newLabelValue.id, - ); - if (!oldLabelValue) { - return []; + if (oldLabel.name !== newLabel.name) { + for (const newValue of newLabel.values) { + const oldValue = oldLabel.values.find((value) => value.id === newValue.id); + if (!oldValue) { + continue; } + changedLabels.push({ + oldLabel: `${oldLabel.name}.${oldValue.text}`, + newLabel: `${newLabel.name}.${newValue.text}`, + }); + } + } + for (const newValue of newLabel.values) { + const oldValue = oldLabel.values.find((value) => value.id === newValue.id); + if (!oldValue) { + continue; + } + if (oldValue.text !== newValue.text) { + changedLabels.push({ + oldLabel: `${newLabel.name}.${oldValue.text}`, + newLabel: `${newLabel.name}.${newValue.text}`, + }); + } + } + } - return [[oldLabelValue, newLabelValue]]; - }) - .filter(([oldLabelValue, newLabelValue]) => oldLabelValue.text !== newLabelValue?.text) - .map(([oldLabelValue, newLabelValue]) => ({ - labelType: currentLabelType, - oldLabelValue, - newLabelValue, - })); - }); - - this.logger.log(this, "Changed label types", changedLabelTypes); - this.logger.log(this, "Changed label values", changedLabelValues); + this.logger.log(this, "Changed labels", changedLabels); const model = await this.commandStack.executeAll([]); + const tree = new ReplaceAutoCompleteTree(TreeBuilder.buildTree(model, this.registry)); this.traverseDfdOutputPorts(model, (port) => { - this.processLabelRenameForPort(port, changedLabelTypes, changedLabelValues); + this.renameLabelsForPort(port, changedLabels, tree); }); this.previousLabelTypes = structuredClone(currentLabelTypes); } + private renameLabelsForPort(port: DfdOutputPortImpl, labelChanges: LabelChange[], tree: ReplaceAutoCompleteTree) { + let lines = port.behavior.split(/\n/); + for (const change of labelChanges) { + lines = tree.replace(lines, change.oldLabel, change.newLabel); + } + port.behavior = lines.join("\n"); + } + private traverseDfdOutputPorts(element: SModelElementImpl, cb: (port: DfdOutputPortImpl) => void) { if (element instanceof DfdOutputPortImpl) { cb(element); @@ -114,56 +113,12 @@ export class DFDBehaviorRefactorer { } } - private processLabelRenameForPort( - port: DfdOutputPortImpl, - changedLabelTypes: LabelTypeChange[], - changedLabelValues: LabelTypeValueChange[], - ): void { - const behaviorLines = port.behavior.split("\n"); - const newBehaviorLines = behaviorLines.map((line) => { - if (!line.startsWith("set")) { - return line; - } - - // replace the old label type with the new one using a regex (\.?oldLabelType\.) - // and ensure before it is a non alphanumeric character. - // Otherwise it could replace a substring when a type has the same ending as another type. - // Also ensure after it is a dot because after a label type there is always a dot to access the value of the label. - let newLine = line; - changedLabelTypes.forEach((changedLabelType) => { - newLine = newLine.replace( - // eslint-disable-next-line no-useless-escape - new RegExp(`([^a-zA-Z0-9_])${changedLabelType.oldLabelType.name}(\.)`, "g"), - `$1${changedLabelType.newLabelType.name}$2`, - ); - }); - - // replace the old label value with the new one using a regex (oldLabelType\.oldLabelValue) - // and ensure before and after it is a non alphanumeric character or the end of the line. - // Otherwise it could replace a substring when a value has the same beginning as another value - // or the type has the same ending as another type - changedLabelValues.forEach((changedLabelValue) => { - newLine = newLine.replace( - new RegExp( - // eslint-disable-next-line no-useless-escape - `([^a-zA-Z0-9_])${changedLabelValue.labelType.name}\.${changedLabelValue.oldLabelValue.text}([^a-zA-Z0-9_]|$)`, - "g", - ), - `$1${changedLabelValue.labelType.name}.${changedLabelValue.newLabelValue.text}$2`, - ); - }); - - return newLine; - }); - - port.behavior = newBehaviorLines.join("\n"); - } - processInputLabelRename( label: SLabelImpl, port: DfdInputPortImpl, oldLabelText: string, newLabelText: string, + root: SModelRootImpl, ): Map { label.text = oldLabelText; const oldInputName = port.getName(); @@ -176,41 +131,27 @@ export class DFDBehaviorRefactorer { return behaviorChanges; } + const tree = new ReplaceAutoCompleteTree(TreeBuilder.buildTree(root, this.registry)); + node.children.forEach((child) => { if (!(child instanceof DfdOutputPortImpl)) { return; } - behaviorChanges.set(child.id, this.processInputRenameForPort(child, oldInputName, newInputName)); + behaviorChanges.set(child.id, this.processInputRenameForPort(child, oldInputName, newInputName, tree)); }); return behaviorChanges; } - private processInputRenameForPort(port: DfdOutputPortImpl, oldInputName: string, newInputName: string): string { + private processInputRenameForPort( + port: DfdOutputPortImpl, + oldInputName: string, + newInputName: string, + tree: ReplaceAutoCompleteTree, + ): string { const lines = port.behavior.split("\n"); - const newLines = lines.map((line) => { - if (line.startsWith("forward")) { - const inputString = line.substring("forward".length); - // Update all inputs. Must be surrounded by non-alphanumeric characters to avoid replacing substrings of other inputs. - const updatedInputs = inputString.replace( - new RegExp(`([^a-zA-Z0-9])${oldInputName}([^a-zA-Z0-9]|$)`, "g"), - `$1${newInputName}$2`, - ); - return `forward ${updatedInputs.trim()}`; - } else if (line.startsWith("set")) { - // Before the input name there is always a space. After it must be a dot to access the label type - // inside the input. We can use these two constraints to identify the input name - // and only change inputs with that name. Label types/values with the same name are not replaced - // because of these constraints. - // eslint-disable-next-line no-useless-escape - return line.replace(new RegExp(`( )${oldInputName}(\.)`, "g"), `$1${newInputName}$2`); - } else { - // Idk what to do with this line, just return it as is - return line; - } - }); - + const newLines = tree.replace(lines, oldInputName, newInputName); return newLines.join("\n"); } } @@ -268,6 +209,7 @@ export class RefactorInputNameInDFDBehaviorCommand extends Command { port, oldInputName, newInputName, + context.root, ); behaviorChanges.forEach((updatedBehavior, id) => { const port = context.root.index.getById(id); From 82c9cd84ecf12d282de5504ff40efb6626365243 Mon Sep 17 00:00:00 2001 From: Alexander Vogt Date: Wed, 30 Jul 2025 12:16:56 +0200 Subject: [PATCH 4/5] add replacement type --- .../dfdElements/AssignmentLanguage.ts | 50 ++++++++++--------- .../dfdElements/behaviorRefactorer.ts | 4 +- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/src/features/dfdElements/AssignmentLanguage.ts b/src/features/dfdElements/AssignmentLanguage.ts index d0c3050..be0c723 100644 --- a/src/features/dfdElements/AssignmentLanguage.ts +++ b/src/features/dfdElements/AssignmentLanguage.ts @@ -76,8 +76,14 @@ export const assignemntLanguageMonarchDefinition: monaco.languages.IMonarchLangu }, }; +interface ReplacementData { + old: string; + replacement: string; + type: string; +} + interface ReplaceableAbstractWord extends AbstractWord { - replaceWord(text: string, old: string, replacement: string): string; + replaceWord(text: string, replacement: ReplacementData): string; } type WordOrReplacableWord = ReplaceableAbstractWord | AbstractWord; @@ -87,9 +93,9 @@ export class ReplaceAutoCompleteTree extends AutoCompleteTree { super(roots); } - public replace(lines: string[], old: string, replacement: string): string[] { + public replace(lines: string[], replacement: ReplacementData): string[] { const tokens = this.tokenize(lines); - const replaced = this.replaceToken(this.roots, tokens, 0, old, replacement); + const replaced = this.replaceToken(this.roots, tokens, 0, replacement); const newLines: string[] = []; let currentLine = ""; for (let i = 0; i < tokens.length; i++) { @@ -109,8 +115,7 @@ export class ReplaceAutoCompleteTree extends AutoCompleteTree { nodes: AutoCompleteNode[], tokens: Token[], index: number, - old: string, - replacement: string, + replacement: ReplacementData, skipStartCheck = false, ): string[] { if (index >= tokens.length) { @@ -120,13 +125,13 @@ export class ReplaceAutoCompleteTree extends AutoCompleteTree { if (!skipStartCheck && tokens[index].column == 1) { const matchesAnyRoot = this.roots.some((n) => n.word.verifyWord(tokens[index].text).length === 0); if (matchesAnyRoot) { - return this.replaceToken(this.roots, tokens, index, old, replacement, true); + return this.replaceToken(this.roots, tokens, index, replacement, true); } } let text = tokens[index].text; for (const n of nodes) { if ((n.word as ReplaceableAbstractWord).replaceWord) { - text = (n.word as ReplaceableAbstractWord).replaceWord(text, old, replacement); + text = (n.word as ReplaceableAbstractWord).replaceWord(text, replacement); } } return [ @@ -135,7 +140,6 @@ export class ReplaceAutoCompleteTree extends AutoCompleteTree { nodes.flatMap((n) => n.children), tokens, index + 1, - old, replacement, ), ]; @@ -314,9 +318,9 @@ class LabelWord implements ReplaceableAbstractWord { return []; } - replaceWord(text: string, old: string, replacement: string) { - if (text == old) { - return replacement; + replaceWord(text: string, replacement: ReplacementData) { + if (replacement.type == "Label" && text == replacement.old) { + return replacement.replacement; } return text; } @@ -348,9 +352,9 @@ class LabelListWord implements ReplaceableAbstractWord { return errors; } - replaceWord(text: string, old: string, replacement: string) { + replaceWord(text: string, replacement: ReplacementData) { const parts = text.split(","); - const newParts = parts.map((part) => this.labelWord.replaceWord(part, old, replacement)); + const newParts = parts.map((part) => this.labelWord.replaceWord(part, replacement)); return newParts.join(","); } } @@ -372,9 +376,9 @@ class InputWord extends InputAwareWord implements ReplaceableAbstractWord { return [`Unknown input "${word}"`]; } - replaceWord(text: string, old: string, replacement: string) { - if (text == old) { - return replacement; + replaceWord(text: string, replacement: ReplacementData) { + if (replacement.type == "Input" && text == replacement.old) { + return replacement.replacement; } return text; } @@ -411,9 +415,9 @@ class InputListWord implements ReplaceableAbstractWord { return errors; } - replaceWord(text: string, old: string, replacement: string) { + replaceWord(text: string, replacement: ReplacementData) { const parts = text.split(","); - const newParts = parts.map((part) => this.inputWord.replaceWord(part, old, replacement)); + const newParts = parts.map((part) => this.inputWord.replaceWord(part, replacement)); return newParts.join(","); } } @@ -457,12 +461,12 @@ class InputLabelWord implements ReplaceableAbstractWord { return [...inputErrors, ...labelErrors]; } - replaceWord(text: string, old: string, replacement: string) { + replaceWord(text: string, replacement: ReplacementData) { const [input, label] = this.getParts(text); - if (input === old) { - return replacement + (label ? "." + label : ""); - } else if (label === old) { - return input + "." + replacement; + if (replacement.type == "Input" && input === replacement.old) { + return replacement.replacement + (label ? "." + label : ""); + } else if (replacement.type == "Label" && label === replacement.old) { + return input + "." + replacement.replacement; } return text; } diff --git a/src/features/dfdElements/behaviorRefactorer.ts b/src/features/dfdElements/behaviorRefactorer.ts index c70d31a..48025aa 100644 --- a/src/features/dfdElements/behaviorRefactorer.ts +++ b/src/features/dfdElements/behaviorRefactorer.ts @@ -98,7 +98,7 @@ export class DFDBehaviorRefactorer { private renameLabelsForPort(port: DfdOutputPortImpl, labelChanges: LabelChange[], tree: ReplaceAutoCompleteTree) { let lines = port.behavior.split(/\n/); for (const change of labelChanges) { - lines = tree.replace(lines, change.oldLabel, change.newLabel); + lines = tree.replace(lines, { old: change.oldLabel, replacement: change.newLabel, type: "Label" }); } port.behavior = lines.join("\n"); } @@ -151,7 +151,7 @@ export class DFDBehaviorRefactorer { tree: ReplaceAutoCompleteTree, ): string { const lines = port.behavior.split("\n"); - const newLines = tree.replace(lines, oldInputName, newInputName); + const newLines = tree.replace(lines, { old: oldInputName, replacement: newInputName, type: "Input" }); return newLines.join("\n"); } } From 0d0f2627b9df274b18bf47e3535933749ac6243d Mon Sep 17 00:00:00 2001 From: Alexander Vogt Date: Wed, 30 Jul 2025 12:40:05 +0200 Subject: [PATCH 5/5] fix completions at start of line --- src/features/constraintMenu/AutoCompletion.ts | 8 +- src/features/dfdElements/di.config.ts | 3 - .../outputPortBehaviorValidation.ts | 534 ------------------ 3 files changed, 6 insertions(+), 539 deletions(-) delete mode 100644 src/features/dfdElements/outputPortBehaviorValidation.ts diff --git a/src/features/constraintMenu/AutoCompletion.ts b/src/features/constraintMenu/AutoCompletion.ts index cd01594..6877bac 100644 --- a/src/features/constraintMenu/AutoCompletion.ts +++ b/src/features/constraintMenu/AutoCompletion.ts @@ -205,6 +205,7 @@ export class AutoCompleteTree { column: lines[lines.length - 1].length + 1, }); } + let result: WordCompletion[] = []; if (tokens.length == 0) { for (const r of this.roots) { @@ -220,13 +221,16 @@ export class AutoCompleteTree { nodes: AutoCompleteNode[], tokens: Token[], index: number, + cameFromFinal = false, skipStartCheck = false, ): WordCompletion[] { // check for new start if (!skipStartCheck && tokens[index].column == 1) { const matchesAnyRoot = this.roots.some((n) => n.word.verifyWord(tokens[index].text).length === 0); if (matchesAnyRoot) { - return this.completeNode(this.roots, tokens, index, true); + return this.completeNode(this.roots, tokens, index, cameFromFinal, true); + } else if (cameFromFinal || nodes.length == 0) { + return this.completeNode([...this.roots, ...nodes], tokens, index, cameFromFinal, true); } } @@ -241,7 +245,7 @@ export class AutoCompleteTree { if (n.word.verifyWord(tokens[index].text).length > 0) { continue; } - result = result.concat(this.completeNode(n.children, tokens, index + 1)); + result = result.concat(this.completeNode(n.children, tokens, index + 1, n.canBeFinal || false)); } return result; } diff --git a/src/features/dfdElements/di.config.ts b/src/features/dfdElements/di.config.ts index 3ce2543..86ef126 100644 --- a/src/features/dfdElements/di.config.ts +++ b/src/features/dfdElements/di.config.ts @@ -18,7 +18,6 @@ import { FilledBackgroundLabelView, DfdPositionalLabelView } from "./labels"; import { AlwaysSnapPortsMoveMouseListener, ReSnapPortsAfterLabelChangeCommand, PortAwareSnapper } from "./portSnapper"; import { OutputPortEditUIMouseListener, OutputPortEditUI, SetDfdOutputPortBehaviorCommand } from "./outputPortEditUi"; import { DfdEditLabelValidator, DfdEditLabelValidatorDecorator } from "./editLabelValidator"; -import { PortBehaviorValidator } from "./outputPortBehaviorValidation"; import { DfdNodeAnnotationUI, DfdNodeAnnotationUIMouseListener } from "./nodeAnnotationUi"; import { DFDBehaviorRefactorer, RefactorInputNameInDFDBehaviorCommand } from "./behaviorRefactorer"; @@ -32,8 +31,6 @@ export const dfdElementsModule = new ContainerModule((bind, unbind, isBound, reb bind(TYPES.MouseListener).to(AlwaysSnapPortsMoveMouseListener).inSingletonScope(); configureCommand(context, ReSnapPortsAfterLabelChangeCommand); - bind(PortBehaviorValidator).toSelf().inSingletonScope(); - bind(OutputPortEditUI).toSelf().inSingletonScope(); bind(TYPES.IUIExtension).toService(OutputPortEditUI); bind(SWITCHABLE).toService(OutputPortEditUI); diff --git a/src/features/dfdElements/outputPortBehaviorValidation.ts b/src/features/dfdElements/outputPortBehaviorValidation.ts deleted file mode 100644 index 44179a6..0000000 --- a/src/features/dfdElements/outputPortBehaviorValidation.ts +++ /dev/null @@ -1,534 +0,0 @@ -import { inject, injectable, optional } from "inversify"; -import { LabelTypeRegistry } from "../labels/labelTypeRegistry"; -import { DfdNodeImpl } from "./nodes"; -import { DfdOutputPortImpl } from "./ports"; - -/** - * Validation error for a single line of the behavior text of a dfd output port. - */ -interface PortBehaviorValidationError { - message: string; - // line and column numbers start at 0! - line: number; - colStart?: number; - colEnd?: number; -} - -/** - * Validates the behavior text of a dfd output port (DfdOutputPortImpl). - * Used inside the OutputPortEditUI. - */ -@injectable() -export class PortBehaviorValidator { - // RegEx validating names of input pins - private static readonly INPUT_LABEL_REGEX = /[A-Za-z0-9_~][A-Za-z0-9_~|]+/; - - // RegEx validating names of output labels - private static readonly OUTPUT_LABEL_REGEX = /[A-Za-z0-9_]+\.[A-Za-z0-9_]+/; - - // Regex that validates a term - // Has the label type and label value that should be set as capturing groups. - private static readonly TERM_REGEX = - /(?:\s*|!|TRUE|FALSE|\|\||&&|\(|\)|(?:[A-Za-z0-9_]+\.[A-Za-z0-9_]+(?![A-Za-z0-9_]*\.[A-Za-z0-9_]*)))+/g; - - // Regex that validates assignments - // Matches "assign out_labels if term from in_pins" where out_labels is a comma separated list of output labels, in_pins is a comma separated list of input pins and the from part is optional. - private static readonly ASSIGNMENT_REGEX = new RegExp( - "^assign (" + - PortBehaviorValidator.BUILD_COMMA_SEPARATED_LIST_REGEX(PortBehaviorValidator.OUTPUT_LABEL_REGEX).source + - ") if (" + - PortBehaviorValidator.TERM_REGEX.source + - ")(?: from (" + - PortBehaviorValidator.BUILD_COMMA_SEPARATED_LIST_REGEX(PortBehaviorValidator.INPUT_LABEL_REGEX).source + - "))?$", - ); - - // Regex that validates forwarding - // Matches "forward input_pins" where input_pins is a comma separated list of input pins. - private static readonly FORWARDING_REGEX = new RegExp( - "^forward " + - PortBehaviorValidator.BUILD_COMMA_SEPARATED_LIST_REGEX(PortBehaviorValidator.INPUT_LABEL_REGEX).source + - "$", - ); - - private static readonly SET_AND_UNSET_REGEX = new RegExp( - "^(un)?set " + - PortBehaviorValidator.BUILD_COMMA_SEPARATED_LIST_REGEX(PortBehaviorValidator.OUTPUT_LABEL_REGEX).source + - "$", - ); - - // Regex matching alphanumeric characters. - public static readonly REGEX_ALPHANUMERIC = /[A-Za-z0-9_|]+/; - - constructor(@inject(LabelTypeRegistry) @optional() private readonly labelTypeRegistry?: LabelTypeRegistry) {} - - /** - * validates the whole behavior text of a port. - * @param behaviorText the behavior text to validate - * @param port the port that the behavior text should be tested against (relevant for available inputs) - * @returns errors, if everything is fine the array is empty - */ - validate(behaviorText: string, port: DfdOutputPortImpl): PortBehaviorValidationError[] { - const lines = behaviorText.split("\n"); - const errors: PortBehaviorValidationError[] = []; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const lineErrors = this.validateLine(line, i, port); - if (lineErrors) { - const errorsCols = lineErrors.map((error) => { - // Set cols to start/end of line if not set. - error.colEnd ??= line.length; - error.colStart ??= 0; - - return error; - }); - - errors.push(...errorsCols); - } - } - - return errors; - } - - /** - * Validates a single line and returns an error message if the line is invalid. - * Otherwise returns undefined. - */ - private validateLine( - line: string, - lineNumber: number, - port: DfdOutputPortImpl, - ): PortBehaviorValidationError[] | undefined { - if (line === "" || line.startsWith("#") || line.startsWith("//")) { - return; - } - - if (line.startsWith("forward")) { - return this.validateForwardStatement(line, lineNumber, port); - } - - if (line.startsWith("set") || line.startsWith("unset")) { - return this.validateSetAndUnsetStatement(line, lineNumber); - } - - if (line.startsWith("assign")) { - return this.validateAssignStatement(line, lineNumber, port); - } - - return [ - { - line: lineNumber, - message: "Unknown statement", - }, - ]; - } - - private validateForwardStatement( - line: string, - lineNumber: number, - port: DfdOutputPortImpl, - ): PortBehaviorValidationError[] | undefined { - const match = line.match(PortBehaviorValidator.FORWARDING_REGEX); - if (!match) { - return [ - { - line: lineNumber, - message: "invalid forwarding(Template: forward input_pins)", - }, - ]; - } - - const inputsString = line.substring("forward ".length); - const inputs = inputsString.split(",").map((input) => input.trim()); - if (inputs.filter((input) => input !== "").length === 0) { - return [ - { - line: lineNumber, - message: "forward needs at least one input", - }, - ]; - } - - const emptyInput = inputs.findIndex((input) => input === ""); - if (emptyInput !== -1) { - // Find position of empty input given the index of the empty input. - let emptyInputPosition = line.indexOf(","); - for (let i = 1; i < emptyInput; i++) { - emptyInputPosition = line.indexOf(",", emptyInputPosition + 1); - } - - return [ - { - line: lineNumber, - message: "trailing comma without being followed by an input", - colStart: emptyInputPosition, - colEnd: emptyInputPosition + 1, - }, - ]; - } - - const duplicateInputs = inputs.filter((input) => inputs.filter((i) => i === input).length > 1); - if (duplicateInputs.length > 0) { - const distinctDuplicateInputs = [...new Set(duplicateInputs)]; - - return distinctDuplicateInputs.flatMap((input) => { - // find all occurrences of the duplicate input - const indices = []; - let idx = line.indexOf(input); - while (idx !== -1) { - // Ensure this is not a substring of another input by - // ensuring the character before and after the input are not alphanumeric. - // E.g. Input "te" should not detect input "test" as a duplicate of "te". - if ( - !line[idx - 1]?.match(PortBehaviorValidator.REGEX_ALPHANUMERIC) && - !line[idx + input.length]?.match(PortBehaviorValidator.REGEX_ALPHANUMERIC) - ) { - indices.push(idx); - } - - idx = line.indexOf(input, idx + 1); - } - - // Create an error for each occurrence of the duplicate input - return indices.map((index) => ({ - line: lineNumber, - message: `duplicate input: ${input}`, - colStart: index, - colEnd: index + input.length, - })); - }); - } - - const node = port.parent; - if (!(node instanceof DfdNodeImpl)) { - throw new Error("Expected port parent to be a DfdNodeImpl."); - } - - const availableInputs = node.getAvailableInputs(); - - const unavailableInputs = inputs.filter((input) => !availableInputs.includes(input)); - if (unavailableInputs.length > 0) { - return unavailableInputs.map((input) => { - let foundCorrectInput = false; - let idx = line.indexOf(input); - while (!foundCorrectInput) { - // Ensure this is not a substring of another input. - // Same as above. - foundCorrectInput = - !line[idx - 1]?.match(PortBehaviorValidator.REGEX_ALPHANUMERIC) && - !line[idx + input.length]?.match(PortBehaviorValidator.REGEX_ALPHANUMERIC); - - if (!foundCorrectInput) { - idx = line.indexOf(input, idx + 1); - } - } - - return { - line: lineNumber, - message: `invalid/unknown input: ${input}`, - colStart: idx, - colEnd: idx + input.length, - }; - }); - } - - return undefined; - } - - private validateSetAndUnsetStatement(line: string, lineNumber: number): PortBehaviorValidationError[] | undefined { - const match = line.match(PortBehaviorValidator.SET_AND_UNSET_REGEX); - if (!match) { - return [ - { - line: lineNumber, - message: - "invalid assignment(Template:" + - (line.startsWith("set") ? "set" : "unset") + - " out_labels)", - }, - ]; - } - - const inputAccessErrors = []; - - const outLabel = line - .substring((line.startsWith("set") ? "set" : "unset").length + 1) - .trim() - .split(",") - .map((variable) => variable.trim()); - - for (const typeValuePair of outLabel) { - if (typeValuePair === "") continue; - - const inputLabelType = typeValuePair.split(".")[0].trim(); - - if (typeValuePair.indexOf(".") !== -1) { - if (typeValuePair.split(".")[1] === null || typeValuePair.split(".")[1] === "") continue; - const inputLabelValue = typeValuePair.split(".")[1].trim(); - - const inputLabelTypeObject = this.labelTypeRegistry - ?.getLabelTypes() - .find((type) => type.name === inputLabelType); - if (!inputLabelTypeObject) { - let idx = line.indexOf(inputLabelType); - while (idx !== -1) { - // Check that this is not a substring of another label type. - if (line[idx + inputLabelType.length] === ".") { - inputAccessErrors.push({ - line: lineNumber, - message: `unknown label type: ${inputLabelType}`, - colStart: idx, - colEnd: idx + inputLabelType.length, - }); - } - - idx = line.indexOf(inputLabelType, idx + 1); - } - } else if (!inputLabelTypeObject.values.find((value) => value.text === inputLabelValue)) { - let idx = line.indexOf(inputLabelValue); - while (idx !== -1) { - // Check that this is not a substring of another label value. - if ( - // must start after a dot and end at the end of the alphanumeric text - line[idx - 1] === "." && - // Might be at the end of the line - (!line[idx + inputLabelValue.length] || - !line[idx + inputLabelValue.length].match(PortBehaviorValidator.REGEX_ALPHANUMERIC)) - ) { - inputAccessErrors.push({ - line: lineNumber, - message: `unknown label value of label type ${inputLabelType}: ${inputLabelValue}`, - colStart: idx, - colEnd: idx + inputLabelValue.length, - }); - } - - idx = line.indexOf(inputLabelValue, idx + 1); - } - } - } - - if (typeValuePair.split(".")[2] !== undefined) { - inputAccessErrors.push({ - line: lineNumber, - message: `invalid label definition`, - }); - } - } - - return inputAccessErrors.length > 0 ? inputAccessErrors : []; - } - - private validateAssignStatement( - line: string, - lineNumber: number, - port: DfdOutputPortImpl, - ): PortBehaviorValidationError[] | undefined { - const match = line.match(PortBehaviorValidator.ASSIGNMENT_REGEX); - if (!match) { - return [ - { - line: lineNumber, - message: "invalid assignment(Template:assign out_labels if term from in_pins)", - }, - ]; - } - - // Extract all used inputs, label types and the corresponding label values. - const term = match[2]; - - const termMatch = term.match(PortBehaviorValidator.TERM_REGEX); - if (term == "" || !termMatch) { - return [ - { - line: lineNumber, - message: "invalid term", - }, - ]; - } - - const matches = [ - ...term.matchAll(new RegExp("(" + PortBehaviorValidator.OUTPUT_LABEL_REGEX.source + ")", "g")), - ]; - const inputAccessErrors = []; - - for (const inputMatch of matches) { - const inputLabelType = inputMatch[1]; - const inputLabelValue = inputMatch[2]; - - const inputLabelTypeObject = this.labelTypeRegistry - ?.getLabelTypes() - .find((type) => type.name === inputLabelType); - if (!inputLabelTypeObject) { - let idx = line.indexOf(inputLabelType); - while (idx !== -1) { - // Check that this is not a substring of another label type. - if ( - // must start after a dot and end before a dot - line[idx - 1] === "." && - line[idx + inputLabelType.length] === "." - ) { - inputAccessErrors.push({ - line: lineNumber, - message: `unknown label type: ${inputLabelType}`, - colStart: idx, - colEnd: idx + inputLabelType.length, - }); - } - - idx = line.indexOf(inputLabelType, idx + 1); - } - } else if ( - inputLabelValue === undefined || - inputLabelValue === "" || - !inputLabelTypeObject.values.find((value) => value.text === inputLabelValue) - ) { - let idx = line.indexOf(inputLabelValue); - while (idx !== -1) { - // Check that this is not a substring of another label value. - if ( - // must start after a dot and end at the end of the alphanumeric text - line[idx - 1] === "." && - // Might be at the end of the line - (!line[idx + inputLabelValue.length] || - !line[idx + inputLabelValue.length].match(PortBehaviorValidator.REGEX_ALPHANUMERIC)) - ) { - inputAccessErrors.push({ - line: lineNumber, - message: `unknown label value of label type ${inputLabelType}: ${inputLabelValue}`, - colStart: idx, - colEnd: idx + inputLabelValue.length, - }); - } - - idx = line.indexOf(inputLabelValue, idx + 1); - } - } - - if (inputMatch[3] !== undefined) { - inputAccessErrors.push({ - line: lineNumber, - message: `invalid label definition`, - }); - } - } - - const node = port.parent; - if (!(node instanceof DfdNodeImpl)) { - throw new Error("Expected port parent to be a DfdNodeImpl."); - } - const availableInputs = node.getAvailableInputs(); - - const outLabel = match[1].split(",").map((variable) => variable.trim()); - const inPorts = match[3] ? match[3].split(",").map((variable) => variable.trim()) : []; - - // Check for each input access that the input exists and that the label type and value are valid. - - for (const inPortName of inPorts) { - if (!availableInputs.includes(inPortName) && inPortName !== "") { - // Find all occurrences of the unavailable input. - const idx = line.indexOf(inPortName); - inputAccessErrors.push({ - line: lineNumber, - message: `invalid/unknown input: ${inPortName}`, - colStart: idx, - colEnd: idx + inPortName.length, - }); - - continue; - } - } - - for (const typeValuePair of outLabel) { - if (typeValuePair === "") continue; - - const inputLabelType = typeValuePair.split(".")[0].trim(); - const inputLabelTypeObject = this.labelTypeRegistry - ?.getLabelTypes() - .find((type) => type.name === inputLabelType); - if (!inputLabelTypeObject) { - let idx = line.indexOf(inputLabelType); - while (idx !== -1) { - // Check that this is not a substring of another label type. - if ( - // must start after a dot and end before a dot - line[idx - 1] === "." && - line[idx + inputLabelType.length] === "." - ) { - inputAccessErrors.push({ - line: lineNumber, - message: `unknown label type: ${inputLabelType}`, - colStart: idx, - colEnd: idx + inputLabelType.length, - }); - } - - idx = line.indexOf(inputLabelType, idx + 1); - } - } - - if (typeValuePair.indexOf(".") !== -1) { - if (typeValuePair.split(".")[1] === null || typeValuePair.split(".")[1] === "") continue; - const inputLabelValue = typeValuePair.split(".")[1].trim(); - - const inputLabelTypeObject = this.labelTypeRegistry - ?.getLabelTypes() - .find((type) => type.name === inputLabelType); - if (!inputLabelTypeObject) { - let idx = line.indexOf(inputLabelType); - while (idx !== -1) { - // Check that this is not a substring of another label type. - if ( - // must start after a dot and end before a dot - line[idx - 1] === "." && - line[idx + inputLabelType.length] === "." - ) { - inputAccessErrors.push({ - line: lineNumber, - message: `unknown label type: ${inputLabelType}`, - colStart: idx, - colEnd: idx + inputLabelType.length, - }); - } - - idx = line.indexOf(inputLabelType, idx + 1); - } - } else if (!inputLabelTypeObject.values.find((value) => value.text === inputLabelValue)) { - let idx = line.indexOf(inputLabelValue); - while (idx !== -1) { - // Check that this is not a substring of another label value. - if ( - // must start after a dot and end at the end of the alphanumeric text - line[idx - 1] === "." && - // Might be at the end of the line - (!line[idx + inputLabelValue.length] || - !line[idx + inputLabelValue.length].match(PortBehaviorValidator.REGEX_ALPHANUMERIC)) - ) { - inputAccessErrors.push({ - line: lineNumber, - message: `unknown label value of label type ${inputLabelType}: ${inputLabelValue}`, - colStart: idx, - colEnd: idx + inputLabelValue.length, - }); - } - - idx = line.indexOf(inputLabelValue, idx + 1); - } - } - } - - if (typeValuePair.split(".")[2] !== undefined) { - inputAccessErrors.push({ - line: lineNumber, - message: `invalid label definition`, - }); - } - } - - return inputAccessErrors.length > 0 ? inputAccessErrors : []; - } - - private static BUILD_COMMA_SEPARATED_LIST_REGEX(regex: RegExp): RegExp { - return new RegExp(regex.source + "(?:, *" + regex.source + ")*"); - } -}