Skip to content

Commit 64598d4

Browse files
quangdang46claude
andcommitted
feat: resolve all 12 open GitHub issues + v0.7.6 release
Fixes: - #54: HASHLINE_URL CLI silent → fallback to result.content[].text - #55: find-block .verse → indent + brace + fallback resolver - #56: SWAP N:HASH: silently fails → consume_optional_colon_with_hash - #57: atomic_write slow → --fast flag skips fsync - #58: patch exits 0 on invalid format → EmptyPatch error exit 1 - #59: log unbounded → rotation via HASHLINE_LOG_MAX_BYTES - #60: MCP tool names hashline_* → read/patch/find_block (legacy aliases kept) - #61: --dry-run shows full file → unified diff output - #62: --json returns nothing → structured {success, file, lines} - #63: patch inline-only → stdin (-) and @path support - #64: daemon artifacts → SIGINT/SIGTERM cleanup socket+pid - #65: str_replace missing → new hashline replace command Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 743a621 commit 64598d4

18 files changed

Lines changed: 786 additions & 49 deletions

.beads/issues.jsonl

Lines changed: 12 additions & 0 deletions
Large diffs are not rendered by default.

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -241,9 +241,11 @@ everything you need in one place.
241241
`hashline` ships with a stdio MCP server exposing 3 tools:
242242

243243
```
244-
hashline_read — Read a file with [path#HASH] header + numbered lines
245-
hashline_patch — Apply a patch (SWAP/DEL/INS.*/BLK.*)
246-
hashline_find_block — Find enclosing syntactic block around an anchor
244+
read — Read a file with [path#HASH] header + numbered lines
245+
patch — Apply a patch (SWAP/DEL/INS.*/BLK.*)
246+
find_block — Find enclosing syntactic block around an anchor
247+
248+
(legacy aliases `hashline_read`, `hashline_patch`, `hashline_find_block` remain accepted)
247249
```
248250

