From af21cf478073e9782256df7dc08ad9943eb77611 Mon Sep 17 00:00:00 2001 From: mattorp Date: Wed, 12 Jul 2023 13:52:12 +0200 Subject: [PATCH 1/2] Add codeblock support in markdown Includes syntax highlighting and run cypher in codeblock --- CHANGELOG.md | 11 ++++++ package-lock.json | 4 +- package.json | 25 +++++++++++- src/commands/cypher/run-cypher.ts | 52 +++++++++++++++++++++++-- syntaxes/cypher-markdown-injection.json | 45 +++++++++++++++++++++ 5 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 syntaxes/cypher-markdown-injection.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0892fb1..dee72f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to the "neo4j-vscode" extension will be documented in this file. +## [0.1.9] + +- Added support for Cypher code blocks in Markdown files +- Automatically selects the text within the code block when running a query if no selection is made + +```cypher +match (n) return n limit 1 +``` + +[Reference](https://stackoverflow.com/a/76239666/3876654) + ## [0.1.8] - Applied case insensitivity to parameter commands diff --git a/package-lock.json b/package-lock.json index a12a1f0..b9c32cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "neo4j-vscode", - "version": "0.1.8", + "version": "0.1.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "neo4j-vscode", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "neo4j-driver": "^5.3.0" }, diff --git a/package.json b/package.json index d734dbe..c7afba8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "type": "git", "url": "https://github.com/adam-cowley/neo4j-vscode" }, - "version": "0.1.8", + "version": "0.1.9", "engines": { "vscode": "^1.74.0" }, @@ -27,12 +27,32 @@ ], "main": "./out/extension.js", "activationEvents": [], + "markdown.codeLanguages": [ + { + "id": "cypher", + "aliases": [ + "cypher", + "Cypher Query Language" + ] + } + ], "contributes": { "grammars": [ { "language": "cypher", "scopeName": "source.cypher", "path": "./cypher/cypher.tmLanguage" + }, + { + "language": "cypher-markdown-injection", + "scopeName": "markdown.cypher.codeblock", + "path": "./syntaxes/cypher-markdown-injection.json", + "injectTo": [ + "text.html.markdown" + ], + "embeddedLanguages": { + "meta.embedded.block.cypher": "cypher" + } } ], "viewsContainers": { @@ -187,6 +207,9 @@ } ], "languages": [ + { + "id": "cypher-markdown-injection" + }, { "id": "cypher", "aliases": [ diff --git a/src/commands/cypher/run-cypher.ts b/src/commands/cypher/run-cypher.ts index 794f6bf..807da91 100644 --- a/src/commands/cypher/run-cypher.ts +++ b/src/commands/cypher/run-cypher.ts @@ -1,8 +1,38 @@ -import { window } from 'vscode' +import { TextEditor, window } from 'vscode' import ConnectionManager from '../../connections/connection-manager.class' import { Method } from '../../constants' import CypherRunner from '../../cypher/runner' +// Check if we are inside a code block based on the cursor position +const handleMarkdown = (editor: TextEditor): + | {text: string; error?: never} + | {text?: never; error: string} => { + const markdown = editor.document.getText() + + const codeBlockRegex = /(```|~~~)+\s*cypher\s*\n([\s\S]*?)\n\1/g + + const cursorPosition = editor.selection.active + + let match: RegExpExecArray | null + + while ((match = codeBlockRegex.exec(markdown)) !== null) { + const codeBlockStart = editor.document.positionAt(match.index) + const codeBlockEnd = editor.document.positionAt(match.index + match[0].length) + + if (cursorPosition.isAfterOrEqual(codeBlockStart) + && cursorPosition.isBeforeOrEqual(codeBlockEnd) + ) { + const text = match[2].trim() + if (text.length === 0) { + return {error: 'No text in code block'} + } + + return {text} + } // Else: Check if the cursor is inside the next code block + } + return {error: 'Cursor is not inside a cypher code block'} +} + export default async function runCypher( connections: ConnectionManager, cypherRunner: CypherRunner, @@ -27,9 +57,25 @@ export default async function runCypher( && editor.document.getText(selection) ) - // Attempt to run entire file + // Attempt to run entire file or code block if no selection if (selections.length === 0) { - const documentText = editor.document.getText() + const isMarkdown = editor.document.languageId === 'markdown' + + let documentText: string + if (isMarkdown) { + const result = await handleMarkdown(editor) + if (result.error) { + window.showErrorMessage(result.error) + return + } + documentText = result.text! + } else { + documentText = editor.document.getText() + if (!documentText) { + window.showErrorMessage(`No text in document`) + return + } + } await cypherRunner.run(activeConnection, documentText, method) diff --git a/syntaxes/cypher-markdown-injection.json b/syntaxes/cypher-markdown-injection.json new file mode 100644 index 0000000..8a025dd --- /dev/null +++ b/syntaxes/cypher-markdown-injection.json @@ -0,0 +1,45 @@ +{ + "fileTypes": [], + "injectionSelector": "L:text.html.markdown", + "patterns": [ + { + "include": "#cypher-code-block" + } + ], + "repository": { + "cypher-code-block": { + "begin": "(^|\\G)(\\s*)(\\`{3,}|~{3,})\\s*(?i:(cypher)(\\s+[^`~]*)?$)", + "name": "markup.fenced_code.block.markdown", + "end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$", + "beginCaptures": { + "3": { + "name": "punctuation.definition.markdown" + }, + "4": { + "name": "fenced_code.block.language.markdown" + }, + "5": { + "name": "fenced_code.block.language.attributes.markdown" + } + }, + "endCaptures": { + "3": { + "name": "punctuation.definition.markdown" + } + }, + "patterns": [ + { + "begin": "(^|\\G)(\\s*)(.*)", + "while": "(^|\\G)(?!\\s*([`~]{3,})\\s*$)", + "contentName": "meta.embedded.block.cypher", + "patterns": [ + { + "include": "source.cypher" + } + ] + } + ] + } + }, + "scopeName": "markdown.cypher.codeblock" +} From dd4f5fb7e8d224dbca5d2ac45ef8eeba474c1253 Mon Sep 17 00:00:00 2001 From: mattorp Date: Wed, 12 Jul 2023 13:52:12 +0200 Subject: [PATCH 2/2] add handle multiple blocks --- CHANGELOG.md | 15 ++-- src/commands/cypher/run-cypher.ts | 111 ++++++++++++++++++++++-------- 2 files changed, 95 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dee72f6..80ae6fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,14 +4,21 @@ All notable changes to the "neo4j-vscode" extension will be documented in this f ## [0.1.9] -- Added support for Cypher code blocks in Markdown files -- Automatically selects the text within the code block when running a query if no selection is made +- Automatically selects the text within code block(s) when running a query if no selection is made, based on cursor position(s) + +Example: + +The following will execute the queries separately + +```cypher +match (n) return n limit 1 // A cursor is here +``` ```cypher -match (n) return n limit 1 +match (n) return n limit 10 // A cursor is here ``` -[Reference](https://stackoverflow.com/a/76239666/3876654) +- Added support for Cypher code blocks in Markdown files [Reference](https://stackoverflow.com/a/76239666/3876654) ## [0.1.8] diff --git a/src/commands/cypher/run-cypher.ts b/src/commands/cypher/run-cypher.ts index 807da91..71347a0 100644 --- a/src/commands/cypher/run-cypher.ts +++ b/src/commands/cypher/run-cypher.ts @@ -3,34 +3,91 @@ import ConnectionManager from '../../connections/connection-manager.class' import { Method } from '../../constants' import CypherRunner from '../../cypher/runner' -// Check if we are inside a code block based on the cursor position -const handleMarkdown = (editor: TextEditor): - | {text: string; error?: never} - | {text?: never; error: string} => { +type CodeBlockErrors = { + notInsideCodeBlock: number[]; + emptyCodeBlock: number[]; +} + +function showBlockError(message: string, lines: number[]) { + window.showErrorMessage(`${message} at line${ + lines.length > 1 ? 's' : '' + }: ${lines.join(', ')}`) +} + +/** + * Handle errors for code blocks + * @param errors The potential errors to handle + * @returns Boolean indicating if there were errors + */ +function handleCodeBlockErrors({ + notInsideCodeBlock, emptyCodeBlock +}: CodeBlockErrors) { + if (notInsideCodeBlock.length > 0) { + showBlockError(`Cursor is not inside a code block`, notInsideCodeBlock) + return true + } + + if (emptyCodeBlock.length > 0) { + showBlockError(`Empty code block`, emptyCodeBlock) + return true + } +} + +/** + * Get code blocks from markdown based on cursor position(s) + * Informs the user if there are any errors + * @param editor The text editor to get code blocks from + * @returns The code blocks if no errors, else undefined + */ +function getCodeBlocks(editor: TextEditor): string[] | undefined { const markdown = editor.document.getText() const codeBlockRegex = /(```|~~~)+\s*cypher\s*\n([\s\S]*?)\n\1/g - const cursorPosition = editor.selection.active - - let match: RegExpExecArray | null + // Sorting and filtering prevents errors in code block matching + const selections = [...editor.selections] + .sort((a, b) => a.active.line - b.active.line) + // Remove duplicate selections that share the same line + .filter((selection, index, selections) => + selection.active.line !== selections[index - 1]?.active.line) - while ((match = codeBlockRegex.exec(markdown)) !== null) { - const codeBlockStart = editor.document.positionAt(match.index) - const codeBlockEnd = editor.document.positionAt(match.index + match[0].length) + const initialAccumulator = { + codeBlocks: [] as string[], + errors: {notInsideCodeBlock: [] as number[], emptyCodeBlock: [] as number[]} + } - if (cursorPosition.isAfterOrEqual(codeBlockStart) - && cursorPosition.isBeforeOrEqual(codeBlockEnd) - ) { - const text = match[2].trim() - if (text.length === 0) { - return {error: 'No text in code block'} + // Get code blocks from markdown based on cursor position(s) + const {codeBlocks, errors} = selections.reduce(({codeBlocks, errors}, selection) => { + const cursorPosition = selection.active + let match: RegExpExecArray | null + + // Find all code blocks + while ((match = codeBlockRegex.exec(markdown)) !== null) { + const codeBlockStart = editor.document.positionAt(match.index) + const codeBlockEnd = editor.document.positionAt(match.index + match[0].length) + + // Check if cursor is inside code block + if (cursorPosition.isAfterOrEqual(codeBlockStart) + && cursorPosition.isBeforeOrEqual(codeBlockEnd)) { + const text = match[2].trim() + if (text.length === 0) { + errors.emptyCodeBlock.push(codeBlockStart.line + 1) + } else { + codeBlocks.push(text) + return {codeBlocks, errors} + } } + } + + errors.notInsideCodeBlock.push(cursorPosition.line + 1) + return {codeBlocks, errors} + }, initialAccumulator) - return {text} - } // Else: Check if the cursor is inside the next code block + if (handleCodeBlockErrors(errors)) { + return } - return {error: 'Cursor is not inside a cypher code block'} + + return codeBlocks } export default async function runCypher( @@ -57,27 +114,27 @@ export default async function runCypher( && editor.document.getText(selection) ) - // Attempt to run entire file or code block if no selection + // Attempt to run entire file or code block(s) if no selection if (selections.length === 0) { const isMarkdown = editor.document.languageId === 'markdown' - let documentText: string if (isMarkdown) { - const result = await handleMarkdown(editor) - if (result.error) { - window.showErrorMessage(result.error) + const codeBlocks = getCodeBlocks(editor) + if (!codeBlocks) { return } - documentText = result.text! + await Promise.all(codeBlocks.map((codeBlock) => + cypherRunner.run(activeConnection, codeBlock, method) + )) } else { - documentText = editor.document.getText() + const documentText = editor.document.getText() if (!documentText) { window.showErrorMessage(`No text in document`) return } + await cypherRunner.run(activeConnection, documentText, method) } - await cypherRunner.run(activeConnection, documentText, method) return }