Convert manwhere to submodule

This commit is contained in:
Jeremie Fraeys 2026-02-10 18:05:51 -05:00
parent 3c104f7ced
commit 50244b7eb9
No known key found for this signature in database
9 changed files with 4 additions and 1378 deletions

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "scripts/manwhere"]
path = scripts/manwhere
url = https://git.jfraeys.com/jfraeysd/manwhere.git

1
scripts/manwhere Submodule

@ -0,0 +1 @@
Subproject commit 26eb50d38f31ad0f791242e84481f57c4d2326e9

View file

@ -1,162 +0,0 @@
# manwhere
A fast, simple command-line tool to search man pages by keyword, written in Zig.
## Features
- **Fast keyword search** using `man -k` (apropos)
- **Section filtering** to search specific man page sections (1-9)
- **Path resolution** to show actual file locations
- **Verbose mode** with timing information
- **POSIX compatible** - works on Linux and macOS
- **Simple and reliable** - no complex dependencies
## Project Structure
The project is organized into logical modules for maintainability:
```
src/
├── main.zig # Main program entry point
├── types.zig # Data structures and types
├── parser.zig # Command line argument parsing
├── search.zig # Man page search functionality
├── display.zig # Output formatting and display
└── performance.zig # Essential performance optimizations
```
### Module Responsibilities
- **`types.zig`**: Contains `ManEntry`, `ManEntryList`, and `SearchConfig` structures
- **`parser.zig`**: Handles command line argument parsing, validation, and help text
- **`search.zig`**: Manages man page searching, parsing, and path resolution
- **`display.zig`**: Formats and displays search results and progress information
- **`performance.zig`**: Essential optimizations for fast string operations and I/O
- **`main.zig`**: Orchestrates the program flow and coordinates between modules
## Performance Features
The tool is optimized for speed while maintaining simplicity:
- **Early filtering**: Section filtering happens during parsing for better performance
- **Efficient I/O**: Optimized buffer sizes and reading strategies
- **Fast string operations**: Optimized string comparisons for common cases
- **Memory efficiency**: Exponential growth strategy for collections
- **POSIX optimization**: Common man page directories checked first
## Usage
### Basic Search
```bash
manwhere sleep # Find all man pages mentioning "sleep"
```
### Section Filtering
```bash
manwhere -s 1 sleep # Find only commands (section 1)
manwhere --section 3 sleep # Find only library functions (section 3)
```
### Verbose Mode
```bash
manwhere -v sleep # Show timing and processing details
manwhere --verbose sleep # Same as above
```
### Path Resolution
```bash
manwhere --paths sleep # Show file paths (slower but more detailed)
```
### Combined Options
```bash
manwhere -v --paths -s 1 sleep # Verbose, with paths, section 1 only
```
### Help
```bash
manwhere -h # Show help message
manwhere --help # Same as above
```
## Man Page Sections
- **1**: Commands (user commands)
- **2**: System calls
- **3**: Library functions
- **4**: Device files
- **5**: File formats
- **6**: Games
- **7**: Miscellaneous
- **8**: System administration
- **9**: Kernel routines
## Building
### Prerequisites
- Zig 0.15.1 or later
### Build Commands
```bash
# Debug build (good for development)
zig build
# Release build (maximum performance)
zig build -Doptimize=ReleaseFast
# Clean build
zig build clean
```
### Install to ~/.local/bin
```bash
zig build release
```
## Performance
The tool is designed to be fast and efficient:
- **Typical search time**: 700-800ms for common keywords
- **Section filtering**: 20-30% faster than searching all sections
- **Path resolution**: Adds ~200-300ms for detailed results
- **Memory usage**: ~8-12MB for typical searches
- **Scalability**: Handles large result sets efficiently
## POSIX Compatibility
Works reliably on:
- **Linux**: Standard `/usr/share/man` and `/usr/local/share/man`
- **macOS**: Standard paths plus Homebrew (`/opt/homebrew/share/man`) and MacPorts (`/opt/local/share/man`)
- **Other Unix-like systems**: Standard man page directory structures
## Error Handling
The program provides clear error messages for:
- Invalid command line options
- Missing required arguments
- Invalid section numbers
- Multiple keywords (not supported)
- Search failures
## Why Simple and Fast?
This tool prioritizes:
1. **Reliability** - Works consistently across POSIX systems
2. **Speed** - Essential optimizations without complexity
3. **Simplicity** - Easy to understand, maintain, and debug
4. **POSIX compatibility** - No platform-specific dependencies
5. **Performance** - Fast enough for daily use without over-engineering
## Contributing
The simple structure makes it easy to:
- Add new search features
- Implement different output formats
- Add support for additional platforms
- Extend the man page section detection
- Optimize performance further
## License
This project is open source. See the source code for license details.

View file

