Chapter 06Project Grep Lite

项目

概述

我们的第二个项目从算术运算升级到文本处理:一个微型的grep克隆,它接受搜索模式和文件路径,然后只打印匹配的行。这个练习巩固了前一章中的参数处理,同时引入了标准库中的文件I/O和切片实用工具。#Command-line-flags, File.zig

我们不再逐字节流式处理,而是依赖Zig的内存安全助手来加载文件、将其拆分成行,并通过简单的子字符串检查来显示匹配项。每个失败路径在退出前都会产生用户友好的消息,因此该工具在shell脚本中表现可预测——这是我们将在下一个项目中延续的主题。有关相关API,请参见#Command-line-flagsFile.zig,有关错误处理模式,请参见#Error-Handling

学习目标

  • 实现一个支持--help、强制执行参数数量并在误用时优雅终止的命令行解析例程。
  • 使用std.fs.File.readToEndAllocstd.mem.splitScalar来加载和迭代文件内容(参见mem.zig)。
  • 使用std.mem.indexOf过滤行,并通过stdout报告结果,同时将诊断信息定向到stderr(参见debug.zig)。

构建搜索框架

我们从连接CLI前端开始:分配参数、支持--help,并确认恰好有两个位置参数——模式和路径——存在。任何偏差都会打印使用横幅并以代码1退出,避免堆栈跟踪,同时仍然向调用者发出失败信号。

验证参数和使用路径

这个框架镜像了第5章的TempConv CLI,但现在我们将诊断信息发送到stderr,并在输入错误或无法打开文件时显式退出。printUsage将横幅保持在一个地方,而std.process.exit确保我们在消息写入后立即停止。

加载和拆分文件

我们不再处理部分读取,而是使用File.readToEndAlloc将文件加载到内存中,将大小限制为8兆字节以防止意外的巨大文件。然后,对std.mem.splitScalar的单个调用会生成一个按换行符分隔的段的迭代器,我们会为Windows风格的回车符进行修剪。

理解 std.fs 结构

在深入文件操作之前,了解Zig的文件系统API是如何组织的很有帮助。std.fs模块提供了一个分层层次结构,使文件访问具有可移植性和可组合性:

graph TB subgraph "File System API Hierarchy" CWD["std.fs.cwd()<br/>Returns: Dir"] DIR["Dir type<br/>(fs/Dir.zig)"] FILE["File type<br/>(fs/File.zig)"] end subgraph "Dir Operations" OPENFILE["openFile(path, flags)<br/>Returns: File"] MAKEDIR["makeDir(path)"] OPENDIR["openDir(path)<br/>Returns: Dir"] ITERATE["iterate()<br/>Returns: Iterator"] end subgraph "File Operations" READ["read(buffer)<br/>Returns: bytes read"] READTOEND["readToEndAlloc(allocator, max_size)<br/>Returns: []u8"] WRITE["write(bytes)<br/>Returns: bytes written"] SEEK["seekTo(pos)"] CLOSE["close()"] end CWD --> DIR DIR --> OPENFILE DIR --> MAKEDIR DIR --> OPENDIR DIR --> ITERATE OPENFILE --> FILE OPENDIR --> DIR FILE --> READ FILE --> READTOEND FILE --> WRITE FILE --> SEEK FILE --> CLOSE

关键概念:

  • 入口点std.fs.cwd()返回一个表示当前工作目录的Dir句柄
  • 目录类型:提供目录级操作,如打开文件、创建子目录和迭代内容
  • 文件类型:表示具有读/写操作的打开文件
  • 链式调用:你调用cwd().openFile()是因为openFile()Dir类型上的方法

为什么这种结构对Grep-Lite很重要:

Zig
// 这就是我们这样写的原因:
const file = try std.fs.cwd().openFile(path, .{});
//                    ^        ^
//                    |        +-- Dir上的方法
//                    +----------- 返回Dir句柄

两步过程(cwd()openFile())让你可以控制在哪个目录中打开文件。虽然这个例子使用当前目录,但你同样可以使用:

  • std.fs.openDirAbsolute()用于绝对路径
  • dir.openFile()用于相对于任何目录句柄的文件
  • std.fs.openFileAbsolute()完全跳过Dir

这种可组合的设计使文件系统代码可测试(使用临时目录)且可移植(相同的API在所有平台上工作)。

扫描匹配项

一旦我们拥有每行的切片,匹配就是使用std.mem.indexOf的一行代码。我们重用TempConv模式,将stdout保留用于成功输出,stderr用于诊断,使工具对管道友好。

完整的Grep-Lite清单

