diff --git a/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js b/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js index 074161a..e904903 100644 --- a/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js +++ b/app/components/Workflow/DependencyGraph/CustomNodes/Code/CodeNode.js @@ -15,6 +15,7 @@ const ICON_TYPES = { DATA: `${ICON_PATH}data.svg`, FIGURE: `${ICON_PATH}figure.svg`, RUST: `${ICON_PATH}rust.svg`, + DART: `${ICON_PATH}dart.svg`, SQL: `${ICON_PATH}sql.svg`, GO: `${ICON_PATH}go.svg`, C: `${ICON_PATH}c.svg`, @@ -48,6 +49,8 @@ function CodeNode({ node, renderType }) { iconUrl = ICON_TYPES.GO; } else if (node.assetType === 'c') { iconUrl = ICON_TYPES.C; + } else if (node.assetType === 'dart') { + iconUrl = ICON_TYPES.DART; } 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 090c747..b48beab 100644 --- a/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js +++ b/app/components/Workflow/DependencyGraph/DependencyGraphEChart.js @@ -22,6 +22,7 @@ const ICON_TYPES = { DATA: `${ICON_PATH}data.svg`, FIGURE: `${ICON_PATH}figure.svg`, RUST: `${ICON_PATH}rust.svg`, + DART: `${ICON_PATH}dart.svg`, SQL: `${ICON_PATH}sql.svg`, GO: `${ICON_PATH}go.svg`, C: `${ICON_PATH}c.svg`, @@ -56,6 +57,8 @@ function getIcon(node) { iconUrl = ICON_TYPES.GO; } else if (node.value === 'c') { iconUrl = ICON_TYPES.C; + } else if (node.value === 'dart') { + iconUrl = ICON_TYPES.DART; } 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 7ac0199..e692866 100644 --- a/app/constants/assets-config.js +++ b/app/constants/assets-config.js @@ -59,6 +59,11 @@ module.exports = { extensions: ['rs'], categories: ['code'], }, + { + name: 'Dart', + extensions: ['dart'], + categories: ['code'], + }, { name: 'SQL', extensions: ['sql'], diff --git a/app/images/dart.svg b/app/images/dart.svg new file mode 100644 index 0000000..29f294c --- /dev/null +++ b/app/images/dart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/preload.js b/app/preload.js index dc526aa..ff09b64 100644 --- a/app/preload.js +++ b/app/preload.js @@ -20,6 +20,7 @@ import CppHandler from './services/assets/handlers/cpp'; import CHandler from './services/assets/handlers/c'; import Constants from './constants/constants'; import SQLHandler from './services/assets/handlers/sql'; +import DartHandler from './services/assets/handlers/dart'; const projectService = new ProjectService(); const projectListService = new ProjectListService(); @@ -64,6 +65,7 @@ contextBridge.exposeInMainWorld('workerElectronBridge', { new GoHandler(), new CppHandler(), new CHandler(), + new DartHandler(), ]); response.assets = service.scan(project.path); // Returns absolute paths diff --git a/app/services/assets/handlers/dart.js b/app/services/assets/handlers/dart.js new file mode 100644 index 0000000..030e9b8 --- /dev/null +++ b/app/services/assets/handlers/dart.js @@ -0,0 +1,133 @@ +import BaseCodeHandler from './baseCode'; +import Constants from '../../../constants/constants'; + +const FILE_EXTENSION_LIST = ['dart']; + +export default class DartHandler extends BaseCodeHandler { + static id = 'StatWrap.DartHandler'; + + constructor() { + super(DartHandler.id, FILE_EXTENSION_LIST); + } + + id() { + return DartHandler.id; + } + + getLibraryId(moduleName, importName) { + return moduleName || importName || '(unknown)'; + } + + getInputs(uri, text) { + const inputs = []; + if (!text || text.trim() === '') { + return inputs; + } + + const processedPaths = new Set(); + + // Typical Dart file read operations: + // e.g. File('path/to/file') + const fileMatches = [ + ...text.matchAll(/File\s*\(\s*(r?['"]{1}[^'"]+['"]{1})\s*\)/gim), + ...text.matchAll(/loadString\s*\(\s*(r?['"]{1}[^'"]+['"]{1})\s*\)/gim), + ]; + for (let index = 0; index < fileMatches.length; index++) { + const match = fileMatches[index]; + const path = match[1].trim(); + if (!processedPaths.has(path)) { + inputs.push({ + id: `File Read - ${path}`, + type: Constants.DependencyType.DATA, + path, + }); + processedPaths.add(path); + } + } + + // Typical SQLite/database open operations: + // e.g. openDatabase('my_db.db') + const dbMatches = [ + ...text.matchAll(/openDatabase\s*\(\s*(r?['"]{1,}[\s\S]+?['"]{1,})[\s\S]*?\)/gim) + ]; + for (let index = 0; index < dbMatches.length; index++) { + const match = dbMatches[index]; + const path = match[1].trim(); + if (!processedPaths.has(path)) { + inputs.push({ + id: `DB Conn - ${path}`, + type: Constants.DependencyType.DATA, + path, + }); + processedPaths.add(path); + } + } + + return inputs; + } + + getOutputs(uri, text) { + const outputs = []; + if (!text || text.trim() === '') { + return outputs; + } + + const processedPaths = new Set(); + + // Typical Dart file write operations: + // e.g. File('path/to/file').writeAsString() + const fileMatches = [ + ...text.matchAll(/File\s*\(\s*(r?['"]{1,}[\s\S]+?['"]{1,})\s*\)\s*\.\s*write[a-zA-Z]*\s*\(/gim), + ]; + for (let index = 0; index < fileMatches.length; index++) { + const match = fileMatches[index]; + const path = match[1].trim(); + if (!processedPaths.has(path)) { + outputs.push({ + id: `File Write - ${path}`, + type: Constants.DependencyType.DATA, + path, + }); + processedPaths.add(path); + } + } + + return outputs; + } + + getLibraries(uri, text) { + const libraries = []; + if (!text || text.trim() === '') { + return libraries; + } + + // Dart imports: + // import 'package:http/http.dart' as http; + // import 'dart:io'; + // import '../local_file.dart'; + const importMatches = [ + ...text.matchAll(/^import\s+(['"]([^'"]+)['"])(?:\s+as\s+([a-zA-Z0-9_]+))?\s*(?:show\s+[^;]+|hide\s+[^;]+)?\s*;/gm), + ]; + for (let index = 0; index < importMatches.length; index++) { + const match = importMatches[index]; + const importPath = match[2]; + const alias = match[3] || null; + + let moduleName = importPath; + if (moduleName.startsWith('package:')) { + moduleName = moduleName.substring(8); // Remove 'package:' + } else if (moduleName.startsWith('dart:')) { + moduleName = moduleName.substring(5); // Remove 'dart:' + } + + libraries.push({ + id: this.getLibraryId(moduleName, importPath), + module: moduleName, + import: importPath, + alias, + }); + } + + return libraries; + } +} diff --git a/app/utils/workflow.js b/app/utils/workflow.js index 858a4a9..de99d30 100644 --- a/app/utils/workflow.js +++ b/app/utils/workflow.js @@ -10,6 +10,7 @@ import SQLHandler from '../services/assets/handlers/sql'; import GoHandler from '../services/assets/handlers/go'; import CppHandler from '../services/assets/handlers/cpp'; import CHandler from '../services/assets/handlers/c'; +import DartHandler from '../services/assets/handlers/dart'; import path from 'path'; export default class WorkflowUtil { @@ -67,6 +68,8 @@ export default class WorkflowUtil { assetType = 'go'; } else if (AssetUtil.getHandlerMetadata(CHandler.id, asset.metadata)) { assetType = 'c'; + } else if (AssetUtil.getHandlerMetadata(DartHandler.id, asset.metadata)) { + assetType = 'dart'; } return assetType; } @@ -371,6 +374,7 @@ export default class WorkflowUtil { WorkflowUtil._getMetadataDependencies(asset, CppHandler.id, libraries, inputs, outputs); WorkflowUtil._getMetadataDependencies(asset, CHandler.id, libraries, inputs, outputs); + WorkflowUtil._getMetadataDependencies(asset, DartHandler.id, libraries, inputs, outputs); return libraries .map((e) => { return { ...e, direction: Constants.DependencyDirection.IN }; @@ -438,7 +442,7 @@ export default class WorkflowUtil { WorkflowUtil._getMetadataDependencies(asset, GoHandler.id, libraries, [], []); WorkflowUtil._getMetadataDependencies(asset, CppHandler.id, libraries, [], []); WorkflowUtil._getMetadataDependencies(asset, CHandler.id, libraries, [], []); - + WorkflowUtil._getMetadataDependencies(asset, DartHandler.id, libraries, [], []); return libraries; } diff --git a/test/services/assets/handlers/dart.spec.js b/test/services/assets/handlers/dart.spec.js new file mode 100644 index 0000000..9046665 --- /dev/null +++ b/test/services/assets/handlers/dart.spec.js @@ -0,0 +1,224 @@ +import fs from 'fs'; +import DartHandler from '../../../../app/services/assets/handlers/dart'; + +jest.mock('fs'); + +describe('services', () => { + describe('DartHandler', () => { + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + describe('id', () => { + it('should return an id that matches the class name plus StatWrap pseudo-namespace', () => { + expect(new DartHandler().id()).toEqual(`StatWrap.${DartHandler.name}`); + }); + }); + + describe('includeFile', () => { + it('should include Dart files and exclude others', () => { + const handler = new DartHandler(); + // Valid files + expect(handler.includeFile('/path/to/script.dart')).toBeTruthy(); + expect(handler.includeFile('/path/to/MAIN.DART')).toBeTruthy(); + + // Invalid files + expect(handler.includeFile('/path/to/script.js')).toBeFalsy(); + expect(handler.includeFile('/path/to/app.dart.bak')).toBeFalsy(); + expect(handler.includeFile(null)).toBeFalsy(); + }); + }); + + describe('getLibraries', () => { + it('should extract standard import statements', () => { + const libraries = new DartHandler().getLibraries( + 'test.uri', + "import 'package:http/http.dart';\r\nimport 'dart:io';" + ); + expect(libraries.length).toEqual(2); + expect(libraries[0]).toMatchObject({ + id: 'http/http.dart', + module: 'http/http.dart', + import: 'package:http/http.dart', + alias: null, + }); + expect(libraries[1]).toMatchObject({ + id: 'io', + module: 'io', + import: 'dart:io', + alias: null, + }); + }); + + it('should extract import statements with aliases', () => { + const libraries = new DartHandler().getLibraries( + 'test.uri', + 'import "package:path/path.dart" as p;' + ); + expect(libraries.length).toEqual(1); + expect(libraries[0]).toMatchObject({ + id: 'path/path.dart', + module: 'path/path.dart', + import: 'package:path/path.dart', + alias: 'p', + }); + }); + + it('should handle show and hide combinators', () => { + const libraries = new DartHandler().getLibraries( + 'test.uri', + 'import "package:math/math.dart" show max;\nimport "package:async/async.dart" hide Stream;' + ); + expect(libraries.length).toEqual(2); + expect(libraries[0].id).toEqual('math/math.dart'); + expect(libraries[1].id).toEqual('async/async.dart'); + }); + }); + + describe('getInputs', () => { + it('should detect file read operations', () => { + const inputs = new DartHandler().getInputs( + 'test.uri', + 'final content = File("input.txt").readAsStringSync();' + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: 'File Read - "input.txt"', + type: 'data', + path: '"input.txt"', + }); + }); + + it('should detect database open operations', () => { + const inputs = new DartHandler().getInputs( + 'test.uri', + 'var db = await openDatabase("my_database.db");' + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: 'DB Conn - "my_database.db"', + type: 'data', + path: '"my_database.db"', + }); + }); + + it('should deduplicate multiple reads of the same file', () => { + const inputs = new DartHandler().getInputs( + 'test.uri', + 'File("input.txt").readAsLines();\nFile("input.txt").readAsString();' + ); + expect(inputs.length).toEqual(1); + }); + + it('should detect loadString operations', () => { + const inputs = new DartHandler().getInputs( + 'test.uri', + 'rootBundle.loadString("assets/data.json");' + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: 'File Read - "assets/data.json"', + type: 'data', + path: '"assets/data.json"', + }); + }); + + it('should detect file reads with fully qualified linux/macos paths', () => { + const inputs = new DartHandler().getInputs( + 'test.uri', + 'File("/test/dir/test.txt").readAsStringSync();' + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: 'File Read - "/test/dir/test.txt"', + type: 'data', + path: '"/test/dir/test.txt"', + }); + }); + + it('should detect file reads with fully qualified windows paths', () => { + const inputs = new DartHandler().getInputs( + 'test.uri', + `File(r'C:\\test\\dir\\file.txt');\nFile('C:/test/dir/file.txt');\nFile('C:\\\\test\\\\dir\\\\file.txt');` + ); + expect(inputs.length).toEqual(3); + expect(inputs[0]).toMatchObject({ + id: `File Read - r'C:\\test\\dir\\file.txt'`, + type: 'data', + path: `r'C:\\test\\dir\\file.txt'`, + }); + expect(inputs[1]).toMatchObject({ + id: `File Read - 'C:/test/dir/file.txt'`, + type: 'data', + path: `'C:/test/dir/file.txt'`, + }); + expect(inputs[2]).toMatchObject({ + id: `File Read - 'C:\\\\test\\\\dir\\\\file.txt'`, + type: 'data', + path: `'C:\\\\test\\\\dir\\\\file.txt'`, + }); + }); + + it('should detect file reads with relative paths', () => { + const inputs = new DartHandler().getInputs( + 'test.uri', + 'File("../data/test.csv").readAsStringSync();' + ); + expect(inputs.length).toEqual(1); + expect(inputs[0]).toMatchObject({ + id: 'File Read - "../data/test.csv"', + type: 'data', + path: '"../data/test.csv"', + }); + }); + }); + + describe('getOutputs', () => { + it('should detect file write operations', () => { + const outputs = new DartHandler().getOutputs( + 'test.uri', + 'final file = await File("output.txt").writeAsString("Hello World");' + ); + expect(outputs.length).toEqual(1); + expect(outputs[0]).toMatchObject({ + id: 'File Write - "output.txt"', + type: 'data', + path: '"output.txt"', + }); + }); + + it('should deduplicate multiple writes to the same file', () => { + const outputs = new DartHandler().getOutputs( + 'test.uri', + 'File("output.txt").writeAsString("A");\nFile("output.txt").writeAsBytes([1, 2]);' + ); + expect(outputs.length).toEqual(1); + }); + }); + + describe('scan', () => { + it('should return metadata for a valid Dart file', () => { + fs.readFileSync.mockReturnValue('import "dart:math";\nvoid main() {}'); + + const testAsset = { + uri: '/path/to/main.dart', + type: 'file', + metadata: [], + }; + + const response = new DartHandler().scan(testAsset); + expect(response.metadata[0]).toMatchObject({ + id: 'StatWrap.DartHandler', + libraries: [ + { + id: 'math', + module: 'math', + import: 'dart:math', + } + ] + }); + }); + }); + }); +});