@ -1,139 +0,0 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
// ============================================================================
// MODULES
// ============================================================================
// Main module (uses standard optimize option)
const root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// Debug module (always Debug optimization)
const debug_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = .Debug,
});
// Release module (always ReleaseFast optimization)
const release_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = .ReleaseFast,
});
// Test module (uses standard optimize option)
const test_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
// ============================================================================
// EXECUTABLES
// ============================================================================
// Main executable
const exe = b.addExecutable(.{
.name = "manwhere",
.root_module = root_module,
});
b.installArtifact(exe);
// Debug executable
const debug_exe = b.addExecutable(.{
.name = "manwhere-debug",
.root_module = debug_module,
});
// Release executable
const release_exe = b.addExecutable(.{
.name = "manwhere",
.root_module = release_module,
});
// Check executable (for compilation check)
const check_exe = b.addExecutable(.{
.name = "check-compile",
.root_module = root_module,
});
// Test executable
const unit_tests = b.addTest(.{
.root_module = test_module,
});
// ============================================================================
// BUILD STEPS
// ============================================================================
// Default run step
const run_cmd = b.addRunArtifact(exe);
if (b.args) |args| run_cmd.addArgs(args);
const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step);
// Debug build step
const debug_step = b.step("debug", "Build with Debug optimization");
b.installArtifact(debug_exe);
debug_step.dependOn(b.getInstallStep());
// Debug run step
const debug_run_cmd = b.addRunArtifact(debug_exe);
if (b.args) |args| debug_run_cmd.addArgs(args);
debug_run_cmd.step.dependOn(&debug_exe.step);
const debug_run_step = b.step("debug-run", "Run the app with Debug optimization");
debug_run_step.dependOn(&debug_run_cmd.step);
// Release step - build optimized binary and install to ~/.local/bin
const release_step = b.step("release", "Build optimized binary and install to ~/.local/bin/");
// Get home directory from environment
const home = std.process.getEnvVarOwned(b.allocator, "HOME") catch {
std.debug.print("Error: HOME environment variable not set\n", .{});
return;
};
defer b.allocator.free(home);
const install_path = std.fs.path.join(b.allocator, &.{ home, ".local", "bin" }) catch {
std.debug.print("Error: Failed to construct install path\n", .{});
return;
};
defer b.allocator.free(install_path);
const install_exe = b.addInstallArtifact(release_exe, .{
.dest_dir = .{ .override = .{ .custom = install_path } },
});
release_step.dependOn(&install_exe.step);
// Test step - run all unit tests
const run_unit_tests = b.addRunArtifact(unit_tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_unit_tests.step);
// Check step - compilation check without output
const check_step = b.step("check", "Check code formatting and compilation");
check_step.dependOn(&check_exe.step);
// Format step - format source code
const fmt_step = b.step("fmt", "Format source code");
const fmt_cmd = b.addFmt(.{
.paths = &.{ "src", "build.zig" },
.check = false,
});
fmt_step.dependOn(&fmt_cmd.step);
// Clean step - remove build artifacts
const clean_step = b.step("clean", "Remove build artifacts");
const clean_cmd = b.addSystemCommand(&.{
"rm", "-rf", ".zig-cache", "zig-out",
});
clean_step.dependOn(&clean_cmd.step);
}

View file

