From b703ee787e4d757981b8b4be8dfc419d5f5785c4 Mon Sep 17 00:00:00 2001 From: Carbon Date: Mon, 4 May 2026 14:53:24 +0800 Subject: [PATCH 1/2] feat(readFile): add lines range option with streaming support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for reading specific line ranges via readFile API: await readFile('./test.ts', { lines: { from: 110, to: 120 } }) Implementation uses BufReader for streaming line-by-line reading, avoiding loading the entire file into memory. This makes it suitable for large files (GB-scale) — memory usage is O(target lines) instead of O(file size). Features: - 1-based inclusive line range selection - Streaming via BufReader (64KB buffer) — O(1) memory overhead - Graceful handling of out-of-bounds ranges (empty string) - Auto-clamping when 'to' exceeds total lines - Buffer mode (no encoding) ignores lines option - TypeScript type definitions updated Edge Cases Handled: | Scenario | Behavior | |----------|----------| | from < 1 | Returns empty string | | from > total lines | Returns empty string | | to > total lines | Clamped to last line | | from == to | Returns single line | | Buffer mode | lines ignored, full buffer returned | Closes issue #21 --- __test__/read_file.spec.ts | 48 ++++++++++++++++++++++ index.d.ts | 6 +++ src/read_file.rs | 84 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+) diff --git a/__test__/read_file.spec.ts b/__test__/read_file.spec.ts index d1224f1..f9b9bfd 100644 --- a/__test__/read_file.spec.ts +++ b/__test__/read_file.spec.ts @@ -4,6 +4,8 @@ import * as nodeFs from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' +const multilineFixture = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join('\n') + test('readFileSync: should read file as Buffer by default', (t) => { const result = readFileSync('./package.json') t.true(Buffer.isBuffer(result)) @@ -63,3 +65,49 @@ test('dual-run: readFileSync utf8 string should match node:fs', (t) => { const hyperResult = readFileSync('./package.json', { encoding: 'utf8' }) as string t.is(hyperResult, nodeResult) }) + +test('readFile: async should read a line range', async (t) => { + const fixturePath = join(tmpdir(), `rush-fs-read-lines-${Date.now()}-range.txt`) + writeFileSync(fixturePath, multilineFixture) + + const result = await readFile(fixturePath, { encoding: 'utf8', lines: { from: 1, to: 5 } }) + + t.is(result, ['line 1', 'line 2', 'line 3', 'line 4', 'line 5'].join('\n')) +}) + +test('readFile: async should return empty string when line range starts beyond file length', async (t) => { + const fixturePath = join(tmpdir(), `rush-fs-read-lines-${Date.now()}-empty.txt`) + writeFileSync(fixturePath, multilineFixture) + + const result = await readFile(fixturePath, { encoding: 'utf8', lines: { from: 20, to: 25 } }) + + t.is(result, '') +}) + +test('readFile: async should read a single line when from equals to', async (t) => { + const fixturePath = join(tmpdir(), `rush-fs-read-lines-${Date.now()}-single.txt`) + writeFileSync(fixturePath, multilineFixture) + + const result = await readFile(fixturePath, { encoding: 'utf8', lines: { from: 7, to: 7 } }) + + t.is(result, 'line 7') +}) + +test('readFile: async should clamp line range to file length', async (t) => { + const fixturePath = join(tmpdir(), `rush-fs-read-lines-${Date.now()}-clamp.txt`) + writeFileSync(fixturePath, multilineFixture) + + const result = await readFile(fixturePath, { encoding: 'utf8', lines: { from: 10, to: 20 } }) + + t.is(result, ['line 10', 'line 11', 'line 12'].join('\n')) +}) + +test('readFile: async should ignore line range in Buffer mode', async (t) => { + const fixturePath = join(tmpdir(), `rush-fs-read-lines-${Date.now()}-buffer.txt`) + writeFileSync(fixturePath, multilineFixture) + + const result = await readFile(fixturePath, { lines: { from: 1, to: 2 } }) + + t.true(Buffer.isBuffer(result)) + t.is((result as Buffer).toString('utf8'), multilineFixture) +}) diff --git a/index.d.ts b/index.d.ts index 1072192..e5164c7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -110,6 +110,11 @@ export declare function globSync( options?: GlobOptions | undefined | null, ): Array | Array +export interface LineRange { + from: number + to: number +} + export declare function link(existingPath: string, newPath: string): Promise export declare function linkSync(existingPath: string, newPath: string): void @@ -169,6 +174,7 @@ export declare function readFile(path: string, options?: string | ReadFileOption export interface ReadFileOptions { encoding?: string flag?: string + lines?: LineRange } export declare function readFileSync( diff --git a/src/read_file.rs b/src/read_file.rs index 837bb75..a653a3c 100644 --- a/src/read_file.rs +++ b/src/read_file.rs @@ -59,11 +59,19 @@ fn base64_encode(data: &[u8], url_safe: bool) -> String { result } +#[napi(object)] +#[derive(Clone)] +pub struct LineRange { + pub from: u32, + pub to: u32, +} + #[napi(object)] #[derive(Clone)] pub struct ReadFileOptions { pub encoding: Option, pub flag: Option, + pub lines: Option, } fn normalize_read_file_options( @@ -73,15 +81,83 @@ fn normalize_read_file_options( Some(Either::A(encoding)) => ReadFileOptions { encoding: Some(encoding), flag: None, + lines: None, }, Some(Either::B(opts)) => opts, None => ReadFileOptions { encoding: None, flag: None, + lines: None, }, } } +fn read_file_with_lines( + path: &Path, + open_opts: &mut fs::OpenOptions, + range: LineRange, + encoding: Option<&str>, +) -> Result { + use std::io::{BufRead, BufReader}; + + if range.from < 1 || range.to < range.from { + return Ok(String::new()); + } + + let file = open_opts.open(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + Error::from_reason(format!( + "ENOENT: no such file or directory, open '{}'", + path.to_string_lossy() + )) + } else if e.kind() == std::io::ErrorKind::PermissionDenied { + Error::from_reason(format!( + "EACCES: permission denied, open '{}'", + path.to_string_lossy() + )) + } else if e.kind() == std::io::ErrorKind::AlreadyExists { + Error::from_reason(format!( + "EEXIST: file already exists, open '{}'", + path.to_string_lossy() + )) + } else { + Error::from_reason(e.to_string()) + } + })?; + + let reader = BufReader::with_capacity(64 * 1024, file); + let mut result = String::new(); + let mut current_line: u32 = 0; + + for line_result in reader.lines() { + let line = line_result.map_err(|e| Error::from_reason(e.to_string()))?; + current_line += 1; + + if current_line > range.to { + break; + } + + if current_line >= range.from { + if !result.is_empty() { + result.push('\n'); + } + result.push_str(&line); + } + } + + // Apply encoding transformation if needed + if encoding.is_some() && encoding != Some("utf8") && encoding != Some("utf-8") { + let bytes = result.into_bytes(); + let decoded = decode_data(bytes, encoding)?; + match decoded { + Either::A(s) => Ok(s), + Either::B(_) => Ok(String::new()), + } + } else { + Ok(result) + } +} + fn read_file_impl( path_str: String, options: Option>, @@ -143,6 +219,14 @@ fn read_file_impl( } })?; + // If lines option is specified with a text encoding, use streaming line-by-line reading + // to avoid loading the entire file into memory. Buffer mode (no encoding) ignores lines. + if let (Some(lines), Some(_)) = (&opts.lines, &opts.encoding) { + let contents = + read_file_with_lines(path, &mut open_opts, lines.clone(), opts.encoding.as_deref())?; + return Ok(Either::A(contents)); + } + use std::io::Read; let mut data = Vec::new(); file From 832280395a3bb6711aecb5c10f8af56ee7e48174 Mon Sep 17 00:00:00 2001 From: Carbon Date: Mon, 4 May 2026 15:42:22 +0800 Subject: [PATCH 2/2] fix: remove unused symlinkSync import in cp.spec.ts Pre-existing oxlint warning fix. --- __test__/cp.spec.ts | 2 +- src/read_file.rs | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/__test__/cp.spec.ts b/__test__/cp.spec.ts index 337eca1..66b2721 100644 --- a/__test__/cp.spec.ts +++ b/__test__/cp.spec.ts @@ -1,7 +1,7 @@ import test from 'ava' import { cpSync, cp } from '../index.js' import * as nodeFs from 'node:fs' -import { writeFileSync, readFileSync, existsSync, mkdirSync, symlinkSync, readdirSync } from 'node:fs' +import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' diff --git a/src/read_file.rs b/src/read_file.rs index a653a3c..0b2780f 100644 --- a/src/read_file.rs +++ b/src/read_file.rs @@ -222,8 +222,12 @@ fn read_file_impl( // If lines option is specified with a text encoding, use streaming line-by-line reading // to avoid loading the entire file into memory. Buffer mode (no encoding) ignores lines. if let (Some(lines), Some(_)) = (&opts.lines, &opts.encoding) { - let contents = - read_file_with_lines(path, &mut open_opts, lines.clone(), opts.encoding.as_deref())?; + let contents = read_file_with_lines( + path, + &mut open_opts, + lines.clone(), + opts.encoding.as_deref(), + )?; return Ok(Either::A(contents)); }