diff --git a/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js b/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js index 29c44425..f0efeb31 100644 --- a/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js +++ b/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js @@ -13,6 +13,7 @@ const ICON_TYPES = { LIBRARY: `${ICON_PATH}library.svg`, DATA: `${ICON_PATH}data.svg`, FIGURE: `${ICON_PATH}figure.svg`, + GO: `${ICON_PATH}go.svg`, }; /** @@ -31,6 +32,8 @@ function CodeNode({ node, renderType }) { iconUrl = ICON_TYPES.STATA; } else if (node.assetType === 'dependency') { iconUrl = ICON_TYPES.LIBRARY; + } else if (node.assetType === 'go') { + iconUrl = ICON_TYPES.GO; } else if (node.assetType === Constants.DependencyType.DATA) { iconUrl = ICON_TYPES.DATA; } else if (node.assetType === Constants.DependencyType.FIGURE) { diff --git a/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js b/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js index bfb87d46..61233c95 100644 --- a/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js +++ b/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js @@ -19,6 +19,7 @@ const ICON_TYPES = { LIBRARY: `${ICON_PATH}library.svg`, DATA: `${ICON_PATH}data.svg`, FIGURE: `${ICON_PATH}figure.svg`, + GO: `${ICON_PATH}go.svg`, }; /** @@ -38,6 +39,8 @@ function getIcon(node) { iconUrl = ICON_TYPES.STATA; } else if (node.value === 'dependency') { iconUrl = ICON_TYPES.LIBRARY; + } else if (node.value === 'go') { + iconUrl = ICON_TYPES.GO; } else if (node.value === Constants.DependencyType.DATA) { iconUrl = ICON_TYPES.DATA; } else if (node.value === Constants.DependencyType.FIGURE) { diff --git a/app/constants/assets-config.js b/app/constants/assets-config.js index e3ea7261..7219d47a 100644 --- a/app/constants/assets-config.js +++ b/app/constants/assets-config.js @@ -54,6 +54,11 @@ module.exports = { extensions: ['java', 'class', 'jar', 'war', 'ear'], categories: ['code'], }, + { + name: 'Go', + extensions: ['go'], + categories: ['code'], + }, { name: 'Text Data File', extensions: ['csv', 'tsv'], diff --git a/app/images/go.svg b/app/images/go.svg new file mode 100644 index 00000000..c0a5dede --- /dev/null +++ b/app/images/go.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/preload.js b/app/preload.js index c5c40226..2c60ac83 100644 --- a/app/preload.js +++ b/app/preload.js @@ -14,6 +14,7 @@ import StataHandler from './services/assets/handlers/stata'; import AssetUtil from './utils/asset'; import ProjectUtil from './utils/project'; import JavaHandler from './services/assets/handlers/java'; +import GoHandler from './services/assets/handlers/go'; import Constants from './constants/constants'; const projectService = new ProjectService(); @@ -54,6 +55,7 @@ contextBridge.exposeInMainWorld('workerElectronBridge', { new SASHandler(), new StataHandler(), new JavaHandler(), + new GoHandler(), ]); response.assets = service.scan(project.path); // Returns absolute paths diff --git a/app/services/assets/handlers/go.js b/app/services/assets/handlers/go.js new file mode 100644 index 00000000..2afe784c --- /dev/null +++ b/app/services/assets/handlers/go.js @@ -0,0 +1,134 @@ +import BaseCodeHandler from './baseCode'; +import Constants from '../../../constants/constants'; + +const FILE_EXTENSION_LIST = ['go']; + +export default class GoHandler extends BaseCodeHandler { + static id = 'StatWrap.GoHandler'; + + constructor() { + super(GoHandler.id, FILE_EXTENSION_LIST); + } + + id() { + return GoHandler.id; + } + + getLibraryId(moduleName, importName) { + return moduleName || importName || '(unknown)'; + } + + getInputs(uri, text) { + const inputs = []; + if (!text || text.trim() === '') { + return inputs; + } + + // Typical Go file read operations: + + const fileMatches = [ + ...text.matchAll(/(?:os|ioutil)\.(?:Open|ReadFile)\s*\(\s*(['"]{1,}\s*?[\s\S]+?['"]{1,})[\s\S]*?\)/gim), + ]; + for (let index = 0; index < fileMatches.length; index++) { + const match = fileMatches[index]; + const path = match[1].trim(); + inputs.push({ + id: `File Read - ${path}`, + type: Constants.DependencyType.DATA, + path, + }); + } + + const dbMatches = [ + ...text.matchAll(/sql\.Open\s*\(\s*['"]{1,}[\s\S]+?['"]{1,}\s*,\s*(['"]{1,}\s*?[\s\S]+?['"]{1,})[\s\S]*?\)/gim) + ]; + for (let index = 0; index < dbMatches.length; index++) { + const match = dbMatches[index]; + const path = match[1].trim(); + inputs.push({ + id: `DB Conn - ${path}`, + type: Constants.DependencyType.DATA, + path, + }); + } + + return inputs; + } + + getOutputs(uri, text) { + const outputs = []; + if (!text || text.trim() === '') { + return outputs; + } + + // Typical Go file write operations: + + const fileMatches = [ + ...text.matchAll(/(?:os|ioutil)\.(?:Create|WriteFile)\s*\(\s*(['"]{1,}\s*?[\s\S]+?['"]{1,})[\s\S]*?\)/gim), + ]; + for (let index = 0; index < fileMatches.length; index++) { + const match = fileMatches[index]; + const path = match[1].trim(); + outputs.push({ + id: `File Write - ${path}`, + type: Constants.DependencyType.DATA, + path, + }); + } + + return outputs; + } + + getLibraries(uri, text) { + const libraries = []; + if (!text || text.trim() === '') { + return libraries; + } + + // Go imports can be single-line or multiple line or they could go with aliases. + + const singleLineMatches = [ + ...text.matchAll(/^import\s+(?:([a-zA-Z0-9_.]*)\s+)?(['"]([^'"]+)['"])/gm), + ]; + for (let index = 0; index < singleLineMatches.length; index++) { + const match = singleLineMatches[index]; + const alias = match[1] || null; + const importPath = match[3]; + + libraries.push({ + id: importPath, + module: importPath, + import: importPath, + alias, + }); + } + + // Extract import blocks: import + const blockRegex = /import\s*\(\s*([\s\S]*?)\s*\)/gm; + let blockMatch; + while ((blockMatch = blockRegex.exec(text)) !== null) { + const blockContent = blockMatch[1]; + // Inside the block, match for aliases and import paths + const innerMatches = [ + ...blockContent.matchAll(/(?:([a-zA-Z0-9_.]*)\s+)?(?:['"]([^'"]+)['"])/g), + ]; + for (let index = 0; index < innerMatches.length; index++) { + const innerMatch = innerMatches[index]; + const alias = innerMatch[1] || null; + const importPath = innerMatch[2]; + + // Ensure we actually got an import path + if (importPath) { + libraries.push({ + id: importPath, + module: importPath, + import: importPath, + alias, + }); + } + } + } + + return libraries; + } +} diff --git a/app/utils/workflow.js b/app/utils/workflow.js index fa43dbba..55a367f8 100644 --- a/app/utils/workflow.js +++ b/app/utils/workflow.js @@ -5,6 +5,7 @@ import SASHandler from '../services/assets/handlers/sas'; import StataHandler from '../services/assets/handlers/stata'; import Constants from '../constants/constants'; import JavaHandler from '../services/assets/handlers/java'; +import GoHandler from '../services/assets/handlers/go'; import path from 'path'; export default class WorkflowUtil { @@ -52,6 +53,8 @@ export default class WorkflowUtil { assetType = 'stata'; } else if (AssetUtil.getHandlerMetadata(JavaHandler.id, asset.metadata)) { assetType = 'java'; + } else if (AssetUtil.getHandlerMetadata(GoHandler.id, asset.metadata)) { + assetType = 'go'; } return assetType; @@ -343,6 +346,7 @@ export default class WorkflowUtil { WorkflowUtil._getMetadataDependencies(asset, SASHandler.id, libraries, inputs, outputs); WorkflowUtil._getMetadataDependencies(asset, StataHandler.id, libraries, inputs, outputs); WorkflowUtil._getMetadataDependencies(asset, JavaHandler.id, libraries, inputs, outputs); + WorkflowUtil._getMetadataDependencies(asset, GoHandler.id, libraries, inputs, outputs); return libraries .map((e) => { @@ -406,6 +410,7 @@ export default class WorkflowUtil { WorkflowUtil._getMetadataDependencies(asset, SASHandler.id, libraries, [], []); WorkflowUtil._getMetadataDependencies(asset, StataHandler.id, libraries, [], []); WorkflowUtil._getMetadataDependencies(asset, JavaHandler.id, libraries, [], []); + WorkflowUtil._getMetadataDependencies(asset, GoHandler.id, libraries, [], []); return libraries; } diff --git a/test/services/assets/handlers/go.spec.js b/test/services/assets/handlers/go.spec.js new file mode 100644 index 00000000..a8333488 --- /dev/null +++ b/test/services/assets/handlers/go.spec.js @@ -0,0 +1,191 @@ +import fs from 'fs'; +import GoHandler from '../../../../app/services/assets/handlers/go'; +import Constants from '../../../../app/constants/constants'; + +jest.mock('fs'); + +describe('services', () => { + describe('GoHandler', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + describe('id', () => { + it('should return an id that matches the class name plus StatWrap pseudo-namespace', () => { + expect(new GoHandler().id()).toEqual(`StatWrap.${GoHandler.name}`); + }); + }); + + describe('includeFile', () => { + it('should include Go files and exclude others', () => { + const handler = new GoHandler(); + // Valid files + expect(handler.includeFile('/path/to/main.go')).toBeTruthy(); + expect(handler.includeFile('/path/to/utils.GO')).toBeTruthy(); + + // Invalid files + expect(handler.includeFile('/path/to/main.exe')).toBeFalsy(); + expect(handler.includeFile('/path/to/app.py')).toBeFalsy(); + expect(handler.includeFile(null)).toBeFalsy(); + expect(handler.includeFile('/path/to/main.go.bak')).toBeFalsy(); + }); + }); + + describe('getLibraries', () => { + it('should extract single line import statements', () => { + const libraries = new GoHandler().getLibraries( + 'test.uri', + 'import "fmt"\nimport "os"' + ); + expect(libraries.length).toEqual(2); + expect(libraries[0]).toMatchObject({ + id: 'fmt', + module: 'fmt', + import: 'fmt', + alias: null, + }); + expect(libraries[1]).toMatchObject({ + id: 'os', + module: 'os', + import: 'os', + alias: null, + }); + }); + + it('should extract aliased single line imports', () => { + const libraries = new GoHandler().getLibraries( + 'test.uri', + 'import f "fmt"' + ); + expect(libraries.length).toEqual(1); + expect(libraries[0]).toMatchObject({ + id: 'fmt', + alias: 'f', + }); + }); + + it('should extract multi line block import statements', () => { + const libraries = new GoHandler().getLibraries( + 'test.uri', + `import ( + "fmt" + "os" + "github.com/user/project/pkg" + )` + ); + expect(libraries.length).toEqual(3); + expect(libraries[0]).toMatchObject({ + id: 'fmt', + }); + expect(libraries[1]).toMatchObject({ + id: 'os', + }); + expect(libraries[2]).toMatchObject({ + id: 'github.com/user/project/pkg', + }); + }); + + it('should extract multi line block import statements with aliases', () => { + const libraries = new GoHandler().getLibraries( + 'test.uri', + `import ( + f "fmt" + log "github.com/sirupsen/logrus" + )` + ); + expect(libraries.length).toEqual(2); + expect(libraries[0]).toMatchObject({ + id: 'fmt', + alias: 'f' + }); + expect(libraries[1]).toMatchObject({ + id: 'github.com/sirupsen/logrus', + alias: 'log' + }); + }); + }); + + describe('getInputs', () => { + it('should detect file read operations', () => { + const inputs = new GoHandler().getInputs( + 'test.uri', + 'file, err := os.Open("input.txt")' + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: 'File Read - "input.txt"', + type: 'data', + path: '"input.txt"', + }); + }); + + it('should detect various file read classes', () => { + const inputs = new GoHandler().getInputs( + 'test.uri', + ` + file1, _ := os.Open("input1.txt") + data1, _ := ioutil.ReadFile("input2.txt") + data2, _ := os.ReadFile("input3.txt") + ` + ); + expect(inputs.length).toEqual(3); + expect(inputs[0].path).toEqual('"input1.txt"'); + expect(inputs[1].path).toEqual('"input2.txt"'); + expect(inputs[2].path).toEqual('"input3.txt"'); + }); + + it('should detect SQL database connections', () => { + const inputs = new GoHandler().getInputs( + 'test.uri', + 'db, err := sql.Open("postgres", "postgres://user:pass@localhost/db")' + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: 'DB Conn - "postgres://user:pass@localhost/db"', + type: 'data', + path: '"postgres://user:pass@localhost/db"', + }); + }); + }); + + describe('getOutputs', () => { + it('should detect file write operations', () => { + const outputs = new GoHandler().getOutputs( + 'test.uri', + ` + file, err := os.Create("output1.txt") + err = os.WriteFile("output2.txt", data, 0644) + err = ioutil.WriteFile("output3.txt", data, 0644) + ` + ); + expect(outputs.length).toEqual(3); + expect(outputs[0].path).toEqual('"output1.txt"'); + expect(outputs[1].path).toEqual('"output2.txt"'); + expect(outputs[2].path).toEqual('"output3.txt"'); + }); + }); + + describe('scan', () => { + it('should return metadata for a valid Go file', () => { + fs.readFileSync.mockReturnValue('import "fmt"\nfunc main() {}'); + + const testAsset = { + uri: '/path/to/main.go', + type: 'file', + metadata: [], + }; + + const response = new GoHandler().scan(testAsset); + expect(response.metadata[0]).toMatchObject({ + id: 'StatWrap.GoHandler', + libraries: [ + { + id: 'fmt', + } + ] + }); + }); + }); + }); +});