@ -1,164 +0,0 @@
const std = @import("std");
const types = @import("types.zig");
// Following Zig 0.15.1 writergate changes - buffer goes in the interface
var stdout_buffer: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
pub fn displayManEntry(entry: types.ManEntry) !void {
try stdout.print("{s} {s}({s}) - {s}\n", .{ entry.getSectionMarker(), entry.name, entry.section, entry.description });
if (entry.path) |path| {
try stdout.print(" -> {s}\n", .{path});
}
// Don't forget to flush! - per writergate guidelines
try stdout.flush();
}
pub fn displaySearchResults(
entries: types.ManEntryList,
config: types.SearchConfig,
duration_ms: f64,
) !void {
if (config.verbose) {
if (config.target_sections) |sections| {
// e.g., join sections for display
var joined: [32]u8 = undefined;
var pos: usize = 0;
for (sections, 0..) |s, i| {
if (i != 0) {
joined[pos] = ',';
pos += 1;
}
// Copy the string into the buffer
@memcpy(joined[pos .. pos + s.len], s);
pos += s.len;
}
try stdout.print(
"[=] Found {d} entries matching '{s}' in section(s) {s}\n",
.{ entries.items.len, config.keyword, joined[0..pos] },
);
try stdout.print("[=] Found {d} entries matching '{s}'\n", .{ entries.items.len, config.keyword });
}
try stdout.print("\n", .{});
} else {
if (config.target_sections) |sections| {
// Join sections for display
var joined: [32]u8 = undefined;
var pos: usize = 0;
for (sections, 0..) |s, i| {
if (i != 0) {
joined[pos] = ',';
pos += 1;
}
@memcpy(joined[pos .. pos + s.len], s);
pos += s.len;
}
try stdout.print("[=] Found {d} entries matching '{s}' in section {s}\n\n", .{ entries.items.len, config.keyword, joined[0..pos] });
} else {
try stdout.print("[=] Found {d} entries matching '{s}'\n\n", .{ entries.items.len, config.keyword });
}
}
try stdout.flush(); // Flush header
// Iterate over entries
for (entries.items) |entry| {
try displayManEntry(entry);
}
if (config.verbose) {
try stdout.print("───────────────────────────────────────────────\n", .{});
try stdout.print("[=] Completed in {d:.1}ms\n", .{duration_ms});
try stdout.flush(); // Flush footer
}
}
pub fn displaySearchStart(config: types.SearchConfig) !void {
if (config.verbose) {
if (config.target_sections) |sections| {
// Join sections for display (same pattern as displaySearchResults)
var joined: [32]u8 = undefined;
var pos: usize = 0;
for (sections, 0..) |s, i| {
if (i != 0) {
joined[pos] = ',';
pos += 1;
}
@memcpy(joined[pos .. pos + s.len], s);
pos += s.len;
}
try stdout.print("[=] Searching for '{s}' in man pages database (section {s})...\n", .{ config.keyword, joined[0..pos] });
} else {
try stdout.print("[=] Searching for '{s}' in man pages database...\n", .{config.keyword});
}
try stdout.flush(); // Flush search start message
}
}
// ============================================================================
// TESTS
// ============================================================================
test "displayManEntry - basic entry" {
const entry = types.ManEntry{
.name = "ls",
.section = "1",
.description = "list directory contents",
.path = null,
};
// Verify function doesn't panic
try displayManEntry(entry);
}
test "displaySearchResults - non-verbose no sections" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var entries = types.ManEntryList.init(allocator);
defer {
for (entries.items) |entry| {
allocator.free(entry.name);
allocator.free(entry.section);
allocator.free(entry.description);
}
entries.deinit();
}
try entries.append(types.ManEntry{
.name = try allocator.dupe(u8, "ls"),
.section = try allocator.dupe(u8, "1"),
.description = try allocator.dupe(u8, "list files"),
.path = null,
});
const config = types.SearchConfig{
.keyword = "ls",
.target_sections = null,
.show_paths = false,
.verbose = false,
};
try displaySearchResults(entries, config, 100.0);
}
test "displaySearchStart - non-verbose" {
const config = types.SearchConfig{
.keyword = "test",
.target_sections = null,
.show_paths = false,
.verbose = false,
};
// Should not output anything in non-verbose mode
try displaySearchStart(config);
}
test "displaySearchStart - verbose" {
const config = types.SearchConfig{
.keyword = "sleep",
.target_sections = null,
.show_paths = false,
.verbose = true,
};
try displaySearchStart(config);
}

View file

@ -1,54 +0,0 @@
const std = @import("std");
const types = @import("types.zig");
const parser = @import("parser.zig");
const search = @import("search.zig");
const display = @import("display.zig");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Following Zig 0.15.1 writergate changes - buffer goes in the interface
var stderr_buffer: [1024]u8 = undefined;
var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
const stderr = &stderr_writer.interface;
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
// Parse command line arguments
const config = parser.parseArgs(allocator, args) catch |err| switch (err) {
error.HelpRequested => return,
error.InvalidOption, error.InvalidSection, error.MultipleKeywords, error.NoKeyword => std.process.exit(1),
else => return err,
};
// Display search start message
try display.displaySearchStart(config);
// Perform the search
const start_time = std.time.nanoTimestamp();
var entries = search.searchManPages(config.keyword, config.target_sections, config.show_paths, config.verbose, allocator, stderr) catch |err| {
try stderr.print("[X] Search failed: {}\n", .{err});
try stderr.flush(); // Don't forget to flush!
return;
};
defer {
for (entries.items) |entry| {
// Free all the allocated strings from parseManLine
allocator.free(entry.name);
allocator.free(entry.section);
allocator.free(entry.description);
if (entry.path) |path| allocator.free(path);
}
entries.deinit(allocator);
}
// Calculate duration
const end_time = std.time.nanoTimestamp();
const duration_ms = @as(f64, @floatFromInt(end_time - start_time)) / 1_000_000.0;
// Display results
try display.displaySearchResults(entries, config, duration_ms);
}

View file

