feat: indentation mode for read_file (#4887)

Add a read file that select the region of the file based on the
indentation level
This commit is contained in:
jif-oai
2025-10-09 16:55:02 +01:00
committed by GitHub
parent 4300236681
commit 0026b12615
2 changed files with 899 additions and 92 deletions

View File

@@ -1,12 +1,9 @@
use std::path::Path; use std::collections::VecDeque;
use std::path::PathBuf; use std::path::PathBuf;
use async_trait::async_trait; use async_trait::async_trait;
use codex_utils_string::take_bytes_at_char_boundary; use codex_utils_string::take_bytes_at_char_boundary;
use serde::Deserialize; use serde::Deserialize;
use tokio::fs::File;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
use crate::function_tool::FunctionCallError; use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation; use crate::tools::context::ToolInvocation;
@@ -18,22 +15,78 @@ use crate::tools::registry::ToolKind;
pub struct ReadFileHandler; pub struct ReadFileHandler;
const MAX_LINE_LENGTH: usize = 500; const MAX_LINE_LENGTH: usize = 500;
const TAB_WIDTH: usize = 4;
fn default_offset() -> usize { // TODO(jif) add support for block comments
1 const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"];
}
fn default_limit() -> usize { /// JSON arguments accepted by the `read_file` tool handler.
2000 #[derive(Deserialize)]
struct ReadFileArgs {
/// Absolute path to the file that will be read.
file_path: String,
/// 1-indexed line number to start reading from; defaults to 1.
#[serde(default = "defaults::offset")]
offset: usize,
/// Maximum number of lines to return; defaults to 2000.
#[serde(default = "defaults::limit")]
limit: usize,
/// Determines whether the handler reads a simple slice or indentation-aware block.
#[serde(default)]
mode: ReadMode,
/// Optional indentation configuration used when `mode` is `Indentation`.
#[serde(default)]
indentation: Option<IndentationArgs>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct ReadFileArgs { #[serde(rename_all = "snake_case")]
file_path: String, enum ReadMode {
#[serde(default = "default_offset")] Slice,
offset: usize, Indentation,
#[serde(default = "default_limit")] }
limit: usize, /// Additional configuration for indentation-aware reads.
#[derive(Deserialize, Clone)]
struct IndentationArgs {
/// Optional explicit anchor line; defaults to `offset` when omitted.
#[serde(default)]
anchor_line: Option<usize>,
/// Maximum indentation depth to collect; `0` means unlimited.
#[serde(default = "defaults::max_levels")]
max_levels: usize,
/// Whether to include sibling blocks at the same indentation level.
#[serde(default = "defaults::include_siblings")]
include_siblings: bool,
/// Whether to include header lines above the anchor block. This made on a best effort basis.
#[serde(default = "defaults::include_header")]
include_header: bool,
/// Optional hard cap on returned lines; defaults to the global `limit`.
#[serde(default)]
max_lines: Option<usize>,
}
#[derive(Clone, Debug)]
struct LineRecord {
number: usize,
raw: String,
display: String,
indent: usize,
}
impl LineRecord {
fn trimmed(&self) -> &str {
self.raw.trim_start()
}
fn is_blank(&self) -> bool {
self.trimmed().is_empty()
}
fn is_comment(&self) -> bool {
COMMENT_PREFIXES
.iter()
.any(|prefix| self.raw.trim().starts_with(prefix))
}
} }
#[async_trait] #[async_trait]
@@ -64,6 +117,8 @@ impl ToolHandler for ReadFileHandler {
file_path, file_path,
offset, offset,
limit, limit,
mode,
indentation,
} = args; } = args;
if offset == 0 { if offset == 0 {
@@ -85,7 +140,13 @@ impl ToolHandler for ReadFileHandler {
)); ));
} }
let collected = read_file_slice(&path, offset, limit).await?; let collected = match mode {
ReadMode::Slice => slice::read(&path, offset, limit).await?,
ReadMode::Indentation => {
let indentation = indentation.unwrap_or_default();
indentation::read_block(&path, offset, limit, indentation).await?
}
};
Ok(ToolOutput::Function { Ok(ToolOutput::Function {
content: collected.join("\n"), content: collected.join("\n"),
success: Some(true), success: Some(true),
@@ -93,14 +154,22 @@ impl ToolHandler for ReadFileHandler {
} }
} }
async fn read_file_slice( mod slice {
use crate::function_tool::FunctionCallError;
use crate::tools::handlers::read_file::format_line;
use std::path::Path;
use tokio::fs::File;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
pub async fn read(
path: &Path, path: &Path,
offset: usize, offset: usize,
limit: usize, limit: usize,
) -> Result<Vec<String>, FunctionCallError> { ) -> Result<Vec<String>, FunctionCallError> {
let file = File::open(path) let file = File::open(path).await.map_err(|err| {
.await FunctionCallError::RespondToModel(format!("failed to read file: {err}"))
.map_err(|err| FunctionCallError::RespondToModel(format!("failed to read file: {err}")))?; })?;
let mut reader = BufReader::new(file); let mut reader = BufReader::new(file);
let mut collected = Vec::new(); let mut collected = Vec::new();
@@ -150,6 +219,216 @@ async fn read_file_slice(
Ok(collected) Ok(collected)
} }
}
mod indentation {
use crate::function_tool::FunctionCallError;
use crate::tools::handlers::read_file::IndentationArgs;
use crate::tools::handlers::read_file::LineRecord;
use crate::tools::handlers::read_file::TAB_WIDTH;
use crate::tools::handlers::read_file::format_line;
use crate::tools::handlers::read_file::trim_empty_lines;
use std::collections::VecDeque;
use std::path::Path;
use tokio::fs::File;
use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader;
pub async fn read_block(
path: &Path,
offset: usize,
limit: usize,
options: IndentationArgs,
) -> Result<Vec<String>, FunctionCallError> {
let anchor_line = options.anchor_line.unwrap_or(offset);
if anchor_line == 0 {
return Err(FunctionCallError::RespondToModel(
"anchor_line must be a 1-indexed line number".to_string(),
));
}
let guard_limit = options.max_lines.unwrap_or(limit);
if guard_limit == 0 {
return Err(FunctionCallError::RespondToModel(
"max_lines must be greater than zero".to_string(),
));
}
let collected = collect_file_lines(path).await?;
if collected.is_empty() || anchor_line > collected.len() {
return Err(FunctionCallError::RespondToModel(
"anchor_line exceeds file length".to_string(),
));
}
let anchor_index = anchor_line - 1;
let effective_indents = compute_effective_indents(&collected);
let anchor_indent = effective_indents[anchor_index];
// Compute the min indent
let min_indent = if options.max_levels == 0 {
0
} else {
anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH)
};
// Cap requested lines by guard_limit and file length
let final_limit = limit.min(guard_limit).min(collected.len());
if final_limit == 1 {
return Ok(vec![format!(
"L{}: {}",
collected[anchor_index].number, collected[anchor_index].display
)]);
}
// Cursors
let mut i: isize = anchor_index as isize - 1; // up (inclusive)
let mut j: usize = anchor_index + 1; // down (inclusive)
let mut i_counter_min_indent = 0;
let mut j_counter_min_indent = 0;
let mut out = VecDeque::with_capacity(limit);
out.push_back(&collected[anchor_index]);
while out.len() < final_limit {
let mut progressed = 0;
// Up.
if i >= 0 {
let iu = i as usize;
if effective_indents[iu] >= min_indent {
out.push_front(&collected[iu]);
progressed += 1;
i -= 1;
// We do not include the siblings (not applied to comments).
if effective_indents[iu] == min_indent && !options.include_siblings {
let allow_header_comment =
options.include_header && collected[iu].is_comment();
let can_take_line = allow_header_comment || i_counter_min_indent == 0;
if can_take_line {
i_counter_min_indent += 1;
} else {
// This line shouldn't have been taken.
out.pop_front();
progressed -= 1;
i = -1; // consider using Option<usize> or a control flag instead of a sentinel
}
}
// Short-cut.
if out.len() >= final_limit {
break;
}
} else {
// Stop moving up.
i = -1;
}
}
// Down.
if j < collected.len() {
let ju = j;
if effective_indents[ju] >= min_indent {
out.push_back(&collected[ju]);
progressed += 1;
j += 1;
// We do not include the siblings (applied to comments).
if effective_indents[ju] == min_indent && !options.include_siblings {
if j_counter_min_indent > 0 {
// This line shouldn't have been taken.
out.pop_back();
progressed -= 1;
j = collected.len();
}
j_counter_min_indent += 1;
}
} else {
// Stop moving down.
j = collected.len();
}
}
if progressed == 0 {
break;
}
}
// Trim empty lines
trim_empty_lines(&mut out);
Ok(out
.into_iter()
.map(|record| format!("L{}: {}", record.number, record.display))
.collect())
}
async fn collect_file_lines(path: &Path) -> Result<Vec<LineRecord>, FunctionCallError> {
let file = File::open(path).await.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to read file: {err}"))
})?;
let mut reader = BufReader::new(file);
let mut buffer = Vec::new();
let mut lines = Vec::new();
let mut number = 0usize;
loop {
buffer.clear();
let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| {
FunctionCallError::RespondToModel(format!("failed to read file: {err}"))
})?;
if bytes_read == 0 {
break;
}
if buffer.last() == Some(&b'\n') {
buffer.pop();
if buffer.last() == Some(&b'\r') {
buffer.pop();
}
}
number += 1;
let raw = String::from_utf8_lossy(&buffer).into_owned();
let indent = measure_indent(&raw);
let display = format_line(&buffer);
lines.push(LineRecord {
number,
raw,
display,
indent,
});
}
Ok(lines)
}
fn compute_effective_indents(records: &[LineRecord]) -> Vec<usize> {
let mut effective = Vec::with_capacity(records.len());
let mut previous_indent = 0usize;
for record in records {
if record.is_blank() {
effective.push(previous_indent);
} else {
previous_indent = record.indent;
effective.push(previous_indent);
}
}
effective
}
fn measure_indent(line: &str) -> usize {
line.chars()
.take_while(|c| matches!(c, ' ' | '\t'))
.map(|c| if c == '\t' { TAB_WIDTH } else { 1 })
.sum()
}
}
fn format_line(bytes: &[u8]) -> String { fn format_line(bytes: &[u8]) -> String {
let decoded = String::from_utf8_lossy(bytes); let decoded = String::from_utf8_lossy(bytes);
@@ -160,93 +439,560 @@ fn format_line(bytes: &[u8]) -> String {
} }
} }
fn trim_empty_lines(out: &mut VecDeque<&LineRecord>) {
while matches!(out.front(), Some(line) if line.raw.trim().is_empty()) {
out.pop_front();
}
while matches!(out.back(), Some(line) if line.raw.trim().is_empty()) {
out.pop_back();
}
}
mod defaults {
use super::*;
impl Default for IndentationArgs {
fn default() -> Self {
Self {
anchor_line: None,
max_levels: max_levels(),
include_siblings: include_siblings(),
include_header: include_header(),
max_lines: None,
}
}
}
impl Default for ReadMode {
fn default() -> Self {
Self::Slice
}
}
pub fn offset() -> usize {
1
}
pub fn limit() -> usize {
2000
}
pub fn max_levels() -> usize {
0
}
pub fn include_siblings() -> bool {
false
}
pub fn include_header() -> bool {
true
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::indentation::read_block;
use super::slice::read;
use super::*; use super::*;
use pretty_assertions::assert_eq;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
#[tokio::test] #[tokio::test]
async fn reads_requested_range() { async fn reads_requested_range() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new().expect("create temp file"); let mut temp = NamedTempFile::new()?;
use std::io::Write as _; use std::io::Write as _;
writeln!(temp, "alpha").unwrap(); write!(
writeln!(temp, "beta").unwrap(); temp,
writeln!(temp, "gamma").unwrap(); "alpha
beta
gamma
"
)?;
let lines = read_file_slice(temp.path(), 2, 2) let lines = read(temp.path(), 2, 2).await?;
.await
.expect("read slice");
assert_eq!(lines, vec!["L2: beta".to_string(), "L3: gamma".to_string()]); assert_eq!(lines, vec!["L2: beta".to_string(), "L3: gamma".to_string()]);
Ok(())
} }
#[tokio::test] #[tokio::test]
async fn errors_when_offset_exceeds_length() { async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new().expect("create temp file"); let mut temp = NamedTempFile::new()?;
use std::io::Write as _; use std::io::Write as _;
writeln!(temp, "only").unwrap(); writeln!(temp, "only")?;
let err = read_file_slice(temp.path(), 3, 1) let err = read(temp.path(), 3, 1)
.await .await
.expect_err("offset exceeds length"); .expect_err("offset exceeds length");
assert_eq!( assert_eq!(
err, err,
FunctionCallError::RespondToModel("offset exceeds file length".to_string()) FunctionCallError::RespondToModel("offset exceeds file length".to_string())
); );
Ok(())
} }
#[tokio::test] #[tokio::test]
async fn reads_non_utf8_lines() { async fn reads_non_utf8_lines() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new().expect("create temp file"); let mut temp = NamedTempFile::new()?;
use std::io::Write as _; use std::io::Write as _;
temp.as_file_mut().write_all(b"\xff\xfe\nplain\n").unwrap(); temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?;
let lines = read_file_slice(temp.path(), 1, 2) let lines = read(temp.path(), 1, 2).await?;
.await
.expect("read slice");
let expected_first = format!("L1: {}{}", '\u{FFFD}', '\u{FFFD}'); let expected_first = format!("L1: {}{}", '\u{FFFD}', '\u{FFFD}');
assert_eq!(lines, vec![expected_first, "L2: plain".to_string()]); assert_eq!(lines, vec![expected_first, "L2: plain".to_string()]);
Ok(())
} }
#[tokio::test] #[tokio::test]
async fn trims_crlf_endings() { async fn trims_crlf_endings() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new().expect("create temp file"); let mut temp = NamedTempFile::new()?;
use std::io::Write as _; use std::io::Write as _;
write!(temp, "one\r\ntwo\r\n").unwrap(); write!(temp, "one\r\ntwo\r\n")?;
let lines = read_file_slice(temp.path(), 1, 2) let lines = read(temp.path(), 1, 2).await?;
.await
.expect("read slice");
assert_eq!(lines, vec!["L1: one".to_string(), "L2: two".to_string()]); assert_eq!(lines, vec!["L1: one".to_string(), "L2: two".to_string()]);
Ok(())
} }
#[tokio::test] #[tokio::test]
async fn respects_limit_even_with_more_lines() { async fn respects_limit_even_with_more_lines() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new().expect("create temp file"); let mut temp = NamedTempFile::new()?;
use std::io::Write as _; use std::io::Write as _;
writeln!(temp, "first").unwrap(); write!(
writeln!(temp, "second").unwrap(); temp,
writeln!(temp, "third").unwrap(); "first
second
third
"
)?;
let lines = read_file_slice(temp.path(), 1, 2) let lines = read(temp.path(), 1, 2).await?;
.await
.expect("read slice");
assert_eq!( assert_eq!(
lines, lines,
vec!["L1: first".to_string(), "L2: second".to_string()] vec!["L1: first".to_string(), "L2: second".to_string()]
); );
Ok(())
} }
#[tokio::test] #[tokio::test]
async fn truncates_lines_longer_than_max_length() { async fn truncates_lines_longer_than_max_length() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new().expect("create temp file"); let mut temp = NamedTempFile::new()?;
use std::io::Write as _; use std::io::Write as _;
let long_line = "x".repeat(MAX_LINE_LENGTH + 50); let long_line = "x".repeat(MAX_LINE_LENGTH + 50);
writeln!(temp, "{long_line}").unwrap(); writeln!(temp, "{long_line}")?;
let lines = read_file_slice(temp.path(), 1, 1) let lines = read(temp.path(), 1, 1).await?;
.await
.expect("read slice");
let expected = "x".repeat(MAX_LINE_LENGTH); let expected = "x".repeat(MAX_LINE_LENGTH);
assert_eq!(lines, vec![format!("L1: {expected}")]); assert_eq!(lines, vec![format!("L1: {expected}")]);
Ok(())
}
#[tokio::test]
async fn indentation_mode_captures_block() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new()?;
use std::io::Write as _;
write!(
temp,
"fn outer() {{
if cond {{
inner();
}}
tail();
}}
"
)?;
let options = IndentationArgs {
anchor_line: Some(3),
include_siblings: false,
max_levels: 1,
..Default::default()
};
let lines = read_block(temp.path(), 3, 10, options).await?;
assert_eq!(
lines,
vec![
"L2: if cond {".to_string(),
"L3: inner();".to_string(),
"L4: }".to_string()
]
);
Ok(())
}
#[tokio::test]
async fn indentation_mode_expands_parents() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new()?;
use std::io::Write as _;
write!(
temp,
"mod root {{
fn outer() {{
if cond {{
inner();
}}
}}
}}
"
)?;
let mut options = IndentationArgs {
anchor_line: Some(4),
max_levels: 2,
..Default::default()
};
let lines = read_block(temp.path(), 4, 50, options.clone()).await?;
assert_eq!(
lines,
vec![
"L2: fn outer() {".to_string(),
"L3: if cond {".to_string(),
"L4: inner();".to_string(),
"L5: }".to_string(),
"L6: }".to_string(),
]
);
options.max_levels = 3;
let expanded = read_block(temp.path(), 4, 50, options).await?;
assert_eq!(
expanded,
vec![
"L1: mod root {".to_string(),
"L2: fn outer() {".to_string(),
"L3: if cond {".to_string(),
"L4: inner();".to_string(),
"L5: }".to_string(),
"L6: }".to_string(),
"L7: }".to_string(),
]
);
Ok(())
}
#[tokio::test]
async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new()?;
use std::io::Write as _;
write!(
temp,
"fn wrapper() {{
if first {{
do_first();
}}
if second {{
do_second();
}}
}}
"
)?;
let mut options = IndentationArgs {
anchor_line: Some(3),
include_siblings: false,
max_levels: 1,
..Default::default()
};
let lines = read_block(temp.path(), 3, 50, options.clone()).await?;
assert_eq!(
lines,
vec![
"L2: if first {".to_string(),
"L3: do_first();".to_string(),
"L4: }".to_string(),
]
);
options.include_siblings = true;
let with_siblings = read_block(temp.path(), 3, 50, options).await?;
assert_eq!(
with_siblings,
vec![
"L2: if first {".to_string(),
"L3: do_first();".to_string(),
"L4: }".to_string(),
"L5: if second {".to_string(),
"L6: do_second();".to_string(),
"L7: }".to_string(),
]
);
Ok(())
}
#[tokio::test]
async fn indentation_mode_handles_python_sample() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new()?;
use std::io::Write as _;
write!(
temp,
"class Foo:
def __init__(self, size):
self.size = size
def double(self, value):
if value is None:
return 0
result = value * self.size
return result
class Bar:
def compute(self):
helper = Foo(2)
return helper.double(5)
"
)?;
let options = IndentationArgs {
anchor_line: Some(7),
include_siblings: true,
max_levels: 1,
..Default::default()
};
let lines = read_block(temp.path(), 1, 200, options).await?;
assert_eq!(
lines,
vec![
"L2: def __init__(self, size):".to_string(),
"L3: self.size = size".to_string(),
"L4: def double(self, value):".to_string(),
"L5: if value is None:".to_string(),
"L6: return 0".to_string(),
"L7: result = value * self.size".to_string(),
"L8: return result".to_string(),
]
);
Ok(())
}
#[tokio::test]
#[ignore]
async fn indentation_mode_handles_javascript_sample() -> anyhow::Result<()> {
let mut temp = NamedTempFile::new()?;
use std::io::Write as _;
write!(
temp,
"export function makeThing() {{
const cache = new Map();
function ensure(key) {{
if (!cache.has(key)) {{
cache.set(key, []);
}}
return cache.get(key);
}}
const handlers = {{
init() {{
console.log(\"init\");
}},
run() {{
if (Math.random() > 0.5) {{
return \"heads\";
}}
return \"tails\";
}},
}};
return {{ cache, handlers }};
}}
export function other() {{
return makeThing();
}}
"
)?;
let options = IndentationArgs {
anchor_line: Some(15),
max_levels: 1,
..Default::default()
};
let lines = read_block(temp.path(), 15, 200, options).await?;
assert_eq!(
lines,
vec![
"L10: init() {".to_string(),
"L11: console.log(\"init\");".to_string(),
"L12: },".to_string(),
"L13: run() {".to_string(),
"L14: if (Math.random() > 0.5) {".to_string(),
"L15: return \"heads\";".to_string(),
"L16: }".to_string(),
"L17: return \"tails\";".to_string(),
"L18: },".to_string(),
]
);
Ok(())
}
fn write_cpp_sample() -> anyhow::Result<NamedTempFile> {
let mut temp = NamedTempFile::new()?;
use std::io::Write as _;
write!(
temp,
"#include <vector>
#include <string>
namespace sample {{
class Runner {{
public:
void setup() {{
if (enabled_) {{
init();
}}
}}
// Run the code
int run() const {{
switch (mode_) {{
case Mode::Fast:
return fast();
case Mode::Slow:
return slow();
default:
return fallback();
}}
}}
private:
bool enabled_ = false;
Mode mode_ = Mode::Fast;
int fast() const {{
return 1;
}}
}};
}} // namespace sample
"
)?;
Ok(temp)
}
#[tokio::test]
async fn indentation_mode_handles_cpp_sample_shallow() -> anyhow::Result<()> {
let temp = write_cpp_sample()?;
let options = IndentationArgs {
include_siblings: false,
anchor_line: Some(18),
max_levels: 1,
..Default::default()
};
let lines = read_block(temp.path(), 18, 200, options).await?;
assert_eq!(
lines,
vec![
"L15: switch (mode_) {".to_string(),
"L16: case Mode::Fast:".to_string(),
"L17: return fast();".to_string(),
"L18: case Mode::Slow:".to_string(),
"L19: return slow();".to_string(),
"L20: default:".to_string(),
"L21: return fallback();".to_string(),
"L22: }".to_string(),
]
);
Ok(())
}
#[tokio::test]
async fn indentation_mode_handles_cpp_sample() -> anyhow::Result<()> {
let temp = write_cpp_sample()?;
let options = IndentationArgs {
include_siblings: false,
anchor_line: Some(18),
max_levels: 2,
..Default::default()
};
let lines = read_block(temp.path(), 18, 200, options).await?;
assert_eq!(
lines,
vec![
"L13: // Run the code".to_string(),
"L14: int run() const {".to_string(),
"L15: switch (mode_) {".to_string(),
"L16: case Mode::Fast:".to_string(),
"L17: return fast();".to_string(),
"L18: case Mode::Slow:".to_string(),
"L19: return slow();".to_string(),
"L20: default:".to_string(),
"L21: return fallback();".to_string(),
"L22: }".to_string(),
"L23: }".to_string(),
]
);
Ok(())
}
#[tokio::test]
async fn indentation_mode_handles_cpp_sample_no_headers() -> anyhow::Result<()> {
let temp = write_cpp_sample()?;
let options = IndentationArgs {
include_siblings: false,
include_header: false,
anchor_line: Some(18),
max_levels: 2,
..Default::default()
};
let lines = read_block(temp.path(), 18, 200, options).await?;
assert_eq!(
lines,
vec![
"L14: int run() const {".to_string(),
"L15: switch (mode_) {".to_string(),
"L16: case Mode::Fast:".to_string(),
"L17: return fast();".to_string(),
"L18: case Mode::Slow:".to_string(),
"L19: return slow();".to_string(),
"L20: default:".to_string(),
"L21: return fallback();".to_string(),
"L22: }".to_string(),
"L23: }".to_string(),
]
);
Ok(())
}
#[tokio::test]
async fn indentation_mode_handles_cpp_sample_siblings() -> anyhow::Result<()> {
let temp = write_cpp_sample()?;
let options = IndentationArgs {
include_siblings: true,
include_header: false,
anchor_line: Some(18),
max_levels: 2,
..Default::default()
};
let lines = read_block(temp.path(), 18, 200, options).await?;
assert_eq!(
lines,
vec![
"L7: void setup() {".to_string(),
"L8: if (enabled_) {".to_string(),
"L9: init();".to_string(),
"L10: }".to_string(),
"L11: }".to_string(),
"L12: ".to_string(),
"L13: // Run the code".to_string(),
"L14: int run() const {".to_string(),
"L15: switch (mode_) {".to_string(),
"L16: case Mode::Fast:".to_string(),
"L17: return fast();".to_string(),
"L18: case Mode::Slow:".to_string(),
"L19: return slow();".to_string(),
"L20: default:".to_string(),
"L21: return fallback();".to_string(),
"L22: }".to_string(),
"L23: }".to_string(),
]
);
Ok(())
} }
} }