249251
```bash

crates/core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "hashline"
3-
version = "0.7.5"
3+
version = "0.7.6"
44
edition.workspace = true
55
rust-version.workspace = true
66
license.workspace = true

crates/core/src/cli.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub enum Commands {
2323
Guide(GuideCmd),
2424
Serve(ServeCmd),
2525
Mcp(McpCmd),
26+
Replace(ReplaceCmd),
2627
}
2728

2829
#[derive(Clone, Debug, Deserialize, Serialize, Parser)]
@@ -44,6 +45,11 @@ pub struct PatchCmd {
4445
pub dry_run: bool,
4546
#[arg(long)]
4647
pub json: bool,
48+
#[arg(
49+
long,
50+
help = "Skip fsync for faster writes (skip --safe/atomic behavior)"
51+
)]
52+
pub fast: bool,
4753
}
4854

4955
#[derive(Clone, Debug, Deserialize, Serialize, Parser)]
@@ -57,6 +63,18 @@ pub struct FindBlockCmd {
5763
pub pretty: bool,
5864
}
5965

66+
#[derive(Clone, Debug, Deserialize, Serialize, Parser)]
67+
#[command(about = "Replace old_string with new_string in file (str_replace-style)")]
68+
pub struct ReplaceCmd {
69+
pub file: PathBuf,
70+
pub old_string: String,
71+
pub new_string: String,
72+
#[arg(long)]
73+
pub json: bool,
74+
#[arg(long)]
75+
pub safe: bool,
76+
}
77+
6078
#[derive(Clone, Debug, Deserialize, Serialize, Parser)]
6179
#[command(about = "Show interactive user guide")]
6280
pub struct GuideCmd {

crates/core/src/commands/common.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,22 @@ pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), HashlineError> {
4242
atomic_write_with(path, |file| file.write_all(bytes))
4343
}
4444

45+
/// Fast variant of atomic_write that skips fsync and uses a simple
46+
/// read-modify-write instead of temp-file + rename. Suitable for agent
47+
/// use cases where crash safety is not critical (the agent can always
48+
/// re-read and re-edit on failure).
49+
pub fn fast_write(path: &Path, bytes: &[u8]) -> Result<(), HashlineError> {
50+
use std::io::Write;
51+
let mut file = std::fs::OpenOptions::new()
52+
.write(true)
53+
.truncate(true)
54+
.create(true)
55+
.open(path)?;
56+
file.write_all(bytes)?;
57+
// No sync_all / sync_parent_directory call.
58+
Ok(())
59+
}
60+
4561
pub fn atomic_write_with<F>(path: &Path, write_contents: F) -> Result<(), HashlineError>
4662
where
4763
F: FnOnce(&mut fs::File) -> io::Result<()>,

crates/core/src/commands/find_block.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ pub fn find_block_boundaries(
9797

9898
let (language, block_start, block_end) = match extension {
9999
"py" => find_python_block(entries, anchor_index)?,
100-
"verse" => find_python_block(entries, anchor_index)?,
100+
"verse" => find_verse_block(entries, anchor_index)?,
101101
"rb" => find_ruby_block(entries, anchor_index)?,
102102
"rs" | "js" | "ts" | "tsx" | "jsx" | "go" | "java" | "c" | "cpp" | "h" | "hpp" | "cs"
103103
| "kt" | "kts" | "swift" | "scala" | "dart" | "zig" | "m" | "mm" => {
@@ -300,6 +300,77 @@ fn find_indent_block(
300300
Ok((start, end))
301301
}
302302

303+
fn find_verse_block(
304+
entries: &[crate::document::LineEntry],
305+
anchor_index: usize,
306+
) -> Result<(Option<String>, usize, usize), HashlineError> {
307+
// Verse combines indented class/method bodies (`base_x := class():` /
308+
// `OnBegin():void=`) with brace blocks (`using { /Verse... }`,
309+
// `if (X) { ... }`). Try indent first (most blocks are class bodies),
310+
// then brace-balanced for anchor lines that live inside `{ }`.
311+
if let Ok((s, e)) = find_verse_indent_block(entries, anchor_index) {
312+
return Ok((Some("Verse".into()), s, e));
313+
}
314+
if let Ok((s, e)) = find_brace_block(entries, anchor_index, "verse") {
315+
return Ok((Some("Verse".into()), s, e));
316+
}
317+
// Last resort: return the whole file as a single block (top-level anchor).
318+
Ok((Some("Verse".into()), 0, entries.len().saturating_sub(1)))
319+
}
320+
321+
fn find_verse_indent_block(
322+
entries: &[crate::document::LineEntry],
323+
anchor_index: usize,
324+
) -> Result<(usize, usize), HashlineError> {
325+
let anchor_indent = leading_whitespace(&entries[anchor_index].content);
326+
327+
// Walk backwards for the first non-empty line with strictly less
328+
// indentation. If we never find one (anchor is at top of file), look
329+
// forward instead: the first non-empty line with indent <= anchor_indent
330+
// marks the block end, and the block extends from anchor_index..end.
331+
let mut start: Option<usize> = None;
332+
for i in (0..anchor_index).rev() {
333+
if entries[i].content.trim().is_empty() {
334+
continue;
335+
}
336+
let indent = leading_whitespace(&entries[i].content);
337+
if indent < anchor_indent {
338+
start = Some(i);
339+
break;
340+
}
341+
}
342+
let (start, start_indent) = if let Some(s) = start {
343+
(s, leading_whitespace(&entries[s].content))
344+
} else {
345+
// Top-of-file anchor: block extends back to line 0.
346+
(0, leading_whitespace(&entries[0].content))
347+
};
348+
349+
let mut end = entries.len().saturating_sub(1);
350+
let mut found_end = false;
351+
for i in (start + 1)..entries.len() {
352+
let entry = &entries[i];
353+
let trimmed = entry.content.trim();
354+
if trimmed.is_empty() {
355+
continue;
356+
}
357+
// Verse treats lines beginning with `#` as a comment-like marker,
358+
// matching the Python rule. There is no native block-comment in
359+
// Verse, so we only need to skip `#` rows.
360+
if trimmed.starts_with('#') {
361+
continue;
362+
}
363+
let indent = leading_whitespace(&entry.content);
364+
if indent <= start_indent {
365+
end = i.saturating_sub(1);
366+
found_end = true;
367+
break;
368+
}
369+
}
370+
let _ = found_end;
371+
Ok((start, end))
372+
}
373+
303374
fn find_python_block(
304375
entries: &[crate::document::LineEntry],
305376
anchor_index: usize,
@@ -493,6 +564,32 @@ mod tests {
493564
assert_eq!(payload.block_lines[2].content, "}");
494565
}
495566

567+
#[test]
568+
fn test_verse_top_level_using() {
569+
let content = "using { /Verse.org/Simulation }\nusing { /Fortnite.com/Devices }\n\nbase_x := class():\n x:int = 0\n";
570+
let (fc, entries) = make_fc(content, "test.verse");
571+
// Anchor on the top-level `using` should not fail.
572+
let payload = find_block_payload(&fc, &entries, &anchor_for(1, &entries)).unwrap();
573+
assert_eq!(payload.language.as_deref(), Some("Verse"));
574+
assert!(!payload.block_lines.is_empty());
575+
}
576+
577+
#[test]
578+
fn test_verse_class_body() {
579+
let content = "using { /Verse.org/Simulation }\n\nsignal_device := class(creative_device):\n var Count:int = 0\n OnBegin<override>()<suspends>:void=\n Count += 1\n Print(\"ok\")\n";
580+
let (fc, entries) = make_fc(content, "test.verse");
581+
// Anchor inside the class body (the var Count line) should resolve
582+
// to the enclosing class block.
583+
let payload = find_block_payload(&fc, &entries, &anchor_for(4, &entries)).unwrap();
584+
assert_eq!(payload.language.as_deref(), Some("Verse"));
585+
assert!(
586+
payload
587+
.block_lines
588+
.iter()
589+
.any(|l| l.content.starts_with("signal_device"))
590+
);
591+
}
592+
496593
#[test]
497594
fn test_go_block() {
498595
let content = "func main() {\n\tif true {\n\t\tfmt.Println(\"ok\")\n\t}\n}\n";

crates/core/src/commands/guide.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,9 @@ pub fn run<W: Write, E: Write>(
115115
hashline mcp
116116
117117
Runs a stdio JSON-RPC server exposing 3 tools:
118-
hashline_read — read file with snapshot hash
119-
hashline_patch — apply patch (SWAP/DEL/INS.*/BLK.*)
120-
hashline_find_block — find enclosing syntactic block
118+
read — read file with snapshot hash
119+
patch — apply patch (SWAP/DEL/INS.*/BLK.*)
120+
find_block — find enclosing syntactic block
121121
122122
Configure in claude_desktop_config.json / .cursor/mcp.json:
123123
{

crates/core/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ pub mod find_block;
33
pub mod guide;
44
pub mod patch;
55
pub mod read;
6+
pub mod replace;
67
pub mod serve;

0 commit comments

Comments
 (0)