@ -1,203 +0,0 @@
const std = @import("std");
const types = @import("types.zig");
const print = std.debug.print;
pub const HELP_TEXT =
\\Usage: manwhere [OPTIONS] <keyword>
\\Find man pages containing a keyword.
\\
\\OPTIONS:
\\ -h, --help Show this help message
\\ -v, --verbose Show timing and extra details
\\ --paths Show file paths (slower)
\\ -s, --section NUM Only show results from section NUM (1-9)
\\ Can be specified multiple times
\\
\\EXAMPLES:
\\ manwhere sleep # find all man pages mentioning "sleep"
\\ manwhere -s 1 sleep # find only commands (section 1) mentioning "sleep"
\\ manwhere -s 1 -s 3 sleep # find in sections 1 and 3
\\ manwhere -v --paths ssl # detailed search with paths and timing
\\ manwhere --section 3 printf # find only library functions (section 3)
\\
;
pub fn parseArgs(allocator: std.mem.Allocator, args: [][:0]u8) !types.SearchConfig {
if (args.len < 2) {
print("{s}", .{HELP_TEXT});
return error.NoKeyword;
}
var keyword: []const u8 = "";
var sections_list = std.ArrayList([]const u8){};
defer sections_list.deinit(allocator);
var show_paths = false;
var verbose = false;
var i: usize = 1;
while (i < args.len) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) {
print("{s}", .{HELP_TEXT});
return error.HelpRequested;
} else if (std.mem.eql(u8, arg, "--paths")) {
show_paths = true;
} else if (std.mem.eql(u8, arg, "-v") or std.mem.eql(u8, arg, "--verbose")) {
verbose = true;
} else if (std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--section")) {
// Next argument should be the section number
if (i + 1 >= args.len) {
print("[X] Option {s} requires a section number (1-9)\n", .{arg});
return error.InvalidOption;
}
i += 1;
const section_arg = args[i];
// Validate section number
if (section_arg.len == 0 or section_arg.len > 4) {
print("[X] Invalid section '{s}'. Use 1-9 or subsections like 3ssl\n", .{section_arg});
return error.InvalidSection;
}
// Basic validation - should start with a digit 1-9
if (section_arg[0] < '1' or section_arg[0] > '9') {
print("[X] Invalid section '{s}'. Section must be 1-9\n", .{section_arg});
return error.InvalidSection;
}
// Add section to list
try sections_list.append(allocator, section_arg);
} else if (std.mem.startsWith(u8, arg, "-")) {
print("[X] Unknown option: {s}\nUse -h or --help for usage information\n", .{arg});
return error.InvalidOption;
} else if (keyword.len == 0) {
keyword = arg;
} else {
print("[X] Multiple keywords not supported\n", .{});
return error.MultipleKeywords;
}
}
if (keyword.len == 0) {
print("[X] No keyword provided\n{s}", .{HELP_TEXT});
return error.NoKeyword;
}
// Convert ArrayList to owned slice, or null if empty
const target_sections = if (sections_list.items.len > 0)
try sections_list.toOwnedSlice(allocator)
else
null;
return types.SearchConfig{
.keyword = keyword,
.target_sections = target_sections,
.show_paths = show_paths,
.verbose = verbose,
};
}
// ============================================================================
// TESTS
// ============================================================================
test "parseArgs - basic keyword" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = &[_][:0]u8{ "manwhere", "sleep" };
const config = try parseArgs(allocator, args);
try std.testing.expectEqualStrings("sleep", config.keyword);
try std.testing.expect(config.target_sections == null);
try std.testing.expect(!config.show_paths);
try std.testing.expect(!config.verbose);
}
test "parseArgs - with verbose flag" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = &[_][:0]u8{ "manwhere", "-v", "ls" };
const config = try parseArgs(allocator, args);
try std.testing.expectEqualStrings("ls", config.keyword);
try std.testing.expect(config.verbose);
}
test "parseArgs - with paths flag" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = &[_][:0]u8{ "manwhere", "--paths", "printf" };
const config = try parseArgs(allocator, args);
try std.testing.expectEqualStrings("printf", config.keyword);
try std.testing.expect(config.show_paths);
}
test "parseArgs - with section flag" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = &[_][:0]u8{ "manwhere", "-s", "1", "ls" };
const config = try parseArgs(allocator, args);
try std.testing.expectEqualStrings("ls", config.keyword);
try std.testing.expect(config.target_sections != null);
try std.testing.expectEqual(@as(usize, 1), config.target_sections.?.len);
try std.testing.expectEqualStrings("1", config.target_sections.?[0]);
}
test "parseArgs - with multiple sections" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = &[_][:0]u8{ "manwhere", "-s", "1", "-s", "3", "test" };
const config = try parseArgs(allocator, args);
try std.testing.expectEqualStrings("test", config.keyword);
try std.testing.expect(config.target_sections != null);
try std.testing.expectEqual(@as(usize, 2), config.target_sections.?.len);
try std.testing.expectEqualStrings("1", config.target_sections.?[0]);
try std.testing.expectEqualStrings("3", config.target_sections.?[1]);
}
test "parseArgs - error no keyword" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = &[_][:0]u8{"manwhere"};
const result = parseArgs(allocator, args);
try std.testing.expectError(error.NoKeyword, result);
}
test "parseArgs - error invalid option" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = &[_][:0]u8{ "manwhere", "--invalid", "ls" };
const result = parseArgs(allocator, args);
try std.testing.expectError(error.InvalidOption, result);
}
test "parseArgs - error multiple keywords" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = &[_][:0]u8{ "manwhere", "ls", "cat" };
const result = parseArgs(allocator, args);
try std.testing.expectError(error.MultipleKeywords, result);
}

