ext_path - Path Manipulation
The ext_path crate provides pure string-based path manipulation utilities through the runtime:path module. All operations work consistently across platforms without requiring filesystem access or permissions.
Overview
Path manipulation is a fundamental need in desktop applications. The runtime:path module provides cross-platform utilities for building, parsing, and extracting components from file paths.
Key Capabilities:
- 🔀 Join path segments with platform-appropriate separators
- 📂 Extract directory names, basenames, and extensions
- 🔍 Parse paths into structured components
- 🌍 Automatic cross-platform separator handling
- ⚡ Pure string operations - no filesystem access
- 🛡️ No permissions required
Quick Start
import { join, dirname, basename, extname, parts } from "runtime:path";
// Join path segments - automatically uses correct separatorsconst configPath = join("./data", "config.json");// Unix: "./data/config.json"// Windows: ".\\data\\config.json"
// Extract componentsconst dir = dirname("/usr/local/bin/node"); // "/usr/local/bin"const file = basename("/usr/local/bin/node"); // "node"const ext = extname("document.pdf"); // ".pdf"
// Parse complete pathconst p = parts("./logs/app.log");console.log(p.dir); // "./logs"console.log(p.base); // "app.log"console.log(p.ext); // ".log"Module: runtime:path
Import path manipulation functions:
import { join, // Combine path segments dirname, // Extract directory path basename, // Extract filename extname, // Extract file extension parts // Parse into components} from "runtime:path";Core Concepts
Pure String Operations
All path operations are pure functions - they transform strings without accessing the filesystem:
// These work even if the paths don't existconst path1 = join("./nonexistent", "file.txt");const path2 = dirname("/fake/path/file.txt");const ext = extname("imaginary.jpg");Platform-Appropriate Separators
The extension automatically uses the correct path separator for your platform:
// Same code, different output per platformconst path = join("data", "config", "app.json");
// Unix (macOS/Linux): "data/config/app.json"// Windows: "data\\config\\app.json"Empty Results for Missing Components
Functions return empty strings when components don’t exist (never errors):
dirname("file.txt"); // "" (no directory)extname("README"); // "" (no extension)basename("/path/to/"); // "" (ends with separator)API Reference
Types
PathParts
Result of parsing a path into components:
interface PathParts { dir: string; // Directory path (empty if no directory) base: string; // Base filename including extension ext: string; // File extension including dot (empty if no extension)}Functions
join(base, ...segments): string
Joins path segments into a single path using platform-appropriate separators.
Parameters:
base: string- The base path to start from...segments: string[]- Additional path segments to append
Returns: string - Combined path with platform-appropriate separators
Examples:
// Basic joiningjoin("./data", "config.json")// Unix: "./data/config.json"// Windows: ".\\data\\config.json"
// Multiple segmentsjoin("./assets", "images", "logo.png")// Unix: "./assets/images/logo.png"// Windows: ".\\assets\\images\\logo.png"
// Absolute pathsjoin("/usr", "local", "bin", "node")// Unix: "/usr/local/bin/node"dirname(path): string
Extracts the directory path from a file path.
Parameters:
path: string- The path to extract the directory from
Returns: string - The directory portion, or empty string if none
Examples:
dirname("/usr/local/bin/node") // "/usr/local/bin"dirname("./data/config.json") // "./data"dirname("file.txt") // "" (no directory)dirname("/path/to/") // "/path/to"basename(path): string
Extracts the final component of a path (filename with extension).
Parameters:
path: string- The path to extract the basename from
Returns: string - The filename portion, or empty string if none
Examples:
basename("/usr/local/bin/node") // "node"basename("./data/config.json") // "config.json"basename("readme.md") // "readme.md"basename("/path/to/") // "" (ends with separator)extname(path): string
Extracts the file extension from a path.
Parameters:
path: string- The path to extract the extension from
Returns: string - The extension including the dot, or empty string if none
Examples:
extname("file.txt") // ".txt"extname("archive.tar.gz") // ".gz" (only last extension)extname("README") // "" (no extension)extname(".gitignore") // "" (dot prefix is not an extension)extname(".config.json") // ".json" (has real extension)parts(path): PathParts
Parses a path into its directory, basename, and extension components.
Parameters:
path: string- The path to parse
Returns: PathParts - Object with dir, base, and ext properties
Examples:
parts("/usr/local/bin/node")// { dir: "/usr/local/bin", base: "node", ext: "" }
parts("./data/config.json")// { dir: "./data", base: "config.json", ext: ".json" }
parts("file.txt")// { dir: "", base: "file.txt", ext: ".txt" }Usage Examples
Building Dynamic File Paths
Construct paths programmatically with correct separators:
import { join } from "runtime:path";
function getLogPath(appName: string, date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0');
return join("./logs", appName, `${year}-${month}-${day}.log`);}
const logPath = getLogPath("myapp", new Date());// "./logs/myapp/2025-12-19.log"Validating File Extensions
Check file types using extension extraction:
import { extname } from "runtime:path";
function isImageFile(path: string): boolean { const ext = extname(path).toLowerCase(); return ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);}
function isMarkdownFile(path: string): boolean { return extname(path).toLowerCase() === '.md';}
console.log(isImageFile("photo.jpg")); // trueconsole.log(isImageFile("document.pdf")); // falseconsole.log(isMarkdownFile("README.md")); // trueGenerating Output Paths
Create modified versions of existing paths:
import { parts, join } from "runtime:path";
// Add suffix before extensionfunction getOutputPath(inputPath: string, suffix: string): string { const p = parts(inputPath); const baseName = p.base.slice(0, -p.ext.length); return join(p.dir, `${baseName}${suffix}${p.ext}`);}
const output = getOutputPath("./video.mp4", ".compressed");console.log(output); // "./video.compressed.mp4"
// Create thumbnail pathfunction getThumbnailPath(imagePath: string): string { const p = parts(imagePath); return join(p.dir, `thumb_${p.base}`);}
const thumb = getThumbnailPath("./images/photo.jpg");console.log(thumb); // "./images/thumb_photo.jpg"Path Component Analysis
Extract and analyze path components:
import { dirname, basename, extname } from "runtime:path";
function analyzeFilePath(path: string) { const ext = extname(path); const base = basename(path); const nameWithoutExt = base.slice(0, -ext.length);
return { directory: dirname(path), filename: base, extension: ext, nameOnly: nameWithoutExt };}
const info = analyzeFilePath("./docs/guide.md");console.log(info);// {// directory: "./docs",// filename: "guide.md",// extension: ".md",// nameOnly: "guide"// }Integration with Filesystem
Combine with runtime:fs for file operations:
import { join, extname } from "runtime:path";import { readTextFile, writeTextFile, readDir } from "runtime:fs";
// Load config file from app directoryasync function loadConfig(appDir: string): Promise<object> { const configPath = join(appDir, "config.json"); const content = await readTextFile(configPath); return JSON.parse(content);}
// Process all markdown files in directoryasync function processMarkdownFiles(dir: string): Promise<void> { const entries = await readDir(dir);
for (const entry of entries) { if (entry.isFile && extname(entry.name) === '.md') { const filePath = join(dir, entry.name); const content = await readTextFile(filePath); const processed = content.toUpperCase(); // Example processing await writeTextFile(filePath, processed); } }}Best Practices
✅ Do: Use join() for All Path Construction
Always use join() instead of manual string concatenation:
// ✅ Correct - cross-platformconst path = join(baseDir, "data", "config.json");
// ❌ Wrong - breaks on Windowsconst path = `${baseDir}/data/config.json`;const path = baseDir + "/data/config.json";✅ Do: Let the Extension Handle Separators
Don’t hardcode path separators:
// ✅ Correctconst parts = ["home", "user", "documents"];const path = join(...parts);
// ❌ Wrongconst path = parts.join('/'); // Breaks on Windows✅ Do: Use parts() for Complex Path Manipulation
When you need multiple components, use parts():
// ✅ Correct - single operationconst p = parts(filepath);const newPath = join(p.dir, `modified_${p.base}`);
// ❌ Less efficient - multiple operationsconst dir = dirname(filepath);const base = basename(filepath);const newPath = join(dir, `modified_${base}`);✅ Do: Check for Empty Results
Handle cases where components don’t exist:
const dir = dirname(filepath);if (dir === "") { // File is in current directory or has no directory component console.log("No directory component");}Common Pitfalls
❌ Assuming Separators
Don’t split paths on hardcoded separators:
// ❌ Wrong - breaks on Windowsconst parts = filepath.split('/');
// ✅ Correct - use dirname and basenameconst dir = dirname(filepath);const file = basename(filepath);❌ Concatenating Paths Manually
Don’t build paths with string concatenation:
// ❌ Wrong - separator issuesconst path = dir + '/' + filename;
// ✅ Correct - use joinconst path = join(dir, filename);❌ Expecting Multiple Extensions
extname() only returns the last extension:
const ext = extname("archive.tar.gz"); // ".gz" not ".tar.gz"
// If you need all extensions, use string operations:const fullPath = "archive.tar.gz";const allExts = fullPath.substring(fullPath.indexOf('.')); // ".tar.gz"❌ Treating Dot Prefix as Extension
Hidden files with dot prefixes don’t have extensions:
extname(".gitignore") // "" (not ".gitignore")
// Files with both prefix and extension work correctly:extname(".config.json") // ".json"Edge Cases
Hidden Files
Dot prefixes are not treated as file extensions:
basename(".gitignore") // ".gitignore"extname(".gitignore") // ""
basename(".config.json") // ".config.json"extname(".config.json") // ".json"Trailing Separators
Paths ending with separators return empty basename:
dirname("/path/to/") // "/path/to"basename("/path/to/") // ""Root Paths
Root directory behavior:
dirname("/") // "/"dirname("C:\\") // "C:\\" (Windows)basename("/") // ""Empty Strings
All functions handle empty input gracefully:
dirname("") // ""basename("") // ""extname("") // ""parts("") // { dir: "", base: "", ext: "" }Platform Support
Cross-Platform Separators
| Platform | Separator | Example |
|---|---|---|
| Unix (macOS/Linux) | / | /usr/local/bin |
| Windows | \ | C:\Program Files |
The extension automatically uses the correct separator for the current platform.
Forward Slash Input
You can use forward slashes in your TypeScript code - they work on all platforms as input:
// This works on all platforms (forward slashes in input)const path = join("./data", "config.json");
// Output automatically uses platform separator:// Unix: "./data/config.json"// Windows: ".\\data\\config.json"No Permissions Required
Unlike filesystem operations (runtime:fs), path utilities:
- ✅ Don’t access the filesystem
- ✅ Don’t require permissions in
manifest.app.toml - ✅ Work with any path string (existing or not)
- ✅ Are pure string transformations
- ✅ Never fail or throw errors
No configuration needed:
# No [permissions.path] section needed - operations are always allowedImplementation
Rust Backend
Operations use Rust’s std::path::Path for platform-specific handling:
use std::path::{Path, PathBuf};
fn path_join(base: String, segments: Vec<String>) -> String { let mut pb = PathBuf::from(base); for seg in segments { pb.push(seg); } pb.to_string_lossy().to_string()}
fn path_dirname(path: &str) -> String { Path::new(&path) .parent() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default()}
fn path_extname(path: &str) -> String { Path::new(&path) .extension() .map(|p| format!(".{}", p.to_string_lossy())) .unwrap_or_default()}Extension Registration
Registered as a Tier 0 (ExtensionOnly) extension - no state initialization required:
ExtensionDescriptor { id: "runtime_path", init_fn: ExtensionInitFn::ExtensionOnly(path_extension), tier: ExtensionTier::ExtensionOnly, required: false,}Code Generation
TypeScript bindings generated via forge-weld:
ExtensionBuilder::new("runtime_path", "runtime:path") .ts_path("ts/init.ts") .ops(&[ "op_path_join", "op_path_dirname", "op_path_basename", "op_path_extname", "op_path_parts" ]) .use_inventory_types() .generate_sdk_module("../../sdk") .build()File Structure
crates/ext_path/├── src/│ └── lib.rs # Path manipulation implementation├── ts/│ └── init.ts # TypeScript module with JSDoc├── build.rs # forge-weld configuration├── Cargo.toml # Crate metadata└── README.md # Developer documentationDependencies
| Dependency | Purpose |
|---|---|
deno_core | Op definitions and extension system |
serde | Serialization for PathParts struct |
forge-weld-macro | #[weld_op] and #[weld_struct] macros |
linkme | Compile-time symbol collection |
Related Extensions
- ext_fs - Filesystem operations (read, write, stat)
- ext_process - Process spawning with working directories
- ext_os_compat - OS compatibility utilities
See Also
- Getting Started Guide - Introduction to Forge
- Architecture - System architecture overview
- Rust std::path - Underlying Rust implementation