// src/editor/__tests__/markdown-context.test.ts // // Pin-down tests for the public contract of `markdown-context.ts` helpers, // specifically the boundary behavior of `isRangeInCodeContext` and // `isRangeInBlockCodeContext`: // // 1. End-of-document offsets must NOT be silently shifted to `length - 1` // in a way that flips the answer for queries right at end-of-doc. // 2. Empty ranges (`from === to`) are treated as offset queries at `from`, // so a blank line inside a fenced code block still reports as code. // // These tests pin the conservative answers required by callers (e.g. // callout-decoration), which expect blank lines and end-of-doc positions // inside fenced code to be excluded from markdown enhancement. import { describe, it, expect } from 'vitest' import { EditorState } from '@codemirror/state' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { GFM } from '@lezer/markdown' import { isRangeInBlockCodeContext, isRangeInCodeContext, isOffsetRangeInTableCell, getTablePositionAt, markdownFrontmatterField, } from '../extensions/markdown-context' import { reamsMarkdownLanguage } from '../markdown-language' function createState(doc: string): EditorState { return EditorState.create({ doc, extensions: [ markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, ], }) } describe('markdown-context — end-of-document boundary', () => { it('reports an end-of-doc empty-range query inside a fenced code block as code', () => { // Fence with no trailing newline — the end-of-doc offset is inside the // fence. Previously the clamp to `length - 1` could flip the answer here. const doc = '```\ncode\n```' const state = createState(doc) const eod = state.doc.length // Empty-range query exactly at end-of-doc. expect(isRangeInBlockCodeContext(state, eod, eod)).toBe(true) expect(isRangeInCodeContext(state, eod, eod)).toBe(true) }) it('reports a non-empty range that ends at end-of-doc inside a fence as code', () => { const doc = '```\ncode\n```' const state = createState(doc) const eod = state.doc.length // Range covering the closing fence backticks up to end-of-doc. const from = doc.lastIndexOf('```') expect(isRangeInBlockCodeContext(state, from, eod)).toBe(true) }) }) describe('markdown-context — empty range contract', () => { it('treats an empty range on a blank line inside a fenced code block as code', () => { // Blank line between two code lines must still report as inside the fence // even though `iterate` over an empty range is a no-op. const doc = '```\nfirst\n\nlast\n```\n' const state = createState(doc) // Offset of the blank line (the second \n is the blank line position). const blankLineStart = doc.indexOf('first\n') + 'first\n'.length expect(state.doc.lineAt(blankLineStart).text).toBe('') expect(isRangeInBlockCodeContext(state, blankLineStart, blankLineStart)).toBe(true) expect(isRangeInCodeContext(state, blankLineStart, blankLineStart)).toBe(true) }) it('reports an empty range outside any code context as not in code', () => { const doc = 'plain prose paragraph\n' const state = createState(doc) const offset = 5 expect(isRangeInBlockCodeContext(state, offset, offset)).toBe(false) expect(isRangeInCodeContext(state, offset, offset)).toBe(false) }) }) describe('markdown-context — delimiter-free inline-code tree', () => { it('treats opening delimiter, content, and closing delimiter as code context', () => { const doc = 'before `code` after' const state = EditorState.create({ doc, extensions: [ markdown({ base: reamsMarkdownLanguage, extensions: [GFM], addKeymap: false }), markdownFrontmatterField, ], }) for (const offset of [7, 8, 11, 12]) { expect(isRangeInCodeContext(state, offset, offset)).toBe(true) } expect(isRangeInCodeContext(state, 13, 13)).toBe(false) expect(isRangeInCodeContext(state, 7, 9)).toBe(true) expect(isRangeInCodeContext(state, 11, 13)).toBe(true) }) it('recognizes every position in a variable-length code span', () => { const doc = 'before ``a ` b`` after' const state = EditorState.create({ doc, extensions: [markdown({ base: reamsMarkdownLanguage, extensions: [GFM], addKeymap: false })], }) const start = doc.indexOf('``') const end = doc.lastIndexOf('``') + 2 for (let offset = start; offset < end; offset++) { expect(isRangeInCodeContext(state, offset, offset)).toBe(true) } expect(isRangeInCodeContext(state, end, end)).toBe(false) }) }) describe('markdown-context — empty document and range edges', () => { it('reports an empty document as not in any context', () => { const state = createState('') expect(isRangeInCodeContext(state, 0, 0)).toBe(false) expect(isRangeInBlockCodeContext(state, 0, 0)).toBe(false) expect(isOffsetRangeInTableCell(state, 0, 0)).toBe(false) expect(getTablePositionAt(state, 0)).toBeNull() }) it('treats a range starting outside a fence but ending inside it as code', () => { // Fence starts at offset 2; from=0 is outside, to-1=4 is inside. const doc = 'x\n```\ncode\n```\n' const state = createState(doc) expect(isRangeInBlockCodeContext(state, 0, 5)).toBe(true) }) it('handles end-of-document offsets in table helpers', () => { const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const state = createState(doc) const eod = state.doc.length expect(isOffsetRangeInTableCell(state, eod, eod)).toBe(false) expect(getTablePositionAt(state, eod)).toBeNull() }) it('rejects a range straddling a table cell boundary', () => { // Cell 1 spans [2, 3) in the header row; a range overlapping the // boundary must not be treated as fully inside the cell. const doc = '| A | B |\n|---|---|\n| 1 | 2 |\n' const state = createState(doc) expect(isOffsetRangeInTableCell(state, 2, 3)).toBe(true) expect(isOffsetRangeInTableCell(state, 2, 6)).toBe(false) expect(isOffsetRangeInTableCell(state, 1, 6)).toBe(false) }) }) describe('markdown-context — table separator positions', () => { // '| A | B |\n' (0-9) // '|---|---|\n' (10-19) separator; pipes at 10, 14, 18 // '| 1 | 2 |\n' (20-29) const TABLE = '| A | B |\n|---|---|\n| 1 | 2 |\n' it('resolves cursor positions on the separator line', () => { const state = createState(TABLE) expect(getTablePositionAt(state, 12)).toMatchObject({ rowKind: 'separator', columnIndex: 0, cellOffset: 1 }) expect(getTablePositionAt(state, 16)).toMatchObject({ rowKind: 'separator', columnIndex: 1, cellOffset: 1 }) }) it('snaps a cursor exactly on a separator pipe to the next cell', () => { const state = createState(TABLE) expect(getTablePositionAt(state, 10)).toMatchObject({ rowKind: 'separator', columnIndex: 0, cellOffset: 0 }) expect(getTablePositionAt(state, 14)).toMatchObject({ rowKind: 'separator', columnIndex: 1, cellOffset: 0 }) }) it('resolves the trailing separator pipe to the last cell gap', () => { const state = createState(TABLE) expect(getTablePositionAt(state, 18)).toMatchObject({ rowKind: 'separator', columnIndex: 1, cellOffset: 3 }) }) it('leaves a separator cursor at the line end unresolved', () => { const state = createState(TABLE) // Position 19 is the separator line's newline (sep.to), outside every // [pipe, pipe] segment. expect(getTablePositionAt(state, 19)).toMatchObject({ rowKind: 'separator', columnIndex: -1, cellOffset: -1 }) }) })