mirror of
https://github.com/compiler-explorer/compiler-explorer.git
synced 2026-09-10 13:27:41 -04:00
maskRootdir() built its strip-regex from this process's os.tmpdir(), so it only masked paths whose root matched the current tmpdir. That is fragile in two real cases: - The path being masked is recorded when the compile runs and need not share the masking process's tmpdir. Snapshots/fixtures that freeze /tmp/... paths only mask on hosts where os.tmpdir() is /tmp (they fail where it is e.g. /usr/tmp). - On macOS os.tmpdir() returns /var/folders/... while real temp paths often resolve via /private/var/folders/..., so masking silently missed. - A configured temp root (temp dirs created outside os.tmpdir()) was never matched at all. Key the regex off the distinctive ce_temp_prefix marker instead, matching whatever path segments precede it. This is tmpdir-independent and unifies the Windows and non-Windows branches into one rule (also masking embedded temp paths like `-I/tmp/.../include` on Windows, which the old anchored branch missed). Adds unit tests for maskRootdir (previously untested) covering /tmp, /usr/tmp, macOS /private/var, Windows, the -I include case, and non-temp paths left untouched. <!-- THIS COMMENT IS INVISIBLE IN THE FINAL PR, BUT FEEL FREE TO REMOVE IT Thanks for taking the time to improve CE. We really appreciate it. Before opening the PR, please make sure that the tests & linter pass their checks, by running `make check`. In the best case scenario, you are also adding tests to back up your changes, but don't sweat it if you don't. We can discuss them at a later date. Feel free to append your name to the CONTRIBUTORS.md file Thanks again, we really appreciate this! --> --------- Co-authored-by: Matt Godbolt <mattgodbolt@hudson-trading.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
677 lines
23 KiB
TypeScript
677 lines
23 KiB
TypeScript
// Copyright (c) 2016, Compiler Explorer Authors
|
|
// All rights reserved.
|
|
//
|
|
// Redistribution and use in source and binary forms, with or without
|
|
// modification, are permitted provided that the following conditions are met:
|
|
//
|
|
// * Redistributions of source code must retain the above copyright notice,
|
|
// this list of conditions and the following disclaimer.
|
|
// * Redistributions in binary form must reproduce the above copyright
|
|
// notice, this list of conditions and the following disclaimer in the
|
|
// documentation and/or other materials provided with the distribution.
|
|
//
|
|
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
// POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
import crypto from 'node:crypto';
|
|
import fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import {fileURLToPath} from 'node:url';
|
|
import {Buffer} from 'buffer';
|
|
|
|
import {ComponentConfig, ItemConfigType} from 'golden-layout';
|
|
import semverParser from 'semver';
|
|
import _ from 'underscore';
|
|
|
|
import type {ParsedAsmResultLine} from '../types/asmresult/asmresult.interfaces.js';
|
|
import type {CacheableValue} from '../types/cache.interfaces.js';
|
|
import {BasicExecutionResult, UnprocessedExecResult} from '../types/execution/execution.interfaces.js';
|
|
import {LanguageKey} from '../types/languages.interfaces.js';
|
|
import type {Fix, ResultLine} from '../types/resultline/resultline.interfaces.js';
|
|
|
|
const tabsRe = /\t/g;
|
|
const lineRe = /\r?\n/;
|
|
|
|
export const ce_temp_prefix = 'compiler-explorer-compiler';
|
|
|
|
export function splitLines(text: string): string[] {
|
|
if (!text) return [];
|
|
const result = text.split(lineRe);
|
|
if (result.length > 0 && result[result.length - 1] === '') return result.slice(0, -1);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Extracts text lines from either raw assembly string or parsed assembly objects.
|
|
* Handles the union type safely without unsafe casting.
|
|
*/
|
|
export function extractTextLines(asm: string | ParsedAsmResultLine[]): string[] {
|
|
if (typeof asm === 'string') {
|
|
return splitLines(asm);
|
|
}
|
|
if (Array.isArray(asm)) {
|
|
// Already parsed - extract text from each line object
|
|
return asm.map(line => line.text);
|
|
}
|
|
throw new Error(`extractTextLines called with unexpected type: ${typeof asm}`);
|
|
}
|
|
|
|
/**
|
|
* Converts assembly data to string format for parser consumption.
|
|
* Handles the union type from CompilationInfo.asm safely.
|
|
*/
|
|
export function normalizeAsmToString(asm: string | ParsedAsmResultLine[] | undefined): string {
|
|
if (!asm) return '';
|
|
return typeof asm === 'string' ? asm : extractTextLines(asm).join('\n');
|
|
}
|
|
|
|
/**
|
|
* Applies a function to each line of text split by `splitLines`
|
|
*/
|
|
export function eachLine(text: string, func: (line: string) => void): void {
|
|
for (const line of splitLines(text)) {
|
|
func(line);
|
|
}
|
|
}
|
|
|
|
export function expandTabs(line: string): string {
|
|
let extraChars = 0;
|
|
return line.replaceAll(tabsRe, (match, offset) => {
|
|
const total = offset + extraChars;
|
|
const spacesNeeded = (total + 8) & 7;
|
|
extraChars += spacesNeeded - 1;
|
|
return ' '.substring(spacesNeeded);
|
|
});
|
|
}
|
|
|
|
// Matches everything up to and through a CE temp dir, `.../<ce_temp_prefix><suffix>/`.
|
|
// We key off the ce_temp_prefix marker, NOT the live os.tmpdir(): the temp root varies
|
|
// by host/config (macOS /var vs /private/var, an execution.tempDirRoot elsewhere) and
|
|
// the path being masked may have been recorded under a different tmpdir than this one.
|
|
// The marker is the only invariant — it's the same constant we create the dir with.
|
|
// `(?:[A-Za-z]:)?` + `/` handles Windows too; `[^/\s]+` confines the match to one
|
|
// non-empty path token so an embedded `-I/tmp/<prefix>XXX/inc` still masks to
|
|
// `-I/app/inc` (real temp paths never have empty `//` segments).
|
|
// A user path that itself contains a `<ce_temp_prefix>...` segment would be masked too,
|
|
// but that only affects displayed output (never what's compiled/executed) and needs a
|
|
// deliberately odd dir name, so it's not worth a costlier scheme to prevent.
|
|
const TEMPDIR_RE = new RegExp(`(?:[A-Za-z]:)?/(?:[^/\\s]+/)*${ce_temp_prefix}[\\w.-]*/`);
|
|
|
|
/**
|
|
* Removes the root dir from the given filepath, so that it will match to the user's filenames used
|
|
* note: will keep /app/ if instead of filepath something like '-I/tmp/path' is used
|
|
*/
|
|
export function maskRootdir(filepath: string): string {
|
|
// TODO: this falsy guard is load-bearing against runtime `undefined` that the types
|
|
// don't catch — not declared `string | undefined` callers (TS would reject those),
|
|
// but type holes: a JSON.parse(...) result typed `any` (base-compiler cleanup) and a
|
|
// Record<number,string> index that's really `undefined` when the key is missing
|
|
// (noUncheckedIndexedAccess is off). Tighten those two sites, then this can go.
|
|
if (!filepath) return filepath;
|
|
// TEMPDIR_RE has a repeated path-segment group that backtracks on long input, and
|
|
// maskRootdir runs on every output line. Gate it behind a cheap linear substring
|
|
// check: the regex cannot match without the marker anyway. The trailing /app/ strip
|
|
// is anchored (no backtracking) and must run regardless, as paths may already be
|
|
// /app/-rooted from an earlier mask.
|
|
const masked = filepath.includes(ce_temp_prefix) ? filepath.replace(TEMPDIR_RE, '/app/') : filepath;
|
|
return masked.replace(/^\/app\//, '');
|
|
}
|
|
|
|
export function changeExtension(filename: string, newExtension: string): string {
|
|
const lastDot = filename.lastIndexOf('.');
|
|
if (lastDot === -1) return filename + newExtension;
|
|
return filename.substring(0, lastDot) + newExtension;
|
|
}
|
|
|
|
const ansiColoursRe = /\x1B\[[\d;]*[Km]/g;
|
|
const terminalHyperlinkEscapeRe = /\x1B]8;;.*?(\x1B\\|\x07)(.*?)\x1B]8;;\1/g;
|
|
|
|
export function filterEscapeSequences(line: string): string {
|
|
return line.replaceAll(ansiColoursRe, '').replaceAll(terminalHyperlinkEscapeRe, '$2');
|
|
}
|
|
|
|
function _parseOutputLine(line: string, inputFilename?: string, pathPrefix?: string) {
|
|
line = line.split('<stdin>').join('<source>');
|
|
if (pathPrefix) line = line.replace(pathPrefix, '');
|
|
if (inputFilename) {
|
|
line = line.split(inputFilename).join('<source>');
|
|
|
|
if (inputFilename.indexOf('./') === 0) {
|
|
line = line.split('/home/ubuntu/' + inputFilename.substring(2)).join('<source>');
|
|
line = line.split('/home/ce/' + inputFilename.substring(2)).join('<source>');
|
|
}
|
|
}
|
|
return line;
|
|
}
|
|
|
|
function parseSeverity(message: string): number {
|
|
if (message.startsWith('warning')) return 2;
|
|
if (message.startsWith('note')) return 1;
|
|
return 3;
|
|
}
|
|
|
|
const SOURCE_RE = /^\s*<source>[(:](\d+)(:?,?(\d+):?)?[):]*\s*(.*)/;
|
|
const SOURCE_WITH_FILENAME = /^\s*([\w.]*\.[\w.]+)[(:](\d+)(:?,?(\d+):?)?[):]*\s*(.*)/;
|
|
const ATFILELINE_RE = /\s*at ([\w-/.]+):(\d+)/;
|
|
|
|
export enum LineParseOption {
|
|
SourceMasking = 0,
|
|
RootMasking = 1,
|
|
SourceWithLineMessage = 2,
|
|
FileWithLineMessage = 3,
|
|
AtFileLine = 4,
|
|
}
|
|
|
|
export type LineParseOptions = LineParseOption[];
|
|
|
|
export const DefaultLineParseOptions = [
|
|
LineParseOption.SourceMasking,
|
|
LineParseOption.RootMasking,
|
|
LineParseOption.SourceWithLineMessage,
|
|
LineParseOption.FileWithLineMessage,
|
|
];
|
|
|
|
function applyParse_SourceWithLine(lineObj: ResultLine, filteredLine: string, inputFilename?: string) {
|
|
const match = filteredLine.match(SOURCE_RE);
|
|
if (match) {
|
|
const message = match[4].trim();
|
|
lineObj.tag = {
|
|
line: Number.parseInt(match[1], 10),
|
|
column: Number.parseInt(match[3] || '0', 10),
|
|
text: message,
|
|
severity: parseSeverity(message),
|
|
file: inputFilename ? path.basename(inputFilename) : undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
function applyParse_FileWithLine(lineObj: ResultLine, filteredLine: string) {
|
|
const match = filteredLine.match(SOURCE_WITH_FILENAME);
|
|
if (match) {
|
|
const filename = match[1];
|
|
const message = match[5].trim();
|
|
lineObj.tag = {
|
|
file: filename,
|
|
line: Number.parseInt(match[2], 10),
|
|
column: Number.parseInt(match[4] || '0', 10),
|
|
text: message,
|
|
severity: parseSeverity(message),
|
|
};
|
|
}
|
|
}
|
|
|
|
function applyParse_AtFileLine(lineObj: ResultLine, filteredLine: string) {
|
|
const match = filteredLine.match(ATFILELINE_RE);
|
|
if (match) {
|
|
if (match[1].startsWith('/app/')) {
|
|
lineObj.tag = {
|
|
file: match[1].replace(/^\/app\//, ''),
|
|
line: Number.parseInt(match[2], 10),
|
|
column: 0,
|
|
text: filteredLine,
|
|
severity: 3,
|
|
};
|
|
} else if (!match[1].startsWith('/')) {
|
|
lineObj.tag = {
|
|
file: match[1],
|
|
line: Number.parseInt(match[2], 10),
|
|
column: 0,
|
|
text: filteredLine,
|
|
severity: 3,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
export function parseOutput(
|
|
lines: string,
|
|
inputFilename?: string,
|
|
pathPrefix?: string,
|
|
options: LineParseOptions = DefaultLineParseOptions,
|
|
): ResultLine[] {
|
|
const result: ResultLine[] = [];
|
|
eachLine(lines, line => {
|
|
if (options.includes(LineParseOption.SourceMasking)) {
|
|
line = _parseOutputLine(line, inputFilename, pathPrefix);
|
|
}
|
|
if (!inputFilename && options.includes(LineParseOption.RootMasking)) {
|
|
line = maskRootdir(line);
|
|
}
|
|
if (line !== null) {
|
|
const lineObj: ResultLine = {text: line};
|
|
const filteredLine = filterEscapeSequences(line);
|
|
|
|
if (options.includes(LineParseOption.SourceWithLineMessage))
|
|
applyParse_SourceWithLine(lineObj, filteredLine, inputFilename);
|
|
|
|
if (!lineObj.tag && options.includes(LineParseOption.FileWithLineMessage))
|
|
applyParse_FileWithLine(lineObj, filteredLine);
|
|
|
|
if (!lineObj.tag && options.includes(LineParseOption.AtFileLine))
|
|
applyParse_AtFileLine(lineObj, filteredLine);
|
|
|
|
result.push(lineObj);
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
export function parseRustOutput(lines: string, inputFilename?: string, pathPrefix?: string) {
|
|
const inputBasename = inputFilename ? path.basename(inputFilename) : undefined;
|
|
const quickfixes: {re: RegExp; makeFix: (match: string[]) => Fix}[] = [
|
|
{
|
|
re: / *help: add `#!\[feature\((.*?)\)]`/,
|
|
makeFix: ([_, featureName]) => ({
|
|
title: `Add feature flag \`${featureName}\``,
|
|
edits: [
|
|
{
|
|
line: 1,
|
|
column: 1,
|
|
endline: 1,
|
|
endcolumn: 1,
|
|
text: `#![feature(${featureName})]\n`,
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
{
|
|
re: / *(\d+) *\+ ( *)use (.*?);$/,
|
|
makeFix: ([_, line, indentation, path]) => ({
|
|
title: `Add import for \`${path}\``,
|
|
edits: [
|
|
{
|
|
line: Number.parseInt(line, 10),
|
|
column: 1,
|
|
endline: Number.parseInt(line, 10),
|
|
endcolumn: 1,
|
|
text: `${indentation}use ${path};\n`,
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
];
|
|
|
|
const re = /^\s+-->\s+(?<filename>.*):(?<line>\d+):(?<column>\d+)/;
|
|
const result: ResultLine[] = [];
|
|
let currentDiagnostic: ResultLine | undefined;
|
|
eachLine(lines, line => {
|
|
line = _parseOutputLine(line, inputFilename, pathPrefix);
|
|
if (line !== null) {
|
|
const lineObj: ResultLine = {text: line};
|
|
const filteredLine = filterEscapeSequences(line);
|
|
const match = filteredLine.match(re);
|
|
|
|
if (match?.groups) {
|
|
const file =
|
|
match.groups.filename === '<source>' ? inputBasename : path.basename(match.groups.filename);
|
|
const line = Number.parseInt(match.groups.line, 10);
|
|
const column = Number.parseInt(match.groups.column, 10);
|
|
|
|
currentDiagnostic = result.pop();
|
|
if (currentDiagnostic !== undefined) {
|
|
const text = filterEscapeSequences(currentDiagnostic.text);
|
|
currentDiagnostic.tag = {
|
|
file,
|
|
line,
|
|
column,
|
|
text,
|
|
severity: parseSeverity(text),
|
|
};
|
|
result.push(currentDiagnostic);
|
|
}
|
|
|
|
lineObj.tag = {
|
|
file,
|
|
line,
|
|
column,
|
|
text: '', // Left empty so that it does not show up in the editor
|
|
severity: 3,
|
|
};
|
|
}
|
|
|
|
const fixes = quickfixes.flatMap(({re, makeFix}) => {
|
|
const match = filteredLine.match(re);
|
|
if (match) {
|
|
return [makeFix(match)];
|
|
} else {
|
|
return [];
|
|
}
|
|
});
|
|
|
|
if (currentDiagnostic?.tag && fixes.length !== 0) {
|
|
lineObj.tag = {...currentDiagnostic.tag, fixes};
|
|
}
|
|
|
|
result.push(lineObj);
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/***
|
|
* Anonymizes given IP.
|
|
* For IPv4, it removes the last octet
|
|
* For IPv6, it removes the last three hextets
|
|
*
|
|
* @param {string} ip - IP string, of either type (localhost|IPv4|IPv6)
|
|
* @returns {string} Anonymized IP
|
|
*/
|
|
export function anonymizeIp(ip: string): string {
|
|
if (ip.includes('localhost')) {
|
|
return ip;
|
|
}
|
|
if (ip.includes(':')) {
|
|
// IPv6
|
|
return ip.replace(/(?::[\dA-Fa-f]{0,4}){3}$/, ':0:0:0');
|
|
}
|
|
// IPv4
|
|
return ip.replace(/\.\d{1,3}$/, '.0');
|
|
}
|
|
|
|
/***
|
|
*
|
|
* @param {*} object
|
|
* @returns {string}
|
|
*/
|
|
function objectToHashableString(object: CacheableValue): string {
|
|
// See https://stackoverflow.com/questions/899574/which-is-best-to-use-typeof-or-instanceof/6625960#6625960
|
|
return typeof object === 'string' ? object : JSON.stringify(object);
|
|
}
|
|
|
|
const DefaultHash = 'Compiler Explorer Default Version 1';
|
|
|
|
/***
|
|
* Gets the hash (as a binary buffer) of the given object
|
|
*
|
|
* Limitation: object shall not have circular references
|
|
* @param {*} object - Object to get hash from
|
|
* @param {string} [HashVersion=DefaultHash] - Hash "version" key
|
|
* @returns {Buffer} - Hash of object
|
|
*/
|
|
export function getBinaryHash(object: CacheableValue, HashVersion = DefaultHash): Buffer {
|
|
return crypto.createHmac('sha256', HashVersion).update(objectToHashableString(object)).digest();
|
|
}
|
|
|
|
/***
|
|
* Gets the hash (as a hex string) of the given object
|
|
*
|
|
* Limitation: object shall not have circular references
|
|
* @param {*} object - Object to get hash from
|
|
* @param {string} [HashVersion=DefaultHash] - Hash "version" key
|
|
* @returns {string} - Hash of object
|
|
*/
|
|
export function getHash(object: CacheableValue, HashVersion = DefaultHash): string {
|
|
return crypto.createHmac('sha256', HashVersion).update(objectToHashableString(object)).digest('hex');
|
|
}
|
|
|
|
interface glEditorMainContent {
|
|
// Editor content
|
|
source: string;
|
|
// Editor syntax language
|
|
language: LanguageKey;
|
|
}
|
|
|
|
interface glCompilerMainContent {
|
|
// Compiler id
|
|
compiler: string;
|
|
}
|
|
|
|
interface glContents {
|
|
editors: glEditorMainContent[];
|
|
compilers: glCompilerMainContent[];
|
|
}
|
|
|
|
/***
|
|
* Gets every (source, lang) & (compilerId) available
|
|
* @param {Array} content - GoldenLayout config topmost content field
|
|
* @returns {glContents}
|
|
*/
|
|
export function glGetMainContents(content: ItemConfigType[] = []): glContents {
|
|
const contents: glContents = {editors: [], compilers: []};
|
|
_.each(content, element => {
|
|
if (element.type === 'component') {
|
|
const component = element as ComponentConfig;
|
|
if (component.componentName === 'codeEditor') {
|
|
contents.editors.push({
|
|
source: component.componentState.source,
|
|
language: component.componentState.lang as LanguageKey,
|
|
});
|
|
} else if (component.componentName === 'compiler') {
|
|
contents.compilers.push({
|
|
compiler: component.componentState.compiler,
|
|
});
|
|
}
|
|
} else {
|
|
const subComponents = glGetMainContents(element.content);
|
|
contents.editors = contents.editors.concat(subComponents.editors);
|
|
contents.compilers = contents.compilers.concat(subComponents.compilers);
|
|
}
|
|
});
|
|
return contents;
|
|
}
|
|
|
|
export function squashHorizontalWhitespace(line: string, atStart = true): string {
|
|
if (line.trim().length === 0) {
|
|
return '';
|
|
}
|
|
const splat = line.split(/\s+/);
|
|
if (splat[0] === '' && atStart) {
|
|
// An indented line: preserve a two-space indent (max)
|
|
const intent = line[1] === ' ' ? ' ' : ' ';
|
|
return intent + splat.slice(1).join(' ');
|
|
}
|
|
return splat.join(' ');
|
|
}
|
|
|
|
export function toProperty(prop: string): boolean | number | string {
|
|
if (prop === 'true' || prop === 'yes') return true;
|
|
if (prop === 'false' || prop === 'no') return false;
|
|
if (/^-?(0|[1-9]\d*)$/.test(prop)) return Number.parseInt(prop, 10);
|
|
if (/^-?\d*\.\d+$/.test(prop)) return Number.parseFloat(prop);
|
|
return prop;
|
|
}
|
|
|
|
// Initially based on http://philzimmermann.com/docs/human-oriented-base-32-encoding.txt
|
|
const BASE32_ALPHABET = '13456789EGKMPTWYabcdefhjnoqrsvxz';
|
|
|
|
/***
|
|
* A Base32 encoding implementation valid for our needs.
|
|
* Of importance, note that there's no padding
|
|
* @param {Buffer} buffer
|
|
* @returns {string}
|
|
*/
|
|
export function base32Encode(buffer: Buffer): string {
|
|
let output = '';
|
|
// This can grow up to 12 bits
|
|
let digest = 0;
|
|
// How many bits are we actually using from digest
|
|
let bits = 0;
|
|
|
|
function encodeNewWord() {
|
|
// Get first 5 bits
|
|
const word = digest & 0b11111;
|
|
const character = BASE32_ALPHABET[word];
|
|
output += character;
|
|
bits -= 5;
|
|
// Shift out the newly processed word
|
|
digest = digest >>> 5;
|
|
}
|
|
|
|
for (const byte of buffer) {
|
|
// Append current byte to digest
|
|
digest = (byte << bits) | digest;
|
|
bits += 8;
|
|
|
|
// Add characters until we run out of bits
|
|
while (bits >= 5) {
|
|
encodeNewWord();
|
|
}
|
|
}
|
|
// Do we need a last pass?
|
|
if (bits !== 0) {
|
|
encodeNewWord();
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
// Splits a : separated list into its own array, or to default if input is undefined
|
|
export function splitIntoArray(input?: string, defaultArray: string[] = []): string[] {
|
|
if (input === undefined) {
|
|
return defaultArray;
|
|
}
|
|
return input.split(':');
|
|
}
|
|
|
|
/***
|
|
* Absolute path to the root of the application
|
|
*/
|
|
export const APP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
export function resolvePathFromAppRoot(...args: string[]) {
|
|
return path.resolve(APP_ROOT, ...args);
|
|
}
|
|
|
|
export async function fileExists(filename: string): Promise<boolean> {
|
|
try {
|
|
const stat = await fs.stat(filename);
|
|
return stat.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export async function dirExists(dir: string): Promise<boolean> {
|
|
try {
|
|
const stat = await fs.stat(dir);
|
|
return stat.isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function countOccurrences<T>(collection: Iterable<T>, item: T): number {
|
|
// _.reduce(collection, (total, value) => value === item ? total + 1 : total, 0) would work, but is probably slower
|
|
let result = 0;
|
|
for (const element of collection) {
|
|
if (element === item) {
|
|
result++;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export enum magic_semver {
|
|
trunk = '99999999.99999.999',
|
|
non_trunk = '99999998.99999.999',
|
|
}
|
|
|
|
export function asSafeVer(semver: string | number | null | undefined): string {
|
|
if (semver != null) {
|
|
if (typeof semver === 'number') {
|
|
semver = `${semver}`;
|
|
}
|
|
const splits = semver.split(' ');
|
|
if (splits.length > 0) {
|
|
let interestingPart = splits[0];
|
|
let dotCount = countOccurrences(interestingPart, '.');
|
|
for (; dotCount < 2; dotCount++) {
|
|
interestingPart += '.0';
|
|
}
|
|
const validated: string | null = semverParser.valid(interestingPart, true);
|
|
if (validated != null) {
|
|
return validated;
|
|
}
|
|
}
|
|
|
|
if (semver.includes('trunk') || semver.includes('main')) {
|
|
return magic_semver.trunk;
|
|
}
|
|
}
|
|
return magic_semver.non_trunk;
|
|
}
|
|
|
|
export function processExecutionResult(input: UnprocessedExecResult, inputFilename?: string): BasicExecutionResult {
|
|
const start = performance.now();
|
|
const stdout = parseOutput(input.stdout, inputFilename);
|
|
const stderr = parseOutput(input.stderr, inputFilename);
|
|
const end = performance.now();
|
|
return {
|
|
...input,
|
|
stdout,
|
|
stderr,
|
|
processExecutionResultTime: end - start,
|
|
};
|
|
}
|
|
|
|
export function getEmptyExecutionResult(): BasicExecutionResult {
|
|
return {
|
|
code: -1,
|
|
okToCache: false,
|
|
filenameTransform: x => x,
|
|
stdout: [],
|
|
stderr: [],
|
|
execTime: 0,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
|
|
export function deltaTimeNanoToMili(startTime: bigint, endTime: bigint): number {
|
|
return Number((endTime - startTime) / BigInt(1_000_000));
|
|
}
|
|
|
|
/**
|
|
* Sleep for a number of milliseconds.
|
|
*/
|
|
export async function sleep(ms: number) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export function resultLinesToText(lines: ResultLine[]): string {
|
|
return lines.map(line => line.text).join('\n');
|
|
}
|
|
|
|
/**
|
|
* Try and read a file as a utf-8 text file, returning its contents if present, or undefined if not.
|
|
*/
|
|
export async function tryReadTextFile(filename: string): Promise<string | undefined> {
|
|
try {
|
|
return await fs.readFile(filename, 'utf8');
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try and read a file as a utf-8 json file, returning its contents if present, or undefined if not.
|
|
*/
|
|
export async function tryReadJsonFile(filename: string): Promise<any | undefined> {
|
|
const text = await tryReadTextFile(filename);
|
|
return text === undefined ? undefined : JSON.parse(text);
|
|
}
|
|
|
|
/**
|
|
* Output a file, creating any necessary directories.
|
|
*/
|
|
export async function outputTextFile(filepath: string, contents: string): Promise<void> {
|
|
await fs.mkdir(path.dirname(filepath), {recursive: true});
|
|
await fs.writeFile(filepath, contents, 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Create an empty file (and directories leading to it), leaving it alone if already present.
|
|
*/
|
|
export async function ensureFileExists(filepath: string): Promise<void> {
|
|
await fs.mkdir(path.dirname(filepath), {recursive: true});
|
|
await (await fs.open(filepath, 'a')).close();
|
|
}
|