diff --git a/src/extension.ts b/src/extension.ts index 5ed42f0f..602c89c1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,6 +27,10 @@ export const incLangId = "objectscript-macros"; export const cspLangId = "objectscript-csp"; export const outputLangId = "vscode-objectscript-output"; +const dotPrefixRegex = /^(\s*(?:\.\s*)+)/; +const dotIndentLanguages = new Set([macLangId, intLangId]); +const dotIndentSkipDocuments = new Set(); + import * as url from "url"; import path = require("path"); import { @@ -1050,6 +1054,72 @@ export async function activate(context: vscode.ExtensionContext): Promise { } } + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(async (event) => { + if (!dotIndentLanguages.has(event.document.languageId)) { + return; + } + + const docUriString = event.document.uri.toString(); + if (dotIndentSkipDocuments.has(docUriString)) { + return; + } + + const editor = vscode.window.visibleTextEditors.find((e) => e.document === event.document); + if (!editor) { + return; + } + + for (const change of event.contentChanges) { + if (!change.text.includes("\n")) { + continue; + } + + const newLineNumber = change.range.start.line + 1; + if (newLineNumber >= event.document.lineCount || newLineNumber <= 0) { + continue; + } + + const previousLine = event.document.lineAt(newLineNumber - 1).text; + const prefixMatch = previousLine.match(dotPrefixRegex); + if (!prefixMatch) { + continue; + } + + let insertText = prefixMatch[1]; + if (!insertText.endsWith(" ")) { + insertText += " "; + } + + const remainder = previousLine.slice(prefixMatch[1].length); + if (remainder.startsWith(";")) { + insertText += ";"; + } + + const newLine = event.document.lineAt(newLineNumber); + if (newLine.text.startsWith(insertText)) { + continue; + } + + const indentMatch = newLine.text.match(/^\s*/); + const indentLength = indentMatch ? indentMatch[0].length : 0; + const replaceRange = new vscode.Range(newLine.range.start, new vscode.Position(newLineNumber, indentLength)); + + dotIndentSkipDocuments.add(docUriString); + try { + await editor.edit( + (editBuilder) => { + editBuilder.replace(replaceRange, insertText); + }, + { undoStopBefore: false, undoStopAfter: false } + ); + } finally { + dotIndentSkipDocuments.delete(docUriString); + } + } + }) + ); + openedClasses = workspaceState.get("openedClasses") ?? []; /** The stringified URIs of all `isfs` documents that are currently open in a UI tab */ diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index d2cdb1a9..3f4ba604 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -21,6 +21,17 @@ async function waitForIndexedDocument(documentName: string, workspaceFolderName: assert.fail(`Timed out waiting for '${documentName}' to be indexed in workspace folder '${workspaceFolderName}'.`); } +async function waitForCondition(predicate: () => boolean, timeoutMs = 1000, message?: string): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(message ?? "Timed out waiting for condition"); +} + function getDefinitionTargets(definitions: (vscode.Location | vscode.DefinitionLink)[]): vscode.Uri[] { return definitions .map((definition) => ("targetUri" in definition ? definition.targetUri : definition.uri)) @@ -44,6 +55,42 @@ suite("Extension Test Suite", () => { assert.ok("All good"); }); + test("Dot-prefixed statements continue on newline", async () => { + const document = await vscode.workspace.openTextDocument({ + language: "objectscript", + content: " . Do ##class(Test).Run()", + }); + const editor = await vscode.window.showTextDocument(document); + try { + await editor.edit((editBuilder) => { + editBuilder.insert(document.lineAt(0).range.end, "\n"); + }); + await waitForCondition(() => document.lineCount > 1); + await waitForCondition(() => document.lineAt(1).text.length > 0); + assert.strictEqual(document.lineAt(1).text, " . "); + } finally { + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + } + }); + + test("Dot-prefixed semicolon comments continue on newline", async () => { + const document = await vscode.workspace.openTextDocument({ + language: "objectscript", + content: " . ; Comment", + }); + const editor = await vscode.window.showTextDocument(document); + try { + await editor.edit((editBuilder) => { + editBuilder.insert(document.lineAt(0).range.end, "\n"); + }); + await waitForCondition(() => document.lineCount > 1); + await waitForCondition(() => document.lineAt(1).text.length > 0); + assert.strictEqual(document.lineAt(1).text, " . ;"); + } finally { + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + } + }); + test("Go to Definition resolves to sibling workspace folder", async function () { this.timeout(10000); await waitForIndexedDocument("MultiRoot.Shared.cls", "shared");