diff --git a/app/components/CodeMirrorEditorDemo.tsx b/app/components/CodeMirrorEditorDemo.tsx index 96c558f..d1697fa 100644 --- a/app/components/CodeMirrorEditorDemo.tsx +++ b/app/components/CodeMirrorEditorDemo.tsx @@ -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*. @@ -123,6 +132,7 @@ export function CodeMirrorEditorDemo({ className="text-sm bg-background border border-border rounded px-2 py-1" > + diff --git a/registry.json b/registry.json index 0adac32..fdf8579 100644 --- a/registry.json +++ b/registry.json @@ -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", @@ -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", diff --git a/registry/editor/codemirror-editor.tsx b/registry/editor/codemirror-editor.tsx index 92c85d8..43c0e42 100644 --- a/registry/editor/codemirror-editor.tsx +++ b/registry/editor/codemirror-editor.tsx @@ -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 { @@ -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(() => { diff --git a/registry/editor/index.ts b/registry/editor/index.ts index cb685d1..ac0c68e 100644 --- a/registry/editor/index.ts +++ b/registry/editor/index.ts @@ -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, diff --git a/registry/editor/ipython.ts b/registry/editor/ipython.ts new file mode 100644 index 0000000..0ff63f8 --- /dev/null +++ b/registry/editor/ipython.ts @@ -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 = { + // 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
Hello
") // "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(); + 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 }, +); diff --git a/registry/editor/languages.ts b/registry/editor/languages.ts index cb27383..e6adf33 100644 --- a/registry/editor/languages.ts +++ b/registry/editor/languages.ts @@ -4,13 +4,25 @@ import { json } from "@codemirror/lang-json"; import { markdown } from "@codemirror/lang-markdown"; import { python } from "@codemirror/lang-python"; import { sql } from "@codemirror/lang-sql"; +import { indentUnit } from "@codemirror/language"; import type { Extension } from "@codemirror/state"; +import { + CELL_MAGIC_LANGUAGES, + detectCellMagic, + getCellMagicLanguage, + ipythonHighlighting, + ipythonIndent, + ipythonStyles, + ipythonStylesDark, +} from "./ipython"; + /** * Supported languages for the CodeMirror editor */ export type SupportedLanguage = | "python" + | "ipython" | "markdown" | "sql" | "html" @@ -22,10 +34,22 @@ export type SupportedLanguage = /** * Get the CodeMirror language extension for a given language */ +// PEP 8 specifies 4-space indentation for Python +const pythonIndent = indentUnit.of(" "); + export function getLanguageExtension(language: SupportedLanguage): Extension { switch (language) { case "python": - return python(); + return [python(), pythonIndent]; + case "ipython": + return [ + python(), + pythonIndent, + ipythonIndent, + ipythonHighlighting(), + ipythonStyles, + ipythonStylesDark, + ]; case "markdown": return markdown(); case "sql": @@ -43,11 +67,70 @@ export function getLanguageExtension(language: SupportedLanguage): Extension { } } +/** + * Get the language extension for IPython content, detecting cell magics. + * + * If the content starts with a cell magic (e.g., %%html, %%bash), + * returns the appropriate language extension for that magic. + * Otherwise returns the standard IPython extension. + * + * @param content - The editor content to analyze + * @returns Object with language extension and detected cell magic (if any) + * + * @example + * // Cell magic - returns HTML language + * getIPythonExtension("%%html\n
Hello
") + * // { extension: html(), cellMagic: "html", language: "html" } + * + * // No cell magic - returns IPython + * getIPythonExtension("%time x = sum(range(100))") + * // { extension: [python(), ...], cellMagic: null, language: "ipython" } + */ +export function getIPythonExtension(content: string): { + extension: Extension; + cellMagic: string | null; + language: SupportedLanguage; +} { + const magic = detectCellMagic(content); + + if (magic) { + const langId = getCellMagicLanguage(magic); + const language = ( + langId in languageDisplayNames ? langId : "plain" + ) as SupportedLanguage; + + // For cell magics, use the target language but add IPython decoration + // for the first line (the %%magic declaration) + const baseExtension = getLanguageExtension(language); + return { + extension: [ + baseExtension, + ipythonHighlighting(), + ipythonStyles, + ipythonStylesDark, + ], + cellMagic: magic, + language, + }; + } + + // No cell magic - use standard IPython mode + return { + extension: getLanguageExtension("ipython"), + cellMagic: null, + language: "ipython", + }; +} + +// Re-export cell magic utilities for consumers +export { CELL_MAGIC_LANGUAGES, detectCellMagic, getCellMagicLanguage }; + /** * Language display names for UI */ export const languageDisplayNames: Record = { python: "Python", + ipython: "IPython", markdown: "Markdown", sql: "SQL", html: "HTML", @@ -62,6 +145,7 @@ export const languageDisplayNames: Record = { */ export const fileExtensionToLanguage: Record = { ".py": "python", + ".ipy": "ipython", ".md": "markdown", ".markdown": "markdown", ".sql": "sql",