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/__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..0b2780f 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,18 @@ 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