View File

@@ -392,11 +392,72 @@ fn create_read_file_tool() -> ToolSpec {
description: Some("The maximum number of lines to return.".to_string()), description: Some("The maximum number of lines to return.".to_string()),
}, },
); );
properties.insert(
"mode".to_string(),
JsonSchema::String {
description: Some(
"Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \
to expand around an anchor line."
.to_string(),
),
},
);
let mut indentation_properties = BTreeMap::new();
indentation_properties.insert(
"anchor_line".to_string(),
JsonSchema::Number {
description: Some(
"Anchor line to center the indentation lookup on (defaults to offset).".to_string(),
),
},
);
indentation_properties.insert(
"max_levels".to_string(),
JsonSchema::Number {
description: Some(
"How many parent indentation levels (smaller indents) to include.".to_string(),
),
},
);
indentation_properties.insert(
"include_siblings".to_string(),
JsonSchema::Boolean {
description: Some(
"When true, include additional blocks that share the anchor indentation."
.to_string(),
),
},
);
indentation_properties.insert(
"include_header".to_string(),
JsonSchema::Boolean {
description: Some(
"Include doc comments or attributes directly above the selected block.".to_string(),
),
},
);
indentation_properties.insert(
"max_lines".to_string(),
JsonSchema::Number {
description: Some(
"Hard cap on the number of lines returned when using indentation mode.".to_string(),
),
},
);
properties.insert(
"indentation".to_string(),
JsonSchema::Object {
properties: indentation_properties,
required: None,
additional_properties: Some(false.into()),
},
);
ToolSpec::Function(ResponsesApiTool { ToolSpec::Function(ResponsesApiTool {
name: "read_file".to_string(), name: "read_file".to_string(),
description: description:
"Reads a local file with 1-indexed line numbers and returns up to the requested number of lines." "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes."
.to_string(), .to_string(),
strict: false, strict: false,
parameters: JsonSchema::Object { parameters: JsonSchema::Object {