下面的完整清单展示了辅助函数如何组合在一起。注意将每个块与上面章节联系起来的注释。

Zig
const std = @import("std");

// 第6章 - Grep-Lite:逐行流式处理文件并仅回显匹配项到stdout,
// 同时将错误转换为stderr上的清晰诊断信息。

const CliError = error{MissingArgs};

fn printUsage() void {
    std.debug.print("usage: grep-lite <pattern> <path>\n", .{});
}

fn trimNewline(line: []const u8) []const u8 {
    if (line.len > 0 and line[line.len - 1] == '\r') {
        return line[0 .. line.len - 1];
    }
    return line;
}

pub fn main() !void {
    const allocator = std.heap.page_allocator;
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len == 1 or (args.len == 2 and std.mem.eql(u8, args[1], "--help"))) {
        printUsage();
        return;
    }

    if (args.len != 3) {
        std.debug.print("error: expected a pattern and a path\n", .{});
        printUsage();
        std.process.exit(1);
    }

    const pattern = args[1];
    const path = args[2];

    var file = std.fs.cwd().openFile(path, .{ .mode = .read_only }) catch {
        std.debug.print("error: unable to open '{s}'\n", .{path});
        std.process.exit(1);
    };
    defer file.close();

    // 使用现代Writer API的缓冲stdout
    var out_buf: [8 * 1024]u8 = undefined;
    var file_writer = std.fs.File.writer(std.fs.File.stdout(), &out_buf);
    const stdout = &file_writer.interface;

    // 第1.2节:积极加载完整文件,同时强制执行保护,
    // 防止意外的多兆字节输入耗尽内存。
    const max_bytes = 8 * 1024 * 1024;
    const contents = file.readToEndAlloc(allocator, max_bytes) catch |err| switch (err) {
        error.FileTooBig => {
            std.debug.print("error: file exceeds {} bytes limit\n", .{max_bytes});
            std.process.exit(1);
        },
        else => return err,
    };
    defer allocator.free(contents);

    // 第2.1节:在换行符处分割缓冲区;每个切片引用
    // 原始分配,因此不会产生额外的副本。
    var lines = std.mem.splitScalar(u8, contents, '\n');
    var matches: usize = 0;

    while (lines.next()) |raw_line| {
        const line = trimNewline(raw_line);

        // 第2节:重用`std.mem.indexOf`以精确高亮匹配项,
        // 而无需构建临时切片。
        if (std.mem.indexOf(u8, line, pattern) != null) {
            matches += 1;
            try stdout.print("{s}\n", .{line});
        }
    }

    if (matches == 0) {
        std.debug.print("no matches for '{s}' in {s}\n", .{ pattern, path });
    }

    // 刷新缓冲stdout并定位文件位置
    try file_writer.end();
}
运行
Shell
$ zig run grep_lite.zig -- pattern grep_lite.zig
输出
Shell
    std.debug.print("usage: grep-lite <pattern> <path>\n", .{});
        std.debug.print("error: expected a pattern and a path\n", .{});
    const pattern = args[1];
        if (std.mem.indexOf(u8, line, pattern) != null) {
        std.debug.print("no matches for '{s}' in {s}\n", .{ pattern, path });

输出显示包含字面单词pattern的每个源代码行。当针对其他文件运行时,你的匹配列表会有所不同。

优雅地检测缺失文件

为了保持shell脚本的可预测性,当文件路径无法打开时,该工具会发出单行诊断信息并以非零状态退出。

Shell
$ zig run grep_lite.zig -- foo missing.txt
输出
Shell
error: unable to open 'missing.txt'

注意事项

  • readToEndAlloc很简单但会加载整个文件;如果你需要处理非常大的输入,稍后添加流式读取器。
  • 大小限制防止失控的分配。一旦你信任你的部署环境,可以提高它或使其可配置。
  • 这个例子使用缓冲的stdout写入器进行匹配,并使用std.debug.print向stderr进行诊断;我们通过写入器的end()在退出时刷新(参见Io.zig)。

练习

  • 在命令行上接受多个文件,并为每个匹配项打印path:line前缀(参见#for)。
  • 通过使用std.ascii.toLower规范化模式和每行来添加--ignore-case标志(参见ascii.zig)。
  • 通过在整个缓冲区加载后集成第三方匹配器来支持正则表达式。

替代方案与边缘情况

  • Windows文件通常以\r\n结束行;修剪回车符保持子字符串检查的清洁。
  • 空模式当前匹配每一行。如果你倾向于将空字符串视为误用,请引入显式防护。
  • 要与更大的构建集成,请将zig run替换为zig build-exe步骤,并将二进制文件打包到你的PATH上。

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.