Skip to content
Merged
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
10 changes: 10 additions & 0 deletions app/components/CodeMirrorEditorDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ def greet(name: str) -> str:
return f"Hello, {name}!"

print(greet("nteract"))`,
ipython: `# IPython example with magics and shell commands
%matplotlib inline
%time x = sum(range(1000000))

!pip install pandas

import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3]})
df.head?`,
markdown: `# Markdown Example

This is a **bold** statement and this is *italic*.
Expand Down Expand Up @@ -123,6 +132,7 @@ export function CodeMirrorEditorDemo({
className="text-sm bg-background border border-border rounded px-2 py-1"
>
<option value="python">Python</option>
<option value="ipython">IPython</option>
<option value="markdown">Markdown</option>
<option value="sql">SQL</option>
<option value="html">HTML</option>
Expand Down
7 changes: 6 additions & 1 deletion registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,7 @@
"name": "codemirror-editor",
"type": "registry:component",
"title": "CodeMirror Editor",
"description": "A CodeMirror 6 editor component for notebook cells with syntax highlighting, key bindings, and language support for Python, Markdown, SQL, HTML, JavaScript, TypeScript, and JSON.",
"description": "A CodeMirror 6 editor component for notebook cells with syntax highlighting, key bindings, and language support for Python, IPython (with magics and shell commands), Markdown, SQL, HTML, JavaScript, TypeScript, and JSON.",
"dependencies": [
"@codemirror/view",
"@codemirror/state",
Expand Down Expand Up @@ -439,6 +439,11 @@
"type": "registry:component",
"target": "components/editor/themes.ts"
},
{
"path": "registry/editor/ipython.ts",
"type": "registry:component",
"target": "components/editor/ipython.ts"
},
{
"path": "registry/editor/index.ts",
"type": "registry:component",
Expand Down
17 changes: 12 additions & 5 deletions registry/editor/codemirror-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ import {

import { cn } from "@/lib/utils";
import { defaultExtensions } from "./extensions";
import { getLanguageExtension, type SupportedLanguage } from "./languages";
import {
getIPythonExtension,
getLanguageExtension,
type SupportedLanguage,
} from "./languages";
import { darkTheme, isDarkMode, lightTheme, type ThemeMode } from "./themes";

export interface CodeMirrorEditorRef {
Expand Down Expand Up @@ -140,10 +144,13 @@ export const CodeMirrorEditor = forwardRef<
};
}, [theme]);

const langExtension = useMemo(
() => getLanguageExtension(language),
[language],
);
// For IPython, detect cell magics and switch to appropriate language mode
const langExtension = useMemo(() => {
if (language === "ipython") {
return getIPythonExtension(value).extension;
}
return getLanguageExtension(language);
}, [language, value]);

// Determine which theme to use
const themeExtension = useMemo(() => {
Expand Down
10 changes: 10 additions & 0 deletions registry/editor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@ export {
minimalSetup,
notebookEditorTheme,
} from "./extensions";
export {
CELL_MAGIC_LANGUAGES,
detectCellMagic,
getCellMagicLanguage,
ipythonHighlighting,
ipythonIndent,
ipythonStyles,
ipythonStylesDark,
} from "./ipython";
export {
detectLanguage,
fileExtensionToLanguage,
getIPythonExtension,
getLanguageExtension,
languageDisplayNames,
type SupportedLanguage,
Expand Down
267 changes: 267 additions & 0 deletions registry/editor/ipython.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
import { indentService } from "@codemirror/language";
import { RangeSetBuilder } from "@codemirror/state";
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin } from "@codemirror/view";

/**
* IPython syntax highlighting extension for CodeMirror 6
*
* Highlights IPython-specific syntax on top of standard Python:
* - Shell commands: !ls, !pip install
* - Line magics: %time, %run script.py
* - Cell magics: %%bash, %%javascript (first line only)
* - Help operators: object?, object??
*
* Note: For cell magics (%%), only the first line is decorated.
* The rest of the cell should use the appropriate language mode.
* Use detectCellMagic() and CELL_MAGIC_LANGUAGES to determine
* the correct language for cell magic content.
*/

// Decoration marks for IPython syntax
const shellMark = Decoration.mark({ class: "cm-ipython-shell" });
const magicMark = Decoration.mark({ class: "cm-ipython-magic" });
const cellMagicMark = Decoration.mark({ class: "cm-ipython-cell-magic" });
const helpMark = Decoration.mark({ class: "cm-ipython-help" });

// Patterns for IPython syntax (applied to line content)
const CELL_MAGIC_PATTERN = /^(%%[a-zA-Z_]\w*)/;
const LINE_MAGIC_PATTERN = /^(%[a-zA-Z_]\w*)/;
const SHELL_PATTERN = /^(!)/;
const HELP_PATTERN = /(\?\??)$/;

/**
* Custom indentation service for IPython.
*
* Prevents auto-indent after:
* - Line magics (%time, %matplotlib, etc.)
* - Shell commands (!pip, !ls, etc.)
* - Cell magic declarations (%%bash, %%html, etc.)
*
* These lines don't follow Python's indentation rules,
* so we reset to column 0 after them.
*/
export const ipythonIndent = indentService.of((context, pos) => {
// Get the previous line
const line = context.state.doc.lineAt(pos);
if (line.number <= 1) return null; // Let Python handle first line

const prevLine = context.state.doc.line(line.number - 1);
const prevText = prevLine.text.trim();

// After line magic, shell command, or cell magic - don't auto-indent
if (
LINE_MAGIC_PATTERN.test(prevText) ||
SHELL_PATTERN.test(prevText) ||
CELL_MAGIC_PATTERN.test(prevText)
) {
return 0;
}

// Let Python's indentation handle everything else
return null;
});

/**
* Mapping of cell magic names to language identifiers.
* Use with getLanguageExtension() from languages.ts
*/
export const CELL_MAGIC_LANGUAGES: Record<string, string> = {
// HTML/SVG
html: "html",
HTML: "html",
svg: "html",
SVG: "html",
// JavaScript
javascript: "javascript",
js: "javascript",
// TypeScript
typescript: "typescript",
ts: "typescript",
// SQL
sql: "sql",
SQL: "sql",
// Markdown
markdown: "markdown",
md: "markdown",
// JSON
json: "json",
// Shell (falls back to plain since we don't have shell lang)
bash: "plain",
sh: "plain",
// Python (stays as Python)
python: "python",
python3: "python",
// Others without specific support fall back to plain
};

/**
* Detect cell magic from content.
* Cell magics must be on the first line.
*
* @param content - The editor content
* @returns The magic name (without %%) if found, null otherwise
*
* @example
* detectCellMagic("%%html\n<div>Hello</div>") // "html"
* detectCellMagic("%time x = 1") // null (line magic, not cell magic)
* detectCellMagic("print('hello')") // null
*/
export function detectCellMagic(content: string): string | null {
const firstLine = content.split("\n")[0].trim();
const match = firstLine.match(/^%%([a-zA-Z_]\w*)/);
return match ? match[1] : null;
}

/**
* Get the language identifier for a cell magic.
*
* @param magic - The cell magic name (without %%)
* @returns The language identifier to use with getLanguageExtension(),
* or "plain" for unsupported magics
*
* @example
* getCellMagicLanguage("html") // "html"
* getCellMagicLanguage("bash") // "plain" (no shell support)
* getCellMagicLanguage("unknown") // "plain"
*/
export function getCellMagicLanguage(magic: string): string {
return CELL_MAGIC_LANGUAGES[magic] ?? "plain";
}

class IPythonHighlighter {
decorations: DecorationSet;

constructor(view: EditorView) {
this.decorations = this.buildDecorations(view);
}

update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = this.buildDecorations(update.view);
}
}

buildDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const doc = view.state.doc;

// Check if we're in cell magic mode (first line is %%)
const firstLineText = doc.line(1).text.trim();
const isInCellMagicMode = CELL_MAGIC_PATTERN.test(firstLineText);

for (const { from, to } of view.visibleRanges) {
const startLine = doc.lineAt(from).number;
const endLine = doc.lineAt(to).number;

for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
const line = doc.line(lineNum);
const lineText = line.text;
const trimmedText = lineText.trimStart();
const leadingSpaces = lineText.length - trimmedText.length;
const lineStart = line.from + leadingSpaces;

// Cell magic on first line - decorate it
if (lineNum === 1) {
const cellMagicMatch = trimmedText.match(CELL_MAGIC_PATTERN);
if (cellMagicMatch) {
builder.add(
lineStart,
lineStart + cellMagicMatch[1].length,
cellMagicMark,
);
continue;
}
}

// If we're in cell magic mode, don't apply IPython decorations
// to subsequent lines (they belong to the cell magic's language)
if (isInCellMagicMode && lineNum > 1) {
continue;
}

// Line magic (%magic)
const lineMagicMatch = trimmedText.match(LINE_MAGIC_PATTERN);
if (lineMagicMatch) {
builder.add(
lineStart,
lineStart + lineMagicMatch[1].length,
magicMark,
);
continue;
}

// Shell command (!command)
const shellMatch = trimmedText.match(SHELL_PATTERN);
if (shellMatch) {
builder.add(lineStart, line.to, shellMark);
continue;
}

// Help operator (object? or object??)
const helpMatch = lineText.match(HELP_PATTERN);
if (helpMatch && helpMatch.index !== undefined) {
const helpStart = line.from + helpMatch.index;
const helpEnd = helpStart + helpMatch[1].length;
builder.add(helpStart, helpEnd, helpMark);
}
}
}

return builder.finish();
}
}

/**
* CodeMirror extension that adds IPython syntax highlighting.
*
* For cells with cell magics (%%html, %%bash, etc.), only the first
* line is decorated. Use detectCellMagic() and getLanguageExtension()
* to set the appropriate language for the cell content.
*/
export function ipythonHighlighting() {
return ViewPlugin.fromClass(IPythonHighlighter, {
decorations: (v) => v.decorations,
});
}

/**
* Light theme styles for IPython syntax
*/
export const ipythonStyles = EditorView.theme({
".cm-ipython-shell": {
color: "#0550ae",
},
".cm-ipython-magic": {
color: "#6639ba",
},
".cm-ipython-cell-magic": {
color: "#6639ba",
fontWeight: "bold",
},
".cm-ipython-help": {
color: "#0969da",
},
});

/**
* Dark theme styles for IPython syntax
*/
export const ipythonStylesDark = EditorView.theme(
{
".cm-ipython-shell": {
color: "#79c0ff",
},
".cm-ipython-magic": {
color: "#d2a8ff",
},
".cm-ipython-cell-magic": {
color: "#d2a8ff",
fontWeight: "bold",
},
".cm-ipython-help": {
color: "#58a6ff",
},
},
{ dark: true },
);
Loading