From b362770d9002fcaad33aea090d0713f97b581d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kevin=20Iv=C3=A1n=20Dom=C3=ADnguez=20Jim=C3=A9nez?= Date: Thu, 31 Jul 2025 17:42:00 -0600 Subject: [PATCH] Improved syntax, added full word select --- language-configuration.json | 4 +- package.json | 119 +++++----- src/extension.ts | 339 +++++++++++++++-------------- syntaxes/4690basic.tmLanguage.json | 145 ++---------- 4 files changed, 264 insertions(+), 343 deletions(-) diff --git a/language-configuration.json b/language-configuration.json index 0253645..0590e18 100644 --- a/language-configuration.json +++ b/language-configuration.json @@ -1,7 +1,9 @@ { "comments": { // symbol used for single line comment. Remove this entry if your language does not support line comments - "lineComment": "!" + "lineComment": { + "comment": "!" + } }, // symbols used as brackets "brackets": [ diff --git a/package.json b/package.json index e8acf1f..af15c3a 100644 --- a/package.json +++ b/package.json @@ -1,54 +1,69 @@ { - "name": "format-4690", - "displayName": "format-4690", - "description": "Formatter for 4690 BASIC", - "version": "0.0.1", - "engines": { - "vscode": "^1.101.0" - }, - "categories": [ - "Programming Languages" - ], - "activationEvents": [], - "main": "./dist/extension.js", - "contributes": { - "languages": [{ - "id": "4690basic", - "aliases": ["4690 BASIC", "4690basic"], - "extensions": [".BAS", ".J86"], - "configuration": "./language-configuration.json" - }], - "grammars": [ - { - "language": "4690basic", - "scopeName": "source.4690basic", - "path": "./syntaxes/4690basic.tmLanguage.json" - } - ] - }, - "scripts": { - "vscode:prepublish": "npm run package", - "compile": "webpack", - "watch": "webpack --watch", - "package": "webpack --mode production --devtool hidden-source-map", - "compile-tests": "tsc -p . --outDir out", - "watch-tests": "tsc -p . -w --outDir out", - "pretest": "npm run compile-tests && npm run compile && npm run lint", - "lint": "eslint src", - "test": "vscode-test" - }, - "devDependencies": { - "@types/vscode": "^1.101.0", - "@types/mocha": "^10.0.10", - "@types/node": "20.x", - "@typescript-eslint/eslint-plugin": "^8.31.1", - "@typescript-eslint/parser": "^8.31.1", - "eslint": "^9.25.1", - "typescript": "^5.8.3", - "ts-loader": "^9.5.2", - "webpack": "^5.99.7", - "webpack-cli": "^6.0.1", - "@vscode/test-cli": "^0.0.10", - "@vscode/test-electron": "^2.5.2" - } + "name": "format-4690", + "displayName": "format-4690", + "description": "Formatter for 4690 BASIC", + "version": "0.0.1", + "engines": { + "vscode": "^1.101.0" + }, + "categories": [ + "Programming Languages" + ], + "activationEvents": [], + "main": "./dist/extension.js", + "contributes": { + "languages": [ + { + "id": "4690basic", + "aliases": [ + "4690 BASIC", + "4690basic" + ], + "extensions": [ + ".BAS", + ".J86" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "4690basic", + "scopeName": "source.4690basic", + "path": "./syntaxes/4690basic.tmLanguage.json" + } + ], + "keybindings": [ + { + "command": "4690basic.selectWord", + "key": "ctrl+d", + "when": "editorTextFocus && editorLangId == 4690basic" + } + ] + }, + "scripts": { + "vscode:prepublish": "npm run package", + "compile": "webpack", + "watch": "webpack --watch", + "package": "webpack --mode production --devtool hidden-source-map", + "compile-tests": "tsc -p . --outDir out", + "watch-tests": "tsc -p . -w --outDir out", + "pretest": "npm run compile-tests && npm run compile && npm run lint", + "lint": "eslint src", + "test": "vscode-test" + }, + "devDependencies": { + "@types/vscode": "^1.101.0", + "@types/mocha": "^10.0.10", + "@types/node": "20.x", + "@typescript-eslint/eslint-plugin": "^8.31.1", + "@typescript-eslint/parser": "^8.31.1", + "eslint": "^9.25.1", + "typescript": "^5.8.3", + "ts-loader": "^9.5.2", + "webpack": "^5.99.7", + "webpack-cli": "^6.0.1", + "@vscode/test-cli": "^0.0.10", + "@vscode/test-electron": "^2.5.2" + } } \ No newline at end of file diff --git a/src/extension.ts b/src/extension.ts index a6ae603..9f599d9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,10 @@ import * as vscode from 'vscode'; +interface FunctionParameterInfo { + name: string; + type: string; +} + interface FunctionDefinition { name: string; location: vscode.Location; @@ -14,7 +19,29 @@ const symbolIndex = new Map(); // File index - maps file URIs to their function definitions const fileIndex = new Map(); +function getCustomWordRange(document: vscode.TextDocument, position: vscode.Position) { + const line = document.lineAt(position.line); + const text = line.text; + const char = position.character; + + const wordPattern = /(\?|[a-zA-Z])([a-zA-Z0-9#\.]*)[$#%]?/g; + + let match; + while ((match = wordPattern.exec(text)) !== null) { + const startChar = match.index; + const endChar = match.index + match[0].length; + + if (char >= startChar && char <= endChar) { + return new vscode.Range( + new vscode.Position(position.line, startChar), + new vscode.Position(position.line, endChar) + ); + } + } +} + export async function activate(context: vscode.ExtensionContext) { + const selector: vscode.DocumentSelector = { language: '4690basic' }; const saveWatcher = vscode.workspace.onDidSaveTextDocument(async (document) => { @@ -39,6 +66,22 @@ export async function activate(context: vscode.ExtensionContext) { // Build initial symbol index await buildInitialSymbolIndex(); + const disposable = vscode.commands.registerCommand('4690basic.selectWord', () => { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== '4690basic') return; + + const position = editor.selection.active; + const document = editor.document; + const wordRange = getCustomWordRange(document, position); + + if (wordRange) { + editor.selection = new vscode.Selection(wordRange.start, wordRange.end); + } + }); + + + + // Existing formatter provider (unchanged) const formatterProvider: vscode.DocumentFormattingEditProvider = { provideDocumentFormattingEdits(document: vscode.TextDocument): vscode.TextEdit[] { @@ -52,205 +95,178 @@ export async function activate(context: vscode.ExtensionContext) { let indentLevel = 0; let i = 0; - // Helper function to remove comments from a line (but preserve line continuations) - function removeComments(line: string): string { + // Helper function to remove comments and continuation markers from a line + function cleanLine(line: string): string { let inQuotes = false; let quoteChar = ''; + let result = ''; + + for (let j = 0; j < line.length; j++) { + const char = line[j]; - for (let i = 0; i < line.length; i++) { - const char = line[i]; if (!inQuotes && (char === '"' || char === "'")) { inQuotes = true; quoteChar = char; + result += char; } else if (inQuotes && char === quoteChar) { inQuotes = false; quoteChar = ''; + result += char; } else if (!inQuotes && char === '!') { - return line.substring(0, i).trim(); + // Comment starts here, ignore rest of line + break; + } else if (!inQuotes && char === '\\') { + // Continuation marker, ignore rest of line + break; + } else { + result += char; } } - return line; + + return result.trim(); } - // Helper function to check if a line should continue - function shouldContinue(line: string, allLines: string[], currentIndex: number): boolean { - const trimmedLine = line.trim(); + // Helper function to build a complete logical line by looking ahead + function buildLogicalLine(startIndex: number): { logicalLine: string, physicalLines: string[], endIndex: number } { + const physicalLines = []; + let logicalParts = []; + let currentIndex = startIndex; - // Skip comment lines that start with backslash (like \REM!!) - // These should not be treated as continuation lines - if (/^\s*\\(REM|rem)/i.test(trimmedLine)) { - return false; + if (!/(IF|THEN|ENDIF|ELSE|FUNCTION|DEF|SUB|WHILE|FOR)\b/i.test(lines[currentIndex])) { + return { logicalLine: lines[currentIndex], physicalLines: [lines[currentIndex]], endIndex: currentIndex }; } - const withoutComments = removeComments(line).trim(); + while (currentIndex < lines.length) { + const line = lines[currentIndex]; + const trimmedLine = line.trim(); - // Check for explicit continuation with \ - if (/^(?:[^"\\]|"[^"]*")*\\/.test(withoutComments)) { - // Special case: if this is a THEN \ line, don't continue - // Let the next line be processed as its own logical group - if (/\bTHEN\s*\\/.test(withoutComments)) { - return false; - } - return true; - } - - // Check for IF without THEN on the same line - if (/^\s*IF\b/i.test(withoutComments) && !/\bTHEN\b/i.test(withoutComments)) { - return true; - } - - // Check if current line looks like a continuation of a condition - if (/^\s*(AND\b|OR\b|NOT\b|\()/i.test(withoutComments) && currentIndex > 0) { - // BUT: if this line contains THEN, it terminates the condition, so it should NOT continue - if (/\bTHEN\b/i.test(withoutComments)) { - return false; + // Skip comment lines that start with backslash (like \REM!!) + if (/^\s*(\\|REM|!)/i.test(trimmedLine)) { + physicalLines.push(line); + currentIndex++; + continue; } - // Look back to see if we're in a continuation chain - let prevIndex = currentIndex - 1; - while (prevIndex >= 0) { - const prevLine = removeComments(allLines[prevIndex]).trim(); - if (prevLine === '') { - prevIndex--; - continue; - } - if (/\\.*$/.test(prevLine) || - /^\s*IF\b/i.test(prevLine) || - /^\s*(AND\b|OR\b|NOT\b|\()/i.test(prevLine)) { - return true; + physicalLines.push(line); + const cleanedLine = cleanLine(line); + + if (cleanedLine) { + logicalParts.push(cleanedLine); + } + + // Check if this line continues (ends with backslash outside quotes/comments) + let hasContinuation = false; + let inQuotes = false; + + for (let j = 0; j < line.length; j++) { + const char = line[j]; + if (!inQuotes && char === '"') { + inQuotes = true; + } else if (inQuotes && char === '"') { + inQuotes = false; + } else if (!inQuotes && char === '!') { + // Comment starts, no continuation possible after this + break; + } else if (!inQuotes && char === '\\') { + if (!/BEGIN/.test(cleanedLine)) { + hasContinuation = true; + } + const nextLineText = lines[currentIndex + 1]; + if (nextLineText && /THEN\s*(!|\\)\\/i.test(cleanedLine) && /^\s*(IF|WHILE|FOR)\b/i.test(nextLineText)) { + hasContinuation = false; + } + break; } + } + + currentIndex++; + + // If no continuation marker, this logical line is complete + if (!hasContinuation) { break; } } + const logicalLine = logicalParts.join(' ').trim(); + return { + logicalLine, + physicalLines, + endIndex: currentIndex - 1 + }; + } + + // Helper function to check if a logical line should start an indent block + function shouldStartIndentBlock(logicalLine: string): boolean { + + if (/^\s*((FUNCTION|DEF|SUB|FOR|WHILE)\b)|(BEGIN\b)/i.test(logicalLine)) { + return true; + } + return false; } + // Helper function to check if a logical line should end an indent block + function shouldEndIndentBlock(logicalLine: string): boolean { + return /^\s*(END\s+FUNCTION|FEND|END\s+SUB|ENDIF|WEND|NEXT)\b/i.test(logicalLine); + } + + // Helper function to check if a logical line is ELSE (which decreases then increases indent) + function isElseStatement(logicalLine: string): boolean { + return /^\s*ELSE\b/i.test(logicalLine) && !/\bBEGIN\b/i.test(logicalLine); + } + while (i < lines.length) { - const originalLine = lines[i]; - const trimmedLine = originalLine.trim(); // Skip empty lines - if (trimmedLine === '') { + if (lines[i].trim() === '') { i++; continue; } - // Collect continuation lines - let continuationLines = [originalLine]; - let j = i; + // Build complete logical line + const { logicalLine, physicalLines, endIndex } = buildLogicalLine(i); - while (j < lines.length && shouldContinue(lines[j], lines, j)) { - if (j + 1 < lines.length) { - j++; - continuationLines.push(lines[j]); - } else { - break; - } - } + // console.log({ logicalLine, physicalLines, endIndex }); - // Build the complete logical line by joining all parts - let logicalParts: string[] = []; - for (let k = 0; k < continuationLines.length; k++) { - let part = continuationLines[k].trim(); - part = removeComments(part); - if (part.endsWith('\\')) { - part = part.slice(0, -1).trim(); - } - if (part) { - logicalParts.push(part); - } - } + // Determine indentation changes + // const isCompound = isCompoundStatement(logicalLine); + const isElse = isElseStatement(logicalLine); + let shouldEnd = shouldEndIndentBlock(logicalLine); + const shouldStart = shouldStartIndentBlock(logicalLine); - const fullLogical = logicalParts.join(' ').trim(); - const firstLineTrimmed = removeComments(continuationLines[0]).trim(); - - // Check if this line follows a THEN \ continuation - const isPrevThenContinuation = i > 0 && /\bTHEN\s*\\/.test(removeComments(lines[i - 1])); - - // Check if this is a THEN continuation that starts a new block - const isThenContinuation = /\bTHEN\s*\\/.test(removeComments(continuationLines[0])); - let thenBlockStarts = false; - - if (isThenContinuation && continuationLines.length > 1) { - // Check if the continuation contains an opening construct - for (let k = 1; k < continuationLines.length; k++) { - const contLine = removeComments(continuationLines[k]).trim(); - if (/^\s*(FUNCTION|DEF|SUB|WHILE|FOR|IF|BEGIN)\b/i.test(contLine)) { - thenBlockStarts = true; - break; - } - } - } - - // Simple approach: look for keywords that affect indentation - const containsBegin = /\bBEGIN\b/i.test(fullLogical); - const containsNext = /^\s*NEXT\b/i.test(firstLineTrimmed); - - const isClosing = /^\s*(END\s+FUNCTION|FEND|END\s+SUB|ENDIF|WEND|NEXT)\b/i.test(firstLineTrimmed) || - containsNext || - (/^\s*ELSE\b/i.test(firstLineTrimmed) && !containsBegin); - - const isOpening = /^\s*(FUNCTION|DEF|SUB|WHILE|FOR)\b/i.test(fullLogical) || - /^\s*(FUNCTION|DEF|SUB|WHILE|FOR)\b/i.test(firstLineTrimmed) || - containsBegin || - thenBlockStarts; - - // Handle compound statements like ENDIF ELSE BEGIN - const isCompoundEndifElse = /\bENDIF\b.*\bELSE\s+BEGIN\b/i.test(fullLogical); - const isCompoundEndifElseIf = /\bENDIF\s+ELSE\s+IF\b/i.test(fullLogical) && containsBegin; - - // Adjust indent level for closing statements BEFORE formatting - if (isCompoundEndifElse || isCompoundEndifElseIf) { - indentLevel = Math.max(indentLevel - 1, 0); - } else if (isClosing) { + // Adjust indent level BEFORE formatting (for closing statements) + if (isElse || shouldEnd) { indentLevel = Math.max(indentLevel - 1, 0); } - // Format all lines in this logical group with current indent level - for (let k = 0; k < continuationLines.length; k++) { - const lineIndex = i + k; - const original = continuationLines[k]; + shouldEnd = false; + + // Format all physical lines in this logical group + for (let j = 0; j < physicalLines.length; j++) { + const lineIndex = i + j; + const original = physicalLines[j]; const trimmed = original.trim(); if (trimmed === '') continue; - // For continuation lines (k > 0), use appropriate indentation + // Determine indentation for this line let lineIndentLevel = indentLevel; - if (isPrevThenContinuation) { - // This line follows a THEN \ - it should be indented - lineIndentLevel = indentLevel + 1; - } else if (k > 0 && isThenContinuation) { - // This is a continuation after THEN - it should be indented - lineIndentLevel = indentLevel + 1; - } else if (k > 0) { - // Regular continuation line - check if the previous line ended with \ - const prevLine = removeComments(continuationLines[k - 1]).trim(); - if (prevLine.endsWith('\\')) { - // Keep the same indentation as the main statement - lineIndentLevel = indentLevel; - } - } - const indent = indentUnit.repeat(lineIndentLevel); const formatted = indent + trimmed; - if (formatted !== original) { - const lineRange = document.lineAt(lineIndex).range; - edits.push(vscode.TextEdit.replace(lineRange, formatted)); - } + // if (formatted !== original) { + const lineRange = document.lineAt(lineIndex).range; + edits.push(vscode.TextEdit.replace(lineRange, formatted)); + // } } - // Adjust indent level for opening statements AFTER formatting - if (isCompoundEndifElse || isCompoundEndifElseIf) { - indentLevel++; - } else if (isOpening) { + if (shouldStart && !shouldEnd) { indentLevel++; } - // Move to the next logical group - i = j + 1; + // Move to next logical group + i = endIndex + 1; } return edits; @@ -262,6 +278,7 @@ export async function activate(context: vscode.ExtensionContext) { const definitions: FunctionDefinition[] = []; const lines = document.getText().split('\n'); const identifierRegex = /(\?|[a-zA-Z])([a-zA-Z0-9#\.]*)[\$#%]?/; + const labelRegex = /(\?|[a-zA-Z])([a-zA-Z0-9#\.]*)[\$#%]?:/; // Helper function to remove comments from a line function removeComments(line: string): string { @@ -500,26 +517,26 @@ export async function activate(context: vscode.ExtensionContext) { position: vscode.Position, token: vscode.CancellationToken ): Promise { - const wordRange = document.getWordRangeAtPosition(position, /(\?|[a-zA-Z])([a-zA-Z0-9#\.]*)[\$#%]?/); - if (!wordRange) { + const functionWordRange = document.getWordRangeAtPosition(position, /(\?|[a-zA-Z])([a-zA-Z0-9#\.]*)[\$#%]?/); + if (!functionWordRange) { return undefined; } - const word = document.getText(wordRange).toLowerCase(); - const definitions = symbolIndex.get(word); + const word = document.getText(functionWordRange).toLowerCase(); + const functionDefinitions = symbolIndex.get(word); - if (!definitions || definitions.length === 0) { + if (!functionDefinitions || functionDefinitions.length === 0) { return undefined; } // If multiple definitions exist, prefer the one in the current document - const localDef = definitions.find(def => def.location.uri.toString() === document.uri.toString()); + const localDef = functionDefinitions.find(def => def.location.uri.toString() === document.uri.toString()); if (localDef) { return localDef.location; } // Otherwise return the first definition found - return definitions[0].location; + return functionDefinitions[0].location; } }; @@ -613,16 +630,12 @@ export async function activate(context: vscode.ExtensionContext) { function findFunctionEnd(startLine: number, functionType: 'FUNCTION' | 'DEF' | 'SUB'): number { let endPattern: RegExp; - // console.log('functionType: ', functionType); - if (functionType === 'FUNCTION' || functionType === 'DEF') { endPattern = /^\s*(END\s+FUNCTION)|(FEND)\b/i; } else { // SUB endPattern = /^\s*END\s+SUB\b/i; } - // console.log('endPattern: ', endPattern); - for (let i = startLine + 1; i < lines.length; i++) { const line = lines[i]; @@ -636,17 +649,12 @@ export async function activate(context: vscode.ExtensionContext) { continue; } - // console.log('Evaluating for end: ', line); - // Check if this line matches the end pattern const endMatch = endPattern.exec(line); if (endMatch) { - // console.log('Found match: ', endMatch); - // Check if the end keyword is not commented out if (!isKeywordCommentedOut(line, endMatch)) { - // console.log('Returning i'); return i; } } @@ -683,21 +691,13 @@ export async function activate(context: vscode.ExtensionContext) { continue; } - // console.log('Current line: ', trimmedLine); - // Extract the function type and name const defType = functionMatch[1].toUpperCase() as 'FUNCTION' | 'SUB' | 'DEF'; - // console.log('functionMatch: ', functionMatch); - // console.log('Definition type: ', defType); - // Find the identifier after the function keyword const afterKeyword = line.substring(functionMatch[0].length).trim(); const nameMatch = identifierRegex.exec(afterKeyword); - // console.log('afterKeyword: ', afterKeyword); - // console.log('nameMatch: ', nameMatch); - if (!nameMatch) { continue; } @@ -805,7 +805,8 @@ export async function activate(context: vscode.ExtensionContext) { vscode.languages.registerDocumentSymbolProvider(selector, documentSymbolProvider), vscode.languages.registerHoverProvider(selector, hoverProvider), saveWatcher, - fsWatcher + fsWatcher, + disposable, ); } diff --git a/syntaxes/4690basic.tmLanguage.json b/syntaxes/4690basic.tmLanguage.json index 34154c0..1427a84 100644 --- a/syntaxes/4690basic.tmLanguage.json +++ b/syntaxes/4690basic.tmLanguage.json @@ -12,6 +12,10 @@ "name": "string.regexp", "match": "\\b[\\w\\.]+:" }, + { + "name": "meta", + "match": "\\s:" + }, { "include": "#function-decl" }, @@ -42,6 +46,12 @@ { "include": "#if-statement-end" }, + { + "include": "#else" + }, + { + "include": "#then" + }, { "include": "#for-statement" }, @@ -407,7 +417,7 @@ }, "logical-operator": { "name": "keyword.operator.logical", - "match": "(?i)\\b(NOT|AND|OR|XOR)\\b" + "match": "(?i)\\b(NOT|AND|OR|XOR)($|\\n|\\s\\()" }, "string-operator": { "name": "keyword.operator", @@ -513,7 +523,7 @@ }, { "name": "support.function", - "match": "(?i)\\b(NULL|TRUE|FALSE|READ|WRITE|KEY)\\b[^%\\$\\.]" + "match": "(?i)\\b(NULL|TRUE|FALSE|READ|WRITE|KEY|NOWRITE|NODEL)\\b[^%\\$\\.]" } ] }, @@ -633,127 +643,20 @@ } }, "if-statement": { - "name": "meta.if", - "begin": "(?i)\\b(IF)", - "beginCaptures": { - "0": { - "name": "keyword.control" - } - }, - "patterns": [ - { - "include": "#math-function" - }, - { - "include": "#string-function" - }, - { - "include": "#number-literal" - }, - { - "include": "#arithmetic-operator" - }, - { - "include": "#relational-operator" - }, - { - "include": "#logical-operator" - }, - { - "include": "#keywords" - }, - { - "include": "#strings" - }, - { - "name": "keyword.control", - "match": "(?i)THEN" - }, - { - "name": "keyword.control", - "match": "(?i)ELSE" - }, - { - "name": "keyword.control", - "match": "(?i)(BEGIN)" - }, - { - "include": "#identifier" - }, - { - "include": "#line-continuation" - }, - { - "include": "#comments" - }, - { - "match": "\\s+", - "name": "text.whitespace" - } - ], - "end": "(?