Skip to content

Magic Command Support #1582

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion client/src/components/notebook/Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import { getSession } from "../../connection";
import { SASCodeDocument } from "../utils/SASCodeDocument";
import { getCodeDocumentConstructionParameters } from "../utils/SASCodeDocumentHelper";
import { Deferred, deferred } from "../utils/deferred";
import { NotebookMagicLanguageSwitcher } from "./NotebookMagicLanguageSwitcher";

export class NotebookController {
readonly controllerId = "sas-notebook-controller-id";
readonly notebookType = "sas-notebook";
readonly label = "SAS Notebook";
readonly supportedLanguages = ["sas", "sql", "python"];
readonly supportedLanguages = ["sas", "sql", "python", "markdown"];

private readonly _controller: vscode.NotebookController;
private readonly _magicLanguageSwitcher: NotebookMagicLanguageSwitcher;
private _executionOrder = 0;
private _interrupted: Deferred<void> | undefined;

Expand All @@ -28,10 +30,12 @@ export class NotebookController {
this._controller.supportsExecutionOrder = true;
this._controller.executeHandler = this._execute.bind(this);
this._controller.interruptHandler = this._interrupt.bind(this);
this._magicLanguageSwitcher = new NotebookMagicLanguageSwitcher();
}

dispose(): void {
this._controller.dispose();
this._magicLanguageSwitcher.dispose();
}

private async _execute(cells: vscode.NotebookCell[]): Promise<void> {
Expand Down
58 changes: 58 additions & 0 deletions client/src/components/notebook/MagicCommandProcessor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export interface MagicCommand {
command: string;
language: string;
aliases?: string[];
}

export const MAGIC_COMMANDS: MagicCommand[] = [
{ command: "%sas", language: "sas", aliases: ["%s"] },
{ command: "%sql", language: "sql", aliases: ["%q"] },
{ command: "%python", language: "python", aliases: ["%py", "%p"] },
{ command: "%markdown", language: "markdown", aliases: ["%md", "%m"] },
];

export interface MagicProcessResult {
language: string;
code: string;
hasMagic: boolean;
}

export class MagicCommandProcessor {
public static process(
content: string,
defaultLanguage: string,
): MagicProcessResult {
const lines = content.split("\n");

const firstLine = lines[0]?.trim();
if (!firstLine || !firstLine.startsWith("%")) {
return {
language: defaultLanguage,
code: content,
hasMagic: false,
};
}
const magicCommand = firstLine.split(/\s+/)[0].toLowerCase();

const matchedCommand = MAGIC_COMMANDS.find(
(cmd) =>
cmd.command === magicCommand || cmd.aliases?.includes(magicCommand),
);

if (!matchedCommand) {
return {
language: defaultLanguage,
code: content,
hasMagic: false,
};
}

const remainingContent = lines.slice(1).join("\n");

return {
language: matchedCommand.language,
code: remainingContent,
hasMagic: true,
};
}
}
117 changes: 117 additions & 0 deletions client/src/components/notebook/NotebookMagicLanguageSwitcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import * as vscode from "vscode";

import { MagicCommandProcessor } from "./MagicCommandProcessor";

export class NotebookMagicLanguageSwitcher {
private _disposables: vscode.Disposable[] = [];
private _cellChangeMap = new Map<string, NodeJS.Timeout>();

constructor() {
this._disposables.push(
vscode.workspace.onDidChangeTextDocument(
this._onDidChangeTextDocument,
this,
),
);
}

dispose(): void {
this._disposables.forEach((d) => d.dispose());
this._cellChangeMap.forEach((timeout) => clearTimeout(timeout));
this._cellChangeMap.clear();
}

private _onDidChangeTextDocument(
event: vscode.TextDocumentChangeEvent,
): void {
if (event.document.uri.scheme !== "vscode-notebook-cell") {
return;
}

const notebook = vscode.workspace.notebookDocuments.find((nb) =>
nb.getCells().some((cell) => cell.document === event.document),
);

if (!notebook) {
return;
}

const cell = notebook.getCells().find((c) => c.document === event.document);
if (!cell) {
return;
}

// Debouncing so changes don't rapidly happen.
const cellId = cell.document.uri.toString();
const existingTimeout = this._cellChangeMap.get(cellId);
if (existingTimeout) {
clearTimeout(existingTimeout);
}

const timeout = setTimeout(() => {
this._checkAndSwitchLanguage(cell);
this._cellChangeMap.delete(cellId);
}, 500);

this._cellChangeMap.set(cellId, timeout);
}

private async _checkAndSwitchLanguage(
cell: vscode.NotebookCell,
): Promise<void> {
const content = cell.document.getText();
const currentLanguage = cell.document.languageId;

const magicResult = MagicCommandProcessor.process(content, currentLanguage);

if (!magicResult.hasMagic || magicResult.language === currentLanguage) {
return;
}

const cellKind =
magicResult.language === "markdown"
? vscode.NotebookCellKind.Markup
: vscode.NotebookCellKind.Code;

await this._replaceCell(
cell,
magicResult.language,
magicResult.code,
cellKind,
);
}

private async _replaceCell(
cell: vscode.NotebookCell,
newLanguage: string,
newCode: string,
cellKind: vscode.NotebookCellKind,
): Promise<void> {
try {
const notebook = cell.notebook;
const cellIndex = notebook.getCells().indexOf(cell);

if (cellIndex === -1) {
return;
}

const newCellData = new vscode.NotebookCellData(
cellKind,
newCode,
newLanguage,
);

const edit = new vscode.WorkspaceEdit();
edit.set(notebook.uri, [
vscode.NotebookEdit.replaceCells(
new vscode.NotebookRange(cellIndex, cellIndex + 1),
[newCellData],
),
]);

await vscode.workspace.applyEdit(edit);
} catch (error) {
console.error("Cell replacement failed:", error);
}
}
}
31 changes: 31 additions & 0 deletions client/src/components/notebook/exporters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,34 @@ export const exportNotebook = async (client: LanguageClient) => {

workspace.fs.writeFile(uri, Buffer.from(content));
};

export const exportNotebookCell = async (client: LanguageClient) => {
const notebook = window.activeNotebookEditor?.notebook;
const activeCell = window.activeNotebookEditor?.selection?.start;

if (!notebook || activeCell === undefined) {
return;
}

const cell = notebook.cellAt(activeCell);
if (!cell) {
return;
}

const uri = await window.showSaveDialog({
filters: { SAS: ["sas"], HTML: ["html"] },
defaultUri: Uri.parse(
`${path.basename(notebook.uri.path, ".sasnb")}_cell_${activeCell + 1}`,
),
});

if (!uri) {
return;
}

const content = uri.path.endsWith(".html")
? await exportToHTML(notebook, client, activeCell)
: exportToSAS(notebook, activeCell);

workspace.fs.writeFile(uri, Buffer.from(content));
};
6 changes: 5 additions & 1 deletion client/src/components/notebook/exporters/toHTML.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ hljs.registerLanguage("sql", sql);
export const exportToHTML = async (
notebook: NotebookDocument,
client: LanguageClient,
cellIndex?: number,
) => {
const cells = notebook.getCells();
const cells =
cellIndex !== undefined
? [notebook.cellAt(cellIndex)]
: notebook.getCells();

let template = readFileSync(`${templatesDir}/default.html`).toString();

Expand Down
12 changes: 10 additions & 2 deletions client/src/components/notebook/exporters/toSAS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@
// SPDX-License-Identifier: Apache-2.0
import { NotebookCell, NotebookDocument } from "vscode";

export const exportToSAS = (notebook: NotebookDocument) =>
notebook
export const exportToSAS = (notebook: NotebookDocument, cellIndex?: number) => {
if (cellIndex !== undefined) {
// Export single cell
const cell = notebook.cellAt(cellIndex);
return exportCell(cell) + "\n";
}

// Export all cells
return notebook
.getCells()
.map((cell) => exportCell(cell) + "\n")
.join("\n");
};

const exportCell = (cell: NotebookCell) => {
const text = cell.document.getText();
Expand Down
8 changes: 7 additions & 1 deletion client/src/node/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,10 @@ import { LogTokensProvider, legend } from "../components/logViewer";
import { sasDiagnostic } from "../components/logViewer/sasDiagnostics";
import { NotebookController } from "../components/notebook/Controller";
import { NotebookSerializer } from "../components/notebook/Serializer";
import { exportNotebook } from "../components/notebook/exporters";
import {
exportNotebook,
exportNotebookCell,
} from "../components/notebook/exporters";
import { ConnectionType } from "../components/profile";
import { SasTaskProvider } from "../components/tasks/SasTaskProvider";
import { SAS_TASK_TYPE } from "../components/tasks/SasTasks";
Expand Down Expand Up @@ -202,6 +205,9 @@ export function activate(context: ExtensionContext) {
commands.registerCommand("SAS.notebook.export", () =>
exportNotebook(client),
),
commands.registerCommand("SAS.notebook.exportCell", () =>
exportNotebookCell(client),
),
tasks.registerTaskProvider(SAS_TASK_TYPE, new SasTaskProvider()),
...sasDiagnostic.getSubscriptions(),
commands.registerTextEditorCommand("SAS.toggleLineComment", (editor) => {
Expand Down
27 changes: 27 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,12 @@
"title": "%commands.SAS.notebook.export%",
"category": "SAS Notebook"
},
{
"command": "SAS.notebook.exportCell",
"title": "%commands.SAS.notebook.exportCell%",
"category": "SAS Notebook",
"icon": "$(export)"
},
{
"command": "SAS.file.new",
"shortTitle": "%commands.SAS.file.new.short%",
Expand Down Expand Up @@ -1097,6 +1103,27 @@
"command": "SAS.notebook.export"
}
],
"notebook/cell/execute": [
{
"command": "SAS.notebook.exportCell",
"when": "notebookType == 'sas-notebook'",
"group": "inline"
}
],
"notebook/cell/title": [
{
"command": "SAS.notebook.exportCell",
"when": "notebookType == 'sas-notebook'",
"group": "inline"
}
],
"notebook/cell/output": [
{
"command": "SAS.notebook.exportCell",
"when": "notebookType == 'sas-notebook'",
"group": "inline"
}
],
"commandPalette": [
{
"when": "editorLangId == sas && !SAS.hideRunMenuItem",
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"commands.SAS.file.new": "New SAS File",
"commands.SAS.file.new.short": "SAS File",
"commands.SAS.notebook.export": "Export",
"commands.SAS.notebook.exportCell": "Export Cell",
"commands.SAS.notebook.new": "New SAS Notebook",
"commands.SAS.notebook.new.short": "SAS Notebook",
"commands.SAS.refresh": "Refresh",
Expand Down
Loading