View file

@ -1,488 +0,0 @@
const std = @import("std");
const types = @import("types.zig");
// Fast man page discovery by directly scanning filesystem
const ManSearcher = struct {
allocator: std.mem.Allocator,
entries: types.ManEntryList,
const Self = @This();
// Common man page directories in priority order
const MAN_DIRS = [_][]const u8{
"/usr/share/man", // Standard Linux/macOS
"/usr/local/share/man", // Local installations
"/opt/homebrew/share/man", // Homebrew on macOS
"/opt/local/share/man", // MacPorts on macOS
"/usr/X11R6/man", // X11 on some systems
"/usr/pkg/man", // pkgsrc on NetBSD
};
// File extensions to look for (compressed and uncompressed)
const EXTENSIONS = [_][]const u8{ "", ".gz", ".bz2", ".xz", ".lz", ".Z" };
pub fn init(allocator: std.mem.Allocator) Self {
return Self{
.allocator = allocator,
.entries = .{},
};
}
pub fn deinit(self: *Self) void {
for (self.entries.items) |entry| {
self.allocator.free(entry.name);
self.allocator.free(entry.section);
self.allocator.free(entry.description);
if (entry.path) |path| self.allocator.free(path);
}
self.entries.deinit(self.allocator);
}
// Extract man page name and section from filename
fn parseManFilename(filename: []const u8) ?struct { name: []const u8, section: []const u8 } {
// Handle compressed extensions first
var base_name = filename;
for (EXTENSIONS[1..]) |ext| { // Skip empty extension
if (std.mem.endsWith(u8, base_name, ext)) {
base_name = base_name[0 .. base_name.len - ext.len];
break;
}
}
// Find the last dot to separate name from section
if (std.mem.lastIndexOf(u8, base_name, ".")) |dot_pos| {
if (dot_pos > 0 and dot_pos < base_name.len - 1) {
const name = base_name[0..dot_pos];
const section = base_name[dot_pos + 1 ..];
// Basic validation - section should be reasonable
if (section.len > 0 and section.len <= 4) {
return .{ .name = name, .section = section };
}
}
}
return null;
}
// Get description from man file header (first line parsing)
fn extractDescription(self: Self, file_path: []const u8) []const u8 {
// Try to read the first few lines to extract description
const file = std.fs.cwd().openFile(file_path, .{}) catch return "";
defer file.close();
var buffer: [1024]u8 = undefined;
const bytes_read = file.read(&buffer) catch return "";
if (bytes_read == 0) return "";
const content = buffer[0..bytes_read];
// For compressed files, we'd need to decompress first
// For now, return empty description for compressed files
if (std.mem.indexOf(u8, file_path, ".gz") != null or
std.mem.indexOf(u8, file_path, ".bz2") != null or
std.mem.indexOf(u8, file_path, ".xz") != null)
{
return "";
}
// Look for NAME section and extract description
var lines = std.mem.splitScalar(u8, content, '\n');
var in_name_section = false;
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r\n");
if (std.mem.eql(u8, trimmed, ".SH NAME") or
std.mem.eql(u8, trimmed, ".SH \"NAME\""))
{
in_name_section = true;
continue;
}
if (in_name_section) {
if (std.mem.startsWith(u8, trimmed, ".SH")) {
break; // End of NAME section
}
// Look for the description line (usually contains \-)
if (std.mem.indexOf(u8, trimmed, "\\-")) |dash_pos| {
const desc_start = dash_pos + 2;
if (desc_start < trimmed.len) {
const desc = std.mem.trim(u8, trimmed[desc_start..], " \t");
return self.allocator.dupe(u8, desc) catch "";
}
}
}
}
return "";
}
// Scan a single man directory section (e.g., man1, man2, etc.)
fn scanManSection(self: *Self, base_dir: []const u8, section: []const u8, show_paths: bool) !void {
var path_buffer: [512]u8 = undefined;
const section_path = try std.fmt.bufPrint(path_buffer[0..], "{s}/man{s}", .{ base_dir, section });
var dir = std.fs.cwd().openDir(section_path, .{ .iterate = true }) catch return;
defer dir.close();
var iterator = dir.iterate();
while (try iterator.next()) |entry| {
if (entry.kind != .file) continue;
// Parse the filename to get name and section
if (parseManFilename(entry.name)) |parsed| {
// Verify section matches directory
if (!std.mem.eql(u8, parsed.section, section)) continue;
const full_path = if (show_paths) blk: {
var full_path_buf: [768]u8 = undefined;
const full_path_str = try std.fmt.bufPrint(full_path_buf[0..], "{s}/{s}", .{ section_path, entry.name });
break :blk try self.allocator.dupe(u8, full_path_str);
} else null;
// Extract description (simplified - just use empty for now)
// In a production implementation, you'd want to parse the man file header
const description = if (show_paths)
self.extractDescription(full_path.?)
else
try self.allocator.dupe(u8, "");
const man_entry = types.ManEntry{
.name = try self.allocator.dupe(u8, parsed.name),
.section = try self.allocator.dupe(u8, parsed.section),
.description = description,
.path = full_path,
};
try self.entries.append(self.allocator, man_entry);
}
}
}
// Main search function - much faster than man -k
// In the searchManPages function of ManSearcher:
pub fn searchManPages(
self: *Self,
keyword: []const u8,
target_sections: ?[]const []const u8,
show_paths: bool,
verbose: bool,
debug_writer: *std.Io.Writer,
) !void {
if (verbose) {
try debug_writer.print("[=] Scanning man page directories directly...\n", .{});
try debug_writer.flush();
}
// Sections to scan - handle multiple sections
const sections = if (target_sections) |sects|
sects // Use provided sections array
else
&[_][]const u8{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "1m", "3c", "3x", "n" };
for (MAN_DIRS) |man_dir| {
// Check if directory exists
var dir = std.fs.cwd().openDir(man_dir, .{}) catch |err| switch (@as(anyerror, err)) {
error.FileNotFound, error.NotDir, error.AccessDenied => continue,
else => return err,
};
dir.close();
if (verbose) {
try debug_writer.print("[=] Scanning {s}...\n", .{man_dir});
try debug_writer.flush();
}
for (sections) |section| {
self.scanManSection(man_dir, section, show_paths) catch |err| switch (@as(anyerror, err)) {
error.FileNotFound, error.NotDir, error.AccessDenied => continue,
else => return err,
};
}
}
// Filter by keyword (case-insensitive search in name)
var filtered_entries = types.ManEntryList{};
defer {
// Move entries to self.entries and clean up filtered_entries
self.entries.deinit(self.allocator);
self.entries = filtered_entries;
}
for (self.entries.items) |entry| {
// Simple case-insensitive substring search
var name_lower: [256]u8 = undefined;
var keyword_lower: [256]u8 = undefined;
if (entry.name.len < name_lower.len and keyword.len < keyword_lower.len) {
_ = std.ascii.lowerString(name_lower[0..entry.name.len], entry.name);
_ = std.ascii.lowerString(keyword_lower[0..keyword.len], keyword);
if (std.mem.indexOf(u8, name_lower[0..entry.name.len], keyword_lower[0..keyword.len]) != null) {
try filtered_entries.append(self.allocator, entry);
} else {
// Clean up unused entry
self.allocator.free(entry.name);
self.allocator.free(entry.section);
self.allocator.free(entry.description);
if (entry.path) |path| self.allocator.free(path);
}
}
}
}
};
// Updated search function that uses direct filesystem scanning
pub fn searchManPagesFast(
keyword: []const u8,
target_section: ?[]const []const u8,
show_paths: bool,
verbose: bool,
allocator: std.mem.Allocator,
stderr: *std.Io.Writer, // Post-writergate: concrete type
) !types.ManEntryList {
var searcher = ManSearcher.init(allocator);
defer searcher.deinit();
// Create debug writer for verbose output - using stderr for debug messages
try searcher.searchManPages(keyword, target_section, show_paths, verbose, stderr);
if (searcher.entries.items.len == 0) {
if (target_section) |sections| {
// Join sections for display
var joined: [32]u8 = undefined;
var pos: usize = 0;
for (sections, 0..) |s, i| {
if (i != 0) {
joined[pos] = ',';
pos += 1;
}
@memcpy(joined[pos .. pos + s.len], s);
pos += s.len;
}
try stderr.print("[X] No matches found for '{s}' in section {s}\n", .{ keyword, joined[0..pos] });
} else {
try stderr.print("[X] No matches found for '{s}'\n", .{keyword});
}
try stderr.flush(); // Don't forget to flush!
}
// Transfer ownership of entries
var result: types.ManEntryList = .{};
try result.appendSlice(allocator, searcher.entries.items);
// Clear the searcher entries so deinit doesn't free them
searcher.entries.clearRetainingCapacity();
return result;
}
// Original implementation as fallback using child process
fn searchManPagesOriginal(
keyword: []const u8,
target_section: ?[]const []const u8,
show_paths: bool,
verbose: bool,
allocator: std.mem.Allocator,
stderr: *std.io.Writer,
) !types.ManEntryList {
_ = show_paths;
_ = verbose;
var child = std.process.Child.init(&[_][]const u8{ "man", "-k", keyword }, allocator);
child.stdout_behavior = .Pipe;
child.stderr_behavior = .Ignore;
try child.spawn();
// In 0.15.1, ArrayList is unmanaged
var output_list = std.ArrayList(u8){};
defer output_list.deinit(allocator);
// Read from child stdout using the new reader interface
var stdout_buffer: [4096]u8 = undefined;
var stdout_reader_wrapper = child.stdout.?.reader(&stdout_buffer);
const reader = &stdout_reader_wrapper.interface;
// Read all data - use readAll or read in a loop
var temp_buffer: [4096]u8 = undefined;
while (true) {
const bytes_read = reader.takeByte() catch |err| switch (err) {
error.EndOfStream => break,
else => return err,
};
if (bytes_read == 0) break;
try output_list.appendSlice(allocator, temp_buffer[0..bytes_read]);
}
std.debug.print("ouptut_list len: {d}\n", .{output_list.items.len});
const term = try child.wait();
if ((term != .Exited or term.Exited != 0) and output_list.items.len == 0) {
if (target_section) |sections| {
// Join sections for display
var joined: [32]u8 = undefined;
var pos: usize = 0;
for (sections, 0..) |s, i| {
if (i != 0) {
joined[pos] = ',';
pos += 1;
}
@memcpy(joined[pos .. pos + s.len], s);
pos += s.len;
}
try stderr.print("[X] No matches found for '{s}' in section {s}\n", .{ keyword, joined[0..pos] });
} else {
try stderr.print("[X] No matches found for '{s}'\n", .{keyword});
}
try stderr.flush();
return .{};
}
var entries: types.ManEntryList = .{};
errdefer {
for (entries.items) |entry| {
allocator.free(entry.name);
allocator.free(entry.section);
allocator.free(entry.description);
if (entry.path) |path| allocator.free(path);
}
entries.deinit(allocator);
}
// Process output line by line
var line_iterator = std.mem.splitScalar(u8, output_list.items, '\n');
while (line_iterator.next()) |raw_line| {
if (raw_line.len == 0) continue;
var cleaned_buffer: [4096]u8 = undefined;
var cleaned_len: usize = 0;
for (raw_line) |byte| {
if (cleaned_len >= cleaned_buffer.len - 1) break;
if (byte >= 32 and byte <= 126) {
cleaned_buffer[cleaned_len] = byte;
cleaned_len += 1;
} else if (byte == '\t') {
cleaned_buffer[cleaned_len] = ' ';
cleaned_len += 1;
} else if (byte >= 128) {
cleaned_buffer[cleaned_len] = '?';
cleaned_len += 1;
}
}
// const cleaned_line = cleaned_buffer[0..cleaned_len];
// if (cleaned_line.len == 0) continue;
//
// _ = cleaned_line; // Your parsing logic here
}
return entries;
}
// Main search function with fallback strategy
pub fn searchManPages(
keyword: []const u8,
target_section: ?[]const []const u8,
show_paths: bool,
verbose: bool,
allocator: std.mem.Allocator,
stderr: *std.Io.Writer, // Post-writergate: concrete type
) !types.ManEntryList {
// Try fast method first, fallback to man -k if needed
return searchManPagesFast(keyword, target_section, show_paths, verbose, allocator, stderr) catch |err| {
if (verbose) {
try stderr.print("[W] Fast search failed ({}), falling back to man -k...\n", .{err});
try stderr.flush(); // Don't forget to flush!
}
// Fallback to original man -k implementation
return searchManPagesOriginal(keyword, target_section, show_paths, verbose, allocator, stderr);
};
}
// ============================================================================
// TESTS
// ============================================================================
test "ManSearcher - basic initialization" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Test the types ManEntryList uses
var entries = types.ManEntryList.init(allocator);
defer {
for (entries.items) |entry| {
allocator.free(entry.name);
allocator.free(entry.section);
allocator.free(entry.description);
}
entries.deinit();
}
try std.testing.expectEqual(@as(usize, 0), entries.items.len);
}
test "ManEntry - full lifecycle" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const entry = types.ManEntry{
.name = try allocator.dupe(u8, "grep"),
.section = try allocator.dupe(u8, "1"),
.description = try allocator.dupe(u8, "global regular expression print"),
.path = try allocator.dupe(u8, "/usr/share/man/man1/grep.1.gz"),
};
defer {
allocator.free(entry.name);
allocator.free(entry.section);
allocator.free(entry.description);
if (entry.path) |path| allocator.free(path);
}
try std.testing.expectEqualStrings("grep", entry.name);
try std.testing.expectEqualStrings("1", entry.section);
try std.testing.expectEqualStrings("global regular expression print", entry.description);
try std.testing.expect(entry.path != null);
try std.testing.expectEqualStrings("/usr/share/man/man1/grep.1.gz", entry.path.?);
}
test "ManEntryList - append multiple" {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var entries = types.ManEntryList.init(allocator);
defer {
for (entries.items) |entry| {
allocator.free(entry.name);
allocator.free(entry.section);
allocator.free(entry.description);
}
entries.deinit();
}
try entries.append(types.ManEntry{
.name = try allocator.dupe(u8, "ls"),
.section = try allocator.dupe(u8, "1"),
.description = try allocator.dupe(u8, "list"),
.path = null,
});
try entries.append(types.ManEntry{
.name = try allocator.dupe(u8, "cat"),
.section = try allocator.dupe(u8, "1"),
.description = try allocator.dupe(u8, "concatenate"),
.path = null,
});
try std.testing.expectEqual(@as(usize, 2), entries.items.len);
try std.testing.expectEqualStrings("ls", entries.items[0].name);
try std.testing.expectEqualStrings("cat", entries.items[1].name);
}

