mirror of
https://github.com/bitburner-official/bitburner-src.git
synced 2026-05-04 14:47:53 +02:00
FILES: Path rework & typesafety (#479)
* Added new types for various file paths, all in the Paths folder. * TypeSafety and other helper functions related to these types * Added basic globbing support with * and ?. Currently only implemented for Script/Text, on nano and download terminal commands * Enforcing the new types throughout the codebase, plus whatever rewrites happened along the way * Server.textFiles is now a map * TextFile no longer uses a fn property, now it is filename * Added a shared ContentFile interface for shared functionality between TextFile and Script. * related to ContentFile change above, the player is now allowed to move a text file to a script file and vice versa. * File paths no longer conditionally start with slashes, and all directory names other than root have ending slashes. The player is still able to provide paths starting with / but this now indicates that the player is specifying an absolute path instead of one relative to root. * Singularized the MessageFilename and LiteratureName enums * Because they now only accept correct types, server.writeToXFile functions now always succeed (the only reasons they could fail before were invalid filepath). * Fix several issues with tab completion, which included pretty much a complete rewrite * Changed the autocomplete display options so there's less chance it clips outside the display area. * Turned CompletedProgramName into an enum. * Got rid of programsMetadata, and programs and DarkWebItems are now initialized immediately instead of relying on initializers called from the engine. * For any executable (program, cct, or script file) pathing can be used directly to execute without using the run command (previously the command had to start with ./ and it wasn't actually using pathing).
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
import { Aliases, GlobalAliases } from "../Alias";
|
||||
import { DarkWebItems } from "../DarkWeb/DarkWebItems";
|
||||
import { Player } from "@player";
|
||||
import { GetAllServers } from "../Server/AllServers";
|
||||
import { parseCommand, parseCommands } from "./Parser";
|
||||
import { HelpTexts } from "./HelpText";
|
||||
import { compile } from "../NetscriptJSEvaluator";
|
||||
import { Flags } from "../NetscriptFunctions/Flags";
|
||||
import { AutocompleteData } from "@nsdefs";
|
||||
import * as libarg from "arg";
|
||||
import { getAllDirectories, resolveDirectory, root } from "../Paths/Directory";
|
||||
import { resolveScriptFilePath } from "../Paths/ScriptFilePath";
|
||||
|
||||
// TODO: this shouldn't be hardcoded in two places with no typechecks to verify equivalence
|
||||
// An array of all Terminal commands
|
||||
const gameCommands = [
|
||||
"alias",
|
||||
"analyze",
|
||||
"backdoor",
|
||||
"cat",
|
||||
"cd",
|
||||
"changelog",
|
||||
"check",
|
||||
"clear",
|
||||
"cls",
|
||||
"connect",
|
||||
"cp",
|
||||
"download",
|
||||
"expr",
|
||||
"free",
|
||||
"grow",
|
||||
"hack",
|
||||
"help",
|
||||
"home",
|
||||
"hostname",
|
||||
"ifconfig",
|
||||
"kill",
|
||||
"killall",
|
||||
"ls",
|
||||
"lscpu",
|
||||
"mem",
|
||||
"mv",
|
||||
"nano",
|
||||
"ps",
|
||||
"rm",
|
||||
"run",
|
||||
"scan-analyze",
|
||||
"scan",
|
||||
"scp",
|
||||
"sudov",
|
||||
"tail",
|
||||
"theme",
|
||||
"top",
|
||||
"vim",
|
||||
"weaken",
|
||||
];
|
||||
|
||||
/** Suggest all completion possibilities for the last argument in the last command being typed
|
||||
* @param terminalText The current full text entered in the terminal
|
||||
* @param baseDir The current working directory.
|
||||
* @returns Array of possible string replacements for the current text being autocompleted.
|
||||
*/
|
||||
export async function getTabCompletionPossibilities(terminalText: string, baseDir = root): Promise<string[]> {
|
||||
// Get the current command text
|
||||
const currentText = /[^ ]*$/.exec(terminalText)?.[0] ?? "";
|
||||
// Remove the current text from the commands string
|
||||
const valueWithoutCurrent = terminalText.substring(0, terminalText.length - currentText.length);
|
||||
// Parse the commands string, this handles alias replacement as well.
|
||||
const commands = parseCommands(valueWithoutCurrent);
|
||||
if (!commands.length) commands.push("");
|
||||
// parse the last command into a commandArgs array, but convert to string
|
||||
const commandArray = parseCommand(commands[commands.length - 1]).map(String);
|
||||
commandArray.push(currentText);
|
||||
|
||||
/** How many separate strings make up the command, e.g. "run a" would result in 2 strings. */
|
||||
const commandLength = commandArray.length;
|
||||
|
||||
// To prevent needing to convert currentArg to lowercase for every comparison
|
||||
const requiredMatch = currentText.toLowerCase();
|
||||
|
||||
// If a relative directory is included in the path, this will store what the absolute path needs to start with to be valid
|
||||
let pathingRequiredMatch = currentText.toLowerCase();
|
||||
|
||||
/** The directory portion of the current input */
|
||||
let relativeDir = "";
|
||||
const slashIndex = currentText.lastIndexOf("/");
|
||||
|
||||
if (slashIndex !== -1) {
|
||||
relativeDir = currentText.substring(0, slashIndex + 1);
|
||||
const path = resolveDirectory(relativeDir, baseDir);
|
||||
// No valid terminal inputs contain a / that does not indicate a path
|
||||
if (path === null) return [];
|
||||
baseDir = path;
|
||||
pathingRequiredMatch = currentText.replace(/^.*\//, path).toLowerCase();
|
||||
} else if (baseDir !== root) {
|
||||
pathingRequiredMatch = (baseDir + currentText).toLowerCase();
|
||||
}
|
||||
|
||||
const possibilities: string[] = [];
|
||||
const currServ = Player.getCurrentServer();
|
||||
const homeComputer = Player.getHomeComputer();
|
||||
|
||||
// --- Functions for adding different types of data ---
|
||||
|
||||
interface AddAllGenericOptions {
|
||||
// The iterable to iterate through the data
|
||||
iterable: Iterable<string>;
|
||||
// Whether the item can be pathed to. Typically this is true for files (programs are an exception)
|
||||
usePathing?: boolean;
|
||||
// Whether to exclude the current text as one of the autocomplete options
|
||||
ignoreCurrent?: boolean;
|
||||
}
|
||||
function addGeneric({ iterable, usePathing, ignoreCurrent }: AddAllGenericOptions) {
|
||||
const requiredStart = usePathing ? pathingRequiredMatch : requiredMatch;
|
||||
for (const member of iterable) {
|
||||
if (ignoreCurrent && member.length <= requiredStart.length) continue;
|
||||
if (member.toLowerCase().startsWith(requiredStart)) {
|
||||
possibilities.push(usePathing ? relativeDir + member.substring(baseDir.length) : member);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addAliases = () => addGeneric({ iterable: Object.keys(Aliases) });
|
||||
const addGlobalAliases = () => addGeneric({ iterable: Object.keys(GlobalAliases) });
|
||||
const addCommands = () => addGeneric({ iterable: gameCommands });
|
||||
const addDarkwebItems = () => addGeneric({ iterable: Object.values(DarkWebItems).map((item) => item.program) });
|
||||
const addServerNames = () => addGeneric({ iterable: GetAllServers().map((server) => server.hostname) });
|
||||
const addScripts = () => addGeneric({ iterable: currServ.scripts.keys(), usePathing: true });
|
||||
const addTextFiles = () => addGeneric({ iterable: currServ.textFiles.keys(), usePathing: true });
|
||||
const addCodingContracts = () => {
|
||||
addGeneric({ iterable: currServ.contracts.map((contract) => contract.fn), usePathing: true });
|
||||
};
|
||||
|
||||
const addLiterature = () => {
|
||||
addGeneric({ iterable: currServ.messages.filter((message) => message.endsWith(".lit")), usePathing: true });
|
||||
};
|
||||
|
||||
const addMessages = () => {
|
||||
addGeneric({ iterable: currServ.messages.filter((message) => message.endsWith(".msg")), usePathing: true });
|
||||
};
|
||||
|
||||
const addReachableServerNames = () => {
|
||||
addGeneric({
|
||||
iterable: GetAllServers()
|
||||
.filter((server) => server.backdoorInstalled || currServ.serversOnNetwork.includes(server.hostname))
|
||||
.map((server) => server.hostname),
|
||||
});
|
||||
};
|
||||
|
||||
const addPrograms = () => {
|
||||
// Only allow completed programs to autocomplete
|
||||
const programs = homeComputer.programs.filter((name) => name.endsWith(".exe"));
|
||||
// At all times, programs can be accessed without pathing
|
||||
addGeneric({ iterable: programs });
|
||||
// If we're on home and a path is being used, also include pathing results
|
||||
if (homeComputer.isConnectedTo && relativeDir) addGeneric({ iterable: programs, usePathing: true });
|
||||
};
|
||||
|
||||
const addDirectories = () => {
|
||||
addGeneric({ iterable: getAllDirectories(currServ), usePathing: true, ignoreCurrent: true });
|
||||
};
|
||||
|
||||
// Just some booleans so the mismatch between command length and arg number are not as confusing.
|
||||
const onCommand = commandLength === 1;
|
||||
const onFirstCommandArg = commandLength === 2;
|
||||
const onSecondCommandArg = commandLength === 3;
|
||||
|
||||
// These are always added.
|
||||
addGlobalAliases();
|
||||
|
||||
// If we're using a relative path, always add directories
|
||||
if (relativeDir) addDirectories();
|
||||
|
||||
// -- Handling different commands -- //
|
||||
// Command is what is being autocompleted
|
||||
if (onCommand) {
|
||||
addAliases();
|
||||
addCommands();
|
||||
// Allow any relative pathing as a command arg to act as previous ./ command
|
||||
if (relativeDir) {
|
||||
addScripts();
|
||||
addPrograms();
|
||||
addCodingContracts();
|
||||
}
|
||||
}
|
||||
|
||||
switch (commandArray[0]) {
|
||||
case "buy":
|
||||
addDarkwebItems();
|
||||
return possibilities;
|
||||
|
||||
case "cat":
|
||||
addScripts();
|
||||
addTextFiles();
|
||||
addMessages();
|
||||
addLiterature();
|
||||
return possibilities;
|
||||
|
||||
case "cd":
|
||||
case "ls":
|
||||
if (onFirstCommandArg && !relativeDir) addDirectories();
|
||||
return possibilities;
|
||||
|
||||
case "check":
|
||||
case "kill":
|
||||
case "mem":
|
||||
case "tail":
|
||||
addScripts();
|
||||
return possibilities;
|
||||
|
||||
case "connect":
|
||||
if (onFirstCommandArg) addReachableServerNames();
|
||||
return possibilities;
|
||||
|
||||
case "cp":
|
||||
if (onFirstCommandArg) {
|
||||
// We're autocompleting a source content file
|
||||
addScripts();
|
||||
addTextFiles();
|
||||
}
|
||||
return possibilities;
|
||||
|
||||
case "download":
|
||||
case "mv":
|
||||
// download only takes one arg, and for mv we only want to autocomplete the first one
|
||||
if (onFirstCommandArg) {
|
||||
addScripts();
|
||||
addTextFiles();
|
||||
}
|
||||
return possibilities;
|
||||
|
||||
case "help":
|
||||
if (onFirstCommandArg) {
|
||||
addGeneric({ iterable: Object.keys(HelpTexts), usePathing: false });
|
||||
}
|
||||
return possibilities;
|
||||
|
||||
case "nano":
|
||||
case "vim":
|
||||
addScripts();
|
||||
addTextFiles();
|
||||
return possibilities;
|
||||
|
||||
case "scp":
|
||||
if (onFirstCommandArg) {
|
||||
addScripts();
|
||||
addTextFiles();
|
||||
addLiterature();
|
||||
} else if (onSecondCommandArg) addServerNames();
|
||||
return possibilities;
|
||||
|
||||
case "rm":
|
||||
addScripts();
|
||||
addPrograms();
|
||||
addLiterature();
|
||||
addTextFiles();
|
||||
addCodingContracts();
|
||||
return possibilities;
|
||||
|
||||
case "run":
|
||||
if (onFirstCommandArg) {
|
||||
addPrograms();
|
||||
addCodingContracts();
|
||||
}
|
||||
// Spill over into next cases
|
||||
case "check":
|
||||
case "tail":
|
||||
case "kill":
|
||||
if (onFirstCommandArg) addScripts();
|
||||
else {
|
||||
const options = await scriptAutocomplete();
|
||||
if (options) addGeneric({ iterable: options, usePathing: false });
|
||||
}
|
||||
return possibilities;
|
||||
|
||||
default:
|
||||
return possibilities;
|
||||
}
|
||||
|
||||
async function scriptAutocomplete(): Promise<string[] | undefined> {
|
||||
let inputCopy = commandArray.join(" ");
|
||||
if (commandLength === 1) inputCopy = "run " + inputCopy;
|
||||
const commands = parseCommands(inputCopy);
|
||||
if (commands.length === 0) return;
|
||||
const command = parseCommand(commands[commands.length - 1]);
|
||||
const filename = resolveScriptFilePath(String(command[1]), baseDir);
|
||||
if (!filename) return; // Not a script path.
|
||||
if (filename.endsWith(".script")) return; // Doesn't work with ns1.
|
||||
const script = currServ.scripts.get(filename);
|
||||
if (!script) return; // Doesn't exist.
|
||||
|
||||
let loadedModule;
|
||||
try {
|
||||
//Will return the already compiled module if recompilation not needed.
|
||||
loadedModule = await compile(script, currServ.scripts);
|
||||
} catch (e) {
|
||||
//fail silently if the script fails to compile (e.g. syntax error)
|
||||
return;
|
||||
}
|
||||
if (!loadedModule || !loadedModule.autocomplete) return; // Doesn't have an autocomplete function.
|
||||
|
||||
const runArgs = { "--tail": Boolean, "-t": Number };
|
||||
const flags = libarg(runArgs, {
|
||||
permissive: true,
|
||||
argv: command.slice(2),
|
||||
});
|
||||
const flagFunc = Flags(flags._);
|
||||
const autocompleteData: AutocompleteData = {
|
||||
servers: GetAllServers().map((server) => server.hostname),
|
||||
scripts: [...currServ.scripts.keys()],
|
||||
txts: [...currServ.textFiles.keys()],
|
||||
flags: (schema: unknown) => {
|
||||
if (!Array.isArray(schema)) throw new Error("flags require an array of array");
|
||||
pos2 = schema.map((f: unknown) => {
|
||||
if (!Array.isArray(f)) throw new Error("flags require an array of array");
|
||||
if (f[0].length === 1) return "-" + f[0];
|
||||
return "--" + f[0];
|
||||
});
|
||||
try {
|
||||
return flagFunc(schema);
|
||||
} catch (err) {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
};
|
||||
let pos: string[] = [];
|
||||
let pos2: string[] = [];
|
||||
const options = loadedModule.autocomplete(autocompleteData, flags._);
|
||||
if (!Array.isArray(options)) throw new Error("autocomplete did not return list of strings");
|
||||
pos = pos.concat(options.map((x) => String(x)));
|
||||
return pos.concat(pos2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user