Initial commit: omp-edit DSH plugin (oh-my-pi port) with docs and tests
This commit is contained in:
commit
71953ffb8f
19 files changed
+2412
No files matched your search
+151
@@ -0,0 +1,151 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user