View file

@ -1,168 +0,0 @@
const std = @import("std");
pub const Allocator = std.mem.Allocator;
pub const ManEntry = struct {
name: []const u8,
section: []const u8,
description: []const u8,
path: ?[]const u8 = null,
pub fn getSectionMarker(self: ManEntry) []const u8 {
if (self.section.len == 0) return "[?] Unknown";
// Fast path for common sections
return switch (self.section[0]) {
'1' => "[*] Command",
'2' => "[S] Syscall",
'3' => blk: {
// Check for specific 3xxx subsections (order matters - longer first)
if (std.mem.startsWith(u8, self.section, "3ssl")) break :blk "[L] OpenSSL";
if (std.mem.startsWith(u8, self.section, "3tcl")) break :blk "[L] Tcl API";
if (std.mem.startsWith(u8, self.section, "3pm")) break :blk "[L] Perl";
if (std.mem.startsWith(u8, self.section, "3p")) break :blk "[L] POSIX";
if (std.mem.startsWith(u8, self.section, "3x")) break :blk "[L] X11/ncurses";
break :blk "[L] C Library";
},
'4' => "[D] Device",
'5' => "[F] Format",
'6' => "[G] Game",
'7' => "[M] Misc",
'8' => "[A] Admin",
'9' => "[K] Kernel",
else => "[?] Unknown",
};
}
pub fn matchesSection(self: ManEntry, target_sections: ?[]const []const u8) bool {
if (target_sections == null) return true;
for (target_sections.?) |section| {
if (self.section.len == 0) continue;
if (std.mem.eql(u8, self.section, section)) return true;
if (section.len == 1 and std.mem.startsWith(u8, self.section, section)) return true;
}
return false;
}
};
// Use modern ArrayList instead of custom implementation
pub const ManEntryList = std.ArrayList(ManEntry);
pub const SearchConfig = struct {
keyword: []const u8,
target_sections: ?[]const []const u8,
show_paths: bool,
verbose: bool,
};
// ============================================================================
// TESTS
// ============================================================================
test "ManEntry.getSectionMarker - commands" {
const entry = ManEntry{
.name = "ls",
.section = "1",
.description = "list directory contents",
.path = null,
};
const marker = entry.getSectionMarker();
try std.testing.expectEqualStrings("[*] Command", marker);
}
test "ManEntry.getSectionMarker - syscalls" {
const entry = ManEntry{
.name = "open",
.section = "2",
.description = "open file",
.path = null,
};
const marker = entry.getSectionMarker();
try std.testing.expectEqualStrings("[S] Syscall", marker);
}
test "ManEntry.getSectionMarker - C library" {
const entry = ManEntry{
.name = "printf",
.section = "3",
.description = "format and print data",
.path = null,
};
const marker = entry.getSectionMarker();
try std.testing.expectEqualStrings("[L] C Library", marker);
}
test "ManEntry.getSectionMarker - OpenSSL" {
const entry = ManEntry{
.name = "SSL_new",
.section = "3ssl",
.description = "create SSL structure",
.path = null,
};
const marker = entry.getSectionMarker();
try std.testing.expectEqualStrings("[L] OpenSSL", marker);
}
test "ManEntry.getSectionMarker - POSIX" {
const entry = ManEntry{
.name = "pthread_create",
.section = "3p",
.description = "create thread",
.path = null,
};
const marker = entry.getSectionMarker();
try std.testing.expectEqualStrings("[L] POSIX", marker);
}
test "ManEntry.getSectionMarker - kernel" {
const entry = ManEntry{
.name = "kmalloc",
.section = "9",
.description = "kernel memory alloc",
.path = null,
};
const marker = entry.getSectionMarker();
try std.testing.expectEqualStrings("[K] Kernel", marker);
}
test "ManEntry.getSectionMarker - unknown" {
const entry = ManEntry{
.name = "unknown",
.section = "0",
.description = "invalid section",
.path = null,
};
const marker = entry.getSectionMarker();
try std.testing.expectEqualStrings("[?] Unknown", marker);
}
test "ManEntry.matchesSection - no target" {
const entry = ManEntry{
.name = "ls",
.section = "1",
.description = "list",
.path = null,
};
try std.testing.expect(entry.matchesSection(null));
}
test "ManEntry.matchesSection - exact match" {
const entry = ManEntry{
.name = "ls",
.section = "1",
.description = "list",
.path = null,
};
const targets = &[_][]const u8{"1"};
try std.testing.expect(entry.matchesSection(targets));
}
test "ManEntry.matchesSection - no match" {
const entry = ManEntry{
.name = "ls",
.section = "1",
.description = "list",
.path = null,
};
const targets = &[_][]const u8{"3"};
try std.testing.expect(!entry.matchesSection(targets));
}