@hpcc-js/comms
Version:
hpcc-js - Communications
507 lines (444 loc) • 16.8 kB
text/typescript
import * as fs from "node:fs";
import * as path from "node:path";
import { Dictionary, DictionaryNoCase, find, SAXStackParser, scopedLogger, XMLNode } from "@hpcc-js/util";
import { ClientTools, locateClientTools } from "./eclcc.ts";
const logger = scopedLogger("clienttools/eclmeta");
export interface IFilePath {
scope: ECLScope;
}
const _inspect = false;
function inspect(obj: any, _id: string, known: any) {
if (_inspect) {
for (const key in obj) {
const id = `${_id}.${key}`;
if (key !== "$" && known[key] === undefined && known[key.toLowerCase() + "s"] === undefined) {
logger.debug(id);
}
}
if (obj.$) {
inspect(obj.$, _id + ".$", known);
}
}
}
export class Attr {
__attrs: { [id: string]: string };
name: string;
constructor(xmlAttr: XMLNode) {
this.__attrs = xmlAttr.$;
this.name = xmlAttr.$.name;
}
}
export class Field {
__attrs: { [id: string]: string };
definition: Definition;
get scope(): ECLScope {
return this.definition;
}
name: string;
type: string;
constructor(definition: Definition, xmlField: XMLNode) {
this.__attrs = xmlField.$;
this.definition = definition;
this.name = xmlField.$.name;
this.type = xmlField.$.type;
}
}
export interface ECLDefinitionLocation {
filePath: string;
line: number;
charPos: number;
definition?: Definition;
source?: Source;
}
export interface ISuggestion {
name: string;
type: string;
}
export class ECLScope implements IFilePath {
get scope(): ECLScope {
return this;
}
name: string;
type: string;
sourcePath: string;
line: number;
start: number;
body: number;
end: number;
definitions: Definition[];
constructor(name: string, type: string, sourcePath: string, xmlDefinitions: XMLNode[], line: number = 1, start: number = 0, body: number = 0, end: number = Number.MAX_VALUE) {
this.name = name;
this.type = type;
this.sourcePath = path.normalize(sourcePath);
this.line = +line - 1;
this.start = +start;
this.body = +body;
this.end = +end;
this.definitions = this.parseDefinitions(xmlDefinitions);
}
private parseDefinitions(definitions: XMLNode[] = []): Definition[] {
return definitions.map(definition => {
const retVal = new Definition(this.sourcePath, definition);
inspect(definition, "definition", retVal);
return retVal;
});
}
contains(charOffset: number) {
return charOffset >= this.start && charOffset <= this.end;
}
scopeStackAt(charOffset: number): ECLScope[] {
let retVal: ECLScope[] = [];
if (this.contains(charOffset)) {
retVal.push(this);
this.definitions.forEach(def => {
retVal = def.scopeStackAt(charOffset).concat(retVal);
});
}
return retVal;
}
private _resolve(defs: Definition[] = [], qualifiedID: string): Definition | undefined {
const qualifiedIDParts = qualifiedID.split(".");
const base = qualifiedIDParts.shift();
const retVal = find(defs, def => {
if (typeof def.name === "string" && typeof base === "string" && def.name.toLowerCase() === base.toLowerCase()) {
return true;
}
return false;
});
if (retVal && retVal.definitions.length && qualifiedIDParts.length) {
return this._resolve(retVal.definitions, qualifiedIDParts.join("."));
}
return retVal;
}
resolve(qualifiedID: string): Definition | undefined {
return this._resolve(this.definitions, qualifiedID);
}
suggestions(): ISuggestion[] {
return this.definitions.map(def => {
return {
name: def.name,
type: this.type
};
});
}
}
export class Definition extends ECLScope {
__attrs: { [id: string]: string };
exported: boolean;
shared: boolean;
fullname: string;
inherittype: string;
attrs: Attr[];
fields: Field[];
constructor(sourcePath: string, xmlDefinition: XMLNode) {
super(xmlDefinition.$.name, xmlDefinition.$.type, sourcePath, xmlDefinition.children("Definition"), xmlDefinition.$.line, xmlDefinition.$.start, xmlDefinition.$.body, xmlDefinition.$.end);
this.__attrs = xmlDefinition.$;
this.exported = !!xmlDefinition.$.exported;
this.shared = !!xmlDefinition.$.shared;
this.fullname = xmlDefinition.$.fullname;
this.inherittype = xmlDefinition.$.inherittype;
this.attrs = this.parseAttrs(xmlDefinition.children("Attr"));
this.fields = this.parseFields(xmlDefinition.children("Field"));
}
private parseAttrs(attrs: XMLNode[] = []): Attr[] {
return attrs.map(attr => {
const retVal = new Attr(attr);
inspect(attr, "attr", retVal);
return retVal;
});
}
private parseFields(fields: XMLNode[] = []): Field[] {
return fields.map(field => {
const retVal = new Field(this, field);
inspect(field, "field", retVal);
return retVal;
});
}
suggestions() {
return super.suggestions().concat(this.fields.map(field => {
return {
name: field.name,
type: field.type
};
}));
}
}
export class Import {
__attrs: { [id: string]: string };
name: string;
ref: string;
start: number;
end: number;
line: number;
constructor(xmlImport: XMLNode) {
this.__attrs = xmlImport.$;
this.name = xmlImport.$.name;
this.ref = xmlImport.$.ref;
this.start = xmlImport.$.start;
this.end = xmlImport.$.end;
this.line = xmlImport.$.line;
}
}
export class Source extends ECLScope {
imports: Import[];
__attrs: { [id: string]: string };
constructor(xmlSource: XMLNode) {
super(xmlSource.$.name, "source", xmlSource.$.sourcePath, xmlSource.children("Definition"));
this.__attrs = xmlSource.$;
const nameParts = xmlSource.$.name.split(".");
nameParts.pop();
const fakeNode = new XMLNode("");
fakeNode.appendAttribute("name", "$");
fakeNode.appendAttribute("ref", nameParts.join("."));
this.imports = [
new Import(fakeNode),
...this.parseImports(xmlSource.children("Import"))
];
}
private parseImports(imports: XMLNode[] = []): Import[] {
return imports.map(imp => {
const retVal = new Import(imp);
inspect(imp, "import", retVal);
return retVal;
});
}
resolve(qualifiedID: string, charOffset?: number): Definition | undefined {
let retVal;
// Check Inner Scopes ---
if (!retVal && charOffset !== undefined) {
const scopes = this.scopeStackAt(charOffset);
scopes.some(scope => {
retVal = scope.resolve(qualifiedID);
return !!retVal;
});
}
// Check Definitions ---
if (!retVal) {
retVal = super.resolve(qualifiedID);
}
return retVal;
}
}
const isHiddenDirectory = source => path.basename(source).indexOf(".") === 0;
const isDirectory = source => fs.lstatSync(source).isDirectory() && !isHiddenDirectory(source);
const isEcl = source => [".ecl", ".ecllib"].indexOf(path.extname(source).toLowerCase()) >= 0;
const modAttrs = source => fs.readdirSync(source).map(name => path.join(source, name)).filter(path => isDirectory(path) || isEcl(path));
export class File extends ECLScope {
constructor(name: string, sourcePath: string) {
super(name, "file", sourcePath, []);
}
suggestions(): ISuggestion[] {
return [];
}
}
export class Folder extends ECLScope {
constructor(name: string, sourcePath: string) {
super(name, "folder", sourcePath, []);
}
suggestions(): ISuggestion[] {
return modAttrs(this.sourcePath).map(folder => {
return {
name: path.basename(folder, ".ecl"),
type: "folder"
};
});
}
}
export class Workspace {
_workspacePath: string;
_eclccPath?: string;
_clientTools: ClientTools;
_sourceByID: DictionaryNoCase<Source> = new DictionaryNoCase<Source>();
_sourceByPath: Dictionary<Source> = new Dictionary<Source>();
private _test: DictionaryNoCase<IFilePath> = new DictionaryNoCase<IFilePath>();
constructor(workspacePath: string, eclccPath?: string) {
this._workspacePath = workspacePath;
this._eclccPath = eclccPath;
}
refresh() {
this.primeWorkspace();
this.primeClientTools();
}
primeClientTools(): Promise<this> {
return locateClientTools(this._eclccPath, "", this._workspacePath).then(clientTools => {
this._clientTools = clientTools;
return clientTools.paths();
}).then(paths => {
for (const knownFolder of ["ECLCC_ECLLIBRARY_PATH", "ECLCC_PLUGIN_PATH"]) {
if (paths[knownFolder] && fs.existsSync(paths[knownFolder])) {
this.walkChildFolders(paths[knownFolder], paths[knownFolder]);
}
}
return this;
});
}
primeWorkspace() {
if (fs.existsSync(this._workspacePath)) {
this.visitFolder(this._workspacePath, this._workspacePath);
}
}
walkChildFolders(folderPath: string, refPath: string, force: boolean = false) {
for (const child of modAttrs(folderPath)) {
if (!isDirectory(child)) {
this.visitFile(child, refPath, force);
} else {
this.visitFolder(child, refPath, force);
}
}
}
visitFile(filePath: string, refPath: string, force: boolean = false) {
const filePathInfo = path.parse(filePath);
const pathNoExt = path.join(filePathInfo.dir, filePathInfo.name);
const name = path.relative(refPath, pathNoExt).split(path.sep).join(".");
if (force || !this._test.has(name)) {
this._test.set(name, new File("", filePath));
}
}
visitFolder(folderPath: string, refPath: string, force: boolean = false) {
const name = path.relative(refPath, folderPath).split(path.sep).join(".");
if (force || !this._test.has(name)) {
this._test.set(name, new Folder(name, folderPath));
this.walkChildFolders(folderPath, refPath, force);
}
}
buildStack(parentStack: string[], name: string, removeDupID: boolean): { stack: string[], qid: string } {
const nameStack = name.split(".");
if (removeDupID && parentStack[parentStack.length - 1] === nameStack[0]) {
nameStack.shift();
}
const stack = [...parentStack, ...nameStack];
const qid: string = stack.join(".");
return {
stack,
qid
};
}
walkECLScope(parentStack: string[], scope: ECLScope) {
const info = this.buildStack(parentStack, scope.name, true);
this._test.set(info.qid, scope);
for (const def of scope.definitions) {
this.walkDefinition(info.stack, def);
}
}
walkField(parentStack: string[], field: Field) {
const info = this.buildStack(parentStack, field.name, false);
this._test.set(info.qid, field);
}
walkDefinition(parentStack: string[], definition: Definition) {
const info = this.buildStack(parentStack, definition.name, true);
this.walkECLScope(parentStack, definition);
for (const field of definition.fields) {
this.walkField(info.stack, field);
}
}
walkSource(source: Source) {
// const dirName = path.dirname(source.sourcePath);
// const relName = path.relative(this._workspacePath, dirName).split(path.sep).join(".");
// const folder = new Folder(relName, dirName);
// this._test.set(folder.name, folder);
this.walkECLScope([], source);
}
parseSources(sources: XMLNode[] = []): void {
for (const _source of sources) {
if (_source.$.name) { // Plugins have no name...
const source = new Source(_source);
inspect(_source, "source", source);
this._sourceByID.set(source.name, source);
this._sourceByPath.set(source.sourcePath, source);
// If external source like "std.system.ThorLib" then need to backup to "std" and add its folder
if (source.name) {
const sourceNameParts = source.name.split(".");
let depth = sourceNameParts.length;
if (depth > 1) {
let sourcePath = source.sourcePath;
while (depth > 1) {
sourcePath = path.dirname(sourcePath);
--depth;
}
this.visitFolder(sourcePath, path.dirname(sourcePath));
}
}
this.walkSource(source);
}
}
}
parseMetaXML(metaXML: string): string[] {
const metaParser = new MetaParser();
metaParser.parse(metaXML);
this.parseSources(metaParser.sources);
return metaParser.sources.map(source => path.normalize(source.$.sourcePath));
}
resolveQualifiedID(filePath: string, qualifiedID: string, charOffset?: number): ECLScope | undefined {
let retVal: ECLScope | undefined;
if (!retVal && this._test.has(qualifiedID)) {
retVal = this._test.get(qualifiedID).scope;
}
if (!retVal && this._sourceByPath.has(filePath)) {
const eclSource = this._sourceByPath.get(filePath);
// Resolve Imports ---
const qualifiedIDParts = qualifiedID.split(".");
for (const imp of eclSource.imports) {
if (imp.name.toLowerCase() === qualifiedIDParts[0].toLowerCase()) {
if (imp.ref) {
qualifiedIDParts[0] = imp.ref;
} else {
qualifiedIDParts.shift();
}
break;
}
}
let realQID = qualifiedIDParts.join(".");
if (!retVal && this._test.has(realQID)) {
retVal = this._test.get(realQID).scope;
}
if (!retVal) {
realQID = [...eclSource.name.split("."), ...qualifiedIDParts].join(".");
if (this._test.has(realQID)) {
retVal = this._test.get(realQID).scope;
}
}
}
return retVal;
}
resolvePartialID(filePath: string, partialID: string, charOffset: number): ECLScope | undefined {
partialID = partialID.toLowerCase();
const partialIDParts = partialID.split(".");
partialIDParts.pop();
const partialIDQualifier = partialIDParts.length === 1 ? partialIDParts[0] : partialIDParts.join(".");
return this.resolveQualifiedID(filePath, partialIDQualifier, charOffset);
}
}
const workspaceCache = new Dictionary<Workspace>();
export function attachWorkspace(_workspacePath: string, eclccPath?: string): Workspace {
const workspacePath = path.normalize(_workspacePath);
if (!workspaceCache.has(workspacePath)) {
const workspace = new Workspace(workspacePath, eclccPath);
workspaceCache.set(workspacePath, workspace);
workspace.refresh();
}
return workspaceCache.get(workspacePath);
}
function isQualifiedIDChar(lineText: string, charPos: number, reverse: boolean) {
if (charPos < 0) return false;
const testChar = lineText.charAt(charPos);
return (reverse ? /[a-zA-Z\d_\.$]/ : /[a-zA-Z\d_]/).test(testChar);
}
export function qualifiedIDBoundary(lineText: string, charPos: number, reverse: boolean) {
while (isQualifiedIDChar(lineText, charPos, reverse)) {
charPos += reverse ? -1 : 1;
}
return charPos + (reverse ? 1 : -1);
}
class MetaParser extends SAXStackParser {
sources: XMLNode[] = [];
endXMLNode(e: XMLNode) {
switch (e.name) {
case "Source":
this.sources.push(e);
break;
default:
break;
}
super.endXMLNode(e);
}
}