lsh-framework
Version:
A powerful, extensible shell with advanced job management, database persistence, and modern CLI features
386 lines (385 loc) • 15.1 kB
JavaScript
/**
* POSIX Parameter and Variable Expansion Implementation
* Implements POSIX.1-2017 Section 2.6 Parameter Expansion
*/
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
export class VariableExpander {
context;
constructor(context) {
this.context = context;
}
updateContext(updates) {
this.context = { ...this.context, ...updates };
}
async expandString(input) {
let result = input;
// Process in order: parameter expansion, command substitution, arithmetic expansion
result = await this.processParameterExpansion(result);
result = await this.processCommandSubstitution(result);
result = await this.processArithmeticExpansion(result);
return result;
}
async processParameterExpansion(input) {
// Handle ${parameter} and $parameter forms
// More comprehensive regex to handle all parameter expansion forms
const paramRegex = /\$\{([^}]+)\}|\$([a-zA-Z_][a-zA-Z0-9_]*|\d+|[*@#?$!-])/g;
let result = input;
// Process all matches from right to left to avoid index shifting issues
const matches = Array.from(input.matchAll(paramRegex));
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const fullMatch = match[0];
const paramExpr = match[1] || match[2];
const startIndex = match.index;
const endIndex = startIndex + fullMatch.length;
const expandedValue = await this.expandParameter(paramExpr);
// Replace the match with the expanded value
result = result.slice(0, startIndex) + expandedValue + result.slice(endIndex);
}
return result;
}
async expandParameter(paramExpr) {
// Handle different parameter expansion forms
// String length: ${#VAR}
if (paramExpr.startsWith('#')) {
return this.handleStringLength(paramExpr.substring(1));
}
// Substring extraction: ${VAR:offset:length}
if (paramExpr.match(/^[^:]+:-?\d+/)) {
return this.handleSubstring(paramExpr);
}
// Case conversion: ${VAR^}, ${VAR,}, ${VAR^^}, ${VAR,,}
if (paramExpr.match(/\^+$|,+$/)) {
return this.handleCaseConversion(paramExpr);
}
// Check for parameter expansion operators
if (paramExpr.includes(':-')) {
return this.handleDefaultValue(paramExpr, ':-');
}
else if (paramExpr.includes(':=')) {
return this.handleAssignDefault(paramExpr, ':=');
}
else if (paramExpr.includes(':?')) {
return this.handleErrorIfNull(paramExpr, ':?');
}
else if (paramExpr.includes(':+')) {
return this.handleAlternativeValue(paramExpr, ':+');
}
else if (paramExpr.includes('%')) {
return this.handleSuffixRemoval(paramExpr);
}
else if (paramExpr.includes('#')) {
return this.handlePrefixRemoval(paramExpr);
}
else {
// Simple parameter expansion
return this.getParameterValue(paramExpr);
}
}
getParameterValue(param) {
// Handle special parameters
if (param in this.context.specialParams) {
const value = this.context.specialParams[param];
return Array.isArray(value) ? value.join(' ') : value;
}
// Handle positional parameters
if (/^\d+$/.test(param)) {
const index = parseInt(param, 10);
if (index === 0)
return this.context.specialParams['0'];
const value = this.context.positionalParams[index - 1];
// Implement set -u (nounset): error on unset parameters
if (this.context.options?.nounset && value === undefined) {
throw new Error(`${param}: parameter not set`);
}
return value || '';
}
// Handle regular variables
const value = this.context.variables[param] || this.context.env[param];
// Implement set -u (nounset): error on unset variables
if (this.context.options?.nounset && value === undefined && !(param in this.context.variables) && !(param in this.context.env)) {
throw new Error(`${param}: parameter not set`);
}
return value || '';
}
handleDefaultValue(paramExpr, operator) {
const parts = paramExpr.split(operator);
const param = parts[0].trim();
const defaultValue = parts.slice(1).join(operator).trim();
const value = this.getParameterValue(param);
// Use default if parameter is unset or null (for :- operator)
return (value === '' || value === undefined) ? (defaultValue || '') : value;
}
handleAssignDefault(paramExpr, operator) {
const parts = paramExpr.split(operator);
const param = parts[0].trim();
const defaultValue = parts.slice(1).join(operator).trim();
let value = this.getParameterValue(param);
// Assign default if parameter is unset or null
if (value === '' || value === undefined) {
value = defaultValue || '';
this.context.variables[param] = value;
}
return value;
}
handleErrorIfNull(paramExpr, operator) {
const parts = paramExpr.split(operator);
const param = parts[0].trim();
const errorMessage = parts.slice(1).join(operator).trim();
const value = this.getParameterValue(param);
if (value === '' || value === undefined) {
const message = errorMessage || `${param}: parameter null or not set`;
throw new Error(message);
}
return value;
}
handleAlternativeValue(paramExpr, operator) {
const parts = paramExpr.split(operator);
const param = parts[0].trim();
const altValue = parts.slice(1).join(operator).trim();
const value = this.getParameterValue(param);
// Use alternative value if parameter is set and not null
return (value !== '' && value !== undefined) ? (altValue || '') : '';
}
handleSuffixRemoval(paramExpr) {
const isLongest = paramExpr.includes('%%');
const operator = isLongest ? '%%' : '%';
const [param, pattern] = paramExpr.split(operator, 2);
const value = this.getParameterValue(param);
if (!value)
return '';
return this.removeSuffix(value, pattern, isLongest);
}
handlePrefixRemoval(paramExpr) {
const isLongest = paramExpr.includes('##');
const operator = isLongest ? '##' : '#';
const [param, pattern] = paramExpr.split(operator, 2);
const value = this.getParameterValue(param);
if (!value)
return '';
return this.removePrefix(value, pattern, isLongest);
}
removeSuffix(value, pattern, longest) {
const regex = this.patternToRegex(pattern);
if (longest) {
// Find longest matching suffix
for (let i = 0; i < value.length; i++) {
const suffix = value.slice(i);
if (regex.test(suffix)) {
return value.slice(0, i);
}
}
}
else {
// Find shortest matching suffix
for (let i = value.length; i > 0; i--) {
const suffix = value.slice(i - 1);
if (regex.test(suffix)) {
return value.slice(0, i - 1);
}
}
}
return value;
}
removePrefix(value, pattern, longest) {
const regex = this.patternToRegex(pattern);
if (longest) {
// Find longest matching prefix
for (let i = value.length; i > 0; i--) {
const prefix = value.slice(0, i);
if (regex.test(prefix)) {
return value.slice(i);
}
}
}
else {
// Find shortest matching prefix
for (let i = 1; i <= value.length; i++) {
const prefix = value.slice(0, i);
if (regex.test(prefix)) {
return value.slice(i);
}
}
}
return value;
}
handleStringLength(param) {
const value = this.getParameterValue(param);
return value.length.toString();
}
handleSubstring(paramExpr) {
// Parse ${VAR:offset:length} or ${VAR:offset}
const match = paramExpr.match(/^([^:]+):(-?\d+)(?::(\d+))?$/);
if (!match) {
// Invalid format, return empty
return '';
}
const [, param, offsetStr, lengthStr] = match;
const value = this.getParameterValue(param);
let offset = parseInt(offsetStr, 10);
// Handle negative offset (from end of string)
if (offset < 0) {
offset = value.length + offset;
if (offset < 0)
offset = 0;
}
if (lengthStr) {
const length = parseInt(lengthStr, 10);
return value.substring(offset, offset + length);
}
else {
return value.substring(offset);
}
}
handleCaseConversion(paramExpr) {
// Handle ${VAR^}, ${VAR,}, ${VAR^^}, ${VAR,,}
let operator = '';
let param = paramExpr;
if (paramExpr.endsWith('^^')) {
operator = '^^';
param = paramExpr.slice(0, -2);
}
else if (paramExpr.endsWith('^')) {
operator = '^';
param = paramExpr.slice(0, -1);
}
else if (paramExpr.endsWith(',,')) {
operator = ',,';
param = paramExpr.slice(0, -2);
}
else if (paramExpr.endsWith(',')) {
operator = ',';
param = paramExpr.slice(0, -1);
}
const value = this.getParameterValue(param);
switch (operator) {
case '^':
// Uppercase first character
return value.charAt(0).toUpperCase() + value.slice(1);
case '^^':
// Uppercase all characters
return value.toUpperCase();
case ',':
// Lowercase first character
return value.charAt(0).toLowerCase() + value.slice(1);
case ',,':
// Lowercase all characters
return value.toLowerCase();
default:
return value;
}
}
patternToRegex(pattern) {
// Convert shell pattern to regex
const regex = pattern
.replace(/\*/g, '.*') // * matches any string
.replace(/\?/g, '.') // ? matches any single character
.replace(/\[([^\]]+)\]/g, '[$1]'); // [abc] character class
return new RegExp(`^${regex}$`);
}
async processCommandSubstitution(input) {
// Handle both $(command) and `command` forms
const dollarParenRegex = /\$\(([^)]+)\)/g;
const backtickRegex = /`([^`]+)`/g;
let result = input;
// Process $(command) form
result = await this.processSubstitutionWithRegex(result, dollarParenRegex);
// Process `command` form
result = await this.processSubstitutionWithRegex(result, backtickRegex);
return result;
}
async processSubstitutionWithRegex(input, regex) {
let result = '';
let lastIndex = 0;
let match;
while ((match = regex.exec(input)) !== null) {
result += input.slice(lastIndex, match.index);
const command = match[1];
try {
const { stdout } = await execAsync(command, {
env: { ...this.context.env, ...this.context.variables },
});
// Remove trailing newlines as per POSIX
result += stdout.replace(/\n+$/, '');
}
catch (_error) {
// Command substitution failed, leave empty
result += '';
}
lastIndex = regex.lastIndex;
}
result += input.slice(lastIndex);
return result;
}
async processArithmeticExpansion(input) {
const arithmeticRegex = /\$\(\(([^)]+)\)\)/g;
let result = input;
const matches = Array.from(input.matchAll(arithmeticRegex));
// Process all matches from right to left to avoid index shifting issues
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const fullMatch = match[0];
const expression = match[1];
const startIndex = match.index;
const endIndex = startIndex + fullMatch.length;
const value = this.evaluateArithmetic(expression);
// Replace the match with the evaluated value
result = result.slice(0, startIndex) + value.toString() + result.slice(endIndex);
}
return result;
}
evaluateArithmetic(expression) {
// Simple arithmetic evaluator
// Replace variables with their numeric values
const expr = expression.replace(/[a-zA-Z_][a-zA-Z0-9_]*/g, (match) => {
const value = this.getParameterValue(match);
const numValue = parseInt(value, 10);
return isNaN(numValue) ? '0' : numValue.toString();
});
try {
// Use Function constructor for safe evaluation
// This is a simplified version - full implementation would need proper arithmetic parser
return Function(`"use strict"; return (${expr})`)() || 0;
}
catch (_error) {
return 0;
}
}
// Public method to expand parameter expressions
async expandParameterExpression(paramExpr) {
return this.expandParameter(paramExpr);
}
// Public method to evaluate arithmetic expressions
evaluateArithmeticExpression(expression) {
return this.evaluateArithmetic(expression);
}
// Utility method for field splitting (will be used by shell executor)
splitFields(input, ifs = ' \t\n') {
if (!ifs)
return [input]; // No field splitting if IFS is empty
if (ifs === ' \t\n') {
// Default IFS behavior - split on any whitespace and trim
return input.trim().split(/\s+/).filter(field => field.length > 0);
}
// Custom IFS - more complex splitting rules
const fields = [];
let currentField = '';
for (const char of input) {
if (ifs.includes(char)) {
if (currentField || fields.length === 0) {
fields.push(currentField);
currentField = '';
}
}
else {
currentField += char;
}
}
if (currentField || fields.length === 0) {
fields.push(currentField);
}
return fields;
}
}