Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion __test__/cp.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down
48 changes: 48 additions & 0 deletions __test__/read_file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
})
6 changes: 6 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@ export declare function globSync(
options?: GlobOptions | undefined | null,
): Array<string> | Array<Dirent>

export interface LineRange {
from: number
to: number
}

export declare function link(existingPath: string, newPath: string): Promise<unknown>

export declare function linkSync(existingPath: string, newPath: string): void
Expand Down Expand Up @@ -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(
Expand Down
88 changes: 88 additions & 0 deletions src/read_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub flag: Option<String>,
pub lines: Option<LineRange>,
}

fn normalize_read_file_options(
Expand All @@ -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<String> {
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<Either<String, ReadFileOptions>>,
Expand Down Expand Up @@ -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
Expand Down
Loading