UNPKG

@hpcc-js/comms

Version:
507 lines (444 loc) 16.8 kB
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); } }