smartsh
Version:
Cross-shell command runner enabling Unix-style syntax on any OS.
739 lines (646 loc) • 25 kB
text/typescript
console.log('src/translate.ts LOADED');
import { translateSingleUnixSegment } from "./unixMappings";
import { tokenizeWithPos, tagTokenRoles, tokenizeWithPosEnhancedAndRoles } from "./tokenize";
import { translateForShell } from "./shellMappings";
import { translateBidirectional } from "./bidirectionalMappings";
// -----------------------------
// Lint support helpers
// -----------------------------
import { MAPPINGS } from "./unixMappings";
const DYNAMIC_CMDS = [
"head",
"tail",
"wc",
"sleep",
"whoami",
"sed",
"awk",
"cut",
"tr",
"uniq",
"sort",
"find",
"xargs",
"echo",
"nl"
];
const SUPPORTED_COMMANDS = new Set<string>([...MAPPINGS.map((m) => m.unix), ...DYNAMIC_CMDS]);
export function lintCommand(cmd: string): { unsupported: string[]; suggestions: string[] } {
const unsupported: string[] = [];
const suggestions: string[] = [];
// Pre-compute allowed flag sets for static mappings
const STATIC_ALLOWED_FLAGS: Record<string, Set<string>> = Object.fromEntries(
MAPPINGS.map((m) => [m.unix, new Set(Object.keys(m.flagMap))])
);
// Manual definitions for dynamic translators that aren't part of MAPPINGS
const DYNAMIC_ALLOWED_FLAGS: Record<string, Set<string>> = {
uniq: new Set(["-c"]),
sort: new Set(["-n"]),
cut: new Set(["-d", "-f"]),
tr: new Set([]),
find: new Set(["-name", "-type", "-delete", "-exec"]),
xargs: new Set(["-0"]),
sed: new Set(["-n"]),
};
const connectorParts = splitByConnectors(cmd).filter((p) => p !== "&&" && p !== "||");
for (const part of connectorParts) {
const pipeParts = splitByPipe(part);
for (const seg of pipeParts) {
const trimmed = seg.trim();
if (!trimmed) continue;
if (trimmed.startsWith("(") || trimmed.startsWith("{")) continue; // skip subshell/grouping
const tokens = tokenizeWithPosEnhancedAndRoles(trimmed);
const cmdTok = tokens.find((t) => t.role === "cmd");
if (!cmdTok) continue;
const c = cmdTok.value;
// 1. Unknown command
if (!SUPPORTED_COMMANDS.has(c)) {
unsupported.push(`${trimmed} (unknown command: '${c}')`);
// Provide suggestions for common typos
const cmdSuggestions = getCommandSuggestions(c);
if (cmdSuggestions.length > 0) {
suggestions.push(` Did you mean: ${cmdSuggestions.join(", ")}?`);
}
continue;
}
// 2. Unknown flags for supported command
const allowedFlags = STATIC_ALLOWED_FLAGS[c] ?? DYNAMIC_ALLOWED_FLAGS[c];
if (allowedFlags) {
const flagToks = tokens.filter((t) => t.role === "flag");
for (const fTok of flagToks) {
if (!allowedFlags.has(fTok.value)) {
unsupported.push(`${trimmed} (unsupported flag: '${fTok.value}' for '${c}')`);
// Provide flag suggestions
const flagSuggestions = getFlagSuggestions(c, fTok.value, allowedFlags);
if (flagSuggestions.length > 0) {
suggestions.push(` Available flags for '${c}': ${Array.from(allowedFlags).join(", ")}`);
}
break; // Only report once per segment
}
}
}
}
}
return { unsupported, suggestions };
}
function getCommandSuggestions(unknownCmd: string): string[] {
const allCommands = [...MAPPINGS.map((m) => m.unix), ...DYNAMIC_CMDS];
const suggestions: string[] = [];
// Simple Levenshtein-like matching for common typos
for (const cmd of allCommands) {
if (cmd.length >= 3 && (
cmd.includes(unknownCmd) ||
unknownCmd.includes(cmd) ||
Math.abs(cmd.length - unknownCmd.length) <= 2
)) {
suggestions.push(cmd);
if (suggestions.length >= 3) break; // Limit to 3 suggestions
}
}
return suggestions;
}
function getFlagSuggestions(cmd: string, unknownFlag: string, allowedFlags: Set<string>): string[] {
const suggestions: string[] = [];
// Look for similar flags
for (const flag of Array.from(allowedFlags)) {
if (flag.includes(unknownFlag) || unknownFlag.includes(flag)) {
suggestions.push(flag);
if (suggestions.length >= 3) break;
}
}
return suggestions;
}
export type ShellType = "bash" | "powershell" | "cmd" | "ash" | "dash" | "zsh" | "fish" | "ksh" | "tcsh";
export interface ShellInfo {
type: ShellType;
/**
* Whether this shell natively understands Unix-style conditional connectors (&&, ||).
*/
supportsConditionalConnectors: boolean;
/** Only set for PowerShell */
version?: number | null;
/**
* Whether this shell needs Unix command translations
*/
needsUnixTranslation: boolean;
/**
* The target shell for command translations
*/
targetShell: "powershell" | "cmd" | "bash" | "ash" | "dash" | "zsh" | "fish" | "ksh" | "tcsh";
}
// Allow users to override shell detection via environment variable.
const OVERRIDE_SHELL = (process.env.SMARTSH_SHELL as ShellInfo["type"] | undefined)?.toLowerCase() as ShellInfo["type"] | undefined;
// Optional debug helper controlled by env var
const DEBUG = process.env.SMARTSH_DEBUG === "1" || process.env.SMARTSH_DEBUG === "true";
function debugLog(...args: unknown[]) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.log("[smartsh/sm debug]", ...args);
}
}
/**
* Attempt to synchronously determine the installed PowerShell major version.
* Returns `null` if PowerShell is unavailable or version cannot be determined.
*/
function getPowerShellVersionSync(): number | null {
const { execSync } = require("child_process");
// Be conservative: check traditional powershell first (most common)
const candidates = ["powershell", "pwsh"];
for (const cmd of candidates) {
try {
const output: string = execSync(
`${cmd} -NoProfile -Command "$PSVersionTable.PSVersion.Major"`,
{
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
windowsHide: true,
timeout: 3000,
}
).trim();
const major = parseInt(output, 10);
if (!isNaN(major)) {
debugLog(`Detected PowerShell version ${major} via '${cmd}'.`);
return major;
}
} catch (err: any) {
// If the executable isn't found (ENOENT) just continue; anything else we log in debug mode.
if (err?.code !== "ENOENT" && DEBUG) {
console.error("[smartsh debug]", `Failed to probe '${cmd}':`, err.message ?? err);
}
}
}
debugLog("Unable to determine PowerShell version.");
return null;
}
/**
* Best-effort detection of the current interactive shell and its capabilities.
* Users can override detection by setting SMARTSH_SHELL=cmd|powershell|bash.
*/
export function detectShell(): ShellInfo {
// 1. Honor explicit override
if (OVERRIDE_SHELL) {
debugLog(`Using shell override: ${OVERRIDE_SHELL}`);
if (OVERRIDE_SHELL === "powershell") {
const version = getPowerShellVersionSync();
return {
type: "powershell",
version,
supportsConditionalConnectors: version !== null && version >= 7,
needsUnixTranslation: true,
targetShell: "powershell",
};
}
return {
type: OVERRIDE_SHELL,
supportsConditionalConnectors: true,
needsUnixTranslation: true,
targetShell: OVERRIDE_SHELL,
};
}
// 2. Platform-specific heuristics
if (process.platform === "win32") {
const isCmd = Boolean(process.env.PROMPT) && !process.env.PSModulePath;
if (isCmd) {
debugLog("Detected CMD via PROMPT env.");
return { type: "cmd", supportsConditionalConnectors: true, needsUnixTranslation: true, targetShell: "cmd" };
}
// If PSModulePath is set we are likely in PowerShell (cmd.exe doesn't set it)
if (process.env.PSModulePath) {
const version = getPowerShellVersionSync();
return {
type: "powershell",
version,
supportsConditionalConnectors: version !== null && version >= 7,
needsUnixTranslation: true,
targetShell: "powershell",
};
}
// Check parent process executable via ComSpec; still could be CMD if user launched from there.
const comspec = process.env.ComSpec?.toLowerCase();
if (comspec?.includes("cmd.exe")) {
debugLog("Detected CMD via ComSpec path.");
return { type: "cmd", supportsConditionalConnectors: true, needsUnixTranslation: true, targetShell: "cmd" };
}
// Detect Git Bash / WSL bash via SHELL env even on Windows
const shellEnv = process.env.SHELL?.toLowerCase();
if (shellEnv && shellEnv.includes("bash")) {
debugLog("Detected Bash on Windows via SHELL env:", shellEnv);
return { type: "bash", supportsConditionalConnectors: true, needsUnixTranslation: true, targetShell: "bash" };
}
// Fallback to PowerShell
const version = getPowerShellVersionSync();
return {
type: "powershell",
version,
supportsConditionalConnectors: version !== null && version >= 7,
needsUnixTranslation: true,
targetShell: "powershell",
};
}
// 3. Non-Windows: detect specific shell types
const shellPath = process.env.SHELL;
if (shellPath) {
debugLog(`Detected Unix shell via SHELL env: ${shellPath}`);
// Extract shell name from path
const shellName = shellPath.split('/').pop()?.toLowerCase() || '';
// Map shell names to types
if (shellName.includes('ash') || shellName.includes('busybox')) {
return {
type: "ash",
supportsConditionalConnectors: true,
needsUnixTranslation: false, // ash is already Unix-like
targetShell: "ash"
};
}
if (shellName.includes('dash')) {
return {
type: "dash",
supportsConditionalConnectors: true,
needsUnixTranslation: false, // dash is already Unix-like
targetShell: "dash"
};
}
if (shellName.includes('zsh')) {
return {
type: "zsh",
supportsConditionalConnectors: true,
needsUnixTranslation: false, // zsh is already Unix-like
targetShell: "zsh"
};
}
if (shellName.includes('fish')) {
return {
type: "fish",
supportsConditionalConnectors: true,
needsUnixTranslation: false, // fish is already Unix-like
targetShell: "fish"
};
}
if (shellName.includes('ksh')) {
return {
type: "ksh",
supportsConditionalConnectors: true,
needsUnixTranslation: false, // ksh is already Unix-like
targetShell: "ksh"
};
}
if (shellName.includes('tcsh')) {
return {
type: "tcsh",
supportsConditionalConnectors: true,
needsUnixTranslation: false, // tcsh is already Unix-like
targetShell: "tcsh"
};
}
if (shellName.includes('bash')) {
return {
type: "bash",
supportsConditionalConnectors: true,
needsUnixTranslation: false, // bash is already Unix-like
targetShell: "bash"
};
}
}
// Fallback to bash for Unix-like systems
return {
type: "bash",
supportsConditionalConnectors: true,
needsUnixTranslation: false, // assume Unix-like environment
targetShell: "bash"
};
}
/**
* Translate a Unix-style command string (using && and ||) to something that
* preserves conditional semantics on shells that *don’t* natively support them
* (currently: legacy PowerShell < 7).
*/
export function translateCommand(command: string, shell: ShellInfo): string {
// Detect input format
const inputInfo = parseInput(command);
// If input is Unix and shell needs translation, use existing logic
if (inputInfo.format === "unix" && shell.needsUnixTranslation) {
// First translate any supported Unix commands inside each segment
const parts = splitByConnectors(command).map((part) => {
if (part === "&&" || part === "||") return part;
// Handle pipe-separated subsegments
const pipeParts = splitByPipe(part);
const translatedPipeParts = pipeParts.map((segment) => {
return translateSingleUnixSegmentForShell(segment, shell.targetShell);
});
return translatedPipeParts.join(" | ");
});
const unixTranslated = parts.join(" ");
// Handle backtick-escaped operators for PowerShell compatibility
const finalResult = handleBacktickEscapedOperators(unixTranslated);
// Handle conditional connectors for shells that don't support them natively
if (shell.supportsConditionalConnectors) {
return finalResult;
}
// Legacy PowerShell needs special handling for conditional connectors
if (shell.type === "powershell") {
return translateForLegacyPowerShell(finalResult);
}
return finalResult;
}
// If input is PowerShell or CMD, use bidirectional translation
if (inputInfo.format === "powershell" || inputInfo.format === "cmd") {
const parts = splitByConnectors(command).map((part) => {
if (part === "&&" || part === "||") return part;
// Handle pipe-separated subsegments
const pipeParts = splitByPipe(part);
const translatedPipeParts = pipeParts.map((segment) => {
return translateSingleSegmentBidirectional(segment, inputInfo.format, shell.targetShell);
});
return translatedPipeParts.join(" | ");
});
const translated = parts.join(" ");
// Handle conditional connectors for shells that don't support them natively
if (shell.supportsConditionalConnectors) {
return translated;
}
// Legacy PowerShell needs special handling for conditional connectors
if (shell.type === "powershell") {
return translateForLegacyPowerShell(translated);
}
return translated;
}
// Shells that don't need translation (Unix-like shells): just return original
return command;
}
// Handle PowerShell backtick-escaped operators by converting them to quoted versions
function handleBacktickEscapedOperators(cmd: string): string {
// Convert `&`& to '&&' and `|`| to '||' for PowerShell compatibility
return cmd
.replace(/`&`&/g, "'&&'")
.replace(/`\|`\|/g, "'||'");
}
// Modify splitByConnectors to handle backslash-escaped quotes inside quoted strings.
function splitByConnectors(cmd: string): (string | "&&" | "||")[] {
const parts: (string | "&&" | "||")[] = [];
const tokens = tokenizeWithPosEnhancedAndRoles(cmd);
let segmentStart = 0;
let parenDepth = 0;
let braceDepth = 0;
for (const t of tokens) {
if (t.value === "(") parenDepth++;
else if (t.value === ")") parenDepth = Math.max(0, parenDepth - 1);
else if (t.value === "{") braceDepth++;
else if (t.value === "}") braceDepth = Math.max(0, braceDepth - 1);
if (parenDepth === 0 && braceDepth === 0 && (t.value === "&&" || t.value === "||")) {
const chunk = cmd.slice(segmentStart, t.start).trim();
if (chunk) parts.push(chunk);
parts.push(t.value as "&&" | "||");
segmentStart = t.end;
}
}
const last = cmd.slice(segmentStart).trim();
if (last) parts.push(last);
return parts;
}
// Split a segment by top-level pipe (|) operators while preserving subshell/grouped
// expressions like ( ... | ... ) or { ... | ... }. We scan the token stream and
// keep track of parentheses/brace depth; a pipe only acts as a delimiter when
// we are *not* inside any grouping depth. This mirrors the logic in
// splitByConnectors above and prevents accidental splitting inside subshells.
function splitByPipe(segment: string): string[] {
const tokens = tokenizeWithPos(segment); // we only need raw tokens/positions
const parts: string[] = [];
let lastPos = 0;
let parenDepth = 0;
let braceDepth = 0;
for (const t of tokens) {
// Track grouping depth
if (t.value === "(") {
parenDepth++;
} else if (t.value === ")") {
parenDepth = Math.max(0, parenDepth - 1);
} else if (t.value === "{") {
braceDepth++;
} else if (t.value === "}") {
braceDepth = Math.max(0, braceDepth - 1);
}
// Only treat | as a delimiter at top level (no nested grouping)
if (parenDepth === 0 && braceDepth === 0 && t.value === "|") {
const chunk = segment.slice(lastPos, t.start).trim();
if (chunk) parts.push(chunk);
lastPos = t.end; // skip past the pipe character
}
}
// Add whatever remains after the last pipe (or the whole string if none)
const tail = segment.slice(lastPos).trim();
if (tail) parts.push(tail);
return parts;
}
function translateForLegacyPowerShell(command: string): string {
const tokens = splitByConnectors(command);
if (tokens.length === 0) return command;
// Build a script that chains commands while preserving conditional logic.
let script = tokens[0] as string;
for (let i = 1; i < tokens.length; i += 2) {
const connector = tokens[i] as "&&" | "||";
const nextCmd = tokens[i + 1] as string;
if (connector === "&&") {
script += `; if ($?) { ${nextCmd} }`;
} else {
script += `; if (-not $?) { ${nextCmd} }`;
}
}
return script;
}
// Re-export for unit tests only (not part of public API)
export { splitByConnectors as __test_splitByConnectors };
/**
* Translate a single Unix segment for a specific target shell
*/
function translateSingleUnixSegmentForShell(segment: string, targetShell: string): string {
// For PowerShell, use the existing translation logic
if (targetShell === "powershell") {
// Parse the segment
const roleTokens = tokenizeWithPosEnhancedAndRoles(segment);
if (roleTokens.length === 0) return segment;
const cmdToken = roleTokens.find((t) => t.role === "cmd");
if (!cmdToken) return segment;
const cmd = cmdToken.reconstructedValue;
const flagTokens = roleTokens.filter((t) => t.role === "flag").map((t) => t.reconstructedValue);
const argTokens = roleTokens.filter((t) => t.role === "arg").map((t) => t.reconstructedValue);
// For certain commands that need complex dynamic logic, always use dynamic translation
if (cmd === "cut" || cmd === "find" || cmd === "awk" || cmd === "sed") {
return translateSingleUnixSegment(segment);
}
// Try static mapping with parsed tokens
const result = translateForShell(cmd, targetShell, flagTokens, argTokens);
// If static mapping didn't translate, use dynamic logic (compare to input segment, not just cmd)
if (result === cmd || result === segment) {
return translateSingleUnixSegment(segment);
}
return result;
}
// For other shells, use the new shell-specific translation
if (segment.includes("${")) {
return segment;
}
const trimmed = segment.trim();
if (trimmed.startsWith("(") || trimmed.startsWith("{")) {
return segment;
}
// Tokenise using the shared helpers
const roleTokens = tokenizeWithPosEnhancedAndRoles(segment);
if (roleTokens.length === 0) return segment;
let hasHereDoc = roleTokens.some((t) => t.value === "<<");
const tokensValues = roleTokens.map((t) => t.value);
for (let i = 0; i < tokensValues.length - 1; i++) {
if (tokensValues[i] === "<" && tokensValues[i + 1] === "<") {
hasHereDoc || (hasHereDoc = true);
break;
}
}
if (hasHereDoc) {
return segment;
}
const tokens = roleTokens.map((t) => t.reconstructedValue);
const flagTokens = roleTokens.filter((t) => t.role === "flag").map((t) => t.reconstructedValue);
const argTokens = roleTokens.filter((t) => t.role === "arg").map((t) => t.reconstructedValue);
// First command token gives us the Unix command name
const cmdToken = roleTokens.find((t) => t.role === "cmd");
if (!cmdToken) return segment;
const cmd = cmdToken.reconstructedValue;
// Use shell-specific translation
return translateForShell(cmd, targetShell, flagTokens, argTokens);
}
/**
* Translate a single segment using bidirectional translation
*/
function translateSingleSegmentBidirectional(segment: string, sourceFormat: string, targetShell: string): string {
// Handle special cases that don't need translation
if (segment.includes("${")) {
return segment;
}
const trimmed = segment.trim();
if (trimmed.startsWith("(") || trimmed.startsWith("{")) {
return segment;
}
// Tokenise using the shared helpers
const roleTokens = tokenizeWithPosEnhancedAndRoles(segment);
if (roleTokens.length === 0) return segment;
let hasHereDoc = roleTokens.some((t) => t.value === "<<");
const tokensValues = roleTokens.map((t) => t.value);
for (let i = 0; i < tokensValues.length - 1; i++) {
if (tokensValues[i] === "<" && tokensValues[i + 1] === "<") {
hasHereDoc || (hasHereDoc = true);
break;
}
}
if (hasHereDoc) {
return segment;
}
const tokens = roleTokens.map((t) => t.reconstructedValue);
const flagTokens = roleTokens.filter((t) => t.role === "flag").map((t) => t.reconstructedValue);
const argTokens = roleTokens.filter((t) => t.role === "arg").map((t) => t.reconstructedValue);
// First command token gives us the command name
const cmdToken = roleTokens.find((t) => t.role === "cmd");
if (!cmdToken) return segment;
const cmd = cmdToken.reconstructedValue;
// For Unix → PowerShell translation, use the existing logic (preserves existing functionality)
if (sourceFormat === "unix" && targetShell === "powershell") {
// Always use existing logic for find command (it has special handling for -delete and -exec)
if (cmd === "find") {
return translateSingleUnixSegment(segment);
}
// For other commands, try bidirectional first, fallback to existing logic
const result = translateBidirectional(cmd, sourceFormat, targetShell, flagTokens, argTokens);
if (result === cmd) {
return translateSingleUnixSegment(segment);
}
return result;
}
// For same-shell translations (no translation needed)
if ((sourceFormat === "unix" && targetShell === "bash") ||
(sourceFormat === "unix" && targetShell === "ash") ||
(sourceFormat === "unix" && targetShell === "dash") ||
(sourceFormat === "unix" && targetShell === "zsh") ||
(sourceFormat === "unix" && targetShell === "fish") ||
(sourceFormat === "unix" && targetShell === "ksh") ||
(sourceFormat === "unix" && targetShell === "tcsh") ||
(sourceFormat === "powershell" && targetShell === "powershell") ||
(sourceFormat === "cmd" && targetShell === "cmd")) {
return segment; // No translation needed
}
// Use bidirectional translation for all other cases (PowerShell → Unix, CMD → Unix, etc.)
const result = translateBidirectional(cmd, sourceFormat, targetShell, flagTokens, argTokens);
return result;
}
/**
* Quote backtick-escaped operators for PowerShell compatibility
*/
function quoteBacktickEscapedOperators(segment: string): string {
// Replace backtick-escaped && and || with quoted versions
return segment
.replace(/\`&\`&/g, "'&&'")
.replace(/\`\|\`\|/g, "'||'");
}
export type InputFormat = "unix" | "powershell" | "cmd";
export interface InputInfo {
format: InputFormat;
command: string;
}
/**
* Detect the input format of a command
*/
export function detectInputFormat(command: string): InputFormat {
// PowerShell indicators
if (command.includes("Remove-Item") ||
command.includes("Get-ChildItem") ||
command.includes("Copy-Item") ||
command.includes("Move-Item") ||
command.includes("New-Item") ||
command.includes("Get-Content") ||
command.includes("Select-String") ||
command.includes("Write-Host") ||
command.includes("Clear-Host") ||
command.includes("Get-Location") ||
command.includes("$env:") ||
command.includes("Invoke-")) {
return "powershell";
}
// Unix command indicators (check these before CMD to avoid false positives)
if (/\bfind\b/.test(command) ||
/\bgrep\b/.test(command) ||
/\bls\b/.test(command) ||
/\bcat\b/.test(command) ||
/\brm\b/.test(command) ||
/\bcp\b/.test(command) ||
/\bmv\b/.test(command) ||
/\bmkdir\b/.test(command) ||
/\bchmod\b/.test(command) ||
/\bchown\b/.test(command) ||
/\bsed\b/.test(command) ||
/\bawk\b/.test(command)) {
return "unix";
}
// CMD indicators (check for word boundaries to avoid false positives)
if (/\bdel\b/.test(command) ||
/\bdir\b/.test(command) ||
/\bcopy\b/.test(command) ||
/\bmove\b/.test(command) ||
/\bmd\b/.test(command) ||
/\btype\b/.test(command) ||
/\bfindstr\b/.test(command) ||
/\bcls\b/.test(command) ||
/\bcd\b/.test(command) ||
/\becho\s+%/.test(command) ||
/\btasklist\b/.test(command) ||
/\btaskkill\b/.test(command)) {
return "cmd";
}
// Default to Unix (most common case)
return "unix";
}
/**
* Parse command into InputInfo
*/
export function parseInput(command: string): InputInfo {
return {
format: detectInputFormat(command),
command: command
};
}