/*
* std/edit - pure helpers for applying and validating agent-authored text patches.
*/
import { diff_summary, unified_diff } from "std/diff"
fn __edit_sha256(text) {
return "sha256:" + sha256(text ?? "")
}
fn __edit_collapse_ws(text) {
return trim(regex_replace("\\s+", " ", text ?? ""))
}
fn __edit_normalize_line(line) {
return __edit_collapse_ws(line)
}
fn __edit_structural_line(line) {
let compact = __edit_collapse_ws(line)
return regex_replace("\\s+", "", compact)
}
fn __edit_signature(text, structural) {
var lines = []
for line in split(text ?? "", "\n") {
let normalized = if structural {
__edit_structural_line(line)
} else {
__edit_normalize_line(line)
}
if structural && normalized == "" {
continue
}
lines = lines + [normalized]
}
return join(lines, "\n")
}
fn __edit_nonblank_count(lines) {
var count = 0
for line in lines {
if __edit_structural_line(line) != "" {
count = count + 1
}
}
return count
}
fn __edit_start_line(prefix) {
if prefix == "" {
return 0
}
return len(split(prefix, "\n")) - 1
}
fn __edit_region(start_line, end_line_exclusive, old_text, new_text, match_kind) {
let end_line = if end_line_exclusive > start_line {
end_line_exclusive - 1
} else {
start_line
}
return {
start_line: start_line,
end_line: end_line,
end_line_exclusive: end_line_exclusive,
old_line_count: max(end_line_exclusive - start_line, 0),
new_line_count: len(split(new_text ?? "", "\n")),
old_sha256: __edit_sha256(old_text),
new_sha256: __edit_sha256(new_text),
match_kind: match_kind,
}
}
fn __edit_context(prefix, needle, suffix) {
let before_lines = split(prefix, "\n")
let after_lines = split(suffix, "\n")
let before = if len(before_lines) > 2 {
join(before_lines[-2:], "\n")
} else {
prefix
}
let after = if len(after_lines) > 2 {
join(after_lines[:2], "\n")
} else {
suffix
}
return trim(before + needle + after)
}
fn __edit_candidate_contexts(parts, old_text, max_contexts) {
var contexts = []
var prefix = ""
var idx = 0
let limit = max_contexts ?? 3
let needle_lines = max(len(split(old_text ?? "", "\n")), 1)
while idx < len(parts) - 1 && len(contexts) < limit {
prefix = prefix + parts[idx]
let start_line = __edit_start_line(prefix)
contexts = contexts
+ [
{
start_line: start_line,
end_line: start_line + needle_lines - 1,
snippet: __edit_context(prefix, old_text, parts[idx + 1]),
},
]
prefix = prefix + old_text
idx = idx + 1
}
return contexts
}
fn __edit_line_candidate_contexts(text, candidates, max_contexts) {
let lines = split(text ?? "", "\n")
var contexts = []
let limit = max_contexts ?? 3
var idx = 0
while idx < len(candidates) && len(contexts) < limit {
let candidate = candidates[idx]
let start_line = candidate.start_line
let end_line_exclusive = candidate.end_line_exclusive
let prefix_start = max(start_line - 2, 0)
let suffix_end = min(end_line_exclusive + 2, len(lines))
let snippet = trim(join(lines[prefix_start:suffix_end], "\n"))
contexts = contexts
+ [{start_line: start_line, end_line: end_line_exclusive - 1, snippet: snippet}]
idx = idx + 1
}
return contexts
}
fn __edit_error(code, message, text, old_text, new_text, fields = nil) {
let base = {
ok: false,
changed: false,
error_code: code,
message: message,
patched: text,
before_sha256: __edit_sha256(text),
after_sha256: __edit_sha256(text),
old_sha256: __edit_sha256(old_text),
new_sha256: __edit_sha256(new_text),
errors: [{code: code, message: message}],
warnings: [],
changed_regions: [],
provenance: {module: "std/edit"},
}
return base.merge(fields ?? {})
}
fn __edit_lazy_placeholder_patterns() {
return [
"(?im)^\\s*//\\s*\\.\\.\\.?\\s*(rest|remaining|existing|implementation|code|omit)",
"(?im)^\\s*//\\s*TODO:?\\s*(implement|fill|add|complete)",
"(?im)^\\s*#\\s*\\.\\.\\.?\\s*(rest|remaining|existing|implementation|code)",
"(?im)^\\s*/\\*\\s*\\.\\.\\.?\\s*\\*/\\s*$",
"(?im)^\\s*//\\s*\\.\\.\\.\\s*$",
"(?im)^\\s*#\\s*\\.\\.\\.\\s*$",
"(?im)^\\s*pass\\s*#\\s*\\.\\.\\.",
]
}
fn __edit_lazy_placeholder_phrases() {
return ["unchanged", "omitted for brevity", "same as before", "rest of the file", "remaining code"]
}
fn __edit_lazy_match_count(text) {
let body = text ?? ""
if body == "" {
return 0
}
var total = 0
for line in split(body, "\n") {
let t = lowercase(trim(line))
if t == "..." || t == "…" {
total = total + 1
}
}
for pattern in __edit_lazy_placeholder_patterns() {
let matches = regex_match(pattern, body) ?? []
total = total + len(matches)
}
let lowered = lowercase(body)
for phrase in __edit_lazy_placeholder_phrases() {
if contains(lowered, phrase) {
total = total + 1
}
}
return total
}
fn __edit_has_lazy_placeholder(new_text) {
return __edit_lazy_match_count(new_text) > 0
}
fn __edit_guardrails(old_text, new_text, options) {
let opts = options ?? {}
if old_text == "" && !(opts?.allow_empty_old_text ?? false) {
return {code: "empty_old_text", message: "old_text must not be empty"}
}
if old_text == new_text && !(opts?.allow_noop ?? false) {
return {code: "no_op", message: "patch does not change the selected text"}
}
if __edit_collapse_ws(old_text) == __edit_collapse_ws(new_text)
&& old_text != new_text
&& !(opts?.allow_whitespace_only ?? false) {
return {code: "whitespace_only", message: "patch changes only whitespace"}
}
if __edit_has_lazy_placeholder(new_text) && !(opts?.allow_lazy_placeholders ?? false) {
return {code: "lazy_placeholder", message: "patch contains an omission or unchanged-content placeholder"}
}
let old_len = len(old_text)
let new_len = len(new_text)
let growth = new_len - old_len
let max_growth_bytes = opts?.max_growth_bytes ?? 20000
if max_growth_bytes != nil && growth > max_growth_bytes {
return {code: "excessive_growth", message: "patch grows selected text by more than max_growth_bytes"}
}
let max_growth_ratio = opts?.max_growth_ratio ?? 8
if max_growth_ratio != nil && growth > 1024 && new_len > old_len * max_growth_ratio {
return {code: "excessive_growth", message: "patch grows selected text by more than max_growth_ratio"}
}
return nil
}
fn __edit_success(
text,
patched,
old_text,
new_text,
match_kind,
start_line,
end_line_exclusive,
options,
) {
let before_hash = __edit_sha256(text)
let after_hash = __edit_sha256(patched)
let expected_region = __edit_region(start_line, end_line_exclusive, old_text, new_text, match_kind)
return {
ok: true,
changed: text != patched,
patched: patched,
match_kind: match_kind,
start_line: expected_region.start_line,
end_line: expected_region.end_line,
end_line_exclusive: expected_region.end_line_exclusive,
expected_region: expected_region,
changed_regions: edit_changed_regions(text, patched),
before_sha256: before_hash,
after_sha256: after_hash,
old_sha256: __edit_sha256(old_text),
new_sha256: __edit_sha256(new_text),
errors: [],
warnings: [],
provenance: {
module: "std/edit",
helper: "edit_apply_old_new_patch",
match_kind: match_kind,
before_sha256: before_hash,
after_sha256: after_hash,
caller: options?.provenance,
},
}
}
fn __edit_splice_raw(text, start_line, end_line_exclusive, new_text) {
let lines = split(text ?? "", "\n")
var out = []
if start_line > 0 {
out = out + lines[:start_line]
}
if new_text ?? "" != "" {
out = out + split(new_text ?? "", "\n")
}
if end_line_exclusive < len(lines) {
out = out + lines[end_line_exclusive:]
}
return join(out, "\n")
}
fn __edit_has_distinctive_token(line, min_chars) {
let chars = min_chars ?? 4
if chars <= 0 {
return true
}
let tokens = regex_match("[A-Za-z0-9_]+", line ?? "") ?? []
for token in tokens {
if len(token) >= chars {
return true
}
}
return false
}
fn __edit_structural_anchors_ok(old_text, options) {
let opts = options ?? {}
let min_lines = opts?.structural_min_nonblank_lines ?? 3
let anchor_chars = opts?.structural_anchor_chars ?? 4
let anchor_mode = lowercase(opts?.structural_require_anchored_lines ?? "both")
let needle_lines = split(old_text ?? "", "\n")
var nonblank_lines = []
for line in needle_lines {
if __edit_structural_line(line) != "" {
nonblank_lines = nonblank_lines + [line]
}
}
if len(nonblank_lines) < min_lines {
return false
}
if anchor_mode == "none" || anchor_chars <= 0 {
return true
}
let first = nonblank_lines[0] ?? ""
let last = nonblank_lines[len(nonblank_lines) - 1] ?? ""
let first_ok = __edit_has_distinctive_token(first, anchor_chars)
let last_ok = __edit_has_distinctive_token(last, anchor_chars)
if anchor_mode == "either" {
return first_ok || last_ok
}
return first_ok && last_ok
}
fn __edit_line_candidates(text, old_text, structural, options) {
let lines = split(text ?? "", "\n")
var candidates = []
let target = __edit_signature(old_text, structural)
if target == "" {
return candidates
}
if !structural {
let width = len(split(old_text ?? "", "\n"))
var start = 0
while start + width <= len(lines) {
let segment = join(lines[start:start + width], "\n")
if __edit_signature(segment, false) == target {
candidates = candidates + [{start_line: start, end_line_exclusive: start + width, text: segment}]
}
start = start + 1
}
return candidates
}
if !__edit_structural_anchors_ok(old_text, options) {
return candidates
}
let target_count = __edit_nonblank_count(split(old_text ?? "", "\n"))
var start = 0
while start < len(lines) {
if __edit_structural_line(lines[start]) == "" {
start = start + 1
continue
}
var end = start
var nonblank = 0
while end < len(lines) && nonblank < target_count {
if __edit_structural_line(lines[end]) != "" {
nonblank = nonblank + 1
}
end = end + 1
}
if nonblank == target_count {
while end < len(lines) && __edit_structural_line(lines[end]) == "" {
end = end + 1
}
let segment = join(lines[start:end], "\n")
if __edit_signature(segment, true) == target {
candidates = candidates + [{start_line: start, end_line_exclusive: end, text: segment}]
}
}
start = start + 1
}
return candidates
}
fn __edit_apply_line_candidate(text, old_text, new_text, match_kind, candidates, options) {
if len(candidates) == 0 {
return nil
}
let opts = options ?? {}
if len(candidates) > 1 {
return __edit_error(
"ambiguous_match",
"old_text matched multiple normalized regions",
text,
old_text,
new_text,
{
candidate_count: len(candidates),
match_kind: match_kind,
candidate_contexts: __edit_line_candidate_contexts(text, candidates, opts?.max_candidate_contexts),
},
)
}
let candidate = candidates[0]
let patched = __edit_splice_raw(text, candidate.start_line, candidate.end_line_exclusive, new_text)
let result = __edit_success(
text,
patched,
candidate.text,
new_text,
match_kind,
candidate.start_line,
candidate.end_line_exclusive,
opts,
)
if match_kind == "structural" || match_kind == "line" {
return result.merge({whitespace_explanation: edit_explain_whitespace_difference(old_text, candidate.text)})
}
return result
}
/**
* Apply one old/new anchored patch to text.
*
* Returns `{ok, changed, patched, match_kind, start_line, end_line,
* before_sha256, after_sha256, changed_regions, errors, warnings, provenance}`.
* Matching is exact first by default, then whitespace-normalized line matching,
* then structural matching that ignores blank lines and whitespace.
*
* Structural matches are conservative by default: the needle must contain at
* least `structural_min_nonblank_lines` (3) non-blank lines, and both the
* first and last non-blank anchor lines must carry a distinctive token of at
* least `structural_anchor_chars` (4) alphanumeric characters. Relax with
* `structural_require_anchored_lines: "either"` or `"none"` only when callers
* have a host-side validator that can re-check the result. Pass
* `strip_line_numbers: true` to pre-strip ` N | ` line-number prefixes from
* `old_text` when the model pasted them straight from a numbered file read.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: edit_apply_old_new_patch(text, old_text, new_text, options)
*/
pub fn edit_apply_old_new_patch(text, old_text, new_text, options = nil) {
let source = text ?? ""
let raw_old = old_text ?? ""
let new = new_text ?? ""
let opts = options ?? {}
let old = if opts?.strip_line_numbers ?? false {
edit_strip_line_number_prefixes(raw_old)
} else {
raw_old
}
let guardrail = __edit_guardrails(old, new, opts)
if guardrail != nil {
return __edit_error(guardrail.code, guardrail.message, source, old, new)
}
let mode = opts?.match ?? "auto"
if mode != "line" && mode != "structural" {
let parts = split(source, old)
if len(parts) == 2 {
let start_line = __edit_start_line(parts[0])
let end_line_exclusive = start_line + len(split(old, "\n"))
return __edit_success(
source,
parts[0] + new + parts[1],
old,
new,
"exact",
start_line,
end_line_exclusive,
opts,
)
}
if len(parts) > 2 {
return __edit_error(
"ambiguous_match",
"old_text matched multiple exact regions",
source,
old,
new,
{
candidate_count: len(parts) - 1,
candidate_contexts: __edit_candidate_contexts(parts, old, opts?.max_candidate_contexts ?? 3),
},
)
}
if mode == "exact" {
return __edit_error("no_match", "old_text did not match exactly", source, old, new)
}
}
if mode == "line" || mode == "auto" {
let line_result = __edit_apply_line_candidate(
source,
old,
new,
"line",
__edit_line_candidates(source, old, false, opts),
opts,
)
if line_result != nil {
return line_result
}
if mode == "line" {
return __edit_error("no_match", "old_text did not match any normalized line region", source, old, new)
}
}
if mode == "structural" || mode == "auto" {
let structural_result = __edit_apply_line_candidate(
source,
old,
new,
"structural",
__edit_line_candidates(source, old, true, opts),
opts,
)
if structural_result != nil {
return structural_result
}
}
return __edit_error("no_match", "old_text did not match any safe patch region", source, old, new)
}
/**
* Splice a half-open 0-based line range and return patch metadata.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: edit_splice_lines(text, start_line, end_line_exclusive, new_text, options)
*/
pub fn edit_splice_lines(text, start_line, end_line_exclusive, new_text, options = nil) {
let source = text ?? ""
let lines = split(source, "\n")
let start = start_line ?? 0
let end = end_line_exclusive ?? start
if start < 0 || end < start || end > len(lines) {
return __edit_error(
"invalid_line_range",
"line range must be 0-based, half-open, and inside the text",
source,
"",
new_text ?? "",
)
}
let old = join(lines[start:end], "\n")
let guardrail = __edit_guardrails(old, new_text ?? "", (options ?? {}).merge({allow_empty_old_text: true}))
if guardrail != nil {
return __edit_error(guardrail.code, guardrail.message, source, old, new_text ?? "")
}
let patched = __edit_splice_raw(source, start, end, new_text ?? "")
return __edit_success(source, patched, old, new_text ?? "", "line_splice", start, end, options ?? {})
}
fn __edit_find_resync(before_lines, after_lines, before_index, after_index) {
let window = 80
var distance = 1
while distance <= window {
var before_delta = 0
while before_delta <= distance {
let after_delta = distance - before_delta
let next_before = before_index + before_delta
let next_after = after_index + after_delta
if next_before < len(before_lines)
&& next_after < len(after_lines)
&& before_lines[next_before] == after_lines[next_after] {
return {before_index: next_before, after_index: next_after}
}
before_delta = before_delta + 1
}
distance = distance + 1
}
return {before_index: len(before_lines), after_index: len(after_lines)}
}
fn __edit_hunk(before_start, after_start, before_end, after_end, next_before, next_after) {
return {
start_line: min(before_start, after_start),
end_line: max(before_end, after_end),
end_line_exclusive: max(next_before, next_after),
before_start_line: before_start,
after_start_line: after_start,
before_end_line: before_end,
after_end_line: after_end,
before_line_count: max(before_end - before_start + 1, 0),
after_line_count: max(after_end - after_start + 1, 0),
}
}
/**
* Return deterministic line-level changed-region metadata.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: edit_changed_regions(before, after)
*/
pub fn edit_changed_regions(before, after) {
let before_lines = split(before ?? "", "\n")
let after_lines = split(after ?? "", "\n")
var regions = []
var before_index = 0
var after_index = 0
while before_index < len(before_lines) || after_index < len(after_lines) {
if before_index < len(before_lines)
&& after_index < len(after_lines)
&& before_lines[before_index] == after_lines[after_index] {
before_index = before_index + 1
after_index = after_index + 1
continue
}
let before_start = before_index
let after_start = after_index
let resync = __edit_find_resync(before_lines, after_lines, before_index, after_index)
let next_before = resync.before_index
let next_after = resync.after_index
regions = regions
+ [__edit_hunk(before_start, after_start, next_before - 1, next_after - 1, next_before, next_after)]
before_index = next_before
after_index = next_after
}
return regions
}
fn __edit_expected_end(region) {
if region?.end_line != nil {
return region.end_line
}
if region?.end_line_exclusive != nil {
return region.end_line_exclusive - 1
}
return region?.start_line ?? 0
}
fn __edit_region_within(actual, expected) {
let expected_start = expected?.start_line ?? 0
let expected_end = __edit_expected_end(expected)
return actual.start_line >= expected_start && actual.end_line <= expected_end
}
/**
* Strip ` N | ` line-number prefixes from raw text when at least 60% of
* non-empty lines carry that shape. Useful preprocessing for old_text strings
* that a model pasted verbatim from a numbered file read; otherwise the text
* never matches because each anchor line has an extra `42 | ` prefix.
*
* The 60% threshold is intentional: a file that legitimately contains one or
* two `N | …` lines (e.g. a docstring example) is left alone.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: edit_strip_line_number_prefixes(text)
*/
pub fn edit_strip_line_number_prefixes(text) {
let raw = text ?? ""
if raw == "" {
return raw
}
let lines = split(raw, "\n")
var non_empty_total = 0
var matching = 0
for line in lines {
if trim(line) == "" {
continue
}
non_empty_total = non_empty_total + 1
if regex_match("^\\s*\\d+\\s*\\| ", line) {
matching = matching + 1
}
}
if non_empty_total == 0 {
return raw
}
if matching * 100 / non_empty_total <= 60 {
return raw
}
var stripped = []
for line in lines {
stripped = stripped + [regex_replace("^\\s*\\d+\\s*\\| ", "", line)]
}
return join(stripped, "\n")
}
fn __edit_count_tab_indent(lines) {
var total = 0
for line in lines {
if (line ?? "").starts_with("\t") {
total = total + 1
}
}
return total
}
fn __edit_count_space_indent(lines) {
var total = 0
for line in lines {
let raw = line ?? ""
if raw == "" || raw.starts_with("\t") {
continue
}
if raw.starts_with(" ") {
total = total + 1
}
}
return total
}
fn __edit_min_indent(lines) {
var smallest = -1
for line in lines {
let raw = line ?? ""
if trim(raw) == "" {
continue
}
let leading = len(raw) - len(regex_replace("^[ \\t]+", "", raw))
if smallest == -1 || leading < smallest {
smallest = leading
}
}
return smallest
}
fn __edit_count_blank_lines(lines) {
var total = 0
for line in lines {
if trim(line ?? "") == "" {
total = total + 1
}
}
return total
}
/**
* Diagnose the dominant whitespace discrepancy between an old_text needle and
* the actual matched span in the source. Hosts log this on fuzzy/structural
* matches so the model can learn from each near-miss.
*
* Returns a short human-readable sentence such as
* `"your old_string used tabs but the file uses spaces"` or
* `"your old_string had 1 extra blank line(s) that the file does not"`.
* Returns `""` when needle and matched are identical or no dominant cause is
* detectable.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: edit_explain_whitespace_difference(needle, matched)
*/
pub fn edit_explain_whitespace_difference(needle, matched) {
let needle_text = needle ?? ""
let matched_text = matched ?? ""
if needle_text == matched_text {
return ""
}
let needle_lines = split(needle_text, "\n")
let matched_lines = split(matched_text, "\n")
let needle_tabs = __edit_count_tab_indent(needle_lines)
let needle_spaces = __edit_count_space_indent(needle_lines)
let matched_tabs = __edit_count_tab_indent(matched_lines)
let matched_spaces = __edit_count_space_indent(matched_lines)
if needle_tabs > 0 && matched_tabs == 0 && matched_spaces > 0 {
return "your old_string used tabs but the file uses spaces"
}
if needle_spaces > 0 && matched_spaces == 0 && matched_tabs > 0 {
return "your old_string used spaces but the file uses tabs"
}
let needle_min = __edit_min_indent(needle_lines)
let matched_min = __edit_min_indent(matched_lines)
if needle_min != matched_min && needle_min >= 0 && matched_min >= 0 {
return "your old_string's base indent was ${needle_min} but the file's matched block starts at indent ${matched_min}"
}
let needle_blanks = __edit_count_blank_lines(needle_lines)
let matched_blanks = __edit_count_blank_lines(matched_lines)
if needle_blanks != matched_blanks {
let delta = matched_blanks - needle_blanks
if delta > 0 {
return "your old_string was missing ${delta} blank line(s) that the file has"
}
return "your old_string had ${0 - delta} extra blank line(s) that the file does not"
}
return "minor whitespace formatting (exact cause not pinpointed)"
}
/**
* Detect whether a whole-file edit looks like a lazy truncation: a file with
* at least `min_old_lines` (10) lines shrunk below `min_keep_pct` (35%) of
* its original line count *and* the new content still contains at least one
* lazy placeholder.
*
* Returns `{ok, lazy, error_code, message, old_lines, new_lines, lazy_hits,
* provenance}`. `ok` is true when the edit is sound; false when the
* shrinkage + placeholder shape is present. Distinct from
* `edit_apply_old_new_patch`'s `lazy_placeholder` guardrail, which fires on
* any placeholder in the patch region — this one is for whole-file rewrites
* where placeholders by themselves are not a signal.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: edit_check_lazy_truncation(old_content, new_content, options)
*/
pub fn edit_check_lazy_truncation(old_content, new_content, options = nil) {
let opts = options ?? {}
let min_old_lines = opts?.min_old_lines ?? 10
let min_keep_pct = opts?.min_keep_pct ?? 35
let old_text = old_content ?? ""
let new_text = new_content ?? ""
let old_lines = len(split(old_text, "\n"))
let new_lines = len(split(new_text, "\n"))
let lazy_hits = __edit_lazy_match_count(new_text)
let provenance = {
module: "std/edit",
helper: "edit_check_lazy_truncation",
before_sha256: __edit_sha256(old_text),
after_sha256: __edit_sha256(new_text),
caller: opts?.provenance,
}
let neutral = {
ok: true,
lazy: false,
error_code: nil,
message: "",
old_lines: old_lines,
new_lines: new_lines,
lazy_hits: lazy_hits,
provenance: provenance,
}
if old_lines < min_old_lines {
return neutral
}
let threshold = old_lines * min_keep_pct / 100
if new_lines > threshold {
return neutral
}
if lazy_hits == 0 {
return neutral
}
return {
ok: false,
lazy: true,
error_code: "lazy_truncation",
message: "lazy edit: file was truncated from ${old_lines} to ${new_lines} lines with placeholder comments — provide the complete implementation",
old_lines: old_lines,
new_lines: new_lines,
lazy_hits: lazy_hits,
provenance: provenance,
}
}
/**
* Validate that before/after text differs only inside expected line regions.
* Expected regions use inclusive `end_line` or half-open `end_line_exclusive`.
*
* @effects: []
* @allocation: heap
* @errors: []
* @api_stability: stable
* @example: edit_validate_changed_regions(before, after, expected_regions, options)
*/
pub fn edit_validate_changed_regions(before, after, expected_regions, options = nil) {
let actual = edit_changed_regions(before ?? "", after ?? "")
let expected = expected_regions ?? []
var errors = []
for region in actual {
var matched = false
for expected_region in expected {
if __edit_region_within(region, expected_region) {
matched = true
break
}
}
if !matched {
errors = errors
+ [
{
code: "unexpected_changed_region",
message: "changed region falls outside expected patch range",
region: region,
},
]
}
}
return {
ok: len(errors) == 0,
actual_regions: actual,
expected_regions: expected,
errors: errors,
error_codes: errors.map({ error -> error.code }),
warnings: [],
provenance: {
module: "std/edit",
helper: "edit_validate_changed_regions",
before_sha256: __edit_sha256(before ?? ""),
after_sha256: __edit_sha256(after ?? ""),
caller: options?.provenance,
},
}
}
/**
* Locate one or more AST nodes via a Tree-Sitter query and replace each
* match's bytes with `replacement`. The byte splice preserves leading
* indentation, trailing trivia, and any whitespace outside the matched
* span — so this is the precise alternative to freeform text patching
* for "change this function body" / "rename this call" style edits.
*
* The query must declare at least one capture; the capture named by
* `target_capture` (default `"target"`) is the replaceable span.
* Single-capture queries accept any capture name.
*
* Multi-match policy via `select`:
*
* - `"unique"` (default) requires exactly one match; else `ambiguous`.
* - `"first"` picks the lowest byte offset.
* - `"all"` rewrites every match.
* - `"nth"` plus `nth: N` picks the 1-based index in document order.
*
* When `validate` is true (default) the rewritten source is re-parsed and
* any tree-sitter ERROR/MISSING node aborts the edit with
* `result == "syntax_error"`. When `dry_run` is true the file is left
* untouched but the response still carries the `preview` text the splice
* would produce.
*
* Reads and writes route through staged-fs (#1722) when a `session_id` is
* supplied so the edit is atomic alongside siblings.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_apply_node({path: "src/lib.rs", query: query, replacement: "{ 42 }"})
*/
pub fn edit_apply_node(params) {
let raw = hostlib_ast_apply_node(params ?? {})
let payload = raw ?? {}
let provenance = {
module: "std/edit",
helper: "edit_apply_node",
path: payload?.path,
dry_run: payload?.dry_run ?? false,
before_sha256: payload?.before_sha256,
after_sha256: payload?.after_sha256,
caller: params?.provenance,
}
return {
ok: payload?.result ?? "" == "applied",
applied: payload?.applied ?? false,
result: payload?.result ?? "no_match",
path: payload?.path,
dry_run: payload?.dry_run ?? false,
match_count: payload?.match_count ?? 0,
edits: payload?.edits ?? [],
spans: payload?.spans ?? [],
preview: payload?.preview,
before_sha256: payload?.before_sha256,
after_sha256: payload?.after_sha256,
query: payload?.query,
target_capture: payload?.target_capture,
details: payload?.details,
fallback_suggestion: payload?.fallback_suggestion,
error_row: payload?.error_row,
error_column: payload?.error_column,
errors: [],
warnings: [],
provenance: provenance,
}
}
/**
* Splice `content` into `path` relative to a unique AST anchor located
* by a Tree-Sitter query.
*
* Companion to `edit_apply_node`: where `apply_node` *replaces* a span,
* this primitive *adds* a sibling or child node. `position` picks the
* slot:
*
* - `"before"` / `"after"` — insert a sibling at the anchor's indent
* depth (e.g. "add a new function after this one").
* - `"first_child"` / `"last_child"` — insert inside the anchor at the
* inferred child depth (e.g. "append a new test to this mod").
*
* The query MUST match exactly one node — multi-match returns
* `result == "ambiguous"`. Tighten queries with `(#eq? @name "…")`
* predicates to disambiguate.
*
* Each line of `content` is re-indented to the target depth unless
* `reindent: false` is supplied. The indent unit is detected from the
* file's existing indentation (tabs win when any line starts with one,
* else the smallest non-zero leading-space run), falling back to a
* language default. Override explicitly via `indent: " "`.
*
* When `validate` is true (default) the rewritten source is re-parsed
* and any tree-sitter ERROR/MISSING node aborts the edit with
* `result == "syntax_error"`. When `dry_run` is true the file is left
* untouched but the response still carries the `preview` text the
* splice would produce. Reads and writes route through staged-fs
* (#1722) when a `session_id` is supplied so the insert is atomic
* alongside siblings.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_insert_at_anchor({path: "src/lib.rs", query: q, position: "after", content: "fn beta() {}"})
*/
pub fn edit_insert_at_anchor(params) {
let raw = hostlib_ast_insert_at_anchor(params ?? {})
let payload = raw ?? {}
let provenance = {
module: "std/edit",
helper: "edit_insert_at_anchor",
path: payload?.path,
position: payload?.position,
dry_run: payload?.dry_run ?? false,
before_sha256: payload?.before_sha256,
after_sha256: payload?.after_sha256,
caller: params?.provenance,
}
return {
ok: payload?.result ?? "" == "applied",
applied: payload?.applied ?? false,
result: payload?.result ?? "no_match",
path: payload?.path,
dry_run: payload?.dry_run ?? false,
position: payload?.position,
insertion_byte: payload?.insertion_byte,
indent: payload?.indent,
inserted_text: payload?.inserted_text,
anchor: payload?.anchor,
anchors: payload?.anchors ?? [],
match_count: payload?.match_count ?? 0,
preview: payload?.preview,
before_sha256: payload?.before_sha256,
after_sha256: payload?.after_sha256,
query: payload?.query,
target_capture: payload?.target_capture,
details: payload?.details,
fallback_suggestion: payload?.fallback_suggestion,
error_row: payload?.error_row,
error_column: payload?.error_column,
errors: [],
warnings: [],
provenance: provenance,
}
}
fn __edit_safe_patch_telemetry(result, hunks_count, failed_hunk_index) {
return {
result: result,
hunks: hunks_count,
stale_base: if result == "stale_base" {
1
} else {
0
},
hunk_conflict: if result == "hunk_conflict" {
1
} else {
0
},
applied: if result == "applied" {
1
} else {
0
},
no_op: if result == "no_op" {
1
} else {
0
},
failed_hunk_index: failed_hunk_index,
}
}
fn __edit_safe_patch_result(ctx, fields = nil) {
let extra = fields ?? {}
let result = extra?.result ?? ctx.result
let matched = result == "applied" || result == "no_op"
let failed_idx = extra?.failed_hunk_index
let bytes_written = extra?.bytes_written ?? 0
hostlib_fs_emit_safe_text_patch_result(
{
session_id: ctx.session_id,
path: ctx.path,
result: result,
hunks_count: ctx.hunks_count,
bytes_written: bytes_written,
failed_hunk_index: failed_idx,
},
)
let base = {
ok: matched,
result: result,
applied: matched,
dry_run: ctx.dry_run ?? false,
path: ctx.path,
before_sha256: ctx.before_hash,
after_sha256: ctx.before_hash,
current_hash: ctx.current_hash,
expected_hash: ctx.expected_hash,
hunks_count: ctx.hunks_count,
hunk_results: extra?.hunk_results ?? [],
failed_hunk_index: failed_idx,
failed_hunk_error_code: extra?.failed_hunk_error_code,
stale_base: result == "stale_base",
bytes_written: bytes_written,
created: false,
preview: nil,
errors: [],
warnings: [],
telemetry: __edit_safe_patch_telemetry(result, ctx.hunks_count, failed_idx),
provenance: {
module: "std/edit",
helper: "edit_safe_text_patch",
path: ctx.path,
before_sha256: ctx.before_hash,
after_sha256: extra?.after_sha256 ?? ctx.before_hash,
current_hash: ctx.current_hash,
expected_hash: ctx.expected_hash,
caller: ctx.options?.provenance,
},
}
return base.merge(extra)
}
/**
* Apply a sequence of old/new hunks to `path` atomically against the
* staged-fs overlay (#1722). Snapshots the current bytes at `path`,
* checks `expected_hash` (when supplied) for stale-base rejection, runs
* each hunk through `edit_apply_old_new_patch` against the running
* post-image, and writes the final bytes back through the same overlay.
*
* All-or-nothing: if any hunk rejects (no match / ambiguous / lazy /
* etc.) the call returns `hunk_conflict` and no bytes are written.
*
* ## Params
*
* - `path`: file to mutate.
* - `hunks`: list of `{old_text, new_text, options?}`. Each hunk's
* `options` override the top-level `match_options` for that hunk.
* - `expected_hash`: `sha256:HEX` of the pre-image the caller observed.
* When omitted the stale-base check is skipped (still atomic w.r.t.
* other staged-fs writers in the same process).
* - `session_id`: hostlib session whose staged-fs overlay should
* intercept the read and the write.
* - `match_options`: default `edit_apply_old_new_patch` options merged
* into every hunk; per-hunk `options` win when keys overlap.
* - `dry_run`: when true the post-image is computed and returned in
* `preview` but no bytes are written.
*
* ## Result
*
* Returns `{ok, result, applied, dry_run, path, before_sha256,
* after_sha256, current_hash, expected_hash, hunks_count,
* hunk_results, failed_hunk_index?, failed_hunk_error_code?,
* stale_base, bytes_written, created, preview?, errors, warnings,
* telemetry, provenance}`. `result` is one of `applied`, `no_op`,
* `stale_base`, `hunk_conflict`; `applied: true` means the matcher
* succeeded (mirrors `edit_apply_node`'s convention — `dry_run: true`
* keeps `applied` true while skipping the on-disk write). `telemetry`
* carries per-call counters hosts roll up into stale-base /
* hunk-conflict rates and average hunks-per-patch.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_safe_text_patch({path: "src/lib.rs", expected_hash: "sha256:...", hunks: [{old_text: "x = 1", new_text: "x = 2"}]})
*/
pub fn edit_safe_text_patch(params) {
let opts = params ?? {}
let path = opts?.path ?? ""
let hunks = opts?.hunks ?? []
let session_id = opts?.session_id
let match_options = opts?.match_options ?? {}
let dry_run = opts?.dry_run ?? false
let expected_hash = opts?.expected_hash
let read_response = hostlib_fs_read_text({path: path, session_id: session_id})
let current_content = read_response?.content ?? ""
let current_hash = read_response?.sha256 ?? __edit_sha256(current_content)
let ctx = {
path: path,
session_id: session_id,
before_hash: current_hash,
current_hash: current_hash,
expected_hash: expected_hash,
hunks_count: len(hunks),
dry_run: dry_run,
result: "applied",
options: opts,
}
if expected_hash != nil && expected_hash != current_hash {
return __edit_safe_patch_result(
ctx,
{
result: "stale_base",
errors: [
{
code: "stale_base",
message: "expected_hash did not match the current pre-image",
current_hash: current_hash,
expected_hash: expected_hash,
},
],
},
)
}
var working = current_content
var hunk_results = []
var idx = 0
while idx < len(hunks) {
let hunk = hunks[idx]
let hunk_opts = match_options.merge(hunk?.options ?? {})
let outcome = edit_apply_old_new_patch(working, hunk?.old_text, hunk?.new_text, hunk_opts)
hunk_results = hunk_results + [outcome]
if !outcome.ok {
let hunk_error_code = outcome?.error_code ?? "no_match"
return __edit_safe_patch_result(
ctx,
{
result: "hunk_conflict",
after_sha256: __edit_sha256(working),
hunk_results: hunk_results,
failed_hunk_index: idx,
failed_hunk_error_code: hunk_error_code,
errors: [
{
code: "hunk_conflict",
message: "hunk " + to_string(idx) + " rejected: " + hunk_error_code,
hunk_index: idx,
hunk_error_code: hunk_error_code,
hunk_message: outcome?.message,
},
],
},
)
}
working = outcome.patched
idx = idx + 1
}
if dry_run {
let after_hash = __edit_sha256(working)
let result_kind = if current_content == working {
"no_op"
} else {
"applied"
}
return __edit_safe_patch_result(
ctx,
{result: result_kind, after_sha256: after_hash, hunk_results: hunk_results, preview: working},
)
}
let commit = hostlib_fs_safe_text_patch(
{
path: path,
content: working,
expected_hash: current_hash,
session_id: session_id,
create_parents: opts?.create_parents ?? true,
overwrite: opts?.overwrite ?? true,
},
)
let commit_result = commit?.result ?? "applied"
let committed_hash = commit?.current_hash ?? current_hash
let committed_after = commit?.after_sha256 ?? current_hash
if commit_result == "stale_base" {
return __edit_safe_patch_result(
ctx.merge({current_hash: committed_hash}),
{
result: "stale_base",
after_sha256: committed_after,
hunk_results: hunk_results,
errors: [
{
code: "stale_base",
message: "another writer committed between snapshot and write",
current_hash: committed_hash,
expected_hash: current_hash,
},
],
},
)
}
return __edit_safe_patch_result(
ctx.merge({current_hash: committed_hash}),
{
result: commit_result,
after_sha256: committed_after,
hunk_results: hunk_results,
bytes_written: commit?.bytes_written ?? 0,
created: commit?.created ?? false,
},
)
}
/**
* Rename a symbol across the workspace using the typed symbol graph
* (#2434). Pass `symbol_ref` (`{name, path, line?, kind?}`), the
* `new_name`, and a `scope` (`"file"` | `"module"` | `"workspace"`).
*
* The helper resolves the seed against the indexed graph, walks every
* file in scope, replaces identifier-context occurrences of the symbol
* name (skipping comments and string literals), and rejects the edit
* with `result: "conflict"` if `new_name` already exists as an
* identifier in any rewritten file. Languages: Rust, TypeScript/TSX,
* JavaScript/JSX, Python, Swift, Go.
*
* Pass `session_id` to route writes through staged-fs (#1722) so all
* touched files succeed or none do — the host still buffers every plan
* in memory and only persists after pre-flight validation passes, so
* the same all-or-nothing guarantee applies even without a session.
*
* `dry_run` returns the planned per-file edits without writing.
* `validate` (default true) re-parses every rewritten file and aborts
* with `result: "syntax_error"` on any ERROR/MISSING node.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_rename_symbol({symbol_ref: {name: "Widget", path: "src/lib.rs", kind: "Type"}, new_name: "Gadget", scope: "workspace"})
*/
pub fn edit_rename_symbol(params) {
let req = params ?? {}
let raw = hostlib_code_index_rename_symbol(req)
let payload = raw ?? {}
let result_tag = payload?.result ?? "no_match"
let provenance = {
module: "std/edit",
helper: "edit_rename_symbol",
symbol: payload?.symbol,
scope: payload?.scope ?? req?.scope,
dry_run: payload?.dry_run ?? false,
touched_count: len(payload?.touched_files ?? []),
caller: req?.provenance,
}
return {
ok: result_tag == "applied",
applied: payload?.applied ?? false,
result: result_tag,
dry_run: payload?.dry_run ?? false,
scope: payload?.scope ?? req?.scope,
symbol: payload?.symbol,
touched_files: payload?.touched_files ?? [],
conflicts: payload?.conflicts ?? [],
match_count: payload?.match_count ?? 0,
failed_paths_with_reasons: payload?.failed_paths_with_reasons ?? [],
details: payload?.details,
errors: [],
warnings: payload?.warnings ?? [],
provenance: provenance,
}
}
/**
* Render a multi-op edit plan as a per-file unified-diff bundle without
* committing anything to disk. The host opens a *transient* staged-fs
* (#1722) session, dispatches each op through its op-specific handler
* with that session id wired in, walks the resulting overlay to produce
* the diff, then discards the session — so the on-disk tree is
* byte-identical before and after the call.
*
* `plan` is an ordered list of operations. Each op carries an `op` tag:
*
* - `{op: "apply_node", path, query, replacement, select?, nth?, target_capture?, language?, validate?}`
* - `{op: "insert_at_anchor", path, query, position, content, target_capture?, language?, validate?}`
* — `position` ∈ `before | after | first_child | last_child`.
* - `{op: "safe_text_patch", path, old_text, new_text}` — exact unique match.
* - `{op: "rename_symbol", symbol_ref, new_name, scope?}` — cross-file
* rename via the typed symbol graph (#2434).
*
* Returns `{ok, result, per_file_unified_diff, summary, ops, provenance,
* errors, warnings}` where `per_file_unified_diff` carries
* `{path, diff, lines_added, lines_removed}` per touched file. The diff
* format is standard unified diff, compatible with `git apply --check`.
* `result` is one of `ok | partial | no_ops_applied`.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_dry_run({plan: [{op: "apply_node", path: "src/lib.rs", query: query, replacement: "{ 42 }"}]})
*/
pub fn edit_dry_run(params) {
let plan = params?.plan ?? []
let raw = hostlib_ast_dry_run({plan: plan})
let payload = raw ?? {}
let summary = payload?.summary ?? {}
return {
ok: payload?.result ?? "no_ops_applied" != "no_ops_applied",
result: payload?.result ?? "no_ops_applied",
per_file_unified_diff: payload?.per_file_unified_diff ?? [],
summary: {
files_touched: summary?.files_touched ?? 0,
lines_added: summary?.lines_added ?? 0,
lines_removed: summary?.lines_removed ?? 0,
ops_applied: summary?.ops_applied ?? 0,
ops_rejected: summary?.ops_rejected ?? 0,
},
ops: payload?.ops ?? [],
errors: [],
warnings: [],
provenance: {module: "std/edit", helper: "edit_dry_run", caller: params?.provenance},
}
}
/**
* Report the per-language AST-precise edit capability matrix — the
* runtime face of the B.7 onboarding contract. The agent loop calls this
* to decide which edit primitive a file qualifies for (and what to fall
* back to) instead of hard-coding a language list in prompts.
*
* Pass `{language: "rust"}` (canonical name or alias) to filter to one
* language; omit it to enumerate every shipped grammar. `apply_node` and
* `insert_at_anchor` are available for every registered grammar;
* `rename_symbol` and `symbols` require a per-language projection.
*
* Returns `{ok, result, fallback_suggestion, languages, details}` where
* each `languages` row is `{language, extension, apply_node,
* insert_at_anchor, rename_symbol, symbols}`. `result` is `"ok"` or
* `"unsupported_language"` when a `language` filter names no grammar.
*
* @effects: [host]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_capabilities({language: "yaml"})
*/
pub fn edit_capabilities(params = nil) {
let raw = hostlib_ast_capabilities(params ?? {})
let payload = raw ?? {}
return {
ok: payload?.result ?? "" == "ok",
result: payload?.result ?? "ok",
fallback_suggestion: payload?.fallback_suggestion,
languages: payload?.languages ?? [],
details: payload?.details,
errors: [],
warnings: [],
provenance: {module: "std/edit", helper: "edit_capabilities", caller: params?.provenance},
}
}
// -------------------------------------------------------------------------------------------------
// Structured refactorings (B.8, issue #2520)
//
// Higher-level, compound, language-aware edits composed on top of the
// B.1–B.5 primitives above (`edit_apply_node`, `edit_insert_at_anchor`,
// `edit_rename_symbol`, `edit_safe_text_patch`, `edit_dry_run`) plus the
// host AST analyzers (`hostlib_ast_symbols`, `hostlib_ast_function_body`,
// `hostlib_ast_undefined_names`).
//
// Every refactoring shares one driver, `__refactor_run`, which lowers a
// list of typed ops into staged-fs writes (#1722): a `dry_run` previews
// the change as a per-file unified diff against a throw-away overlay and
// touches no bytes, while an apply stages all ops into one session and
// commits them atomically (or discards on the first conflict). Callers
// that already own a staged session pass `session_id`; the driver then
// stages into it and leaves the commit to the caller.
//
// Each public entry point first consults a per-(operation, language)
// capability matrix and returns `result: "unsupported"` rather than
// guessing when a language lacks the structure a refactoring needs.
//
// All of these route through gated host builtins (`apply_node`,
// `insert_at_anchor`, `read_text`, `safe_text_patch`), so callers must
// hold the `tools:deterministic` capability.
// -------------------------------------------------------------------------------------------------
fn __refactor_normalize_lang(name) {
let n = lowercase(trim(name ?? ""))
let alias = {golang: "go", js: "javascript", py: "python", rb: "ruby", rs: "rust", ts: "typescript"}
return alias.get(n, n)
}
fn __refactor_detect_language(params) {
let hint = params?.language
if hint != nil && hint != "" {
return __refactor_normalize_lang(hint)
}
let path = params?.path ?? ""
let segments = split(path, "/")
let filename = if len(segments) > 0 {
segments[-1]
} else {
path
}
let dotted = split(filename, ".")
if len(dotted) < 2 {
return ""
}
return __refactor_normalize_lang(dotted[-1])
}
fn __refactor_member(list, item) {
for x in list ?? [] {
if x == item {
return true
}
}
return false
}
fn __refactor_touched_paths(ops) {
var seen = []
for op in ops ?? [] {
let path = op?.path
if path != nil && !__refactor_member(seen, path) {
seen = seen + [path]
}
}
return seen
}
fn __refactor_common_root(paths) {
if len(paths) == 0 {
return "."
}
let first = split(paths[0], "/")
var common = if len(first) > 0 {
first[:len(first) - 1]
} else {
first
}
for p in paths {
let segs = split(p, "/")
let dir = if len(segs) > 0 {
segs[:len(segs) - 1]
} else {
segs
}
var prefix = []
var idx = 0
while idx < len(common) && idx < len(dir) && common[idx] == dir[idx] {
prefix = prefix + [common[idx]]
idx = idx + 1
}
common = prefix
}
let root = join(common, "/")
if root == "" {
return "."
}
return root
}
fn __refactor_new_session(operation) {
return "harn-refactor-" + operation + "-" + uuid_v7()
}
fn __refactor_leading_ws(line) {
let raw = line ?? ""
let body = regex_replace("^[ \\t]+", "", raw)
return raw.substring(0, len(raw) - len(body))
}
fn __refactor_result(operation, language, fields) {
let base = {
ok: false,
applied: false,
result: "unsupported",
operation: operation,
language: language,
dry_run: false,
touched_files: [],
unified_diff: [],
summary: {files_touched: 0, lines_added: 0, lines_removed: 0},
conflicts: [],
details: nil,
errors: [],
warnings: [],
provenance: {module: "std/edit", helper: "edit_" + operation, operation: operation, language: language},
}
return base.merge(fields ?? {})
}
fn __refactor_unsupported(operation, language, reason) {
return __refactor_result(
operation,
language,
{result: "unsupported", details: reason, errors: [{code: "unsupported", message: reason}]},
)
}
fn __refactor_invalid(operation, language, reason) {
return __refactor_result(
operation,
language,
{result: "invalid_params", details: reason, errors: [{code: "invalid_params", message: reason}]},
)
}
fn __refactor_conflict(operation, language, conflicts) {
return __refactor_result(
operation,
language,
{
result: "conflict",
conflicts: conflicts,
details: if len(conflicts) > 0 {
conflicts[0].message
} else {
nil
},
errors: conflicts.map({ c -> {code: c.code, message: c.message} }),
},
)
}
fn __refactor_exec_op(op, session_id, before_map) {
let kind = op?.kind
if kind == "content" {
let before = before_map.get(op.path, "")
let res = hostlib_fs_safe_text_patch(
{
path: op.path,
content: op.after,
expected_hash: __edit_sha256(before),
session_id: session_id,
create_parents: true,
overwrite: true,
},
)
let tag = res?.result ?? "applied"
return {
ok: tag == "applied",
result: tag,
conflict: {code: tag, message: "content write `" + tag + "` at " + op.path, path: op.path},
}
}
if kind == "node_apply" {
let res = edit_apply_node((op?.params ?? {}).merge({session_id: session_id}))
return {
ok: res.result == "applied",
result: res.result,
conflict: {code: res.result, message: res.details ?? ("apply_node " + res.result), path: op?.path},
}
}
if kind == "node_insert" {
let res = edit_insert_at_anchor((op?.params ?? {}).merge({session_id: session_id}))
return {
ok: res.result == "applied",
result: res.result,
conflict: {code: res.result, message: res.details ?? ("insert_at_anchor " + res.result), path: op?.path},
}
}
return {
ok: false,
result: "invalid_op",
conflict: {code: "invalid_op", message: "unknown refactor op kind"},
}
}
/**
* Shared driver for the structured refactorings. Lowers `ops` (typed
* `content` / `node_apply` / `node_insert` records) into staged-fs
* writes, previewing or committing atomically per the contract above.
* Not part of the public API surface.
*/
fn __refactor_run(operation, language, ops, params, warnings) {
let p = params ?? {}
let dry_run = p?.dry_run ?? false
let warns = warnings ?? []
if len(ops) == 0 {
return __refactor_result(
operation,
language,
{ok: true, applied: true, result: "no_op", dry_run: dry_run, warnings: warns},
)
}
let touched = __refactor_touched_paths(ops)
let caller_session = p?.session_id
let manage_session = dry_run || caller_session == nil
let session_id = if manage_session {
__refactor_new_session(operation)
} else {
caller_session
}
if manage_session {
if dry_run {
// A preview never commits, so leave the overlay state at the host
// default (cwd-relative) rather than scattering `.harn/state` next
// to the target files — same as `edit_dry_run`.
hostlib_fs_set_mode({session_id: session_id, mode: "staged"})
} else {
// An apply must commit to disk, so root the overlay at the tightest
// directory enclosing every touched path.
hostlib_fs_set_mode(
{session_id: session_id, mode: "staged", root: p?.root ?? __refactor_common_root(touched)},
)
}
}
var before_map = {}
for path in touched {
let r = hostlib_fs_read_text({path: path, session_id: session_id})
before_map = before_map.merge({[path]: r?.content ?? ""})
}
var conflicts = []
var ok_all = true
for op in ops {
let outcome = __refactor_exec_op(op, session_id, before_map)
if !outcome.ok {
conflicts = conflicts + [outcome.conflict]
ok_all = false
break
}
}
var diffs = []
var changed = []
var added = 0
var removed = 0
if ok_all {
for path in touched {
let before = before_map.get(path, "")
let after_read = hostlib_fs_read_text({path: path, session_id: session_id})
let after = after_read?.content ?? before
if after != before {
let stat = diff_summary(before, after)
changed = changed + [path]
added = added + stat.insertions
removed = removed + stat.deletions
diffs = diffs
+ [
{
path: path,
diff: unified_diff(before, after, {path: path}),
lines_added: stat.insertions,
lines_removed: stat.deletions,
},
]
}
}
}
if manage_session {
if dry_run || !ok_all {
hostlib_fs_discard_staged({session_id: session_id})
} else {
hostlib_fs_commit_staged({session_id: session_id})
}
}
let result_tag = if !ok_all {
"conflict"
} else if len(changed) == 0 {
"no_op"
} else {
"applied"
}
return __refactor_result(
operation,
language,
{
ok: result_tag == "applied",
applied: result_tag == "applied" || result_tag == "no_op",
result: result_tag,
dry_run: dry_run,
touched_files: changed,
unified_diff: diffs,
summary: {files_touched: len(changed), lines_added: added, lines_removed: removed},
conflicts: conflicts,
details: if !ok_all {
conflicts[0].message
} else {
nil
},
errors: conflicts,
warnings: warns,
},
)
}
fn __refactor_var_decl_syntax(language) {
let table = {
go: {kw: "", assign: " := ", term: ""},
javascript: {kw: "const ", assign: " = ", term: ";"},
jsx: {kw: "const ", assign: " = ", term: ";"},
python: {kw: "", assign: " = ", term: ""},
ruby: {kw: "", assign: " = ", term: ""},
rust: {kw: "let ", assign: " = ", term: ";"},
swift: {kw: "let ", assign: " = ", term: ""},
tsx: {kw: "const ", assign: " = ", term: ";"},
typescript: {kw: "const ", assign: " = ", term: ";"},
}
return table.get(language, nil)
}
/**
* Extract a single-line expression range into a freshly-declared local
* binding, replacing the original span with `new_name` and inserting the
* declaration on its own line directly above, at the same indentation.
*
* `range` is `{start_line, start_col, end_line, end_col}` in 0-based
* tree-sitter coordinates (columns are byte offsets within the line).
* Multi-line ranges return `result: "unsupported"` — select an
* expression that lives on one line.
*
* The declaration form is chosen from a per-language table (`let`/`const`/
* `:=`/bare), so languages that need an explicit type to declare a local
* (Java, C, C++) return `result: "unsupported"`.
*
* Shares the staged-fs preview/atomic-apply contract of the other
* structured refactorings: pass `dry_run: true` for a unified-diff
* preview, `session_id` to stage into a caller-owned session.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_extract_variable({path: "src/lib.rs", range: {start_line: 4, start_col: 13, end_line: 4, end_col: 24}, new_name: "total"})
*/
pub fn edit_extract_variable(params) {
let p = params ?? {}
let language = __refactor_detect_language(p)
let syntax = __refactor_var_decl_syntax(language)
if syntax == nil {
return __refactor_unsupported(
"extract_variable",
language,
"extract_variable has no local-declaration form for language `" + language + "`",
)
}
let path = p?.path ?? ""
let new_name = p?.new_name ?? ""
let range = p?.range ?? {}
if path == "" || new_name == "" {
return __refactor_invalid("extract_variable", language, "`path` and `new_name` are required")
}
let start_line = range?.start_line
let end_line = range?.end_line ?? start_line
let start_col = range?.start_col ?? 0
let end_col = range?.end_col
if start_line == nil || end_col == nil {
return __refactor_invalid(
"extract_variable",
language,
"`range` must carry start_line, start_col, end_line, end_col",
)
}
if end_line != start_line {
return __refactor_unsupported(
"extract_variable",
language,
"multi-line ranges are not supported; select an expression on one line",
)
}
let read = hostlib_fs_read_text({path: path, session_id: p?.session_id})
let content = read?.content ?? ""
let lines = split(content, "\n")
if start_line < 0 || start_line >= len(lines) {
return __refactor_invalid("extract_variable", language, "start_line is outside the file")
}
let line = lines[start_line]
let expr = line.substring(start_col, end_col)
if trim(expr) == "" {
return __refactor_invalid("extract_variable", language, "selected range is empty or whitespace")
}
let indent = __refactor_leading_ws(line)
let decl = indent + syntax.kw + new_name + syntax.assign + trim(expr) + syntax.term
let replaced_line = line.substring(0, start_col) + new_name + line.substring(end_col)
var new_lines = if start_line > 0 {
lines[:start_line]
} else {
[]
}
new_lines = new_lines + [decl, replaced_line]
if start_line + 1 < len(lines) {
new_lines = new_lines + lines[start_line + 1:]
}
let after = join(new_lines, "\n")
return __refactor_run("extract_variable", language, [{kind: "content", path: path, after: after}], p, [])
}
/**
* --- Signature-family + return-type refactorings ----------------------------
*
* Tree-sitter node/field names per language were verified empirically: the
* `parameters` field holds the param-list node (whose node type varies:
* `parameters` / `formal_parameters` / `parameter_list`); the return-type
* field is `return_type` (Rust/Python/TS) or `result` (Go); call argument
* lists are the `arguments` / `argument_list` node under a call node's
* `function` field. Replacing a params or args node replaces the enclosing
* parens too, so replacements re-add them. TS return-type nodes include the
* leading `: `, so `ret_prefix` re-adds it.
*/
fn __refactor_lang_spec(language) {
let table = {
go: {
fn_node: "function_declaration",
params_node: "parameter_list",
ret_field: "result",
ret_node: "(_)",
ret_prefix: "",
call_node: "call_expression",
args_node: "argument_list",
},
javascript: {
fn_node: "function_declaration",
params_node: "formal_parameters",
ret_field: nil,
ret_node: nil,
ret_prefix: "",
call_node: "call_expression",
args_node: "arguments",
},
jsx: {
fn_node: "function_declaration",
params_node: "formal_parameters",
ret_field: nil,
ret_node: nil,
ret_prefix: "",
call_node: "call_expression",
args_node: "arguments",
},
python: {
fn_node: "function_definition",
params_node: "parameters",
ret_field: "return_type",
ret_node: "(type)",
ret_prefix: "",
call_node: "call",
args_node: "argument_list",
},
rust: {
fn_node: "function_item",
params_node: "parameters",
ret_field: "return_type",
ret_node: "(_)",
ret_prefix: "",
call_node: "call_expression",
args_node: "arguments",
},
tsx: {
fn_node: "function_declaration",
params_node: "formal_parameters",
ret_field: "return_type",
ret_node: "(type_annotation)",
ret_prefix: ": ",
call_node: "call_expression",
args_node: "arguments",
},
typescript: {
fn_node: "function_declaration",
params_node: "formal_parameters",
ret_field: "return_type",
ret_node: "(type_annotation)",
ret_prefix: ": ",
call_node: "call_expression",
args_node: "arguments",
},
}
return table.get(language, nil)
}
fn __refactor_symbol_name(p) {
let s = p?.symbol
if type_of(s) == "dict" {
return s?.name ?? ""
}
if type_of(s) == "string" {
return s
}
return p?.name ?? ""
}
fn __refactor_paren_inner(s) {
let raw = s ?? ""
if len(raw) < 2 {
return ""
}
return raw.substring(1, len(raw) - 1)
}
fn __refactor_split_top_level(text) {
var segs = []
var depth = 0
var quote = ""
var cur = ""
for c in (text ?? "").chars() {
if quote != "" {
cur = cur + c
if c == quote {
quote = ""
}
} else if c == "\"" || c == "'" {
quote = c
cur = cur + c
} else if c == "(" || c == "[" || c == "{" {
depth = depth + 1
cur = cur + c
} else if c == ")" || c == "]" || c == "}" {
depth = depth - 1
cur = cur + c
} else if c == "," && depth == 0 {
if trim(cur) != "" {
segs = segs + [trim(cur)]
}
cur = ""
} else {
cur = cur + c
}
}
if trim(cur) != "" {
segs = segs + [trim(cur)]
}
return segs
}
fn __refactor_insert_at(list, index, item) {
let n = len(list)
let idx = max(0, min(index, n))
let head = if idx > 0 {
list[:idx]
} else {
[]
}
let tail = if idx < n {
list[idx:]
} else {
[]
}
return head + [item] + tail
}
fn __refactor_def_query(spec, fn_name) {
return "(" + spec.fn_node + " name: (identifier) @__n parameters: (" + spec.params_node
+ ") @target (#eq? @__n \""
+ fn_name
+ "\"))"
}
fn __refactor_call_query(spec, fn_name) {
return "(" + spec.call_node + " function: (identifier) @__f arguments: (" + spec.args_node
+ ") @target (#eq? @__f \""
+ fn_name
+ "\"))"
}
fn __refactor_probe(query, path, session_id) {
return edit_apply_node(
{
path: path,
query: query,
replacement: "",
select: "all",
dry_run: true,
validate: false,
session_id: session_id,
},
)
}
fn __refactor_read_def_params(spec, path, fn_name, session_id) {
let r = edit_apply_node(
{
path: path,
query: __refactor_def_query(spec, fn_name),
replacement: "",
select: "unique",
dry_run: true,
validate: false,
session_id: session_id,
},
)
if r.result != "applied" {
return {ok: false, result: r.result, inner: "", params: []}
}
let original = if len(r.edits) > 0 {
r.edits[0].original
} else {
"()"
}
let inner = __refactor_paren_inner(original)
return {ok: true, result: "applied", inner: inner, params: __refactor_split_top_level(inner)}
}
fn __refactor_call_sites(spec, path, fn_name, session_id) {
let r = __refactor_probe(__refactor_call_query(spec, fn_name), path, session_id)
if r.result == "applied" {
return {count: r.match_count, originals: r.edits.map({ e -> e.original })}
}
return {count: 0, originals: []}
}
fn __refactor_call_op(spec, path, fn_name, nth, new_args) {
return {
kind: "node_apply",
path: path,
params: {
path: path,
query: __refactor_call_query(spec, fn_name),
replacement: "(" + new_args + ")",
select: "nth",
nth: nth,
},
}
}
fn __refactor_def_op(spec, path, fn_name, new_inner) {
return {
kind: "node_apply",
path: path,
params: {
path: path,
query: __refactor_def_query(spec, fn_name),
replacement: "(" + new_inner + ")",
select: "unique",
},
}
}
/**
* Replace a function's entire parameter list with `new_params` (the inner
* text, without the enclosing parens), updating call sites per
* `callsite_strategy`:
*
* - `"strict"` (default) — refuse with `result: "conflict"` if any call
* site exists, since an arbitrary signature change cannot be reconciled
* automatically.
* - `"default_fill"` — append `fill` (required) to every call site's
* argument list, for the common "added a trailing parameter" case.
* - `"manual"` — rewrite the definition only and report the untouched call
* sites in `warnings` for the agent to fix.
*
* `adapter_shim` from the epic is not yet supported (it needs the old
* signature + a delegating body); use `add_parameter` / `reorder_parameters`
* for the structured cases instead. Languages: rust, python, typescript,
* tsx, javascript, jsx, go.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_change_signature({path: "src/lib.rs", symbol: {name: "add"}, new_params: "a: i64, b: i64, c: i64", callsite_strategy: "strict"})
*/
pub fn edit_change_signature(params) {
let p = params ?? {}
let language = __refactor_detect_language(p)
let spec = __refactor_lang_spec(language)
if spec == nil {
return __refactor_unsupported(
"change_signature",
language,
"no signature grammar for language `" + language + "`",
)
}
let path = p?.path ?? ""
let fn_name = __refactor_symbol_name(p)
let new_params = p?.new_params
if path == "" || fn_name == "" || new_params == nil {
return __refactor_invalid(
"change_signature",
language,
"`path`, `symbol`/`name`, and `new_params` are required",
)
}
let strategy = p?.callsite_strategy ?? "strict"
let probe_session = if p?.dry_run ?? false {
nil
} else {
p?.session_id
}
let def_op = __refactor_def_op(spec, path, fn_name, new_params)
let calls = __refactor_call_sites(spec, path, fn_name, probe_session)
if strategy == "strict" {
if calls.count > 0 {
return __refactor_conflict(
"change_signature",
language,
[
{
code: "has_call_sites",
message: "change_signature(strict) refuses: " + to_string(calls.count)
+ " call site(s) would be left inconsistent; use callsite_strategy `default_fill`/`manual`, or reorder_parameters/add_parameter",
path: path,
call_count: calls.count,
},
],
)
}
return __refactor_run("change_signature", language, [def_op], p, [])
}
if strategy == "manual" {
let warns = if calls.count > 0 {
[
{
code: "call_sites_unchanged",
message: to_string(calls.count) + " call site(s) left for manual update",
},
]
} else {
[]
}
return __refactor_run("change_signature", language, [def_op], p, warns)
}
if strategy == "default_fill" {
let fill = p?.fill
if fill == nil {
return __refactor_invalid(
"change_signature",
language,
"callsite_strategy `default_fill` requires `fill` (text appended to each call's arguments)",
)
}
var ops = [def_op]
var i = 0
while i < calls.count {
let inner = __refactor_paren_inner(calls.originals[i])
let new_args = if trim(inner) == "" {
fill
} else {
inner + ", " + fill
}
ops = ops + [__refactor_call_op(spec, path, fn_name, i + 1, new_args)]
i = i + 1
}
return __refactor_run("change_signature", language, ops, p, [])
}
return __refactor_unsupported(
"change_signature",
language,
"callsite_strategy `" + strategy + "` is not supported (use strict | default_fill | manual)",
)
}
/**
* Insert one `param` into a function's parameter list at `index` (0-based;
* default appends), then reconcile call sites per `callsite_strategy`:
*
* - `"default_fill"` (default) — insert `default` (required) at the same
* index in every call's argument list.
* - `"strict"` — refuse with `result: "conflict"` when call sites exist.
* - `"adapter_shim"` — not yet supported.
*
* `param` is the full declaration text (e.g. `"c: i64"`, `"c"`, `"c int"`).
* Languages: rust, python, typescript, tsx, javascript, jsx, go.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_add_parameter({path: "src/lib.rs", symbol: {name: "add"}, param: "c: i64", default: "0"})
*/
pub fn edit_add_parameter(params) {
let p = params ?? {}
let language = __refactor_detect_language(p)
let spec = __refactor_lang_spec(language)
if spec == nil {
return __refactor_unsupported(
"add_parameter",
language,
"no signature grammar for language `" + language + "`",
)
}
let path = p?.path ?? ""
let fn_name = __refactor_symbol_name(p)
let param = p?.param
if path == "" || fn_name == "" || param == nil || param == "" {
return __refactor_invalid("add_parameter", language, "`path`, `symbol`/`name`, and `param` are required")
}
let probe_session = if p?.dry_run ?? false {
nil
} else {
p?.session_id
}
let def = __refactor_read_def_params(spec, path, fn_name, probe_session)
if !def.ok {
return __refactor_conflict(
"add_parameter",
language,
[{code: def.result, message: "could not locate definition of `" + fn_name + "`", path: path}],
)
}
let index = p?.index ?? len(def.params)
let new_inner = join(__refactor_insert_at(def.params, index, param), ", ")
let def_op = __refactor_def_op(spec, path, fn_name, new_inner)
let calls = __refactor_call_sites(spec, path, fn_name, probe_session)
if calls.count == 0 {
return __refactor_run("add_parameter", language, [def_op], p, [])
}
let strategy = p?.callsite_strategy ?? "default_fill"
if strategy == "strict" {
return __refactor_conflict(
"add_parameter",
language,
[
{
code: "has_call_sites",
message: to_string(calls.count)
+ " call site(s); pass callsite_strategy `default_fill` with a `default` value",
path: path,
call_count: calls.count,
},
],
)
}
if strategy == "adapter_shim" {
return __refactor_unsupported(
"add_parameter",
language,
"adapter_shim is not yet supported; use default_fill (with `default`) or strict",
)
}
let default_val = p?.default
if default_val == nil {
return __refactor_invalid(
"add_parameter",
language,
"callsite_strategy `default_fill` requires `default` (value inserted at each call site)",
)
}
var ops = [def_op]
var i = 0
while i < calls.count {
let arg_list = __refactor_split_top_level(__refactor_paren_inner(calls.originals[i]))
let new_args = join(__refactor_insert_at(arg_list, index, default_val), ", ")
ops = ops + [__refactor_call_op(spec, path, fn_name, i + 1, new_args)]
i = i + 1
}
return __refactor_run("add_parameter", language, ops, p, [])
}
/**
* Permute a function's parameters by `order` (a list of 0-based source
* indices that is a permutation of the current parameters) and apply the
* same permutation to every call site's arguments. A call whose argument
* count differs from the parameter count (variadics, omitted defaults)
* is reported as `result: "conflict"` rather than reordered blindly.
*
* Languages: rust, python, typescript, tsx, javascript, jsx, go.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_reorder_parameters({path: "src/lib.rs", symbol: {name: "add"}, order: [1, 0]})
*/
pub fn edit_reorder_parameters(params) {
let p = params ?? {}
let language = __refactor_detect_language(p)
let spec = __refactor_lang_spec(language)
if spec == nil {
return __refactor_unsupported(
"reorder_parameters",
language,
"no signature grammar for language `" + language + "`",
)
}
let path = p?.path ?? ""
let fn_name = __refactor_symbol_name(p)
let order = p?.order
if path == "" || fn_name == "" || order == nil {
return __refactor_invalid(
"reorder_parameters",
language,
"`path`, `symbol`/`name`, and `order` are required",
)
}
let probe_session = if p?.dry_run ?? false {
nil
} else {
p?.session_id
}
let def = __refactor_read_def_params(spec, path, fn_name, probe_session)
if !def.ok {
return __refactor_conflict(
"reorder_parameters",
language,
[{code: def.result, message: "could not locate definition of `" + fn_name + "`", path: path}],
)
}
let arity = len(def.params)
if len(order) != arity {
return __refactor_invalid(
"reorder_parameters",
language,
"`order` must be a permutation of the " + to_string(arity) + " current parameter(s)",
)
}
for oi in order {
if oi < 0 || oi >= arity {
return __refactor_invalid(
"reorder_parameters",
language,
"`order` index " + to_string(oi) + " is out of range",
)
}
}
let new_inner = join(order.map({ oi -> def.params[oi] }), ", ")
var ops = [__refactor_def_op(spec, path, fn_name, new_inner)]
let calls = __refactor_call_sites(spec, path, fn_name, probe_session)
var i = 0
while i < calls.count {
let arg_list = __refactor_split_top_level(__refactor_paren_inner(calls.originals[i]))
if len(arg_list) != arity {
return __refactor_conflict(
"reorder_parameters",
language,
[
{
code: "arity_mismatch",
message: "call site #" + to_string(i + 1) + " has " + to_string(len(arg_list))
+ " argument(s) but the signature has "
+ to_string(arity)
+ "; cannot reorder safely",
path: path,
},
],
)
}
let new_args = join(order.map({ oi -> arg_list[oi] }), ", ")
ops = ops + [__refactor_call_op(spec, path, fn_name, i + 1, new_args)]
i = i + 1
}
return __refactor_run("reorder_parameters", language, ops, p, [])
}
/**
* Replace a function's declared return type with `new_type`. Languages
* that carry a return-type slot are supported (rust, python, typescript,
* tsx, go); JavaScript/JSX have no annotation and return
* `result: "unsupported"`. A function with no existing return-type
* annotation surfaces as `result: "conflict"` (nothing to replace).
*
* Caller verification is currently syntactic only: the rewritten file is
* re-parsed (tree-sitter) but callers are not type-checked. The count of
* call sites is surfaced in `warnings` so the agent knows what to review.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_change_return_type({path: "src/lib.rs", symbol: {name: "add"}, new_type: "i32"})
*/
pub fn edit_change_return_type(params) {
let p = params ?? {}
let language = __refactor_detect_language(p)
let spec = __refactor_lang_spec(language)
if spec == nil || spec.ret_field == nil {
return __refactor_unsupported(
"change_return_type",
language,
"language `" + language + "` has no return-type annotation to rewrite",
)
}
let path = p?.path ?? ""
let fn_name = __refactor_symbol_name(p)
let new_type = p?.new_type
if path == "" || fn_name == "" || new_type == nil || new_type == "" {
return __refactor_invalid(
"change_return_type",
language,
"`path`, `symbol`/`name`, and `new_type` are required",
)
}
let probe_session = if p?.dry_run ?? false {
nil
} else {
p?.session_id
}
let query = "(" + spec.fn_node + " name: (identifier) @__n " + spec.ret_field + ": " + spec.ret_node
+ " @target (#eq? @__n \""
+ fn_name
+ "\"))"
let op = {
kind: "node_apply",
path: path,
params: {path: path, query: query, replacement: spec.ret_prefix + new_type, select: "unique"},
}
let calls = __refactor_call_sites(spec, path, fn_name, probe_session)
let warns = if calls.count > 0 {
[
{
code: "callers_not_type_checked",
message: to_string(calls.count)
+ " call site(s) were re-parsed but not type-checked against the new return type",
},
]
} else {
[]
}
return __refactor_run("change_return_type", language, [op], p, warns)
}
/** --- extract_function ------------------------------------------------------- */
fn __refactor_fn_synth_kind(language) {
let table = {
javascript: "brace",
jsx: "brace",
python: "python",
ruby: "ruby",
tsx: "brace",
typescript: "brace",
}
return table.get(language, nil)
}
fn __refactor_min_indent(lines) {
var smallest = -1
for line in lines {
if trim(line ?? "") == "" {
continue
}
let body = regex_replace("^[ \\t]+", "", line)
let n = len(line) - len(body)
if smallest == -1 || n < smallest {
smallest = n
}
}
if smallest == -1 {
return 0
}
return smallest
}
fn __refactor_strip_n(line, n) {
let raw = line ?? ""
if len(raw) >= n {
return raw.substring(n)
}
return ""
}
fn __refactor_indent_lines(lines, prefix) {
return lines
.map(
{ l ->
if trim(l) == "" {
""
} else {
prefix + l
}
},
)
}
fn __refactor_undef_names(src, language) {
let r = hostlib_ast_undefined_names({content: src, language: language})
if !(r?.supported ?? false) {
return []
}
var names = []
for d in r?.diagnostics ?? [] {
if d?.kind ?? "identifier" == "identifier" && !__refactor_member(names, d.name) {
names = names + [d.name]
}
}
return names
}
fn __refactor_enclosing_fn(symbols, start_line, end_line) {
var best = nil
for sym in symbols ?? [] {
if sym.kind != "function" || sym.container != nil {
continue
}
if sym.start_row <= start_line && sym.end_row >= end_line {
if best == nil || sym.end_row - sym.start_row < best.end_row - best.start_row {
best = sym
}
}
}
return best
}
fn __refactor_synth_fn(kind, name, params, body_lines) {
let signature = name + "(" + join(params, ", ") + ")"
if kind == "python" {
return ["def " + signature + ":"] + __refactor_indent_lines(body_lines, " ")
}
if kind == "ruby" {
return ["def " + signature] + __refactor_indent_lines(body_lines, " ") + ["end"]
}
return ["function " + signature + " {"] + __refactor_indent_lines(body_lines, " ") + ["}"]
}
fn __refactor_wrap_probe(kind, body_lines) {
if kind == "python" {
return join(["def __harn_probe__():"] + __refactor_indent_lines(body_lines, " "), "\n")
}
if kind == "ruby" {
return join(["def __harn_probe__"] + __refactor_indent_lines(body_lines, " ") + ["end"], "\n")
}
return join(["function __harn_probe__() {"] + __refactor_indent_lines(body_lines, " ") + ["}"], "\n")
}
/**
* Extract a contiguous range of statements (`range`{start_line, end_line},
* 0-based inclusive) out of an enclosing top-level function into a new
* function `new_name`, replacing the range with a call to it.
*
* Captured free variables become parameters: the selection's free names
* (computed via `hostlib_ast_undefined_names` over the block) minus the
* enclosing function's free names (so module-level / imported names stay
* referenced, not parameterized). Pass `params_order` to fix the order.
*
* The new definition is placed at module scope after the enclosing
* function (`target_scope: "after"`, default) or before it
* (`target_scope: "before"`). Only top-level functions are supported as
* the enclosing scope (methods/nested return `result: "unsupported"`),
* which keeps the placement and indentation correct.
*
* v1 produces a `void` function: if the block computes values used after
* the selection, the generated call will not thread them back — adjust the
* call by hand or select a self-contained block. Languages: python,
* javascript, jsx, typescript, tsx, ruby (the set with both capture
* analysis and a parameter form that needs no inferred types).
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_extract_function({path: "app.py", range: {start_line: 3, end_line: 5}, new_name: "summarize"})
*/
pub fn edit_extract_function(params) {
let p = params ?? {}
let language = __refactor_detect_language(p)
let kind = __refactor_fn_synth_kind(language)
if kind == nil {
return __refactor_unsupported(
"extract_function",
language,
"extract_function supports python/javascript/jsx/typescript/tsx/ruby",
)
}
let path = p?.path ?? ""
let new_name = p?.new_name ?? ""
let range = p?.range ?? {}
let start_line = range?.start_line
let end_line = range?.end_line ?? start_line
if path == "" || new_name == "" || start_line == nil || end_line == nil {
return __refactor_invalid(
"extract_function",
language,
"`path`, `new_name`, and `range`{start_line, end_line} are required",
)
}
let probe_session = if p?.dry_run ?? false {
nil
} else {
p?.session_id
}
let read = hostlib_fs_read_text({path: path, session_id: probe_session})
let content = read?.content ?? ""
let lines = split(content, "\n")
if start_line < 0 || end_line >= len(lines) || end_line < start_line {
return __refactor_invalid("extract_function", language, "`range` is outside the file")
}
let block_lines = lines[start_line:end_line + 1]
let dedent = __refactor_min_indent(block_lines)
let dedented = block_lines.map({ l -> __refactor_strip_n(l, dedent) })
let symbols = hostlib_ast_symbols({path: path, language: language})?.symbols ?? []
let enclosing = __refactor_enclosing_fn(symbols, start_line, end_line)
if enclosing == nil {
return __refactor_result(
"extract_function",
language,
{
result: "unsupported",
details: "selected range is not inside a top-level function",
errors: [{code: "no_enclosing_function", message: "range is not inside a top-level function"}],
},
)
}
let block_undef = __refactor_undef_names(__refactor_wrap_probe(kind, dedented), language)
let enclosing_lines = lines[enclosing.start_row:enclosing.end_row + 1]
let enclosing_dedent = __refactor_min_indent(enclosing_lines)
let enclosing_src = join(enclosing_lines.map({ l -> __refactor_strip_n(l, enclosing_dedent) }), "\n")
let enclosing_undef = __refactor_undef_names(enclosing_src, language)
var captures = []
for nm in block_undef {
if !__refactor_member(enclosing_undef, nm) && nm != new_name && !__refactor_member(captures, nm) {
captures = captures + [nm]
}
}
let final_params = p?.params_order ?? captures
let def_lines = __refactor_synth_fn(kind, new_name, final_params, dedented)
let orig_indent = __refactor_leading_ws(lines[start_line])
let term = if kind == "brace" {
";"
} else {
""
}
let call_line = orig_indent + new_name + "(" + join(final_params, ", ") + ")" + term
let head = if start_line > 0 {
lines[:start_line]
} else {
[]
}
let tail = if end_line + 1 < len(lines) {
lines[end_line + 1:]
} else {
[]
}
let replaced = head + [call_line] + tail
let target_scope = p?.target_scope ?? "after"
let new_lines = if target_scope == "before" {
let pre = if enclosing.start_row > 0 {
replaced[:enclosing.start_row]
} else {
[]
}
pre + def_lines + [""] + replaced[enclosing.start_row:]
} else {
let insert_idx = enclosing.end_row + (1 - (end_line - start_line + 1)) + 1
let suffix = if insert_idx < len(replaced) {
replaced[insert_idx:]
} else {
[]
}
replaced[:insert_idx] + [""] + def_lines + suffix
}
let after = join(new_lines, "\n")
return __refactor_run("extract_function", language, [{kind: "content", path: path, after: after}], p, [])
}
/** --- inline + move_decl ----------------------------------------------------- */
fn __refactor_inline_kind(language) {
if language == "python" {
return "python"
}
return "brace"
}
fn __refactor_inline_expr(body_text, kind) {
let lines = split(body_text ?? "", "\n")
// `hostlib_ast_function_body` returns the indented body only for Python,
// but the whole `fn …{ … }` text for brace languages — so brace bodies
// drop the signature/opening-brace line and the closing-brace line.
let inner = if kind == "python" {
lines
} else if len(lines) >= 3 {
lines[1:len(lines) - 1]
} else {
[]
}
var body = []
for l in inner {
if trim(l) != "" {
body = body + [trim(l)]
}
}
if len(body) != 1 {
return nil
}
let stmt = body[0]
if !stmt.starts_with("return ") {
return nil
}
var expr = trim(stmt.substring(7))
if expr.ends_with(";") {
expr = trim(expr.substring(0, len(expr) - 1))
}
if expr == "" {
return nil
}
return expr
}
/**
* Inline a zero-parameter function whose body is a single
* `return <expr>` statement: replace every `name()` call with
* `(<expr>)` and delete the definition. Functions with parameters, or
* with anything other than one return statement, return
* `result: "unsupported"` (the safe, precedence-correct subset — inlining
* parameterized bodies needs argument substitution that is left to a
* future iteration).
*
* Languages: rust, python, typescript, tsx, javascript, jsx, go.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_inline({path: "src/lib.rs", symbol: {name: "answer"}})
*/
pub fn edit_inline(params) {
let p = params ?? {}
let language = __refactor_detect_language(p)
let spec = __refactor_lang_spec(language)
if spec == nil {
return __refactor_unsupported("inline", language, "no signature grammar for language `" + language + "`")
}
let path = p?.path ?? ""
let fn_name = __refactor_symbol_name(p)
if path == "" || fn_name == "" {
return __refactor_invalid("inline", language, "`path` and `symbol`/`name` are required")
}
let probe_session = if p?.dry_run ?? false {
nil
} else {
p?.session_id
}
let content = hostlib_fs_read_text({path: path, session_id: probe_session})?.content ?? ""
let fb = hostlib_ast_function_body({source: content, language: language, function_name: fn_name})
if !(fb?.found ?? false) {
return __refactor_conflict(
"inline",
language,
[{code: "symbol_not_found", message: "no function `" + fn_name + "` found", path: path}],
)
}
let def = __refactor_read_def_params(spec, path, fn_name, probe_session)
if def.ok && len(def.params) != 0 {
return __refactor_unsupported(
"inline",
language,
"inline supports zero-parameter functions only (argument substitution is not yet implemented)",
)
}
let expr = __refactor_inline_expr(fb.body_text, __refactor_inline_kind(language))
if expr == nil {
return __refactor_unsupported(
"inline",
language,
"inline requires a body of exactly one `return <expr>` statement",
)
}
let calls = __refactor_call_sites(spec, path, fn_name, probe_session)
var i = 0
while i < calls.count {
if trim(__refactor_paren_inner(calls.originals[i])) != "" {
return __refactor_conflict(
"inline",
language,
[
{
code: "call_has_arguments",
message: "call site #" + to_string(i + 1) + " passes arguments to a zero-parameter function",
path: path,
},
],
)
}
i = i + 1
}
let call_target = "((" + spec.call_node + " function: (identifier) @__f (#eq? @__f \"" + fn_name + "\")) @target)"
let def_target = "((" + spec.fn_node + " name: (identifier) @__n (#eq? @__n \"" + fn_name + "\")) @target)"
var ops = []
if calls.count > 0 {
ops = ops
+ [
{
kind: "node_apply",
path: path,
params: {path: path, query: call_target, replacement: "(" + expr + ")", select: "all"},
},
]
}
ops = ops
+ [
{
kind: "node_apply",
path: path,
params: {path: path, query: def_target, replacement: "", select: "unique"},
},
]
return __refactor_run("inline", language, ops, p, [])
}
/**
* Move a top-level declaration `symbol` out of its source `path` and into
* `target_file`, deleting it from the source. `target_position` is `"end"`
* (default, appended) or `"start"` (prepended). Both files are written in
* one atomic staged-fs transaction.
*
* The declaration text is lifted with `hostlib_ast_symbol_extract` and
* removed with `hostlib_ast_symbol_delete`, so language support follows
* those builtins (`result: "unsupported"` otherwise). Cross-file import /
* export references are NOT rewritten yet — that is reported in
* `warnings`; combine with `edit_rename_symbol` for re-export safety.
*
* @effects: [host, fs]
* @allocation: heap
* @errors: [backend]
* @api_stability: experimental
* @example: edit_move_decl({path: "src/a.rs", symbol: {name: "helper"}, target_file: "src/b.rs"})
*/
pub fn edit_move_decl(params) {
let p = params ?? {}
let language = __refactor_detect_language(p)
let path = p?.path ?? ""
let target_path = p?.target_file
let fn_name = __refactor_symbol_name(p)
if path == "" || target_path == nil || target_path == "" || fn_name == "" {
return __refactor_invalid("move_decl", language, "`path`, `symbol`/`name`, and `target_file` are required")
}
let probe_session = if p?.dry_run ?? false {
nil
} else {
p?.session_id
}
let src_content = hostlib_fs_read_text({path: path, session_id: probe_session})?.content ?? ""
let extracted = hostlib_ast_symbol_extract({symbol_name: fn_name, source: src_content, language: language})
if extracted.result == "unsupported_language" {
return __refactor_unsupported(
"move_decl",
language,
"symbol extraction is unsupported for language `" + language + "`",
)
}
if extracted.result != "extracted" {
return __refactor_conflict(
"move_decl",
language,
[{code: extracted.result, message: "cannot extract `" + fn_name + "` from " + path, path: path}],
)
}
let deleted = hostlib_ast_symbol_delete({symbol_name: fn_name, source: src_content, language: language})
if deleted.result != "removed" {
return __refactor_conflict(
"move_decl",
language,
[{code: deleted.result, message: "cannot remove `" + fn_name + "` from " + path, path: path}],
)
}
let moved = extracted.text
let target_content = hostlib_fs_read_text({path: target_path, session_id: probe_session})?.content ?? ""
let position = p?.target_position ?? "end"
let new_target = if position == "start" {
moved + "\n\n" + target_content
} else if trim(target_content) == "" {
moved + "\n"
} else if target_content.ends_with("\n") {
target_content + "\n" + moved + "\n"
} else {
target_content + "\n\n" + moved + "\n"
}
let warns = [
{
code: "imports_not_rewritten",
message: "moved `" + fn_name
+ "`; cross-file import/export references may need manual adjustment (pair with edit_rename_symbol)",
},
]
return __refactor_run(
"move_decl",
language,
[
{kind: "content", path: path, after: deleted.source},
{kind: "content", path: target_path, after: new_target},
],
p,
warns,
)
}