// 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 }