plaxtony
Version:
Static code analysis of SC2 Galaxy Script
517 lines (446 loc) • 16.8 kB
text/typescript
import * as lsp from 'vscode-languageserver';
import { TextDocument } from 'vscode-languageserver-textdocument';
import URI from 'vscode-uri';
import * as fs from 'fs-extra';
import * as util from 'util';
import * as path from 'path';
import * as xml from 'xml2js';
import * as glob from 'fast-glob';
import * as trig from './trigger';
import * as cat from './datacatalog';
import * as loc from './localization';
import { logger, logIt } from '../common';
const validArchiveExtensions = [
'sc2map',
'sc2interface',
'sc2campaign',
'sc2mod',
];
const reValidArchiveExtension = new RegExp('\\.(' + validArchiveExtensions.join('|') + ')$', 'i');
export enum BuiltinDeps {
'mods/core.sc2mod',
'mods/glue.sc2mod',
'mods/liberty.sc2mod',
'mods/swarm.sc2mod',
'mods/void.sc2mod',
'mods/libertymulti.sc2mod',
'mods/swarmmulti.sc2mod',
'mods/voidmulti.sc2mod',
'mods/balancemulti.sc2mod',
'mods/starcoop/starcoop.sc2mod',
'mods/war3.sc2mod',
'mods/novastoryassets.sc2mod',
'campaigns/liberty.sc2campaign',
'campaigns/swarm.sc2campaign',
'campaigns/void.sc2campaign',
'campaigns/libertystory.sc2campaign',
'campaigns/swarmstory.sc2campaign',
'campaigns/voidstory.sc2campaign',
}
export type builtinDepName = keyof typeof BuiltinDeps;
const builtinDepsHierarchy = (function() {
function depsFor(modName: builtinDepName) {
let list: string[] = [];
let matches: RegExpExecArray;
if (matches = /^campaigns\/(liberty|swarm|void)story\.sc2campaign$/i.exec(modName)) {
list.push('campaigns/' + matches[1] + '.sc2campaign');
}
else if (matches = /^mods\/(liberty|swarm|void|balance)multi\.sc2mod$/i.exec(modName)) {
if (matches[1] === 'balance') {
list.push('mods/void.sc2mod');
}
else {
list.push('mods/' + matches[1] + '.sc2mod');
}
}
else if (matches = /^campaigns\/(liberty|swarm|void)\.sc2campaign$/i.exec(modName)) {
if (matches[1] === 'void') {
list.push('campaigns/swarm.sc2campaign');
}
else if (matches[1] === 'swarm') {
list.push('campaigns/liberty.sc2campaign');
}
list.push('mods/' + matches[1] + '.sc2mod');
}
else if (matches = /^mods\/(liberty|swarm|void)\.sc2mod$/i.exec(modName)) {
if (matches[1] === 'void') {
list.push('mods/swarm.sc2mod');
}
else if (matches[1] === 'swarm') {
list.push('mods/liberty.sc2mod');
}
}
else {
switch (modName) {
case 'mods/novastoryassets.sc2mod':
case 'mods/starcoop/starcoop.sc2mod':
{
list.push('campaigns/void.sc2campaign');
break;
}
}
}
for (const item of list) {
list = depsFor(item as builtinDepName).concat(list.reverse());
}
return list;
}
const depHierarchy: {[key in builtinDepName]: string[]} = {} as any;
for (const modName of Object.keys(BuiltinDeps).filter(v => typeof (BuiltinDeps as any)[v] === 'number')) {
depHierarchy[modName as builtinDepName] = [];
if (modName !== 'mods/core.sc2mod') {
depHierarchy[modName as builtinDepName].push('mods/core.sc2mod');
}
depHierarchy[modName as builtinDepName] = depHierarchy[modName as builtinDepName].concat(
Array.from(new Set(depsFor(modName as builtinDepName)))
);
}
return depHierarchy;
})();
export function isSC2Archive(directory: string) {
return path.basename(directory).match(reValidArchiveExtension);
}
export async function findSC2ArchiveDirectories(directory: string, opts: { exclude?: string[] } = {}) {
directory = path.resolve(directory);
if (isSC2Archive(directory)) {
return [directory];
}
const results = (await glob(
`**/*.{${validArchiveExtensions.join(',')}}`,
{
caseSensitiveMatch: false,
absolute: true,
cwd: directory,
ignore: opts.exclude,
onlyDirectories: true,
stats: true,
objectMode: true,
}
)).map(x => x.path);
return results.sort((a, b) => {
return (
validArchiveExtensions.indexOf(b.match(reValidArchiveExtension)[1].toLowerCase()) -
validArchiveExtensions.indexOf(a.match(reValidArchiveExtension)[1].toLowerCase())
);
});
}
export abstract class Component {
protected workspace: SC2Workspace;
constructor(workspace: SC2Workspace) {
this.workspace = workspace;
}
public async load() {
return await this.loadData();
}
abstract loadData(): Promise<boolean>;
}
export class TriggerComponent extends Component {
protected store = new trig.TriggerStore();
// protected libraries: trig.Library;
()
public async loadData() {
const trigReader = new trig.XMLReader(this.store);
for (const archive of this.workspace.metadataArchives) {
for (const filename of await archive.findFiles('**/*.{TriggerLib,SC2Lib}')) {
logger.debug(`:: ${archive.name}/${filename}`);
this.store.addLibrary(await trigReader.loadLibrary(await archive.readFile(filename)));
}
if (await archive.hasFile('Triggers')) {
logger.debug(`:: ${archive.name}/Triggers`);
await trigReader.load(await archive.readFile('Triggers'), this.workspace.rootArchive !== archive);
}
}
return true;
}
public getStore() {
return this.store;
}
}
export class CatalogComponent extends Component {
protected store = new cat.CatalogStore();
()
public async loadData() {
for await (const [archive, filename] of this.workspace.findFiles('**/GameData/**/*.xml')) {
logger.debug(`:: ${archive.name}/${filename}`);
const doc = await SC2Workspace.documentFromFile(archive, filename, 'xml');
this.store.update(doc, archive);
}
return true;
}
public getStore() {
return this.store;
}
}
export class LocalizationComponent extends Component {
lang = 'enUS';
triggers = new loc.LocalizationTriggers();
strings = new Map<string, loc.LocalizationTextStore>();
private async loadStrings(name: string) {
const textStore = new loc.LocalizationTextStore();
for (const archive of this.workspace.metadataArchives) {
const filenames = await archive.findFiles('**/' + this.lang + '.SC2Data/LocalizedData/' + name + 'Strings.txt');
if (filenames.length) {
logger.debug(`:: ${archive.name}/${filenames[0]}`);
const locFile = new loc.LocalizationFile();
locFile.read(await archive.readFile(filenames[0]));
textStore.merge(locFile);
}
}
this.strings.set(name, textStore);
}
()
public async loadData() {
for (const archive of this.workspace.metadataArchives) {
const filenames = await archive.findFiles('**/' + this.lang + '.SC2Data/LocalizedData/TriggerStrings.txt');
if (filenames.length) {
logger.debug(`:: ${archive.name}/${filenames[0]}`);
const locFile = new loc.LocalizationFile();
locFile.read(await archive.readFile(filenames[0]));
this.triggers.merge(locFile);
}
}
// await this.loadStrings('Trigger');
await this.loadStrings('Game');
await this.loadStrings('Object');
return true;
}
}
export interface ArchiveLink {
name: string;
src: string;
}
export async function resolveArchiveDirectory(name: string, sources: string[]) {
for (const src of sources) {
const results = await glob(`**/${name}`, {
caseSensitiveMatch: false,
absolute: true,
cwd: src,
onlyDirectories: true,
});
if (results.length) {
return results[0];
}
}
}
type ResolveDependencyOpts = {
overrides?: Map<string, string>;
fallbackResolve?: (name: string) => Promise<string | undefined>;
};
export async function resolveArchiveDependencyList(rootArchive: SC2Archive, sources: string[], opts: ResolveDependencyOpts = {}) {
const list: ArchiveLink[] = [];
const unresolvedNames: string[] = [];
async function resolveWorker(archive: SC2Archive) {
for (const entry of await archive.getDependencyList()) {
if (list.findIndex((item) => item.name === entry) !== -1) {
continue;
}
const link = <ArchiveLink>{
name: entry,
};
let dir: string;
if (opts.overrides && opts.overrides.has(entry)) {
dir = opts.overrides.get(entry);
}
else {
dir = await resolveArchiveDirectory(entry, sources);
}
if (!dir && opts.fallbackResolve) {
dir = await opts.fallbackResolve(entry);
}
if (dir) {
await resolveWorker(new SC2Archive(entry, dir));
link.src = dir;
list.push(link);
}
else {
unresolvedNames.push(entry);
}
}
}
await resolveWorker(rootArchive);
return {
list,
unresolvedNames,
};
}
export async function openArchiveWorkspace(archive: SC2Archive, sources: string[], overrides: Map<string,string> = null, extra: Map<string,string> = null) {
const dependencyArchives: SC2Archive[] = [];
const result = await resolveArchiveDependencyList(archive, sources, {
overrides,
});
if (result.unresolvedNames.length > 0) {
throw new Error(`couldn\'t resolve ${util.inspect(result.unresolvedNames)}\nSources: ${util.inspect(sources)}\nOverrides: ${util.inspect(overrides)}`);
}
for (const link of result.list) {
dependencyArchives.push(new SC2Archive(link.name, link.src));
}
if (extra) {
for (const [name, src] of extra) {
dependencyArchives.push(new SC2Archive(name, src));
}
}
return new SC2Workspace(archive, dependencyArchives);
}
export class SC2Archive {
readonly name: string;
readonly directory: string;
/** lower-cased `fsPath` */
readonly lcFsPath: string;
priority: number = 0;
constructor(name: string = null, directory: string) {
if (name === null) {
name = path.basename(directory);
}
this.name = name.replace(/\\/g, '/').toLowerCase();
this.directory = fs.realpathSync(path.resolve(directory));
this.lcFsPath = this.directory.toLowerCase();
}
public async findFiles(pattern: string | string[]) {
return glob(pattern, {
cwd: this.directory,
caseSensitiveMatch: false,
onlyFiles: true,
objectMode: false,
ignore: [
'base{0..99}.sc2maps/**',
],
});
}
public async hasFile(filename: string) {
return fs.pathExists(path.join(this.directory, filename));
}
public async readFile(filename: string) {
return fs.readFile(path.join(this.directory, filename), 'utf8');
}
public relativePath(uri: lsp.DocumentUri) {
const fsPath = URI.parse(uri).fsPath;
if (fsPath.substring(0, this.lcFsPath.length).toLowerCase() !== this.lcFsPath) return;
const relativeFsPath = fsPath.substring(this.lcFsPath.length + 1);
return relativeFsPath;
}
/**
* returns lowercased and forward slash normalized list
*/
public async getDependencyList() {
let list: string[] = [];
if (builtinDepsHierarchy[this.name as builtinDepName]) {
list = list.concat(builtinDepsHierarchy[this.name as builtinDepName]);
}
if (await this.hasFile('DocumentInfo')) {
try {
const content = await this.readFile('DocumentInfo');
const data = await xml.parseStringPromise(content);
for (const depValue of data.DocInfo.Dependencies[0].Value) {
list.push(depValue.substr(depValue.indexOf('file:') + 5).replace(/\\/g, '/').toLowerCase());
}
}
catch (err) {
logger.warn(`Couldn't read dependencies from "DocumentInfo" of "${this.name}`, (<Error>err).message);
list.push('mods/core.sc2mod');
}
}
return list;
}
get isBuiltin(): boolean {
return builtinDepsHierarchy[this.name as builtinDepName] !== void 0;
}
}
export enum S2ArchiveNsNameKind {
base,
enus,
// TODO: add missing localizations
}
export enum S2ArchiveNsTypeKind {
sc2assets,
sc2data,
}
export interface S2FileNs {
name: keyof typeof S2ArchiveNsNameKind;
type: keyof typeof S2ArchiveNsTypeKind;
}
export interface S2QualifiedFile {
fsPath: string;
relativePath: string;
archiveRelpath: string;
namespace?: S2FileNs;
archive?: SC2Archive;
priority: number;
}
const reArchiveFileNs = /^(?:(?<nsName>[a-z]+)\.(?<nsType>(?:sc2data|sc2assets))(?:\/|\\))?(?<rp>.+)$/i;
const reIsUri = /^[^:/?#]+:\/\//i;
export class SC2Workspace {
rootArchive?: SC2Archive;
allArchives: SC2Archive[] = [];
dependencies: SC2Archive[] = [];
metadataArchives: SC2Archive[] = [];
trigComponent: TriggerComponent = new TriggerComponent(this);
locComponent: LocalizationComponent = new LocalizationComponent(this);
catalogComponent: CatalogComponent = new CatalogComponent(this);
readonly arvMap = new Map<string, SC2Archive>();
constructor(rootArchive?: SC2Archive, dependencies: SC2Archive[] = []) {
this.rootArchive = rootArchive;
this.dependencies = dependencies;
this.allArchives = this.allArchives.concat(this.dependencies);
this.metadataArchives = this.allArchives;
if (rootArchive) {
this.allArchives.push(rootArchive);
}
this.allArchives.forEach((av, index) => {
this.arvMap.set(av.name, av);
av.priority = index * 20;
});
}
public async *findFiles(pattern: string | string[]) {
const stuff = this.allArchives.map(archive => <[SC2Archive, Promise<string[]>]>[archive, archive.findFiles(pattern)]);
for (const [archive, archiveFiles] of stuff) {
for (const filename of await archiveFiles) {
yield <[SC2Archive, string]>[archive, filename];
}
}
}
static async documentFromFile(archive: SC2Archive, filename: string, languageId?: string) {
if (!languageId) {
languageId = path.extname(filename).split('.').pop();
}
return TextDocument.create(
URI.file(path.join(archive.directory, filename)).toString(),
languageId,
0,
await archive.readFile(filename)
);
}
public resolvePath(fsPath: lsp.URI): S2QualifiedFile | undefined {
if (fsPath.match(reIsUri)) {
fsPath = URI.parse(fsPath).fsPath;
}
for (const cArchive of this.allArchives) {
if (!fsPath.toLowerCase().startsWith(cArchive.lcFsPath)) continue;
const m = fsPath.substring(cArchive.directory.length + 1).match(reArchiveFileNs);
if (!m) return;
let priority = cArchive.priority;
let ns: S2FileNs;
if (m.groups['nsName']) {
ns = {
name: <any>m.groups['nsName'].toLowerCase(),
type: <any>m.groups['nsType'].toLowerCase(),
};
priority += S2ArchiveNsTypeKind[ns.type];
if (ns.name !== 'base') {
priority += 5;
}
}
else {
priority += 10;
}
const relativePath = m.groups['rp'].replace(/\\/g, '/');
return {
fsPath: fsPath,
relativePath: relativePath,
archiveRelpath: m.groups['nsName'] ? `${m.groups['nsName']}.${m.groups['nsType']}/${relativePath}` : relativePath,
namespace: ns,
archive: cArchive,
priority: priority,
};
}
}
}