Files
omp-edit/lib/block.js
T

152 lines
6.1 KiB
JavaScript

// Syntactic block resolution for `N*` anchors.
//
// OMP resolves blocks with tree-sitter. This is a dependency-free approximation that covers
// the same shapes for the common languages:
// - Markdown: a heading runs through deeper headings until the next same/higher heading.
// - Indentation languages (Python, YAML, Nim, CoffeeScript, …) and any opener ending in ':'
// with no brackets: the opener plus every following line indented deeper (blank lines
// inside are kept, trailing blank lines are not).
// - Bracket languages (JS/TS/C/Go/Rust/Java/JSON/CSS/…): from the opener to the line that
// closes the first bracket left open, string- and comment-aware.
// Leading decorators/attributes (`@x`, `#[x]`, `[Attr]`) are grouped with the declaration
// that follows them.
import path from 'node:path'
import { EditError } from './text.js'
const INDENT_EXT = new Set(['.py', '.pyi', '.yaml', '.yml', '.nim', '.coffee', '.sass', '.styl', '.pug', '.haml', '.slim', '.gd'])
const MARKDOWN_EXT = new Set(['.md', '.markdown', '.mdx', '.rst'])
const HASH_COMMENT_EXT = new Set(['.py', '.pyi', '.rb', '.sh', '.bash', '.zsh', '.yaml', '.yml', '.toml', '.pl', '.r', '.nim', '.coffee', '.cmake', '.mk', '.dockerfile', '.ex', '.exs', '.jl', '.tf', '.hcl'])
const indentOf = (line) => (line.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length)
const isBlank = (line) => line.trim() === ''
const isDecorator = (line) => /^\s*(@[\w.]|#\[|\[[A-Z]\w*(\(|\]))/.test(line)
/**
* Resolve the block that starts at 1-based `line`.
* @returns {{start:number,end:number}} 1-based inclusive span
*/
export function resolveBlock(filePath, lines, line) {
if (line < 1 || line > lines.length) throw new EditError(`Block anchor ${line} is outside the file (1-${lines.length}).`)
const ext = path.extname(filePath).toLowerCase()
const base = path.basename(filePath).toLowerCase()
const opener = lines[line - 1]
if (isBlank(opener)) throw new EditError(`Block anchor ${line} is a blank line; anchor the construct opener (the first line of the function/class/heading).`)
if (MARKDOWN_EXT.has(ext)) return markdownBlock(lines, line)
// Decorators/attributes group with the declaration they precede.
let declLine = line
while (declLine <= lines.length && isDecorator(lines[declLine - 1]) && !/[{(:]\s*$/.test(lines[declLine - 1].replace(/\)\s*$/, ''))) {
if (declLine === lines.length) break
declLine++
}
const decl = lines[declLine - 1]
const useIndent = INDENT_EXT.has(ext) || (/:\s*(#.*)?$/.test(decl) && !/[{[(]/.test(stripStrings(decl)))
const span = useIndent
? indentBlock(lines, declLine)
: bracketBlock(lines, declLine, HASH_COMMENT_EXT.has(ext) || base === 'dockerfile' || base === 'makefile')
if (!span || span.end <= line) {
throw new EditError(
`Line ${line} does not open a multi-line block (${JSON.stringify(opener.trim().slice(0, 60))}). ` +
'Anchor the construct opener, never a closer, blank line or inner statement; for a single line use an explicit range (PUT N.=N:).',
)
}
return { start: line, end: span.end }
}
function markdownBlock(lines, line) {
const m = /^(#{1,6})\s/.exec(lines[line - 1])
if (!m) {
// Paragraph / list block: until the next blank line.
let end = line
while (end < lines.length && !isBlank(lines[end])) end++
if (end === line) throw new EditError(`Line ${line} is a single-line Markdown block; use an explicit range.`)
return { start: line, end }
}
const level = m[1].length
let end = lines.length
let fence = false
for (let i = line; i < lines.length; i++) {
if (/^\s*(```|~~~)/.test(lines[i])) fence = !fence
if (fence) continue
const h = /^(#{1,6})\s/.exec(lines[i])
if (h && h[1].length <= level) { end = i; break }
}
while (end > line && isBlank(lines[end - 1])) end--
return { start: line, end }
}
function indentBlock(lines, line) {
const base = indentOf(lines[line - 1])
let end = line
// A multi-line opener (e.g. a def whose parameters wrap) continues until the ':' line.
let i = line
if (!/:\s*(#.*)?$/.test(lines[line - 1])) {
let depth = bracketDelta(lines[line - 1])
while (depth > 0 && i < lines.length) { depth += bracketDelta(lines[i]); i++ }
end = i
}
for (let k = i; k < lines.length; k++) {
const l = lines[k]
if (isBlank(l)) continue
if (indentOf(l) > base) end = k + 1
else break
}
return { start: line, end }
}
function stripStrings(s) {
return s.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""')
}
function bracketDelta(s) {
let d = 0
for (const c of stripStrings(s)) {
if (c === '(' || c === '[' || c === '{') d++
else if (c === ')' || c === ']' || c === '}') d--
}
return d
}
/**
* Scan from the opener with a small lexer (strings, template literals, line and block
* comments) and end at the line where depth returns to zero after first opening.
*/
function bracketBlock(lines, line, hashComments) {
let depth = 0
let opened = false
let inBlock = false
let quote = null
for (let i = line - 1; i < lines.length; i++) {
const s = lines[i]
for (let j = 0; j < s.length; j++) {
const c = s[j]
const n = s[j + 1]
if (inBlock) { if (c === '*' && n === '/') { inBlock = false; j++ } continue }
if (quote) {
if (c === '\\') { j++; continue }
if (c === quote) quote = null
continue
}
if (c === '/' && n === '/') break
if (hashComments && c === '#') break
if (c === '/' && n === '*') { inBlock = true; j++; continue }
if (c === '"' || c === "'" || c === '`') { quote = c; continue }
if (c === '{' || c === '(' || c === '[') { depth++; opened = true }
else if (c === '}' || c === ')' || c === ']') {
depth--
if (opened && depth <= 0) {
// Keep scanning the rest of this line for a chained opener: `} else {`, `}).then(() => {`.
const rest = stripStrings(s.slice(j + 1))
if (bracketDelta(rest) > 0) { depth += 0; continue }
return { start: line, end: i + 1 }
}
}
}
if (quote && quote !== '`') quote = null // unterminated single-line string
if (!opened && i > line - 1 + 3) return null // opener must open within a few lines
}
return null
}