From b648ee904d1bb8cdfdc6cdff38bf9a56c1bf1c4c Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Fri, 22 May 2026 16:46:43 +0300 Subject: [PATCH] feat(editor): Mermaid syntax highlighting, linter and autocomplete - StreamLanguage tokenizer for Mermaid: diagram-type keywords, general keywords, directions, arrows/links, strings, %% comments, init directives and YAML frontmatter, with a dark-theme HighlightStyle - live linter via mermaid.parse: maps parse errors to the right editor line (accounting for the frontmatter lines mermaid strips), with wavy underline, gutter marker and message tooltip - autocomplete: Mermaid keyword/type/direction options plus starter diagram snippets offered on the first line; bracket closing - dark-themed completion + diagnostic tooltips Adds @codemirror/lint, @codemirror/autocomplete and @lezer/highlight. --- package-lock.json | 3 + package.json | 3 + src/lib/cm-mermaid.ts | 359 +++++++++++++++++++++++++++++++ src/lib/components/Editor.svelte | 50 ++++- 4 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 src/lib/cm-mermaid.ts diff --git a/package-lock.json b/package-lock.json index cb51419..9373f3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,13 @@ "name": "mermix", "version": "0.1.0", "dependencies": { + "@codemirror/autocomplete": "^6.18.0", "@codemirror/commands": "^6.6.0", "@codemirror/language": "^6.10.2", + "@codemirror/lint": "^6.8.0", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.28.0", + "@lezer/highlight": "^1.2.0", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", "codemirror": "^6.0.1", diff --git a/package.json b/package.json index a4be972..ce8542a 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,13 @@ "app:build": "tauri build" }, "dependencies": { + "@codemirror/autocomplete": "^6.18.0", "@codemirror/commands": "^6.6.0", "@codemirror/language": "^6.10.2", + "@codemirror/lint": "^6.8.0", "@codemirror/state": "^6.4.1", "@codemirror/view": "^6.28.0", + "@lezer/highlight": "^1.2.0", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", "codemirror": "^6.0.1", diff --git a/src/lib/cm-mermaid.ts b/src/lib/cm-mermaid.ts new file mode 100644 index 0000000..aea1cc8 --- /dev/null +++ b/src/lib/cm-mermaid.ts @@ -0,0 +1,359 @@ +import type { Extension } from '@codemirror/state'; +import { HighlightStyle, StreamLanguage, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; +import { linter, lintGutter, type Diagnostic } from '@codemirror/lint'; +import { + autocompletion, + snippetCompletion, + type Completion, + type CompletionContext, + type CompletionResult, +} from '@codemirror/autocomplete'; +import mermaid from 'mermaid'; + +// --------------------------------------------------------------------------- +// Vocabulary +// --------------------------------------------------------------------------- + +/** Diagram-type openers (also offered as snippet anchors in autocomplete). */ +const DIAGRAM_TYPES = [ + 'flowchart', + 'graph', + 'sequenceDiagram', + 'classDiagram-v2', + 'classDiagram', + 'stateDiagram-v2', + 'stateDiagram', + 'erDiagram', + 'journey', + 'gantt', + 'pie', + 'quadrantChart', + 'requirementDiagram', + 'gitGraph', + 'mindmap', + 'timeline', + 'sankey-beta', + 'xychart-beta', + 'block-beta', + 'packet-beta', + 'architecture-beta', + 'kanban', + 'radar', + 'treemap', + 'zenuml', + 'C4Context', + 'C4Container', + 'C4Component', + 'C4Dynamic', + 'C4Deployment', +]; + +/** General keywords shared across diagram types. */ +const KEYWORDS = [ + 'participant', + 'actor', + 'as', + 'activate', + 'deactivate', + 'note', + 'over', + 'left', + 'right', + 'of', + 'loop', + 'alt', + 'else', + 'opt', + 'par', + 'and', + 'critical', + 'break', + 'rect', + 'box', + 'end', + 'autonumber', + 'link', + 'links', + 'subgraph', + 'direction', + 'click', + 'call', + 'href', + 'callback', + 'class', + 'classDef', + 'cssClass', + 'style', + 'linkStyle', + 'state', + 'namespace', + 'choice', + 'fork', + 'join', + 'title', + 'accTitle', + 'accDescr', + 'section', + 'dateFormat', + 'axisFormat', + 'tickInterval', + 'excludes', + 'includes', + 'todayMarker', + 'weekday', + 'done', + 'active', + 'crit', + 'milestone', + 'after', + 'branch', + 'checkout', + 'merge', + 'commit', + 'cherry-pick', + 'order', + 'tag', + 'type', + 'requirement', + 'functionalRequirement', + 'performanceRequirement', + 'interfaceRequirement', + 'physicalRequirement', + 'designConstraint', + 'element', + 'satisfies', + 'contains', + 'copies', + 'derives', + 'refines', + 'traces', + 'verifies', + 'showData', + 'Person', + 'System', + 'Container', + 'Component', + 'Rel', + 'Boundary', + 'beta', +]; + +const DIRECTIONS = ['TB', 'TD', 'BT', 'RL', 'LR']; + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function alternation(words: string[]): RegExp { + const sorted = [...words].sort((a, b) => b.length - a.length).map(escapeRe); + return new RegExp(`^(?:${sorted.join('|')})(?![\\w-])`); +} + +const TYPE_RE = alternation(DIAGRAM_TYPES); +const KEYWORD_RE = alternation(KEYWORDS); +const DIR_RE = new RegExp(`^(?:${DIRECTIONS.join('|')})(?![\\w-])`); +// Links / arrows: -->, ---, ==>, -.->, --x, --o, <-->, ~~~ etc. +const ARROW_RE = /^(?:<{0,2}[-=.]{2,}[->ox]{0,2}|~~~+|x--x|o--o)/; + +// --------------------------------------------------------------------------- +// Syntax highlighting (StreamLanguage) +// --------------------------------------------------------------------------- + +interface MermaidState { + fm: boolean; // inside YAML frontmatter block +} + +const mermaidLanguage = StreamLanguage.define({ + name: 'mermaid', + startState: () => ({ fm: false }), + token(stream, state) { + // Frontmatter fences (`---`) toggle a YAML block at the top of the file. + if (stream.sol() && stream.match(/^---[ \t]*$/)) { + state.fm = !state.fm; + return 'meta'; + } + if (state.fm) { + if (stream.match(/^[ \t]*[\w-]+[ \t]*:/)) return 'keyword'; + stream.skipToEnd(); + return 'string'; + } + + if (stream.eatSpace()) return null; + + // Comments & init directives. + if (stream.match(/^%%\{.*?\}%%/)) return 'meta'; + if (stream.match(/^%%.*/)) return 'comment'; + + // Quoted strings. + if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return 'string'; + + // Links / arrows before words (arrows never start with a letter). + if (stream.match(ARROW_RE)) return 'operator'; + + // Keywords, diagram types and directions. + if (stream.match(TYPE_RE)) return 'keyword'; + if (stream.match(KEYWORD_RE)) return 'keyword'; + if (stream.match(DIR_RE)) return 'direction'; + + if (stream.match(/^\d+(?:\.\d+)?/)) return 'number'; + if (stream.match(/^[A-Za-z_]\w*/)) return 'variable'; + + if (stream.match(/^[[\]{}()]/)) return 'bracket'; + if (stream.match(/^[|]/)) return 'operator'; + if (stream.match(/^[:;,.&#@]/)) return 'punct'; + + stream.next(); + return null; + }, + tokenTable: { + keyword: t.keyword, + direction: t.atom, + number: t.number, + string: t.string, + comment: t.lineComment, + operator: t.operator, + variable: t.variableName, + meta: t.meta, + bracket: t.bracket, + punct: t.punctuation, + }, +}); + +const mermaidHighlight = HighlightStyle.define([ + { tag: t.keyword, color: '#c4b5fd', fontWeight: '600' }, + { tag: t.atom, color: '#f0a868' }, + { tag: t.number, color: '#79c0ff' }, + { tag: t.string, color: '#7ee787' }, + { tag: t.lineComment, color: '#6b7280', fontStyle: 'italic' }, + { tag: t.operator, color: '#ff9d7b' }, + { tag: t.variableName, color: '#e6e9ef' }, + { tag: t.meta, color: '#d29922' }, + { tag: t.bracket, color: '#9aa3b2' }, + { tag: t.punctuation, color: '#9aa3b2' }, +]); + +// --------------------------------------------------------------------------- +// Linter (mermaid.parse) +// --------------------------------------------------------------------------- + +function cleanMessage(raw: string): string { + // Trim the giant "Expecting ..., got ..." tail that jison errors append. + const firstLine = raw.split('\n')[0].trim(); + return firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine || raw.trim(); +} + +function extractLine(err: unknown): number | undefined { + const anyErr = err as { hash?: { loc?: { first_line?: number }; line?: number }; message?: string }; + const loc = anyErr?.hash?.loc?.first_line ?? anyErr?.hash?.line; + if (typeof loc === 'number' && loc > 0) return loc; + const msg = anyErr?.message ?? String(err); + const m = msg.match(/(?:on line|line)\s+(\d+)/i); + return m ? parseInt(m[1], 10) : undefined; +} + +/** + * Mermaid removes a leading `---\n…\n---` YAML frontmatter block before + * parsing, so the line numbers it reports are shifted up by that many lines. + * Returns the number of lines to add back. + */ +function frontmatterOffset(code: string): number { + const m = code.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/); + if (!m) return 0; + return m[0].replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n').length; +} + +const mermaidLinter = linter( + async (view): Promise => { + const code = view.state.doc.toString(); + if (!code.trim()) return []; + try { + await mermaid.parse(code); + return []; + } catch (err) { + const message = cleanMessage( + (err as { message?: string })?.message ?? String(err) + ); + const doc = view.state.doc; + const reported = extractLine(err); + const line = reported ? reported + frontmatterOffset(code) : undefined; + if (line && line >= 1 && line <= doc.lines) { + const l = doc.line(line); + return [{ from: l.from, to: l.to, severity: 'error', message }]; + } + // Unknown location — flag the first non-empty line. + const first = doc.line(1); + return [{ from: first.from, to: first.to, severity: 'error', message }]; + } + }, + { delay: 450 } +); + +// --------------------------------------------------------------------------- +// Autocomplete (keywords + starter snippets) +// --------------------------------------------------------------------------- + +const SNIPPETS: Completion[] = [ + snippetCompletion( + 'flowchart ${TD}\n ${A}[${Start}] --> ${B}[${End}]', + { label: 'flowchart', type: 'class', detail: 'starter', info: 'Flowchart skeleton' } + ), + snippetCompletion( + 'sequenceDiagram\n participant ${A}\n participant ${B}\n ${A}->>${B}: ${message}', + { label: 'sequenceDiagram', type: 'class', detail: 'starter', info: 'Sequence diagram skeleton' } + ), + snippetCompletion( + 'classDiagram\n class ${Name} {\n +${field}\n +${method}()\n }', + { label: 'classDiagram', type: 'class', detail: 'starter', info: 'Class diagram skeleton' } + ), + snippetCompletion( + 'stateDiagram-v2\n [*] --> ${State1}\n ${State1} --> [*]', + { label: 'stateDiagram-v2', type: 'class', detail: 'starter', info: 'State diagram skeleton' } + ), + snippetCompletion( + 'erDiagram\n ${CUSTOMER} ||--o{ ${ORDER} : ${places}', + { label: 'erDiagram', type: 'class', detail: 'starter', info: 'Entity-relationship skeleton' } + ), + snippetCompletion( + 'gantt\n title ${Project}\n dateFormat YYYY-MM-DD\n section ${Phase}\n ${Task} :a1, ${2024-01-01}, ${7d}', + { label: 'gantt', type: 'class', detail: 'starter', info: 'Gantt chart skeleton' } + ), + snippetCompletion('pie\n title ${Title}\n "${A}" : ${40}\n "${B}" : ${60}', { + label: 'pie', + type: 'class', + detail: 'starter', + info: 'Pie chart skeleton', + }), +]; + +const KEYWORD_OPTIONS: Completion[] = [ + ...DIAGRAM_TYPES.map((label) => ({ label, type: 'type' as const })), + ...KEYWORDS.map((label) => ({ label, type: 'keyword' as const })), + ...DIRECTIONS.map((label) => ({ label, type: 'constant' as const })), +]; + +function mermaidCompletions(context: CompletionContext): CompletionResult | null { + const word = context.matchBefore(/[\w-]+/); + if (!word || (word.from === word.to && !context.explicit)) return null; + + // On the very first line, lead with starter snippets. + const onFirstLine = context.state.doc.lineAt(context.pos).number === 1; + const options = onFirstLine ? [...SNIPPETS, ...KEYWORD_OPTIONS] : KEYWORD_OPTIONS; + + return { from: word.from, options, validFor: /^[\w-]*$/ }; +} + +// --------------------------------------------------------------------------- +// Public bundle +// --------------------------------------------------------------------------- + +/** All editor extensions providing Mermaid highlighting, linting and hints. */ +export function mermaidSupport(): Extension[] { + return [ + mermaidLanguage, + syntaxHighlighting(mermaidHighlight), + mermaidLinter, + lintGutter(), + autocompletion({ override: [mermaidCompletions], icons: true }), + ]; +} diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 8d7e78f..64e13eb 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -17,7 +17,10 @@ indentWithTab, } from '@codemirror/commands'; import { bracketMatching, indentOnInput } from '@codemirror/language'; + import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete'; + import { lintKeymap } from '@codemirror/lint'; import { store } from '../store.svelte'; + import { mermaidSupport } from '../cm-mermaid'; let host: HTMLDivElement; let view: EditorView | undefined; @@ -48,6 +51,43 @@ backgroundColor: 'var(--accent-soft) !important', }, '.cm-matchingBracket': { backgroundColor: 'var(--accent-soft)', outline: 'none' }, + + // Autocomplete popup + '.cm-tooltip': { + backgroundColor: 'var(--bg-elev-2)', + border: '1px solid var(--border-strong)', + borderRadius: '6px', + color: 'var(--text)', + boxShadow: '0 8px 28px rgba(0,0,0,0.45)', + }, + '.cm-tooltip.cm-tooltip-autocomplete > ul': { fontFamily: 'var(--mono)', maxHeight: '16em' }, + '.cm-tooltip-autocomplete ul li[aria-selected]': { + backgroundColor: 'var(--accent)', + color: '#fff', + }, + '.cm-completionIcon': { color: 'var(--text-dim)', paddingRight: '0.6em' }, + '.cm-completionDetail': { color: 'var(--text-faint)', fontStyle: 'normal' }, + '.cm-completionInfo': { + backgroundColor: 'var(--bg-elev-2)', + border: '1px solid var(--border-strong)', + borderRadius: '6px', + color: 'var(--text-dim)', + }, + + // Lint diagnostics + '.cm-diagnostic': { + fontFamily: 'var(--mono)', + fontSize: '12px', + borderLeft: '3px solid var(--red)', + padding: '6px 8px', + }, + '.cm-diagnostic-error': { borderLeftColor: 'var(--red)' }, + '.cm-lintRange-error': { + backgroundImage: 'none', + textDecoration: 'underline wavy var(--red)', + textDecorationSkipInk: 'none', + }, + '.cm-lint-marker-error': { content: '""' }, }, { dark: true } ); @@ -65,8 +105,16 @@ drawSelection(), indentOnInput(), bracketMatching(), + closeBrackets(), highlightActiveLine(), - keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]), + ...mermaidSupport(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...historyKeymap, + ...lintKeymap, + indentWithTab, + ]), theme, EditorView.lineWrapping, EditorView.updateListener.of((u) => {