blob: 175b0d870dfe5cfab28db00cb4a282411d696204 [file] [log] [blame] [raw]
// Copyright (c) 2021, 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 _ from 'underscore';
import path from 'path';
import JSZip from 'jszip';
import {Hub} from './hub';
const languages = require('./options').options.languages;
export interface MultifileFile {
fileId: number;
isIncluded: boolean;
isOpen: boolean;
isMainSource: boolean;
filename: string;
content: string;
editorId: number;
langId: string;
}
export interface FiledataPair {
filename: string;
contents: string;
}
export interface MultifileServiceState {
isCMakeProject: boolean;
compilerLanguageId: string;
files: MultifileFile[];
newFileId: number;
}
export class MultifileService {
private files: Array<MultifileFile>;
private compilerLanguageId: string;
private isCMakeProject: boolean;
private hub: Hub;
private newFileId: number;
private alertSystem: any;
private validExtraFilenameExtensions: string[];
private readonly defaultLangIdUnknownExt: string;
private readonly cmakeLangId: string;
private readonly cmakeMainSourceFilename: string;
private readonly maxFilesize: number;
constructor(hub: Hub, alertSystem, state: MultifileServiceState) {
this.hub = hub;
this.alertSystem = alertSystem;
this.isCMakeProject = state.isCMakeProject || false;
this.compilerLanguageId = state.compilerLanguageId || '';
this.files = state.files;
this.newFileId = state.newFileId || 1;
this.validExtraFilenameExtensions = ['.txt', '.md', '.rst', '.sh', '.cmake', '.in'];
this.defaultLangIdUnknownExt = 'c++';
this.cmakeLangId = 'cmake';
this.cmakeMainSourceFilename = 'CMakeLists.txt';
this.maxFilesize = 1024000;
}
private static isHiddenFile(filename: string): boolean {
return filename.length > 0 && filename[0] === '.';
}
private isValidFilename(filename: string): boolean {
if (MultifileService.isHiddenFile(filename)) return false;
const filenameExt = path.extname(filename);
if (this.validExtraFilenameExtensions.includes(filenameExt)) {
return true;
}
return _.any(languages, lang => {
return lang.extensions.includes(filenameExt);
});
}
private isCMakeFile(filename: string): boolean {
const filenameExt = path.extname(filename);
if (filenameExt === '.cmake' || filenameExt === '.in') {
return true;
}
return path.basename(filename) === this.cmakeMainSourceFilename;
}
private getLanguageIdFromFilename(filename: string): string {
const filenameExt = path.extname(filename);
const possibleLang = _.filter(languages, lang => {
return lang.extensions.includes(filenameExt);
});
if (possibleLang.length > 0) {
return possibleLang[0].id;
}
if (this.isCMakeFile(filename)) {
return this.cmakeLangId;
}
return this.defaultLangIdUnknownExt;
}
public async loadProjectFromFile(f, callback) {
this.files = [];
this.newFileId = 1;
const zipFilename = path.basename(f.name, '.zip');
const mainSourcefilename = this.getDefaultMainCMakeFilename();
const zip = await JSZip.loadAsync(f);
zip.forEach(async (relativePath, zipEntry) => {
if (!zipEntry.dir) {
let removeFromName = 0;
if (relativePath.indexOf(zipFilename + '/') === 0) {
removeFromName = zipFilename.length + 1;
}
const properName = relativePath.substring(removeFromName);
if (!this.isValidFilename(properName)) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
let content = await zip.file(zipEntry.name)!.async('string');
if (content.length > this.maxFilesize) {
return;
}
// remove utf8-bom characters
content = content.replace(/^(\ufeff)/, '');
const file: MultifileFile = {
fileId: this.newFileId,
filename: properName,
isIncluded: true,
isOpen: false,
editorId: -1,
isMainSource: properName === mainSourcefilename,
content: content,
langId: this.getLanguageIdFromFilename(properName),
};
this.addFile(file);
callback(file);
}
});
}
public async saveProjectToZipfile(callback: (any) => void) {
const zip = new JSZip();
this.forEachFile((file: MultifileFile) => {
if (file.isIncluded) {
zip.file(file.filename, this.getFileContents(file));
}
});
zip.generateAsync({type: 'blob'}).then(
blob => {
callback(blob);
},
err => {
throw err;
}
);
}
public getState(): MultifileServiceState {
return {
isCMakeProject: this.isCMakeProject,
compilerLanguageId: this.compilerLanguageId,
files: this.files,
newFileId: this.newFileId,
};
}
public getLanguageId() {
return this.compilerLanguageId;
}
public isCompatibleWithCMake(): boolean {
return (
this.compilerLanguageId === 'c++' ||
this.compilerLanguageId === 'c' ||
this.compilerLanguageId === 'fortran'
);
}
public setLanguageId(id: string) {
this.compilerLanguageId = id;
}
public isACMakeProject(): boolean {
return this.isCompatibleWithCMake() && this.isCMakeProject;
}
public setAsCMakeProject(yes: boolean) {
this.isCMakeProject = yes;
}
private checkFileEditor(file?: MultifileFile) {
if (file && file.editorId > 0) {
const editor = this.hub.getEditorById(file.editorId);
if (!editor) {
file.isOpen = false;
file.editorId = -1;
}
}
}
public getFileContents(file: MultifileFile) {
this.checkFileEditor(file);
if (file.isOpen) {
const editor = this.hub.getEditorById(file.editorId);
return editor.getSource();
} else {
return file.content;
}
}
public isEditorPartOfProject(editorId: number) {
const found = _.find(this.files, (file: MultifileFile) => {
return file.isIncluded && file.isOpen && editorId === file.editorId;
});
return !!found;
}
public getFileByFileId(fileId: number): MultifileFile | undefined {
const file = _.find(this.files, (file: MultifileFile) => {
return file.fileId === fileId;
});
this.checkFileEditor(file);
return file;
}
public setAsMainSource(mainFileId: number) {
for (const file of this.files) {
file.isMainSource = false;
}
const mainfile = this.getFileByFileId(mainFileId);
if (mainfile) {
mainfile.isMainSource = true;
}
}
private static isValidFile(file: MultifileFile): boolean {
return file.editorId > 0 || !!file.filename;
}
private filterOutNonsense() {
this.files = _.filter(this.files, (file: MultifileFile) => MultifileService.isValidFile(file));
}
public getFiles(): Array<FiledataPair> {
this.filterOutNonsense();
const filtered = _.filter(this.files, (file: MultifileFile) => {
return !file.isMainSource && file.isIncluded;
});
return _.map(filtered, (file: MultifileFile) => {
return {
filename: file.filename,
contents: this.getFileContents(file),
};
});
}
private isMainSourceFile(file: MultifileFile): boolean {
if (this.isCMakeProject) {
if (file.filename === this.getDefaultMainCMakeFilename()) {
this.setAsMainSource(file.fileId);
} else {
return false;
}
} else {
if (this.compilerLanguageId === 'pascal') {
if (file.filename.endsWith('.dpr')) {
this.setAsMainSource(file.fileId);
} else {
return false;
}
} else {
if (file.filename === MultifileService.getDefaultMainSourceFilename(this.compilerLanguageId)) {
this.setAsMainSource(file.fileId);
} else {
return false;
}
}
}
return file.isMainSource;
}
public getMainSource(): string {
const mainFile = _.find(this.files, (file: MultifileFile) => {
return file.isIncluded && this.isMainSourceFile(file);
});
if (mainFile) {
return this.getFileContents(mainFile);
} else {
return '';
}
}
public getFileByEditorId(editorId: number): MultifileFile | undefined {
return _.find(this.files, (file: MultifileFile) => {
return file.editorId === editorId;
});
}
public getEditorIdByFilename(filename: string): number | null {
const file = _.find(this.files, (file: MultifileFile) => {
return file.isIncluded && file.filename === filename;
});
return file && file.editorId > 0 ? file.editorId : null;
}
public getMainSourceEditorId(): number | null {
const file = _.find(this.files, (file: MultifileFile) => {
return file.isIncluded && this.isMainSourceFile(file);
});
this.checkFileEditor(file);
return file && file.editorId > 0 ? file.editorId : null;
}
private addFile(file: MultifileFile) {
this.newFileId++;
this.files.push(file);
}
public addFileForEditorId(editorId: number) {
const file: MultifileFile = {
fileId: this.newFileId,
isIncluded: false,
isOpen: true,
isMainSource: false,
filename: '',
content: '',
editorId: editorId,
langId: '',
};
this.addFile(file);
}
public removeFileByFileId(fileId: number): MultifileFile | undefined {
const file = this.getFileByFileId(fileId);
if (file) {
this.files = this.files.filter((obj: MultifileFile) => obj.fileId !== fileId);
}
return file;
}
public async excludeByFileId(fileId: number): Promise<void> {
const file = this.getFileByFileId(fileId);
if (file) {
file.isIncluded = false;
}
}
public async includeByFileId(fileId: number): Promise<void> {
const file = this.getFileByFileId(fileId);
if (file) {
file.isIncluded = true;
if (file.filename === '') {
const isRenamed = await this.renameFile(fileId);
if (isRenamed) {
await this.includeByFileId(fileId);
} else {
file.isIncluded = false;
}
} else {
file.isIncluded = true;
}
}
}
public async includeByEditorId(editorId: number): Promise<void> {
const file = this.getFileByEditorId(editorId);
if (file) {
return this.includeByFileId(file.fileId);
} else {
return Promise.reject('File not found');
}
}
public forEachOpenFile(callback: (File) => void) {
this.filterOutNonsense();
for (const file of this.files) {
if (file.isOpen && file.editorId > 0) {
callback(file);
}
}
}
public forEachFile(callback: (File) => void) {
this.filterOutNonsense();
for (const file of this.files) {
callback(file);
}
}
private getDefaultMainCMakeFilename() {
return this.cmakeMainSourceFilename;
}
private static getDefaultMainSourceFilename(langId) {
const lang = languages[langId];
const ext0 = lang.extensions[0];
return 'example' + ext0;
}
private getSuggestedFilename(file: MultifileFile, editor: any): string {
let suggestedFilename = file.filename;
if (file.filename === '') {
let langId: string = file.langId;
if (editor) {
langId = editor.currentLanguage.id;
if (editor.filename) {
suggestedFilename = editor.filename;
}
}
if (!suggestedFilename) {
if (langId === this.cmakeLangId) {
suggestedFilename = this.getDefaultMainCMakeFilename();
} else {
suggestedFilename = MultifileService.getDefaultMainSourceFilename(langId);
}
}
}
return suggestedFilename;
}
private fileExists(filename: string, excludeFile: MultifileFile): boolean {
return !!_.find(this.files, (file: MultifileFile) => {
return file !== excludeFile && file.filename === filename;
});
}
public async renameFile(fileId: number): Promise<boolean> {
const file = this.getFileByFileId(fileId);
if (!file) return Promise.reject('File could not be found');
let editor: any = null;
if (file.isOpen && file.editorId > 0) {
editor = this.hub.getEditorById(file.editorId);
}
const suggestedFilename = this.getSuggestedFilename(file, editor);
return new Promise(resolve => {
this.alertSystem.enterSomething('Rename file', 'Please enter new filename', suggestedFilename, {
yes: (value: string) => {
if (value !== '' && value[0] !== '/') {
if (!this.fileExists(value, file)) {
file.filename = value;
if (editor) {
editor.setFilename(file.filename);
}
resolve(true);
} else {
this.alertSystem.alert('Rename file', 'Filename already exists');
resolve(false);
}
} else {
this.alertSystem.alert('Rename file', 'Filename cannot be empty or start with a "/"');
resolve(false);
}
},
no: () => {
resolve(false);
},
yesClass: 'btn btn-primary',
yesHtml: 'Rename',
noClass: 'btn-outline-info',
noHtml: 'Cancel',
});
});
}
public async renameFileByEditorId(editorId: number): Promise<boolean> {
const file = this.getFileByEditorId(editorId);
if (file) {
return this.renameFile(file.fileId);
} else {
return Promise.reject('File not found');
}
}
}