From 71eb2436d18274cbc652d251805beffddffbf828 Mon Sep 17 00:00:00 2001 From: Mathieu Geukens Date: Thu, 5 Feb 2026 12:14:25 +0100 Subject: [PATCH 1/2] add autocomplete and hover hints --- package.json | 1 + src/components/Editor.tsx | 17 +- src/editor/cashscript/completionData.ts | 547 ++++++++++++++++++++ src/editor/cashscript/completionProvider.ts | 145 ++++++ src/editor/cashscript/hoverProvider.ts | 185 +++++++ src/editor/cashscript/index.ts | 32 ++ src/editor/cashscript/languageDefinition.ts | 178 +++++++ src/editor/cashscript/variableExtractor.ts | 173 +++++++ yarn.lock | 5 + 9 files changed, 1280 insertions(+), 3 deletions(-) create mode 100644 src/editor/cashscript/completionData.ts create mode 100644 src/editor/cashscript/completionProvider.ts create mode 100644 src/editor/cashscript/hoverProvider.ts create mode 100644 src/editor/cashscript/index.ts create mode 100644 src/editor/cashscript/languageDefinition.ts create mode 100644 src/editor/cashscript/variableExtractor.ts diff --git a/package.json b/package.json index 87c61aa..923652e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/react-dom": "18.0.11", "eslint": "^9.36.0", "eslint-config-next": "^15.5.4", + "monaco-editor": "^0.21.2", "typescript": "^5.9.3" } } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 24cabd0..31b95c0 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,7 +1,9 @@ -import React, { useState } from 'react' -import { ControlledEditor } from '@monaco-editor/react' +import React, { useState, useEffect } from 'react' +import { ControlledEditor, monaco } from '@monaco-editor/react' import { Button } from 'react-bootstrap' import { ColumnFlex } from './shared' +import { setupCashScriptLanguage, CASHSCRIPT_LANGUAGE_ID } from '@/editor/cashscript' +import type * as Monaco from 'monaco-editor' interface Props { code: string @@ -11,6 +13,15 @@ interface Props { const Editor: React.FC = ({ code, setCode, compile }) => { const [isEditorReady, setIsEditorReady] = useState(false) + const [isLanguageReady, setIsLanguageReady] = useState(false) + + // Initialize CashScript language support + useEffect(() => { + monaco.init().then((monacoInstance: typeof Monaco) => { + setupCashScriptLanguage(monacoInstance) + setIsLanguageReady(true) + }) + }, []) function handleEditorDidMount() { setIsEditorReady(true) @@ -22,7 +33,7 @@ const Editor: React.FC = ({ code, setCode, compile }) => { style={{ flex: 3, margin: '16px', border: '2px solid black', background: 'white' }} > setCode(code?? "") } diff --git a/src/editor/cashscript/completionData.ts b/src/editor/cashscript/completionData.ts new file mode 100644 index 0000000..d293b89 --- /dev/null +++ b/src/editor/cashscript/completionData.ts @@ -0,0 +1,547 @@ +import type * as Monaco from 'monaco-editor'; + +export interface CompletionItemData { + label: string; + kind: 'Function' | 'Keyword' | 'Class' | 'Constant' | 'Property' | 'Method' | 'Module' | 'Variable'; + detail: string; + documentation: string; + insertText?: string; +} + +// Global functions +export const globalFunctions: CompletionItemData[] = [ + // Math functions + { + label: 'abs', + kind: 'Function', + detail: 'abs(int a) -> int', + documentation: 'Returns the absolute value of the argument.', + insertText: 'abs(${1:value})', + }, + { + label: 'min', + kind: 'Function', + detail: 'min(int a, int b) -> int', + documentation: 'Returns the minimum of two integers.', + insertText: 'min(${1:a}, ${2:b})', + }, + { + label: 'max', + kind: 'Function', + detail: 'max(int a, int b) -> int', + documentation: 'Returns the maximum of two integers.', + insertText: 'max(${1:a}, ${2:b})', + }, + { + label: 'within', + kind: 'Function', + detail: 'within(int x, int lower, int upper) -> bool', + documentation: 'Returns true if x is within the range [lower, upper).', + insertText: 'within(${1:x}, ${2:lower}, ${3:upper})', + }, + // Crypto functions + { + label: 'checkSig', + kind: 'Function', + detail: 'checkSig(sig s, pubkey pk) -> bool', + documentation: 'Checks that the signature is valid for the current transaction and public key.', + insertText: 'checkSig(${1:signature}, ${2:publicKey})', + }, + { + label: 'checkMultiSig', + kind: 'Function', + detail: 'checkMultiSig(sig[] sigs, pubkey[] pks) -> bool', + documentation: 'Checks that all signatures are valid for the current transaction and respective public keys.', + insertText: 'checkMultiSig(${1:signatures}, ${2:publicKeys})', + }, + { + label: 'checkDataSig', + kind: 'Function', + detail: 'checkDataSig(datasig s, bytes msg, pubkey pk) -> bool', + documentation: 'Checks that the signature is valid for the given message and public key.', + insertText: 'checkDataSig(${1:signature}, ${2:message}, ${3:publicKey})', + }, + // Hashing functions + { + label: 'ripemd160', + kind: 'Function', + detail: 'ripemd160(bytes data) -> bytes20', + documentation: 'Returns the RIPEMD-160 hash of the input.', + insertText: 'ripemd160(${1:data})', + }, + { + label: 'sha1', + kind: 'Function', + detail: 'sha1(bytes data) -> bytes20', + documentation: 'Returns the SHA-1 hash of the input.', + insertText: 'sha1(${1:data})', + }, + { + label: 'sha256', + kind: 'Function', + detail: 'sha256(bytes data) -> bytes32', + documentation: 'Returns the SHA-256 hash of the input.', + insertText: 'sha256(${1:data})', + }, + { + label: 'hash160', + kind: 'Function', + detail: 'hash160(bytes data) -> bytes20', + documentation: 'Returns the RIPEMD-160 hash of the SHA-256 hash of the input (HASH160).', + insertText: 'hash160(${1:data})', + }, + { + label: 'hash256', + kind: 'Function', + detail: 'hash256(bytes data) -> bytes32', + documentation: 'Returns the double SHA-256 hash of the input (HASH256).', + insertText: 'hash256(${1:data})', + }, + // Require + { + label: 'require', + kind: 'Function', + detail: 'require(bool condition)', + documentation: 'Requires that the condition evaluates to true. If not, the transaction fails.', + insertText: 'require(${1:condition});', + }, +]; + +// Type keywords +export const typeKeywords: CompletionItemData[] = [ + { + label: 'int', + kind: 'Keyword', + detail: 'Integer type', + documentation: 'Signed integer type. Can hold values from -2^63 to 2^63-1.', + }, + { + label: 'bool', + kind: 'Keyword', + detail: 'Boolean type', + documentation: 'Boolean type that can be either true or false.', + }, + { + label: 'string', + kind: 'Keyword', + detail: 'String type', + documentation: 'UTF-8 encoded string type.', + }, + { + label: 'bytes', + kind: 'Keyword', + detail: 'Byte array type', + documentation: 'Variable-length byte array.', + }, + { + label: 'bytes20', + kind: 'Keyword', + detail: 'Fixed 20-byte array', + documentation: 'Fixed-length 20-byte array. Commonly used for hash160 outputs.', + }, + { + label: 'bytes32', + kind: 'Keyword', + detail: 'Fixed 32-byte array', + documentation: 'Fixed-length 32-byte array. Commonly used for sha256/hash256 outputs.', + }, + { + label: 'pubkey', + kind: 'Keyword', + detail: 'Public key type', + documentation: 'A Bitcoin Cash public key (33 bytes compressed).', + }, + { + label: 'sig', + kind: 'Keyword', + detail: 'Signature type', + documentation: 'A transaction signature used with checkSig.', + }, + { + label: 'datasig', + kind: 'Keyword', + detail: 'Data signature type', + documentation: 'A data signature used with checkDataSig.', + }, +]; + +// Generate bytes1-bytes32 +for (let i = 1; i <= 32; i++) { + if (i !== 20 && i !== 32) { // Skip 20 and 32, already added + typeKeywords.push({ + label: `bytes${i}`, + kind: 'Keyword', + detail: `Fixed ${i}-byte array`, + documentation: `Fixed-length ${i}-byte array.`, + }); + } +} + +// Instantiation types +export const instantiations: CompletionItemData[] = [ + { + label: 'LockingBytecodeP2PKH', + kind: 'Class', + detail: 'new LockingBytecodeP2PKH(bytes20 pkh)', + documentation: 'Creates P2PKH locking bytecode from a public key hash.', + insertText: 'new LockingBytecodeP2PKH(${1:pkh})', + }, + { + label: 'LockingBytecodeP2SH20', + kind: 'Class', + detail: 'new LockingBytecodeP2SH20(bytes20 scriptHash)', + documentation: 'Creates P2SH20 locking bytecode from a 20-byte script hash.', + insertText: 'new LockingBytecodeP2SH20(${1:scriptHash})', + }, + { + label: 'LockingBytecodeP2SH32', + kind: 'Class', + detail: 'new LockingBytecodeP2SH32(bytes32 scriptHash)', + documentation: 'Creates P2SH32 locking bytecode from a 32-byte script hash.', + insertText: 'new LockingBytecodeP2SH32(${1:scriptHash})', + }, + { + label: 'LockingBytecodeNullData', + kind: 'Class', + detail: 'new LockingBytecodeNullData(bytes[] chunks)', + documentation: 'Creates OP_RETURN locking bytecode for data storage.', + insertText: 'new LockingBytecodeNullData([${1:data}])', + }, +]; + +// Time units +export const timeUnits: CompletionItemData[] = [ + { + label: 'seconds', + kind: 'Constant', + detail: 'Time unit', + documentation: 'Time unit in seconds. Used with time-based operations.', + }, + { + label: 'minutes', + kind: 'Constant', + detail: 'Time unit', + documentation: 'Time unit in minutes (60 seconds).', + }, + { + label: 'hours', + kind: 'Constant', + detail: 'Time unit', + documentation: 'Time unit in hours (3600 seconds).', + }, + { + label: 'days', + kind: 'Constant', + detail: 'Time unit', + documentation: 'Time unit in days (86400 seconds).', + }, + { + label: 'weeks', + kind: 'Constant', + detail: 'Time unit', + documentation: 'Time unit in weeks (604800 seconds).', + }, +]; + +// Value units +export const valueUnits: CompletionItemData[] = [ + { + label: 'satoshis', + kind: 'Constant', + detail: 'Value unit', + documentation: 'Base unit of Bitcoin Cash. 1 satoshi = 0.00000001 BCH.', + }, + { + label: 'sats', + kind: 'Constant', + detail: 'Value unit', + documentation: 'Alias for satoshis.', + }, + { + label: 'finney', + kind: 'Constant', + detail: 'Value unit', + documentation: '1 finney = 10 satoshis.', + }, + { + label: 'bits', + kind: 'Constant', + detail: 'Value unit', + documentation: '1 bit = 100 satoshis.', + }, + { + label: 'bitcoin', + kind: 'Constant', + detail: 'Value unit', + documentation: '1 bitcoin = 100,000,000 satoshis.', + }, +]; + +// Keywords +export const keywords: CompletionItemData[] = [ + { + label: 'pragma', + kind: 'Keyword', + detail: 'Pragma directive', + documentation: 'Specifies the CashScript version. Example: pragma cashscript ^0.10.0;', + insertText: 'pragma cashscript ^${1:0.10.0};', + }, + { + label: 'contract', + kind: 'Keyword', + detail: 'Contract definition', + documentation: 'Defines a new CashScript contract.', + insertText: 'contract ${1:ContractName}(${2:params}) {\n\t$0\n}', + }, + { + label: 'function', + kind: 'Keyword', + detail: 'Function definition', + documentation: 'Defines a new function within a contract.', + insertText: 'function ${1:functionName}(${2:params}) {\n\t$0\n}', + }, + { + label: 'if', + kind: 'Keyword', + detail: 'Conditional statement', + documentation: 'Executes code if the condition is true.', + insertText: 'if (${1:condition}) {\n\t$0\n}', + }, + { + label: 'else', + kind: 'Keyword', + detail: 'Else clause', + documentation: 'Executes code if the previous if condition was false.', + insertText: 'else {\n\t$0\n}', + }, + { + label: 'constant', + kind: 'Keyword', + detail: 'Constant modifier', + documentation: 'Declares a compile-time constant value.', + }, + { + label: 'true', + kind: 'Constant', + detail: 'Boolean true', + documentation: 'Boolean literal representing true.', + }, + { + label: 'false', + kind: 'Constant', + detail: 'Boolean false', + documentation: 'Boolean literal representing false.', + }, +]; + +// tx.* properties +export const txProperties: CompletionItemData[] = [ + { + label: 'version', + kind: 'Property', + detail: 'tx.version -> int', + documentation: 'The transaction version number.', + }, + { + label: 'locktime', + kind: 'Property', + detail: 'tx.locktime -> int', + documentation: 'The transaction locktime value.', + }, + { + label: 'inputs', + kind: 'Property', + detail: 'tx.inputs -> Input[]', + documentation: 'Array of all transaction inputs. Access individual inputs with tx.inputs[i].', + }, + { + label: 'outputs', + kind: 'Property', + detail: 'tx.outputs -> Output[]', + documentation: 'Array of all transaction outputs. Access individual outputs with tx.outputs[i].', + }, + { + label: 'time', + kind: 'Property', + detail: 'tx.time -> int', + documentation: 'The transaction time (block height or Unix timestamp depending on locktime type).', + }, +]; + +// tx.inputs[i].* properties +export const inputProperties: CompletionItemData[] = [ + { + label: 'value', + kind: 'Property', + detail: 'tx.inputs[i].value -> int', + documentation: 'The value of the input in satoshis.', + }, + { + label: 'lockingBytecode', + kind: 'Property', + detail: 'tx.inputs[i].lockingBytecode -> bytes', + documentation: 'The locking bytecode of the input.', + }, + { + label: 'outpointTransactionHash', + kind: 'Property', + detail: 'tx.inputs[i].outpointTransactionHash -> bytes32', + documentation: 'The transaction hash of the outpoint being spent.', + }, + { + label: 'outpointIndex', + kind: 'Property', + detail: 'tx.inputs[i].outpointIndex -> int', + documentation: 'The output index of the outpoint being spent.', + }, + { + label: 'unlockingBytecode', + kind: 'Property', + detail: 'tx.inputs[i].unlockingBytecode -> bytes', + documentation: 'The unlocking bytecode of the input.', + }, + { + label: 'sequenceNumber', + kind: 'Property', + detail: 'tx.inputs[i].sequenceNumber -> int', + documentation: 'The sequence number of the input.', + }, + { + label: 'tokenCategory', + kind: 'Property', + detail: 'tx.inputs[i].tokenCategory -> bytes32', + documentation: 'The token category (CashTokens) of the input, or 0x if none.', + }, + { + label: 'tokenAmount', + kind: 'Property', + detail: 'tx.inputs[i].tokenAmount -> int', + documentation: 'The fungible token amount of the input.', + }, + { + label: 'nftCommitment', + kind: 'Property', + detail: 'tx.inputs[i].nftCommitment -> bytes', + documentation: 'The NFT commitment data of the input.', + }, +]; + +// tx.outputs[i].* properties +export const outputProperties: CompletionItemData[] = [ + { + label: 'value', + kind: 'Property', + detail: 'tx.outputs[i].value -> int', + documentation: 'The value of the output in satoshis.', + }, + { + label: 'lockingBytecode', + kind: 'Property', + detail: 'tx.outputs[i].lockingBytecode -> bytes', + documentation: 'The locking bytecode of the output.', + }, + { + label: 'tokenCategory', + kind: 'Property', + detail: 'tx.outputs[i].tokenCategory -> bytes32', + documentation: 'The token category (CashTokens) of the output, or 0x if none.', + }, + { + label: 'tokenAmount', + kind: 'Property', + detail: 'tx.outputs[i].tokenAmount -> int', + documentation: 'The fungible token amount of the output.', + }, + { + label: 'nftCommitment', + kind: 'Property', + detail: 'tx.outputs[i].nftCommitment -> bytes', + documentation: 'The NFT commitment data of the output.', + }, +]; + +// this.* properties +export const thisProperties: CompletionItemData[] = [ + { + label: 'activeInputIndex', + kind: 'Property', + detail: 'this.activeInputIndex -> int', + documentation: 'The index of the input currently being evaluated.', + }, + { + label: 'activeBytecode', + kind: 'Property', + detail: 'this.activeBytecode -> bytes', + documentation: 'The full locking bytecode of the contract.', + }, +]; + +// bytes methods +export const bytesMethods: CompletionItemData[] = [ + { + label: 'split', + kind: 'Method', + detail: 'bytes.split(int index) -> bytes, bytes', + documentation: 'Splits the byte array at the given index, returning two parts.', + insertText: 'split(${1:index})', + }, + { + label: 'reverse', + kind: 'Method', + detail: 'bytes.reverse() -> bytes', + documentation: 'Returns the byte array in reverse order.', + insertText: 'reverse()', + }, + { + label: 'length', + kind: 'Property', + detail: 'bytes.length -> int', + documentation: 'The length of the byte array.', + }, +]; + +// Global objects that trigger dot completion +export const globalObjects: CompletionItemData[] = [ + { + label: 'tx', + kind: 'Module', + detail: 'Transaction introspection', + documentation: 'Access transaction properties like tx.version, tx.inputs, tx.outputs, etc.', + }, + { + label: 'this', + kind: 'Module', + detail: 'Contract introspection', + documentation: 'Access contract properties like this.activeInputIndex and this.activeBytecode.', + }, +]; + +// Helper to create Monaco completion items +export function createCompletionItem( + item: CompletionItemData, + range: Monaco.IRange, + monaco: typeof Monaco +): Monaco.languages.CompletionItem { + const kindMap: Record = { + Function: monaco.languages.CompletionItemKind.Function, + Keyword: monaco.languages.CompletionItemKind.Keyword, + Class: monaco.languages.CompletionItemKind.Class, + Constant: monaco.languages.CompletionItemKind.Constant, + Property: monaco.languages.CompletionItemKind.Property, + Method: monaco.languages.CompletionItemKind.Method, + Module: monaco.languages.CompletionItemKind.Module, + Variable: monaco.languages.CompletionItemKind.Variable, + }; + + return { + label: item.label, + kind: kindMap[item.kind] ?? monaco.languages.CompletionItemKind.Text, + detail: item.detail, + documentation: item.documentation, + insertText: item.insertText ?? item.label, + insertTextRules: item.insertText?.includes('$') + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + range, + }; +} diff --git a/src/editor/cashscript/completionProvider.ts b/src/editor/cashscript/completionProvider.ts new file mode 100644 index 0000000..9734f1b --- /dev/null +++ b/src/editor/cashscript/completionProvider.ts @@ -0,0 +1,145 @@ +import type * as Monaco from 'monaco-editor'; +import { CASHSCRIPT_LANGUAGE_ID } from './languageDefinition'; +import { + globalFunctions, + typeKeywords, + instantiations, + timeUnits, + valueUnits, + keywords, + txProperties, + inputProperties, + outputProperties, + thisProperties, + bytesMethods, + globalObjects, + createCompletionItem, + CompletionItemData, +} from './completionData'; +import { getAvailableVariables, ExtractedVariable } from './variableExtractor'; + +export function registerCompletionProvider(monaco: typeof Monaco): void { + monaco.languages.registerCompletionItemProvider(CASHSCRIPT_LANGUAGE_ID, { + triggerCharacters: ['.', ' '], + provideCompletionItems: (model, position) => { + const lineContent = model.getLineContent(position.lineNumber); + const lineUntilPosition = lineContent.substring(0, position.column - 1); + + // Get the word at cursor position for filtering + const wordInfo = model.getWordUntilPosition(position); + const range: Monaco.IRange = { + startLineNumber: position.lineNumber, + startColumn: wordInfo.startColumn, + endLineNumber: position.lineNumber, + endColumn: wordInfo.endColumn, + }; + + const suggestions: Monaco.languages.CompletionItem[] = []; + + // Check for dot completion + const dotCompletionResult = handleDotCompletion(lineUntilPosition, range, monaco); + if (dotCompletionResult) { + return { suggestions: dotCompletionResult }; + } + + // Check if we're after 'new' keyword + if (/\bnew\s+$/.test(lineUntilPosition)) { + return { + suggestions: instantiations.map(item => createCompletionItem(item, range, monaco)), + }; + } + + // Standard completions + const allCompletions: CompletionItemData[] = [ + ...globalFunctions, + ...typeKeywords, + ...keywords, + ...timeUnits, + ...valueUnits, + ...globalObjects, + ]; + + for (const item of allCompletions) { + suggestions.push(createCompletionItem(item, range, monaco)); + } + + // Add user-declared variables + const sourceCode = model.getValue(); + const offset = model.getOffsetAt(position); + const userVariables = getAvailableVariables(sourceCode, offset); + + for (const variable of userVariables) { + suggestions.push(createVariableCompletionItem(variable, range, monaco)); + } + + return { suggestions }; + }, + }); +} + +/** + * Handles dot completion for tx., this., tx.inputs[i]., tx.outputs[i]., and bytes methods. + */ +function handleDotCompletion( + lineUntilPosition: string, + range: Monaco.IRange, + monaco: typeof Monaco +): Monaco.languages.CompletionItem[] | null { + // Check for tx.inputs[...]. or tx.outputs[...] + if (/tx\.inputs\s*\[[^\]]*\]\s*\.\s*\w*$/.test(lineUntilPosition)) { + return inputProperties.map(item => createCompletionItem(item, range, monaco)); + } + + if (/tx\.outputs\s*\[[^\]]*\]\s*\.\s*\w*$/.test(lineUntilPosition)) { + return outputProperties.map(item => createCompletionItem(item, range, monaco)); + } + + // Check for tx. + if (/\btx\s*\.\s*\w*$/.test(lineUntilPosition)) { + return txProperties.map(item => createCompletionItem(item, range, monaco)); + } + + // Check for this. + if (/\bthis\s*\.\s*\w*$/.test(lineUntilPosition)) { + return thisProperties.map(item => createCompletionItem(item, range, monaco)); + } + + // Check for bytes methods (identifier followed by dot) + // This is a heuristic - we show bytes methods after any identifier followed by a dot + // that doesn't match the above patterns + if (/\b\w+\s*\.\s*\w*$/.test(lineUntilPosition)) { + // Don't show bytes methods for known objects + if (!/\b(?:tx|this)\s*\.\s*\w*$/.test(lineUntilPosition)) { + return bytesMethods.map(item => createCompletionItem(item, range, monaco)); + } + } + + return null; +} + +/** + * Creates a completion item for a user-declared variable. + */ +function createVariableCompletionItem( + variable: ExtractedVariable, + range: Monaco.IRange, + monaco: typeof Monaco +): Monaco.languages.CompletionItem { + let scopeLabel = ''; + if (variable.scope === 'contract') { + scopeLabel = ' (contract parameter)'; + } else if (variable.scope === 'function') { + scopeLabel = ` (${variable.functionName} parameter)`; + } else { + scopeLabel = ' (local variable)'; + } + + return { + label: variable.name, + kind: monaco.languages.CompletionItemKind.Variable, + detail: `${variable.type} ${variable.name}${scopeLabel}`, + documentation: `User-declared variable of type ${variable.type}.`, + insertText: variable.name, + range, + }; +} diff --git a/src/editor/cashscript/hoverProvider.ts b/src/editor/cashscript/hoverProvider.ts new file mode 100644 index 0000000..5c2e895 --- /dev/null +++ b/src/editor/cashscript/hoverProvider.ts @@ -0,0 +1,185 @@ +import type * as Monaco from 'monaco-editor'; +import { CASHSCRIPT_LANGUAGE_ID } from './languageDefinition'; +import { + globalFunctions, + typeKeywords, + instantiations, + timeUnits, + valueUnits, + keywords, + txProperties, + inputProperties, + outputProperties, + thisProperties, + bytesMethods, + globalObjects, + CompletionItemData, +} from './completionData'; +import { extractVariables, ExtractedVariable } from './variableExtractor'; + +// Build lookup maps for efficient hover lookup +const hoverDataMap = new Map(); + +function buildHoverDataMap(): void { + const allItems: CompletionItemData[] = [ + ...globalFunctions, + ...typeKeywords, + ...instantiations, + ...timeUnits, + ...valueUnits, + ...keywords, + ...globalObjects, + ...bytesMethods, + ]; + + for (const item of allItems) { + hoverDataMap.set(item.label, item); + } +} + +// Build the map once +buildHoverDataMap(); + +// Create maps for contextual properties +const txPropertyMap = new Map(txProperties.map(p => [p.label, p])); +const inputPropertyMap = new Map(inputProperties.map(p => [p.label, p])); +const outputPropertyMap = new Map(outputProperties.map(p => [p.label, p])); +const thisPropertyMap = new Map(thisProperties.map(p => [p.label, p])); + +export function registerHoverProvider(monaco: typeof Monaco): void { + monaco.languages.registerHoverProvider(CASHSCRIPT_LANGUAGE_ID, { + provideHover: (model, position) => { + const word = model.getWordAtPosition(position); + if (!word) { + return null; + } + + const wordText = word.word; + const lineContent = model.getLineContent(position.lineNumber); + const lineUntilWord = lineContent.substring(0, word.startColumn - 1); + + // Determine context for property lookups + const hoverContent = getHoverContent(wordText, lineUntilWord, model.getValue()); + + if (!hoverContent) { + return null; + } + + return { + range: new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn + ), + contents: hoverContent, + }; + }, + }); +} + +function getHoverContent( + word: string, + lineUntilWord: string, + sourceCode: string +): Monaco.IMarkdownString[] | null { + // Check for contextual properties first (tx., this., inputs., outputs.) + + // tx.inputs[...].property + if (/tx\.inputs\s*\[[^\]]*\]\s*\.\s*$/.test(lineUntilWord)) { + const prop = inputPropertyMap.get(word); + if (prop) { + return formatHoverContent(prop); + } + } + + // tx.outputs[...].property + if (/tx\.outputs\s*\[[^\]]*\]\s*\.\s*$/.test(lineUntilWord)) { + const prop = outputPropertyMap.get(word); + if (prop) { + return formatHoverContent(prop); + } + } + + // tx.property + if (/\btx\s*\.\s*$/.test(lineUntilWord)) { + const prop = txPropertyMap.get(word); + if (prop) { + return formatHoverContent(prop); + } + } + + // this.property + if (/\bthis\s*\.\s*$/.test(lineUntilWord)) { + const prop = thisPropertyMap.get(word); + if (prop) { + return formatHoverContent(prop); + } + } + + // bytes methods (after any identifier followed by dot, excluding tx/this) + if (/\b\w+\s*\.\s*$/.test(lineUntilWord) && !/\b(?:tx|this)\s*\.\s*$/.test(lineUntilWord)) { + const method = hoverDataMap.get(word); + if (method && (method.kind === 'Method' || method.kind === 'Property')) { + return formatHoverContent(method); + } + } + + // Check for global items + const globalItem = hoverDataMap.get(word); + if (globalItem) { + return formatHoverContent(globalItem); + } + + // Check for user-declared variables + const variables = extractVariables(sourceCode); + const userVariable = variables.find(v => v.name === word); + if (userVariable) { + return formatVariableHover(userVariable); + } + + return null; +} + +function formatHoverContent(item: CompletionItemData): Monaco.IMarkdownString[] { + const contents: Monaco.IMarkdownString[] = []; + + // Detail as code block + if (item.detail) { + contents.push({ + value: `\`\`\`cashscript\n${item.detail}\n\`\`\``, + }); + } + + // Documentation as regular text + if (item.documentation) { + contents.push({ + value: item.documentation, + }); + } + + return contents; +} + +function formatVariableHover(variable: ExtractedVariable): Monaco.IMarkdownString[] { + const contents: Monaco.IMarkdownString[] = []; + + let scopeDescription = ''; + if (variable.scope === 'contract') { + scopeDescription = 'Contract parameter'; + } else if (variable.scope === 'function') { + scopeDescription = `Parameter of function \`${variable.functionName}\``; + } else { + scopeDescription = 'Local variable'; + } + + contents.push({ + value: `\`\`\`cashscript\n${variable.type} ${variable.name}\n\`\`\``, + }); + + contents.push({ + value: scopeDescription, + }); + + return contents; +} diff --git a/src/editor/cashscript/index.ts b/src/editor/cashscript/index.ts new file mode 100644 index 0000000..cb94f8d --- /dev/null +++ b/src/editor/cashscript/index.ts @@ -0,0 +1,32 @@ +import type * as Monaco from 'monaco-editor'; +import { registerCashScriptLanguage, CASHSCRIPT_LANGUAGE_ID } from './languageDefinition'; +import { registerCompletionProvider } from './completionProvider'; +import { registerHoverProvider } from './hoverProvider'; + +export { CASHSCRIPT_LANGUAGE_ID }; + +/** + * Sets up the CashScript language support for Monaco editor. + * This includes: + * - Language registration with Monarch tokenizer for syntax highlighting + * - Completion provider with dot-completion support + * - Hover provider for documentation tooltips + * + * @param monaco The Monaco instance from @monaco-editor/react + */ +export function setupCashScriptLanguage(monaco: typeof Monaco): void { + // Check if already registered to avoid duplicate registration + const languages = monaco.languages.getLanguages(); + if (languages.some(lang => lang.id === CASHSCRIPT_LANGUAGE_ID)) { + return; + } + + // Register language definition and tokenizer + registerCashScriptLanguage(monaco); + + // Register completion provider + registerCompletionProvider(monaco); + + // Register hover provider + registerHoverProvider(monaco); +} diff --git a/src/editor/cashscript/languageDefinition.ts b/src/editor/cashscript/languageDefinition.ts new file mode 100644 index 0000000..f1a58c8 --- /dev/null +++ b/src/editor/cashscript/languageDefinition.ts @@ -0,0 +1,178 @@ +import type * as Monaco from 'monaco-editor'; + +export const CASHSCRIPT_LANGUAGE_ID = 'cashscript'; + +// Extend IMonarchLanguage to include custom token arrays used in tokenizer rules via @name +interface CashScriptMonarchLanguage extends Monaco.languages.IMonarchLanguage { + keywords: string[]; + typeKeywords: string[]; + builtinFunctions: string[]; + instantiations: string[]; + timeUnits: string[]; + valueUnits: string[]; + operators: string[]; + symbols: RegExp; + escapes: RegExp; +} + +export function registerCashScriptLanguage(monaco: typeof Monaco): void { + // Register the language + monaco.languages.register({ id: CASHSCRIPT_LANGUAGE_ID }); + + // Register the Monarch tokenizer for syntax highlighting + const monarchLanguage: CashScriptMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.cashscript', + + keywords: [ + 'pragma', 'cashscript', 'contract', 'function', 'constructor', + 'if', 'else', 'require', 'new', 'constant' + ], + + typeKeywords: [ + 'int', 'bool', 'string', 'pubkey', 'sig', 'datasig', + 'bytes', 'bytes1', 'bytes2', 'bytes3', 'bytes4', 'bytes5', 'bytes6', 'bytes7', 'bytes8', + 'bytes9', 'bytes10', 'bytes11', 'bytes12', 'bytes13', 'bytes14', 'bytes15', 'bytes16', + 'bytes17', 'bytes18', 'bytes19', 'bytes20', 'bytes21', 'bytes22', 'bytes23', 'bytes24', + 'bytes25', 'bytes26', 'bytes27', 'bytes28', 'bytes29', 'bytes30', 'bytes31', 'bytes32' + ], + + builtinFunctions: [ + // Math functions + 'abs', 'min', 'max', 'within', + // Crypto functions + 'checkSig', 'checkMultiSig', 'checkDataSig', + // Hashing functions + 'ripemd160', 'sha1', 'sha256', 'hash160', 'hash256', + // Introspection + 'tx', 'this' + ], + + instantiations: [ + 'LockingBytecodeP2PKH', 'LockingBytecodeP2SH20', 'LockingBytecodeP2SH32', 'LockingBytecodeNullData' + ], + + timeUnits: [ + 'seconds', 'minutes', 'hours', 'days', 'weeks' + ], + + valueUnits: [ + 'satoshis', 'sats', 'finney', 'bits', 'bitcoin' + ], + + operators: [ + '=', '>', '<', '!', '~', '?', ':', + '==', '<=', '>=', '!=', '&&', '||', + '+', '-', '*', '/', '%', '&', '|', '^' + ], + + symbols: /[=>](?!@symbols)/, '@brackets'], + [/@symbols/, { + cases: { + '@operators': 'operator', + '@default': '' + } + }], + + // Numbers + [/0[xX][0-9a-fA-F]+/, 'number.hex'], + [/\d+/, 'number'], + + // Strings + [/"([^"\\]|\\.)*$/, 'string.invalid'], + [/'([^'\\]|\\.)*$/, 'string.invalid'], + [/"/, 'string', '@string_double'], + [/'/, 'string', '@string_single'], + + // Hex literals + [/0x[0-9a-fA-F]+/, 'number.hex'], + ], + + whitespace: [ + [/[ \t\r\n]+/, 'white'], + [/\/\*/, 'comment', '@comment'], + [/\/\/.*$/, 'comment'], + ], + + comment: [ + [/[^\/*]+/, 'comment'], + [/\/\*/, 'comment', '@push'], + [/\*\//, 'comment', '@pop'], + [/[\/*]/, 'comment'] + ], + + string_double: [ + [/[^\\"]+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/"/, 'string', '@pop'] + ], + + string_single: [ + [/[^\\']+/, 'string'], + [/@escapes/, 'string.escape'], + [/\\./, 'string.escape.invalid'], + [/'/, 'string', '@pop'] + ], + } + }; + monaco.languages.setMonarchTokensProvider(CASHSCRIPT_LANGUAGE_ID, monarchLanguage); + + // Set language configuration for bracket matching, auto-closing, etc. + monaco.languages.setLanguageConfiguration(CASHSCRIPT_LANGUAGE_ID, { + comments: { + lineComment: '//', + blockComment: ['/*', '*/'] + }, + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'] + ], + autoClosingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ], + surroundingPairs: [ + { open: '{', close: '}' }, + { open: '[', close: ']' }, + { open: '(', close: ')' }, + { open: '"', close: '"' }, + { open: "'", close: "'" } + ], + indentationRules: { + increaseIndentPattern: /^\s*(?:contract|function|if|else)\b.*\{\s*$/, + decreaseIndentPattern: /^\s*\}/ + } + }); +} diff --git a/src/editor/cashscript/variableExtractor.ts b/src/editor/cashscript/variableExtractor.ts new file mode 100644 index 0000000..60dea9e --- /dev/null +++ b/src/editor/cashscript/variableExtractor.ts @@ -0,0 +1,173 @@ +export interface ExtractedVariable { + name: string; + type: string; + scope: 'contract' | 'function' | 'local'; + functionName?: string; +} + +/** + * Extracts user-declared variables from CashScript source code. + * This includes contract parameters, function parameters, and local variable declarations. + */ +export function extractVariables(sourceCode: string): ExtractedVariable[] { + const variables: ExtractedVariable[] = []; + + // Remove comments to avoid false matches + const codeWithoutComments = removeComments(sourceCode); + + // Extract contract parameters + // Pattern: contract ContractName(type1 param1, type2 param2, ...) + const contractMatch = codeWithoutComments.match(/contract\s+\w+\s*\(([^)]*)\)/); + if (contractMatch && contractMatch[1]) { + const params = parseParameters(contractMatch[1]); + params.forEach(param => { + variables.push({ + name: param.name, + type: param.type, + scope: 'contract', + }); + }); + } + + // Extract function parameters + // Pattern: function functionName(type1 param1, type2 param2, ...) + const functionRegex = /function\s+(\w+)\s*\(([^)]*)\)/g; + let functionMatch; + while ((functionMatch = functionRegex.exec(codeWithoutComments)) !== null) { + const functionName = functionMatch[1]; + const params = parseParameters(functionMatch[2]); + params.forEach(param => { + variables.push({ + name: param.name, + type: param.type, + scope: 'function', + functionName, + }); + }); + } + + // Extract local variable declarations + // Pattern: type varName = expression; + // CashScript types: int, bool, string, bytes, bytes1-32, pubkey, sig, datasig + const typePattern = '(?:int|bool|string|bytes(?:[1-9]|[12][0-9]|3[0-2])?|pubkey|sig|datasig)'; + const localVarRegex = new RegExp(`(${typePattern})\\s+(\\w+)\\s*=`, 'g'); + let localMatch; + while ((localMatch = localVarRegex.exec(codeWithoutComments)) !== null) { + const varType = localMatch[1]; + const varName = localMatch[2]; + // Avoid duplicates + if (!variables.some(v => v.name === varName)) { + variables.push({ + name: varName, + type: varType, + scope: 'local', + }); + } + } + + return variables; +} + +/** + * Parses a parameter list string into individual parameters. + */ +function parseParameters(paramString: string): Array<{ name: string; type: string }> { + const params: Array<{ name: string; type: string }> = []; + + if (!paramString.trim()) { + return params; + } + + // Split by comma, handling potential whitespace + const paramParts = paramString.split(','); + + for (const part of paramParts) { + const trimmed = part.trim(); + if (!trimmed) continue; + + // Pattern: type name (possibly with array brackets) + // Examples: "int amount", "bytes32 hash", "pubkey[] keys" + const match = trimmed.match(/^(\w+(?:\[\])?)\s+(\w+)$/); + if (match) { + params.push({ + type: match[1], + name: match[2], + }); + } + } + + return params; +} + +/** + * Removes single-line and multi-line comments from the source code. + */ +function removeComments(code: string): string { + // Remove multi-line comments + let result = code.replace(/\/\*[\s\S]*?\*\//g, ''); + // Remove single-line comments + result = result.replace(/\/\/.*$/gm, ''); + return result; +} + +/** + * Determines the current function context at a given position. + * Returns the function name if inside a function, undefined otherwise. + */ +export function getCurrentFunctionContext(sourceCode: string, offset: number): string | undefined { + const codeBeforeCursor = sourceCode.substring(0, offset); + + // Find all function declarations before the cursor + const functionRegex = /function\s+(\w+)\s*\([^)]*\)\s*\{/g; + let lastFunctionName: string | undefined; + let lastFunctionStart = -1; + let match; + + while ((match = functionRegex.exec(codeBeforeCursor)) !== null) { + lastFunctionName = match[1]; + lastFunctionStart = match.index; + } + + if (lastFunctionStart === -1) { + return undefined; + } + + // Count braces to see if we're still inside the function + const codeFromFunction = sourceCode.substring(lastFunctionStart, offset); + let braceCount = 0; + for (const char of codeFromFunction) { + if (char === '{') braceCount++; + if (char === '}') braceCount--; + } + + // If braces are balanced or we have more opens than closes, we're inside + return braceCount > 0 ? lastFunctionName : undefined; +} + +/** + * Gets variables available at a specific position in the code. + * Takes into account the current function scope. + */ +export function getAvailableVariables( + sourceCode: string, + offset: number +): ExtractedVariable[] { + const allVariables = extractVariables(sourceCode); + const currentFunction = getCurrentFunctionContext(sourceCode, offset); + + return allVariables.filter(variable => { + // Contract-level variables are always available + if (variable.scope === 'contract') { + return true; + } + + // Function parameters are only available inside their function + if (variable.scope === 'function') { + return variable.functionName === currentFunction; + } + + // For local variables, we'd need more sophisticated analysis + // For now, include them if they appear before the cursor + return true; + }); +} diff --git a/yarn.lock b/yarn.lock index a89a59e..63e2e39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2824,6 +2824,11 @@ minimist@^1.2.0, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== +monaco-editor@^0.21.2: + version "0.21.3" + resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.21.3.tgz#3381b66614b64d1c5e3b77dd5564ad496d1b4e5d" + integrity sha512-9N7wATLpi+googstvtm6IKg97vPQ77FDYEpkow5tLriM/VJ0DaTRyUP4UVzcoH7KlPDX+e/rE7/imcOUeGkT6g== + ms@2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" From 08b333c1da1e3032de52c2636b0f03ec3d2cf78e Mon Sep 17 00:00:00 2001 From: Mathieu Geukens Date: Thu, 5 Feb 2026 12:21:42 +0100 Subject: [PATCH 2/2] update monaco deps --- package.json | 4 ++-- src/components/Editor.tsx | 12 ++++++------ yarn.lock | 26 +++++++++++++------------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index 923652e..ffe3754 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@cashscript/utils": "^0.12.0", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", - "@monaco-editor/react": "^3.6.2", + "@monaco-editor/react": "^4.7.0", "bootstrap": "^5.3.7", "cashc": "^0.12.0", "cashscript": "^0.12.0", @@ -30,7 +30,7 @@ "@types/react-dom": "18.0.11", "eslint": "^9.36.0", "eslint-config-next": "^15.5.4", - "monaco-editor": "^0.21.2", + "monaco-editor": "^0.52.2", "typescript": "^5.9.3" } } diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 31b95c0..34ee9e0 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react' -import { ControlledEditor, monaco } from '@monaco-editor/react' +import MonacoEditor, { loader } from '@monaco-editor/react' import { Button } from 'react-bootstrap' import { ColumnFlex } from './shared' import { setupCashScriptLanguage, CASHSCRIPT_LANGUAGE_ID } from '@/editor/cashscript' @@ -17,13 +17,13 @@ const Editor: React.FC = ({ code, setCode, compile }) => { // Initialize CashScript language support useEffect(() => { - monaco.init().then((monacoInstance: typeof Monaco) => { + loader.init().then((monacoInstance: typeof Monaco) => { setupCashScriptLanguage(monacoInstance) setIsLanguageReady(true) }) }, []) - function handleEditorDidMount() { + function handleEditorMount() { setIsEditorReady(true) } @@ -32,12 +32,12 @@ const Editor: React.FC = ({ code, setCode, compile }) => { id="editor" style={{ flex: 3, margin: '16px', border: '2px solid black', background: 'white' }} > - setCode(code?? "") } - editorDidMount={handleEditorDidMount} + onChange={(value) => setCode(value ?? "")} + onMount={handleEditorMount} />