diff --git a/src/libs/data/bedrock/MolangData.ts b/src/libs/data/bedrock/MolangData.ts new file mode 100644 index 000000000..68cc12e8c --- /dev/null +++ b/src/libs/data/bedrock/MolangData.ts @@ -0,0 +1,118 @@ +import { BedrockProject } from '@/libs/project/BedrockProject' +import { Data } from '../Data' +import { Requirements } from './RequirementsMatcher' + +export interface MolangFunctionArgument { + argumentName: string + type?: 'string' | 'number' + additionalData?: { + values?: string[] + schemaReference?: string + } +} + +export interface MolangValueDefinition { + valueName: string + description?: string + isProperty?: boolean + arguments?: MolangFunctionArgument[] + isDeprecated?: boolean + deprecationMessage?: string +} + +export interface MolangDefinition { + requires?: Requirements + namespace: string[] + values: MolangValueDefinition[] +} + +export interface MolangContextDefinition { + fileType: string + data: MolangDefinition[] +} + +/** + * The Molang namespaces that always exist regardless of the schema. The schema + * only describes built-in values (e.g. `math.*`), but namespaces such as + * `variable`/`temp`/`context` are user-defined and therefore have no schema + * values, yet should still be offered as top-level suggestions. + */ +const standardNamespaces = ['math', 'query', 'q', 'variable', 'v', 'context', 'c', 'temp', 't'] + +/** + * Loads the Molang language schema shipped with the editor packages and exposes + * helpers for building context-sensitive auto-completions. + * + * The schema is split into two files: + * - `main.json` holds the vanilla definitions available everywhere. + * - `context.json` holds definitions that are only available within a specific + * file type (e.g. `v.particle_age` is only valid inside particle files). + */ +export class MolangData { + private baseData: any + private contextData: any + + constructor(public project: BedrockProject) {} + + public async setup() { + this.baseData = await Data.get('packages/minecraftBedrock/language/molang/main.json') + this.contextData = await Data.get('packages/minecraftBedrock/language/molang/context.json') + } + + /** + * Returns the definitions that are valid for the current project, optionally + * including the definitions specific to the given file type. + */ + public getSchema(fileType?: string): MolangDefinition[] { + const definitions: MolangDefinition[] = (this.baseData?.vanilla ?? []).concat( + fileType ? this.getContextSchema(fileType) : [] + ) + + return definitions.filter( + (definition) => !definition.requires || this.project.requirementsMatcher.matches(definition.requires) + ) + } + + /** + * Returns the context specific definitions for a file type, or every context + * definition if no file type is given. + */ + public getContextSchema(fileType?: string): MolangDefinition[] { + const contexts = (this.contextData?.contexts ?? []) as MolangContextDefinition[] + + if (!fileType) return contexts.flatMap((context) => context.data) + + return contexts.find((context) => context.fileType === fileType)?.data ?? [] + } + + /** + * Returns the unique list of namespaces available in the given context, + * e.g. `['math', 'query', 'q', 'variable', 'v']`. + */ + public getNamespaces(fileType?: string): string[] { + return [ + ...new Set([ + ...standardNamespaces, + ...this.getSchema(fileType).flatMap((definition) => definition.namespace), + ]), + ] + } + + /** + * Returns all values that belong to a namespace within the given context. + */ + public getValues(namespace: string, fileType?: string): MolangValueDefinition[] { + return this.getSchema(fileType) + .filter((definition) => definition.namespace.includes(namespace)) + .flatMap((definition) => definition.values) + .filter((value) => !value.isDeprecated) + } + + /** + * Returns the names of every value in the given context, used for syntax + * highlighting. + */ + public getAllValueNames(fileType?: string): string[] { + return [...new Set(this.getSchema(fileType).flatMap((definition) => definition.values.map((value) => value.valueName)))] + } +} diff --git a/src/libs/monaco/languages/Molang.ts b/src/libs/monaco/languages/Molang.ts index d2b353680..7779c7720 100644 --- a/src/libs/monaco/languages/Molang.ts +++ b/src/libs/monaco/languages/Molang.ts @@ -1,10 +1,13 @@ -import { MarkerSeverity, editor, languages } from 'monaco-editor' +import { CancellationToken, MarkerSeverity, Position, editor, languages } from 'monaco-editor' import { CustomMolang } from '@bridge-editor/molang' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { provideCompletionItems, provideInlineJsonCompletionItems } from './Molang/Completions' export function setupMolang() { languages.register({ id: 'molang', extensions: ['.molang'], aliases: ['molang'] }) - languages.setLanguageConfiguration('lang', { + languages.setLanguageConfiguration('molang', { comments: { lineComment: '#', }, @@ -33,36 +36,20 @@ export function setupMolang() { ], }) - languages.setMonarchTokensProvider('lang', { - ignoreCase: true, - brackets: [ - { open: '(', close: ')', token: 'delimiter.parenthesis' }, - { open: '[', close: ']', token: 'delimiter.square' }, - { open: '{', close: '}', token: 'delimiter.curly' }, - ], - keywords: ['return', 'loop', 'for_each', 'break', 'continue', 'this', 'function'], - identifiers: ['v', 't', 'c', 'q', 'f', 'a', 'arg', 'variable', 'temp', 'context', 'query'], - tokenizer: { - root: [ - [/#.*/, 'comment'], - [/'[^']'/, 'string'], - [/[0-9]+(\.[0-9]+)?/, 'number'], - [/true|false/, 'number'], - [/\=|\,|\!|%=|\*=|\+=|-=|\/=|<|=|>|<>/, 'definition'], - [ - /[a-z_$][\w$]*/, - { - cases: { - '@keywords': 'keyword', - '@identifiers': 'type.identifier', - '@default': 'identifier', - }, - }, - ], - ], - }, + languages.registerCompletionItemProvider('molang', { + triggerCharacters: ['.', '(', ',', "'"], + provideCompletionItems, + }) + + languages.registerCompletionItemProvider('json', { + triggerCharacters: ['.', '(', ',', "'"], + provideCompletionItems: provideInlineJsonCompletionItems, }) + // Keep syntax highlighting in sync with the Molang values available in the current project + ProjectManager.updatedCurrentProject.on(() => updateTokensProviderForCurrentProject()) + updateTokensProviderForCurrentProject() + const molang = new CustomMolang({}) editor.onDidCreateModel((model) => { @@ -89,3 +76,47 @@ export function setupMolang() { }) }) } + +function updateTokensProviderForCurrentProject() { + if (!(ProjectManager.currentProject instanceof BedrockProject)) { + updateTokensProvider([]) + + return + } + + updateTokensProvider(ProjectManager.currentProject.molangData.getAllValueNames()) +} + +function updateTokensProvider(valueNames: string[]) { + languages.setMonarchTokensProvider('molang', { + ignoreCase: true, + brackets: [ + { open: '(', close: ')', token: 'delimiter.parenthesis' }, + { open: '[', close: ']', token: 'delimiter.square' }, + { open: '{', close: '}', token: 'delimiter.curly' }, + ], + keywords: ['return', 'loop', 'for_each', 'break', 'continue', 'this', 'function'], + identifiers: ['v', 't', 'c', 'q', 'f', 'a', 'arg', 'variable', 'temp', 'context', 'query', 'math'], + values: valueNames, + tokenizer: { + root: [ + [/#.*/, 'comment'], + [/'[^']'/, 'string'], + [/[0-9]+(\.[0-9]+)?/, 'number'], + [/true|false/, 'number'], + [/\=|\,|\!|%=|\*=|\+=|-=|\/=|<|=|>|<>/, 'definition'], + [ + /[a-z_$][\w$]*/, + { + cases: { + '@keywords': 'keyword', + '@identifiers': 'type.identifier', + '@values': 'variable', + '@default': 'identifier', + }, + }, + ], + ], + }, + }) +} diff --git a/src/libs/monaco/languages/Molang/Completions.ts b/src/libs/monaco/languages/Molang/Completions.ts new file mode 100644 index 000000000..cc1c74104 --- /dev/null +++ b/src/libs/monaco/languages/Molang/Completions.ts @@ -0,0 +1,251 @@ +import { CancellationToken, Position, Range, editor, languages } from 'monaco-editor' +import { ProjectManager } from '@/libs/project/ProjectManager' +import { BedrockProject } from '@/libs/project/BedrockProject' +import { MolangData, MolangValueDefinition } from '@/libs/data/bedrock/MolangData' +import { Data } from '@/libs/data/Data' +import { isMatch } from 'bridge-common-utils' +import { getLocation } from '../Language' + +interface CompletionContext { + molangData: MolangData + fileType?: string + /** The text on the current line from the start of the Molang expression up to the cursor. */ + textBeforeCursor: string + /** The partial word directly before the cursor that completions should replace. */ + currentWord: string + range: Range +} + +/** + * Builds context-sensitive Molang completions. Shared between standalone + * `.molang` files and Molang embedded within JSON string values. + */ +function getMolangCompletions(context: CompletionContext): languages.CompletionItem[] { + const { molangData, fileType, textBeforeCursor, currentWord, range } = context + + const textBeforeWord = textBeforeCursor.slice(0, textBeforeCursor.length - currentWord.length) + + // Member access, e.g. "math." or "v." - only suggest the values of that namespace + if (textBeforeWord.endsWith('.')) { + const beforeDot = textBeforeWord.slice(0, -1) + + const namespaces = molangData + .getNamespaces(fileType) + .filter((namespace) => endsWithToken(beforeDot, namespace)) + + if (namespaces.length === 0) return [] + + const values = namespaces.flatMap((namespace) => molangData.getValues(namespace, fileType)) + + return dedupeByLabel(values.map((value) => valueCompletion(value, range))) + } + + // Global namespaces (math, query, variable, ...) and, when inside a function + // call, the allowed values for the current argument. + const completions: languages.CompletionItem[] = molangData + .getNamespaces(fileType) + .map((namespace) => ({ + label: namespace, + insertText: namespace, + kind: languages.CompletionItemKind.Variable, + range, + })) + + return dedupeByLabel(completions.concat(getArgumentCompletions(context))) +} + +/** + * If the cursor sits inside a function's parentheses and the current argument + * defines a fixed set of values, suggest those values. + */ +function getArgumentCompletions(context: CompletionContext): languages.CompletionItem[] { + const { molangData, fileType, textBeforeCursor, range } = context + + const call = getEnclosingCall(textBeforeCursor) + if (!call) return [] + + const value = molangData + .getValues(call.namespace, fileType) + .find((value) => value.valueName === call.valueName) + + const values = value?.arguments?.[call.argumentIndex]?.additionalData?.values + if (!values) return [] + + return values.map((value) => ({ + label: value, + insertText: value, + kind: languages.CompletionItemKind.Enum, + range, + })) +} + +interface EnclosingCall { + namespace: string + valueName: string + argumentIndex: number +} + +/** + * Walks backwards from the cursor to find the function call the cursor is + * currently within, together with the index of the argument being typed. + */ +function getEnclosingCall(text: string): EnclosingCall | undefined { + let depth = 0 + let argumentIndex = 0 + let openIndex = -1 + + for (let index = text.length - 1; index >= 0; index--) { + const char = text[index] + + if (char === ')') depth++ + else if (char === '(') { + if (depth === 0) { + openIndex = index + break + } + depth-- + } else if (char === ',' && depth === 0) argumentIndex++ + } + + if (openIndex === -1) return undefined + + const match = text.slice(0, openIndex).match(/([a-zA-Z_][\w]*)\.([a-zA-Z_][\w]*)$/) + if (!match) return undefined + + return { namespace: match[1], valueName: match[2], argumentIndex } +} + +function valueCompletion(value: MolangValueDefinition, range: Range): languages.CompletionItem { + const isFunction = !value.isProperty + + return { + label: value.valueName, + // Functions insert parentheses and place the cursor between them. + insertText: isFunction ? `${value.valueName}($0)` : value.valueName, + insertTextRules: isFunction ? languages.CompletionItemInsertTextRule.InsertAsSnippet : undefined, + kind: isFunction ? languages.CompletionItemKind.Function : languages.CompletionItemKind.Variable, + documentation: value.description, + range, + } +} + +/** Whether `text` ends with `token` as a whole word (not as part of a longer identifier). */ +function endsWithToken(text: string, token: string): boolean { + return new RegExp(`(^|[^a-zA-Z0-9_])${escapeRegExp(token)}$`).test(text) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function dedupeByLabel(completions: languages.CompletionItem[]): languages.CompletionItem[] { + return completions.filter( + (completion, index, all) => all.findIndex((other) => other.label === completion.label) === index + ) +} + +function wordRange(model: editor.ITextModel, position: Position): { range: Range; word: string } { + const word = model.getWordUntilPosition(position) + + return { + word: word.word, + range: new Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn), + } +} + +/** + * Completions for standalone `.molang` files. + */ +export async function provideCompletionItems( + model: editor.ITextModel, + position: Position, + _context: languages.CompletionContext, + _token: CancellationToken +): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) return undefined + + const fileType = ProjectManager.currentProject.fileTypeData.get(model.uri.path)?.id + + const textBeforeCursor = model.getValueInRange( + new Range(position.lineNumber, 1, position.lineNumber, position.column) + ) + + const { word, range } = wordRange(model, position) + + return { + suggestions: getMolangCompletions({ + molangData: ProjectManager.currentProject.molangData, + fileType, + textBeforeCursor, + currentWord: word, + range, + }), + } +} + +/** + * Completions for Molang embedded within JSON string values (e.g. inside entity + * or particle files). + */ +export async function provideInlineJsonCompletionItems( + model: editor.ITextModel, + position: Position, + _context: languages.CompletionContext, + _token: CancellationToken +): Promise { + if (!(ProjectManager.currentProject instanceof BedrockProject)) return undefined + + const fileType = ProjectManager.currentProject.fileTypeData.get(model.uri.path) + if (!fileType) return undefined + + const validMolangLocations = await Data.get('packages/minecraftBedrock/location/validMolang.json') + const locationPatterns = validMolangLocations[fileType.id] + if (!locationPatterns) return undefined + + const location = await getLocation(model, position) + if (!isMatch(location, locationPatterns)) return undefined + + const line = model.getLineContent(position.lineNumber) + const cursor = position.column - 1 + + const string = getStringAtCursor(line, cursor) + if (!string) return undefined + + const { word, range } = wordRange(model, position) + + return { + suggestions: getMolangCompletions({ + molangData: ProjectManager.currentProject.molangData, + fileType: fileType.id, + textBeforeCursor: line.slice(string.start, cursor), + currentWord: word, + range, + }), + } +} + +/** + * Returns the boundaries of the JSON string the cursor is inside of, or + * undefined if the cursor is not within a string. + */ +function getStringAtCursor(line: string, cursor: number): { start: number; end: number } | undefined { + let withinString = false + let start = -1 + + for (let index = 0; index < line.length; index++) { + if (line[index] !== '"') continue + + if (!withinString) { + withinString = true + start = index + 1 + } else { + if (start <= cursor && cursor <= index) return { start, end: index } + withinString = false + } + } + + // Unterminated string (still being typed) + if (withinString && start <= cursor) return { start, end: line.length } + + return undefined +} diff --git a/src/libs/project/BedrockProject.ts b/src/libs/project/BedrockProject.ts index 48877b161..b30b83f36 100644 --- a/src/libs/project/BedrockProject.ts +++ b/src/libs/project/BedrockProject.ts @@ -11,6 +11,7 @@ import { RequirementsMatcher } from '@/libs/data/bedrock/RequirementsMatcher' import { Data } from '@/libs/data/Data' import { LangData } from '@/libs/data/bedrock/LangData' import { CommandData } from '@/libs/data/bedrock/CommandData' +import { MolangData } from '@/libs/data/bedrock/MolangData' import { SnippetManager } from '@/libs/snippets/SnippetManager' import { fileSystem } from '@/libs/fileSystem/FileSystem' import { join } from 'pathe' @@ -23,6 +24,7 @@ export class BedrockProject extends Project { public scriptTypeData = new ScriptTypeData(this) public langData = new LangData(this) public commandData = new CommandData(this) + public molangData = new MolangData(this) public indexerService = new IndexerService(this) public dashService = new DashService(this) public requirementsMatcher = new RequirementsMatcher(this) @@ -54,6 +56,8 @@ export class BedrockProject extends Project { await this.requirementsMatcher.setup() + await this.molangData.setup() + this.dashService.build() }