diff --git a/cypress/e2e/02-analysis.cy.ts b/cypress/e2e/02-analysis.cy.ts index af498b3..10e724d 100644 --- a/cypress/e2e/02-analysis.cy.ts +++ b/cypress/e2e/02-analysis.cy.ts @@ -5,7 +5,7 @@ describe("analysis", () => { cy.visit("/"); // submit button disabled by default - cy.get(`[data-cy="snippet-submit"]`).should("be.disabled"); + cy.get(`[data-cy="submit"]`).should("be.disabled"); submitSnippet(); @@ -15,4 +15,3 @@ describe("analysis", () => { }); }); }); - diff --git a/cypress/e2e/utils/submit-snippet.ts b/cypress/e2e/utils/submit-snippet.ts index 5e1f96b..a7c0ad0 100644 --- a/cypress/e2e/utils/submit-snippet.ts +++ b/cypress/e2e/utils/submit-snippet.ts @@ -3,10 +3,10 @@ export const submitSnippet = () => { cy.get(`[data-cy="snippet-input"]`).type(snippetCode, { delay: 1 }); // assert the button is enabled - cy.get(`[data-cy="snippet-submit"]`).should("be.visible"); + cy.get(`[data-cy="submit"]`).should("be.visible"); // submit the snippet - cy.get(`[data-cy="snippet-submit"]`).click(); + cy.get(`[data-cy="submit"]`).click(); }; const snippetCode = ` diff --git a/package-lock.json b/package-lock.json index 6ceddb2..c309f56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4230,6 +4230,21 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.14.195", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", + "integrity": "sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==", + "dev": true + }, + "node_modules/@types/lodash-es": { + "version": "4.17.8", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.8.tgz", + "integrity": "sha512-euY3XQcZmIzSy7YH5+Unb3b2X12Wtk54YWINBvvGQ5SmMvwb11JQskGsfkH/5HXK77Kr8GF0wkVDIxzAisWtog==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/md5": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.2.tgz", @@ -4295,6 +4310,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-csv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/react-csv/-/react-csv-1.1.3.tgz", + "integrity": "sha512-dkEdyRvRpygSnNg4cyzYWSUjukIQ5lAtXJwc7BqyUfzww/Cv2dcAFGYd+sWTFpGiDNZMVPp6vVPLcAPvJID8Kg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.2.6", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.6.tgz", @@ -4629,6 +4653,14 @@ "vite": "^4.2.0" } }, + "node_modules/@vscode/vscode-languagedetection": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/@vscode/vscode-languagedetection/-/vscode-languagedetection-1.0.22.tgz", + "integrity": "sha512-rQ/BgMyLuIXSmbA0MSkIPHtcOw14QkeDbAq19sjvaS9LTRr905yij0S8lsyqN5JgOsbtIx7pAcyOxFMzPmqhZQ==", + "bin": { + "vscode-languagedetection": "cli/index.js" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", @@ -6652,6 +6684,11 @@ "node": ">=8" } }, + "node_modules/devicon": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/devicon/-/devicon-2.15.1.tgz", + "integrity": "sha512-iyapbDVaq1PgMSMZ4IekgjH230aSUU4kHmZ142lQ2TJ+9HB4P4V78eNDkyaQEEoi0KJhxIGPcTSFqxHaZ4OMyw==" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -11068,6 +11105,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -11462,6 +11504,11 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/monaco-editor": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.40.0.tgz", + "integrity": "sha512-1wymccLEuFSMBvCk/jT1YDW/GuxMLYwnFwF9CDyYCxoTw2Pt379J3FUhwy9c43j51JdcxVPjwk0jm0EVDsBS2g==" + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -12649,6 +12696,11 @@ "react": ">= 16.3.0" } }, + "node_modules/react-csv": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/react-csv/-/react-csv-2.2.2.tgz", + "integrity": "sha512-RG5hOcZKZFigIGE8LxIEV/OgS1vigFQT4EkaHeKgyuCbUAu9Nbd/1RYq++bJcJJ9VOqO/n9TZRADsXNDR4VEpw==" + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -15164,9 +15216,12 @@ "@mui/material": "^5.13.5", "axios": "^1.4.0", "filesize": "^10.0.7", + "lodash-es": "^4.17.21", "md5": "^2.3.0", + "monaco-editor": "^0.40.0", "react": "^18.2.0", "react-countup": "^6.4.2", + "react-csv": "^2.2.2", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-icons": "^4.10.1", @@ -15177,8 +15232,10 @@ }, "devDependencies": { "@types/express": "^4.17.17", + "@types/lodash-es": "^4.17.8", "@types/md5": "^2.3.2", "@types/react": "^18.0.37", + "@types/react-csv": "^1.1.3", "@types/react-dom": "^18.0.11", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", @@ -15198,8 +15255,10 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@vscode/vscode-languagedetection": "^1.0.22", "axios": "^1.4.0", "cors": "^2.8.5", + "devicon": "^2.15.1", "dotenv": "^16.3.1", "express": "^4.18.2", "md5": "^2.3.0", diff --git a/packages/app/index.html b/packages/app/index.html index 9a94c5a..829f285 100644 --- a/packages/app/index.html +++ b/packages/app/index.html @@ -7,6 +7,10 @@ content="CodeTotal by OX Security, a megalinter UI" /> + Code Total By OX Security diff --git a/packages/app/package.json b/packages/app/package.json index 8851054..057a608 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -16,9 +16,12 @@ "@mui/material": "^5.13.5", "axios": "^1.4.0", "filesize": "^10.0.7", + "lodash-es": "^4.17.21", "md5": "^2.3.0", + "monaco-editor": "^0.40.0", "react": "^18.2.0", "react-countup": "^6.4.2", + "react-csv": "^2.2.2", "react-dom": "^18.2.0", "react-dropzone": "^14.2.3", "react-icons": "^4.10.1", @@ -29,8 +32,10 @@ }, "devDependencies": { "@types/express": "^4.17.17", + "@types/lodash-es": "^4.17.8", "@types/md5": "^2.3.2", "@types/react": "^18.0.37", + "@types/react-csv": "^1.1.3", "@types/react-dom": "^18.0.11", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", diff --git a/packages/app/src/analysis/actions/analysis-actions.ts b/packages/app/src/analysis/actions/analysis-actions.ts index 10c241e..490e125 100644 --- a/packages/app/src/analysis/actions/analysis-actions.ts +++ b/packages/app/src/analysis/actions/analysis-actions.ts @@ -43,7 +43,8 @@ export const startAnalysis = async (navigate: NavigateFunction) => { }; const createRequestData = (): Analysis | undefined => { - const { inputType, repositoryURL, file, snippet } = AnalysisStore.getState(); + const { inputType, repositoryURL, file, snippet, language } = + AnalysisStore.getState(); const sharedVariables = { name: "Lint", inputType, @@ -55,6 +56,10 @@ const createRequestData = (): Analysis | undefined => { case AnalysisType.File: return { ...sharedVariables, file } as FileAnalysis; case AnalysisType.Snippet: - return { ...sharedVariables, snippet } as SnippetAnalysis; + return { + ...sharedVariables, + snippet, + language, + } as SnippetAnalysis; } }; diff --git a/packages/app/src/analysis/components/CodeSnippetForm.tsx b/packages/app/src/analysis/components/CodeSnippetForm.tsx index 6963172..bfa637e 100644 --- a/packages/app/src/analysis/components/CodeSnippetForm.tsx +++ b/packages/app/src/analysis/components/CodeSnippetForm.tsx @@ -1,17 +1,22 @@ -import { Button, TextField, Theme } from "@mui/material"; +import { TextField, Theme, Typography } from "@mui/material"; import { FC, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { makeStyles } from "tss-react/mui"; +import { LanguageIcon } from "../../common/LanguageIcon"; +import { fetchDetect } from "../../common/utils/detect-lanauge-utils"; import { startAnalysis } from "../actions/analysis-actions"; import { AnalysisStore, useAnalysisStore } from "../stores/analysis-store"; +import { SubmitButton } from "./SubmitButton"; export const CodeSnippetForm: FC = () => { const { classes } = useStyles(); - const { snippet, snippetEnabled } = useAnalysisStore(); + const { snippet, snippetEnabled, sending, language } = useAnalysisStore(); const navigate = useNavigate(); const handleChange = useCallback((e: React.ChangeEvent) => { - AnalysisStore.setState({ snippet: e.target.value }); + const snippet = e.target.value; + AnalysisStore.setState({ snippet }); + fetchDetect(snippet); }, []); const handleSubmit = () => { @@ -33,15 +38,31 @@ export const CodeSnippetForm: FC = () => { "data-cy": "snippet-input", }} /> - +
+
+ {language && language.name && ( +
+ {language.icon ? ( + + ) : ( + + {language.name} + + )} +
+ )} +
+ + Send Snippet + +
); }; @@ -54,4 +75,15 @@ const useStyles = makeStyles()((theme: Theme) => ({ gap: theme.spacing(2), padding: theme.spacing(1), }, + language: { + display: "flex", + gap: theme.spacing(1), + alignSelf: "flex-start", + }, + footer: { + display: "flex", + gap: theme.spacing(1), + alignSelf: "stretch", + justifyContent: "space-between", + }, })); diff --git a/packages/app/src/analysis/components/FileUploadForm.tsx b/packages/app/src/analysis/components/FileUploadForm.tsx index 516f984..83a4d59 100644 --- a/packages/app/src/analysis/components/FileUploadForm.tsx +++ b/packages/app/src/analysis/components/FileUploadForm.tsx @@ -1,4 +1,10 @@ -import { Theme, Typography, alpha, darken } from "@mui/material"; +import { + CircularProgress, + Theme, + Typography, + alpha, + darken, +} from "@mui/material"; import { filesize } from "filesize"; import { FC, useCallback, useState } from "react"; import { FileRejection, useDropzone } from "react-dropzone"; @@ -7,13 +13,18 @@ import { useNavigate } from "react-router-dom"; import { makeStyles } from "tss-react/mui"; import config from "../../config"; import { startAnalysis } from "../actions/analysis-actions"; -import { AnalysisStore } from "../stores/analysis-store"; +import { + AnalysisStore, + AsyncState, + useAnalysisStore, +} from "../stores/analysis-store"; const MAX_SIZE = parseInt(config.CODETOTAL_UPLOAD_FILE_LIMIT_BYTES); export const FileUploadForm: FC = () => { const { classes } = useStyles(); const navigate = useNavigate(); + const { sending } = useAnalysisStore(); const [error, setError] = useState(""); const onDrop = useCallback( @@ -47,28 +58,46 @@ export const FileUploadForm: FC = () => { maxSize: MAX_SIZE, }); + const uploading = sending === AsyncState.Loading; + return ( -
+
- {isDragActive ? ( -

Drop the files here ...

- ) : ( - <> + {uploading && ( +
+ - Drag and drop a file here -
- or -
- Click to browse + Uploading file
- {error && ( - - {error} - - )} +
+ )} + {!uploading && ( + <> +
+ {isDragActive ? ( + + Release to upload... + + ) : ( + <> + + Drag and drop a file here +
+ or +
+ Click to browse +
+ {error && ( + + {error} + + )} + + )} +
)}
@@ -81,16 +110,31 @@ const useStyles = makeStyles()((theme: Theme) => ({ fontSize: "5rem", color: theme.palette.primary.main, }, - dropZone: { - border: `2px dashed ${theme.palette.divider}`, - padding: theme.spacing(2), - cursor: "pointer", - transition: theme.transitions.create("background-color"), + container: { + outline: `2px dashed ${theme.palette.divider}`, + transition: theme.transitions.create("all", { + duration: theme.transitions.duration.short, + }), "&:hover,&:focus-within": { backgroundColor: theme.palette.mode === "dark" ? alpha(theme.palette.background.paper, 0.2) : darken(theme.palette.background.paper, 0.05), + outline: "2px dashed", + outlineColor: + theme.palette.mode === "dark" + ? darken(theme.palette.divider, 0.3) + : darken(theme.palette.divider, 0.3), }, }, + uploading: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing(1), + }, + dropZone: { + padding: theme.spacing(2), + cursor: "pointer", + }, })); diff --git a/packages/app/src/analysis/components/InputForm.tsx b/packages/app/src/analysis/components/InputForm.tsx index 84d6d86..0add499 100644 --- a/packages/app/src/analysis/components/InputForm.tsx +++ b/packages/app/src/analysis/components/InputForm.tsx @@ -1,11 +1,4 @@ -import { - Alert, - CircularProgress, - Paper, - Tab, - Tabs, - Theme, -} from "@mui/material"; +import { Alert, Paper, Tab, Tabs, Theme } from "@mui/material"; import { FC } from "react"; import { AnalysisType, OneOfValues } from "shared-types"; import { makeStyles } from "tss-react/mui"; @@ -37,6 +30,7 @@ export const InputForm: FC = () => { variant="fullWidth" > {
- {sending === "loading" && ( -
- -
- )} -
{sending === "error" && ( @@ -111,13 +99,4 @@ const useStyles = makeStyles()((theme: Theme) => ({ flexGrow: 1, position: "relative", }, - loader: { - width: "100%", - height: "100%", - display: "grid", - placeItems: "center", - position: "absolute", - top: 0, - left: 0, - }, })); diff --git a/packages/app/src/analysis/components/RepositoryForm.tsx b/packages/app/src/analysis/components/RepositoryForm.tsx index 2dcd8dc..5dadd44 100644 --- a/packages/app/src/analysis/components/RepositoryForm.tsx +++ b/packages/app/src/analysis/components/RepositoryForm.tsx @@ -1,14 +1,15 @@ -import { Button, FormControl, TextField, Theme } from "@mui/material"; +import { FormControl, TextField, Theme } from "@mui/material"; import { FC, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { makeStyles } from "tss-react/mui"; import { startAnalysis } from "../actions/analysis-actions"; import { AnalysisStore, useAnalysisStore } from "../stores/analysis-store"; +import { SubmitButton } from "./SubmitButton"; export const RepositoryForm: FC = () => { const { classes } = useStyles(); const navigate = useNavigate(); - const { repositoryURL, repoEnabled } = useAnalysisStore(); + const { repositoryURL, repoEnabled, sending } = useAnalysisStore(); const handleChange = useCallback((e: React.ChangeEvent) => { AnalysisStore.setState({ repositoryURL: e.target.value }); @@ -28,14 +29,15 @@ export const RepositoryForm: FC = () => { /> - +
); }; diff --git a/packages/app/src/analysis/components/SubmitButton.tsx b/packages/app/src/analysis/components/SubmitButton.tsx new file mode 100644 index 0000000..6536660 --- /dev/null +++ b/packages/app/src/analysis/components/SubmitButton.tsx @@ -0,0 +1,25 @@ +import { Button, ButtonProps, CircularProgress } from "@mui/material"; +import { FC } from "react"; +import { BiSend } from "react-icons/bi"; + +export const SubmitButton: FC = ({ loading, ...props }) => { + return ( + + + + ); +}; + +const useStyles = makeStyles()((theme: Theme) => ({ + dialogContent: { + display: "flex", + flexDirection: "column", + gap: theme.spacing(2), + }, +})); diff --git a/packages/app/src/common/LanguageIcon.tsx b/packages/app/src/common/LanguageIcon.tsx new file mode 100644 index 0000000..c7df3ff --- /dev/null +++ b/packages/app/src/common/LanguageIcon.tsx @@ -0,0 +1,57 @@ +import { Theme, Tooltip, useTheme } from "@mui/material"; +import { FC } from "react"; +import { ProgrammingLanguage } from "shared-types"; +import { makeStyles } from "tss-react/mui"; + +export const LanguageIcon: FC = ({ language }) => { + const { classes, cx } = useStyles(); + const theme = useTheme(); + const isDarkmode = theme.palette.mode === "dark"; + if (!language) { + return null; + } + + // only is dark mode and only for "plain" type icons + // they require a background since when used with an img tag + // colors can't be applied through css and we end up with a black colored icon + const requiresBackground = isDarkmode && language.icon === "plain"; + + return ( + + + programming language icon + + + ); +}; +const useStyles = makeStyles()((theme: Theme) => ({ + languageIcon: { + display: "inline-flex", + height: "2em", + width: "2em", + }, + img: { + width: "100%", + height: "100%", + }, + darkmode: { + backgroundColor: theme.palette.text.secondary, + borderRadius: "50%", + display: "inline-flex", + }, +})); + +interface LanguageIconProps { + language?: ProgrammingLanguage; +} diff --git a/packages/app/src/common/utils/detect-lanauge-utils.ts b/packages/app/src/common/utils/detect-lanauge-utils.ts new file mode 100644 index 0000000..7ab4b12 --- /dev/null +++ b/packages/app/src/common/utils/detect-lanauge-utils.ts @@ -0,0 +1,15 @@ +import axios from "axios"; +import debounce from "lodash-es/debounce"; +import { ProgrammingLanguage } from "shared-types"; +import { AnalysisStore } from "../../analysis/stores/analysis-store"; +import config from "../../config"; + +export const detect = async (snippet: string) => { + const res = await axios.post( + `http://${config.CODETOTAL_HTTP_HOST}:${config.CODETOTAL_HTTP_PORT}/detect`, + { snippet } + ); + AnalysisStore.setState({ language: res.data }); +}; + +export const fetchDetect = debounce(detect, 1000); diff --git a/packages/app/src/report/ReportPage.tsx b/packages/app/src/report/ReportPage.tsx index 18eac81..0322086 100644 --- a/packages/app/src/report/ReportPage.tsx +++ b/packages/app/src/report/ReportPage.tsx @@ -1,5 +1,6 @@ import { FC, useEffect, useRef } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { AnalysisErrorDialog } from "../common/AnalysisErrorDialog"; import { initProgress } from "./actions/init-report-action"; import { IssuesTable } from "./components/IssuesTable"; import { ReportDrawer } from "./components/ReportDrawer"; @@ -26,6 +27,7 @@ const ReportPage: FC = () => { +
); }; diff --git a/packages/app/src/report/actions/error-report-action.ts b/packages/app/src/report/actions/error-report-action.ts new file mode 100644 index 0000000..026edda --- /dev/null +++ b/packages/app/src/report/actions/error-report-action.ts @@ -0,0 +1,8 @@ +import { ReportStore } from "../stores/fe-report-store"; + +export const resetReport = () => { + const { unsubscribe, reset } = ReportStore.getState(); + + unsubscribe && unsubscribe(); + reset(); +}; diff --git a/packages/app/src/report/actions/init-report-action.ts b/packages/app/src/report/actions/init-report-action.ts index 9ce442a..e56ec30 100644 --- a/packages/app/src/report/actions/init-report-action.ts +++ b/packages/app/src/report/actions/init-report-action.ts @@ -3,7 +3,7 @@ import { NavigateFunction } from "react-router-dom"; import { AnalysisStatus } from "shared-types"; import config from "../../config"; import { ReportStore } from "../stores/fe-report-store"; -import { subscribeToLintProgress } from "./subscribe-report-action"; +import { subscribeToReportProgress } from "./subscribe-report-action"; export const initProgress = async ( requestId: string, @@ -17,9 +17,7 @@ export const initProgress = async ( switch (status) { case AnalysisStatus.Created: - subscribeToLintProgress(requestId); - break; - case AnalysisStatus.Completed: + subscribeToReportProgress(requestId); break; case AnalysisStatus.NotFound: navigate("/"); diff --git a/packages/app/src/report/actions/subscribe-report-action.ts b/packages/app/src/report/actions/subscribe-report-action.ts index 0cda637..8418a7b 100644 --- a/packages/app/src/report/actions/subscribe-report-action.ts +++ b/packages/app/src/report/actions/subscribe-report-action.ts @@ -1,32 +1,35 @@ import { AnalysisStatus, ReportState } from "shared-types"; -import { subscribe } from "../../ws-client"; import { ReportStore } from "../stores/fe-report-store"; +import { subscribe } from "../utils/ws-client"; -export const subscribeToLintProgress = (requestId: string) => { +export const subscribeToReportProgress = (requestId: string) => { console.log("subscribing to WS..."); // clear previous error - ReportStore.setState({ subscriptionError: undefined }); + ReportStore.setState({ wsError: undefined }); - const unsubscribe = subscribe( + const unsubscribe = subscribe({ requestId, - (msg: Partial) => { - if (msg.status && msg.status === AnalysisStatus.Completed) { + onMessage: (msg: Partial) => { + const completed = msg.status && msg.status === AnalysisStatus.Completed; + const error = !!msg.analysisError; + if (completed || error) { + // close ws connection on completed/error unsubscribe(); } ReportStore.setState({ ...msg }); }, - () => { + onError: () => { ReportStore.setState({ - subscriptionError: "Web socket connection error", + wsError: "Web socket connection error", }); }, - () => { + onClose: () => { const { inProgress } = ReportStore.getState(); inProgress && ReportStore.setState({ - subscriptionError: "Web socket connection closed while scanning", + wsError: "Web socket connection closed while scanning", }); - } - ); + }, + }); ReportStore.setState({ unsubscribe }); }; diff --git a/packages/app/src/report/components/Detection.tsx b/packages/app/src/report/components/Detection.tsx index 36ac66b..59b3843 100644 --- a/packages/app/src/report/components/Detection.tsx +++ b/packages/app/src/report/components/Detection.tsx @@ -21,6 +21,5 @@ const useStyles = makeStyles()(() => ({ detection: { height: "100%", position: "relative", - display: "grid", }, })); diff --git a/packages/app/src/report/components/ExportToCSV.tsx b/packages/app/src/report/components/ExportToCSV.tsx new file mode 100644 index 0000000..5a97d4e --- /dev/null +++ b/packages/app/src/report/components/ExportToCSV.tsx @@ -0,0 +1,47 @@ +import { IconButton, Theme, Tooltip, Zoom } from "@mui/material"; +import { FC } from "react"; +import { CSVLink } from "react-csv"; +import { MdCloudDownload } from "react-icons/md"; +import { AnalysisStatus } from "shared-types"; +import { makeStyles } from "tss-react/mui"; +import { useReportStore } from "../stores/fe-report-store"; + +export const ExportToCSV: FC = () => { + const { classes } = useStyles(); + const { status, allIssues, requestId } = useReportStore(); + + return ( + +
+ + + + + + + + + +
+
+ ); +}; + +const useStyles = makeStyles()((theme: Theme) => ({ + exportToCSV: { + display: "inline-flex", + alignItems: "center", + gap: theme.spacing(1), + color: theme.palette.primary.main, + fontWeight: 400, + textDecoration: "none", + "&:hover": { + textDecoration: "underline", + }, + }, +})); diff --git a/packages/app/src/report/components/ReportHeader.tsx b/packages/app/src/report/components/ReportHeader.tsx index 0e32157..2db0713 100644 --- a/packages/app/src/report/components/ReportHeader.tsx +++ b/packages/app/src/report/components/ReportHeader.tsx @@ -1,13 +1,20 @@ import { + Divider, + IconButton, Paper, Theme, + Tooltip, Typography, useMediaQuery, useTheme, } from "@mui/material"; import { FC } from "react"; +import { IoMdArrowBack } from "react-icons/io"; +import { NavLink } from "react-router-dom"; import { makeStyles } from "tss-react/mui"; +import { LanguageIcon } from "../../common/LanguageIcon"; import { useReportStore } from "../stores/fe-report-store"; +import { ExportToCSV } from "./ExportToCSV"; import { ReportHeaderSection } from "./ReportHeaderSection"; import { Score } from "./Score"; @@ -23,6 +30,7 @@ export const ReportHeader: FC = ({ ready }) => { linters = [], lintersWithIssues, progress, + language, } = useReportStore(); if (!lintersWithIssues) { return null; @@ -64,6 +72,18 @@ export const ReportHeader: FC = ({ ready }) => { value={resourceValue || "-"} dataCy="resource-value" /> + +
+ +
+ + + + + + + +
({ fontWeight: 700, [theme.breakpoints.up("md")]: { fontSize: "1.562rem", - marginBlockEnd: theme.spacing(2), + marginBlockStart: theme.spacing(2), textAlign: "start", }, }, @@ -121,6 +141,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ maxWidth: 600, [theme.breakpoints.up("md")]: { flexDirection: "row", + alignItems: "flex-end", }, }, score: { @@ -134,6 +155,13 @@ const useStyles = makeStyles()((theme: Theme) => ({ nowrap: { wordBreak: "keep-all", }, + footer: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + paddingBlockStart: theme.spacing(1), + justifyContent: "space-between" + }, })); interface ReportBannerProps { diff --git a/packages/app/src/report/components/ReportHeaderSection.tsx b/packages/app/src/report/components/ReportHeaderSection.tsx index 0a6b27a..0dc6d48 100644 --- a/packages/app/src/report/components/ReportHeaderSection.tsx +++ b/packages/app/src/report/components/ReportHeaderSection.tsx @@ -33,6 +33,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ color: theme.palette.text.secondary, }, "& > div": { + marginBlockStart: theme.spacing(0.5), color: theme.palette.text.primary, fontWeight: "bold", textOverflow: "ellipsis", diff --git a/packages/app/src/report/stores/fe-report-store.ts b/packages/app/src/report/stores/fe-report-store.ts index 819823f..6af6e91 100644 --- a/packages/app/src/report/stores/fe-report-store.ts +++ b/packages/app/src/report/stores/fe-report-store.ts @@ -1,7 +1,7 @@ -import { AnalysisStatus, ReportState } from "shared-types"; +import { AnalysisStatus, Issue, ReportState } from "shared-types"; import { createStore, useStore } from "zustand"; import { ScoreColorKey } from "../fe-report-types"; -import { resolveScoreColor } from "../utils/report-utils"; +import { resolveScoreColor } from "../utils/score-utils"; const initialState: InitialState = { status: AnalysisStatus.Created, @@ -15,7 +15,8 @@ const initialState: InitialState = { unsubscribe: undefined, repoDetails: undefined, fileDetails: undefined, - subscriptionError: undefined, + wsError: undefined, + analysisError: undefined, }; export const ReportStore = createStore((set, get) => ({ @@ -53,6 +54,17 @@ export const ReportStore = createStore((set, get) => ({ reset: () => { set({ ...initialState }); }, + allIssues: () => { + const { linters } = get(); + return (linters || []) + .map((linter) => + (linter.issues || []).map((issue) => ({ + ...issue, + linter: linter.name, + })) + ) + .flat(); + }, })); export const useReportStore = () => useStore(ReportStore); @@ -65,12 +77,13 @@ type InitialState = Omit< | "reset" | "lintersCompleted" | "progress" + | "allIssues" >; interface FeReportStoreState extends ReportState { selectedLinterName?: string; inProgress: boolean; - subscriptionError?: string; + wsError?: string; issuesCount(toolName: string): number; unsubscribe?: () => void; scoreColor(): ScoreColorKey; @@ -78,4 +91,5 @@ interface FeReportStoreState extends ReportState { lintersCompleted(): number; progress(): number; reset(): void; + allIssues(): Issue[]; } diff --git a/packages/app/src/report/utils/report-utils.ts b/packages/app/src/report/utils/score-utils.ts similarity index 100% rename from packages/app/src/report/utils/report-utils.ts rename to packages/app/src/report/utils/score-utils.ts diff --git a/packages/app/src/ws-client.ts b/packages/app/src/report/utils/ws-client.ts similarity index 59% rename from packages/app/src/ws-client.ts rename to packages/app/src/report/utils/ws-client.ts index b17c882..5171a84 100644 --- a/packages/app/src/ws-client.ts +++ b/packages/app/src/report/utils/ws-client.ts @@ -1,12 +1,12 @@ import { ReportState } from "shared-types"; -import config from "./config"; +import config from "../../config"; -export const subscribe = ( - requestId: string, - onMessage: (data: ReportState) => void, - onError: () => void, - onClose: () => void -) => { +export const subscribe = ({ + requestId, + onMessage, + onError, + onClose, +}: WSClientSubsciptionOptions) => { const ws = new WebSocket( `ws://${config.CODETOTAL_WS_HOST}:${config.CODETOTAL_WS_PORT}?requestId=${requestId}` ); @@ -24,3 +24,10 @@ export const subscribe = ( }; return unsubscribe; }; + +interface WSClientSubsciptionOptions { + requestId: string; + onMessage: (data: ReportState) => void; + onError: () => void; + onClose: () => void; +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 9890256..0d41398 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -16,8 +16,10 @@ "author": "Itay", "license": "ISC", "dependencies": { + "@vscode/vscode-languagedetection": "^1.0.22", "axios": "^1.4.0", "cors": "^2.8.5", + "devicon": "^2.15.1", "dotenv": "^16.3.1", "express": "^4.18.2", "md5": "^2.3.0", diff --git a/packages/backend/src/actions/create-analysis-request.ts b/packages/backend/src/actions/create-analysis-request.ts index 78c22cd..cd9e01e 100644 --- a/packages/backend/src/actions/create-analysis-request.ts +++ b/packages/backend/src/actions/create-analysis-request.ts @@ -5,6 +5,7 @@ import { Analysis, AnalysisType, FileAnalysis, + ProgrammingLanguage, RepoAnalysis, SnippetAnalysis, } from "shared-types"; @@ -13,7 +14,7 @@ import { logger } from "../utils/logger"; export const createAnalysisRequestData = async ( action: Analysis -): Promise<[unknown, string]> => { +): Promise<[unknown, string, ProgrammingLanguage?]> => { switch (action.inputType) { case AnalysisType.Repo: { return [ @@ -23,7 +24,17 @@ export const createAnalysisRequestData = async ( } case AnalysisType.Snippet: { const snippetMd5 = md5((action).snippet); - return [{ snippet: (action).snippet }, snippetMd5]; + const snippetAction = action as SnippetAnalysis; + const languageId = snippetAction.language?.id; + const snippetExtension = languageId ? `.${languageId}` : undefined; + return [ + { + snippet: snippetAction.snippet, + snippetExtension, + }, + snippetMd5, + snippetAction.language, + ]; } case AnalysisType.File: { const fileAction = action as FileAnalysis; diff --git a/packages/backend/src/actions/create-analysis.ts b/packages/backend/src/actions/create-analysis.ts index a59d086..d0b44e1 100644 --- a/packages/backend/src/actions/create-analysis.ts +++ b/packages/backend/src/actions/create-analysis.ts @@ -10,7 +10,7 @@ export const createAnalysis = async ( action: Analysis ): Promise<{ requestId: string }> => { try { - const [data, resourceValue] = await createAnalysisRequestData(action); + const [data, resourceValue, language] = await createAnalysisRequestData(action); const response = await axios.post<{ request_id: string }>( config.MEGALINTER_ANALYSIS_URL, @@ -22,6 +22,7 @@ export const createAnalysis = async ( reportStore.set({ resourceType: action.inputType, resourceValue, + language }); subscribeToMegaLinter(requestId, reportStore); return { requestId }; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 28e2dab..59fa832 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,6 +1,6 @@ import { subscribeToReport } from "./actions/subscribe-to-report"; import config from "./config"; -import { startHttpServer } from "./transport/http-server"; +import { startHttpServer } from "./transport/http/http-server"; import { startRedisClient } from "./transport/redis-client"; import { createWSServer, listenToWSConnection } from "./transport/ws-server"; diff --git a/packages/backend/src/language-detection/language-detect.test.ts b/packages/backend/src/language-detection/language-detect.test.ts new file mode 100644 index 0000000..7e8e3bb --- /dev/null +++ b/packages/backend/src/language-detection/language-detect.test.ts @@ -0,0 +1,23 @@ +import { detectLanguage } from "./language-detect"; +import { tsCodeSnippet } from "./snippets-mocks"; + +describe("language-detect", () => { + test("resolve language", async () => { + const language = await detectLanguage(tsCodeSnippet); + expect(language.id).toBeDefined(); + expect(language.name).toBeDefined(); + expect(language.icon).toBeDefined(); + }); + + test("resolve language without an icon", async () => { + const language = await detectLanguage("MKDIR mydir Set X=123"); + expect(language.id).toBeDefined(); + expect(language.name).toBeDefined(); + expect(language.icon).toBeUndefined(); + }); + + test("resolveId return undefined when language not detected", async () => { + const language = await detectLanguage(""); + expect(language).toBeUndefined(); + }); +}); diff --git a/packages/backend/src/language-detection/language-detect.ts b/packages/backend/src/language-detection/language-detect.ts new file mode 100644 index 0000000..dff7168 --- /dev/null +++ b/packages/backend/src/language-detection/language-detect.ts @@ -0,0 +1,22 @@ +import { ProgrammingLanguage } from "shared-types"; +import { resolveIcon } from "./language-resolve-icon"; +import { resolveName } from "./language-resolve-name"; +import { resolveId } from "./language-resolve-id"; + +export const detectLanguage = async ( + snippet: string +): Promise => { + const languageId = await resolveId(snippet); + + if (languageId) { + const name = resolveName(languageId); + const icon = resolveIcon(name); + return { + id: languageId, + name, + icon, + }; + } + + return undefined; +}; diff --git a/packages/backend/src/language-detection/language-resolve-icon.test.ts b/packages/backend/src/language-detection/language-resolve-icon.test.ts new file mode 100644 index 0000000..e662861 --- /dev/null +++ b/packages/backend/src/language-detection/language-resolve-icon.test.ts @@ -0,0 +1,23 @@ +import { resolveIcon } from "./language-resolve-icon"; + +describe("language-resolve-icon", () => { + test("resolve TypeScript Icon", () => { + const icon = resolveIcon("TypeScript"); + expect(icon).toBe("original"); + }); + + test("resolve Rust Icon", () => { + const icon = resolveIcon("Rust"); + expect(icon).toBe("plain"); + }); + + test("resolve Python Icon", () => { + const icon = resolveIcon("Python"); + expect(icon).toBe("original"); + }); + + test("resolveIcon return undefined when icon not found", () => { + const name = resolveIcon("123"); + expect(name).toBeUndefined(); + }); +}); diff --git a/packages/backend/src/language-detection/language-resolve-icon.ts b/packages/backend/src/language-detection/language-resolve-icon.ts new file mode 100644 index 0000000..897300f --- /dev/null +++ b/packages/backend/src/language-detection/language-resolve-icon.ts @@ -0,0 +1,12 @@ +import iconsMap from "devicon/devicon.json"; + +export const resolveIcon = (languageName: string): string | undefined => { + const lcName = languageName.toLowerCase(); + const devIconInfo = iconsMap.find((l) => l.name === lcName); + + if (devIconInfo) { + return devIconInfo.versions.svg[0]; + } + + return undefined; +}; diff --git a/packages/backend/src/language-detection/language-resolve-id.test.ts b/packages/backend/src/language-detection/language-resolve-id.test.ts new file mode 100644 index 0000000..1196714 --- /dev/null +++ b/packages/backend/src/language-detection/language-resolve-id.test.ts @@ -0,0 +1,19 @@ +import { resolveId } from "./language-resolve-id"; +import { pyCodeSnippet, tsCodeSnippet } from "./snippets-mocks"; + +describe("language-resolve-id", () => { + test("resolveId language id from snippet", async () => { + const id = await resolveId(pyCodeSnippet); + expect(id).toBe("py"); + }); + + test("resolveId language id from snippet", async () => { + const id = await resolveId(tsCodeSnippet); + expect(id).toBe("ts"); + }); + + test("resolveId return undefined when language not detected", async () => { + const id = await resolveId("123"); + expect(id).toBeUndefined(); + }); +}); diff --git a/packages/backend/src/language-detection/language-resolve-id.ts b/packages/backend/src/language-detection/language-resolve-id.ts new file mode 100644 index 0000000..bded142 --- /dev/null +++ b/packages/backend/src/language-detection/language-resolve-id.ts @@ -0,0 +1,17 @@ +import { ModelOperations } from "@vscode/vscode-languagedetection"; + +// Language detection of code snippet using @vscode/vscode-languagedetection (guesslang) +// Name resolution using highlight.js (https://github.com/highlightjs/highlight.js/blob/main/SUPPORTED_LANGUAGES.md) +// Icon resolution using "devicon" (https://devicon.dev/) + +const modulOperations = new ModelOperations(); + +export const resolveId = async (snippet: string): Promise => { + const detection = await modulOperations.runModel(snippet); + + if (detection.length > 0) { + return detection[0].languageId; + } + + return undefined; +}; diff --git a/packages/backend/src/language-detection/language-resolve-name.test.ts b/packages/backend/src/language-detection/language-resolve-name.test.ts new file mode 100644 index 0000000..42660a1 --- /dev/null +++ b/packages/backend/src/language-detection/language-resolve-name.test.ts @@ -0,0 +1,13 @@ +import { resolveName } from "./language-resolve-name"; + +describe("language-resolve-name", () => { + test("resolveName", () => { + const name = resolveName("ts"); + expect(name).toBe("TypeScript"); + }); + + test("resolveName return undefined when language not found", () => { + const name = resolveName("123"); + expect(name).toBeUndefined(); + }); +}); diff --git a/packages/backend/src/language-detection/language-resolve-name.ts b/packages/backend/src/language-detection/language-resolve-name.ts new file mode 100644 index 0000000..92391cf --- /dev/null +++ b/packages/backend/src/language-detection/language-resolve-name.ts @@ -0,0 +1,9 @@ +import languagesMap from "./languages-id-to-name-map.json"; + +export const resolveName = (languageId: string): string | undefined => { + if (languagesMap[languageId]) { + return languagesMap[languageId]; + } + + return undefined; +}; diff --git a/packages/backend/src/language-detection/languages-id-to-name-map.json b/packages/backend/src/language-detection/languages-id-to-name-map.json new file mode 100644 index 0000000..dfe86f8 --- /dev/null +++ b/packages/backend/src/language-detection/languages-id-to-name-map.json @@ -0,0 +1,420 @@ +{ + "1c": "1C", + "4d": "4D", + "sap-abap": "ABAP", + "abap": "ABAP", + "abnf": "ABNF", + "accesslog": "Access logs", + "ada": "Ada", + "apex": "Apex", + "arduino": "Arduino (C++ w/Arduino libs)", + "ino": "Arduino (C++ w/Arduino libs)", + "armasm": "ARM assembler", + "arm": "ARM assembler", + "avrasm": "AVR assembler", + "actionscript": "ActionScript", + "as": "ActionScript", + "alan": "Alan IF", + "i": "Alan IF", + "ln": "Alan", + "angelscript": "AngelScript", + "asc": "AngelScript", + "apache": "Apache", + "apacheconf": "Apache", + "applescript": "AppleScript", + "osascript": "AppleScript", + "arcade": "Arcade", + "asciidoc": "AsciiDoc", + "adoc": "AsciiDoc", + "aspectj": "AspectJ", + "autohotkey": "AutoHotkey", + "autoit": "AutoIt", + "awk": "Awk", + "mawk": "Awk", + "nawk": "Awk", + "gawk": "Awk", + "bash": "Bash", + "sh": "Bash", + "zsh": "Bash", + "basic": "Basic", + "bbcode": "BBCode", + "blade": "Blade (Laravel)", + "bnf": "BNF", + "bqn": "BQN", + "brainfuck": "Brainfuck", + "bf": "Brainfuck", + "csharp": "C#", + "cs": "C#", + "c": "C", + "h": "C", + "cpp": "C++", + "hpp": "C++", + "cc": "C++", + "hh": "C++", + "c++": "C++", + "h++": "C++", + "cxx": "C++", + "hxx": "C++", + "cal": "C/AL", + "c3": "C3", + "cos": "Cache Object Script", + "cls": "Cache Object Script", + "candid": "Candid", + "did": "Candid", + "cmake": "CMake", + "cmake.in": "CMake", + "cobol": "COBOL", + "standard-cobol": "COBOL", + "coq": "Coq", + "csp": "CSP", + "css": "CSS", + "capnproto": "Cap’n Proto", + "capnp": "Cap’n Proto", + "chaos": "Chaos", + "kaos": "Chaos", + "chapel": "Chapel", + "chpl": "Chapel", + "cisco": "Cisco CLI", + "clojure": "Clojure", + "clj": "Clojure", + "coffeescript": "CoffeeScript", + "coffee": "CoffeeScript", + "cson": "CoffeeScript", + "iced": "CoffeeScript", + "cpc": "CpcdosC+", + "crmsh": "Crmsh", + "crm": "Crmsh", + "pcmk": "Crmsh", + "crystal": "Crystal", + "cr": "Crystal", + "curl": "cURL", + "cypher": "Cypher (Neo4j)", + "d": "D", + "dafny": "Dafny", + "dart": "Dart", + "dpr": "Delphi", + "dfm": "Delphi", + "pas": "Delphi", + "pascal": "Delphi", + "diff": "Diff", + "patch": "Diff", + "django": "Django", + "jinja": "Django", + "dns": "DNS Zone file", + "zone": "DNS Zone file", + "bind": "DNS Zone file", + "dockerfile": "Dockerfile", + "docker": "Dockerfile", + "dos": "DOS", + "bat": "DOS", + "cmd": "DOS", + "dsconfig": "dsconfig", + "dts": "DTS (Device Tree)", + "dust": "Dust", + "dst": "Dust", + "dylan": "Dylan", + "ebnf": "EBNF", + "elixir": "Elixir", + "elm": "Elm", + "erlang": "Erlang", + "erl": "Erlang", + "excel": "Excel", + "xls": "Excel", + "xlsx": "Excel", + "extempore": "Extempore", + "xtlang": "Extempore", + "xtm": "Extempore", + "fsharp": "F#", + "fs": "F#", + "fix": "FIX", + "flix": "Flix", + "fortran": "Fortran", + "f90": "Fortran", + "f95": "Fortran", + "func": "FunC", + "gcode": "G-Code", + "nc": "G-Code", + "gams": "Gams", + "gms": "Gams", + "gauss": "GAUSS", + "gss": "GAUSS", + "godot": "GDScript", + "gdscript": "GDScript", + "gherkin": "Gherkin", + "hbs": "Handlebars", + "glimmer": "Glimmer and EmberJS", + "html.hbs": "Handlebars", + "html.handlebars": "Handlebars", + "htmlbars": "Glimmer and EmberJS", + "gn": "GN for Ninja", + "gni": "GN for Ninja", + "go": "Go", + "golang": "Go", + "gf": "Grammatical Framework", + "golo": "Golo", + "gololang": "Golo", + "gradle": "Gradle", + "graphql": "GraphQL", + "groovy": "Groovy", + "gsql": "GSQL", + "xml": "HTML, XML", + "html": "HTML, XML", + "xhtml": "HTML, XML", + "rss": "HTML, XML", + "atom": "HTML, XML", + "xjb": "HTML, XML", + "xsd": "HTML, XML", + "xsl": "HTML, XML", + "plist": "HTML, XML", + "svg": "HTML, XML", + "http": "HTTP", + "https": "HTTP", + "haml": "Haml", + "handlebars": "Handlebars", + "haskell": "Haskell", + "hs": "Haskell", + "haxe": "Haxe", + "hx": "Haxe", + "hlsl": "High-level shader language", + "hy": "Hy", + "hylang": "Hy", + "ini": "Ini, TOML", + "toml": "Ini, TOML", + "inform7": "Inform7", + "i7": "Inform7", + "irpf90": "IRPF90", + "iptables": "Iptables", + "json": "JSON", + "java": "Java", + "jsp": "Java", + "javascript": "JavaScript", + "js": "JavaScript", + "jsx": "JavaScript", + "jolie": "Jolie", + "iol": "Jolie", + "ol": "Jolie", + "julia": "Julia", + "julia-repl": "Julia", + "kotlin": "Kotlin", + "kt": "Kotlin", + "": "Lang", + "tex": "LaTeX", + "leaf": "Leaf", + "lean": "Lean", + "lasso": "Lasso", + "ls": "LiveScript", + "lassoscript": "Lasso", + "less": "Less", + "ldif": "LDIF", + "lisp": "Lisp", + "livecodeserver": "LiveCode Server", + "livescript": "LiveScript", + "lookml": "LookML", + "lua": "Lua", + "macaulay2": "Macaulay2", + "makefile": "Makefile", + "mk": "Makefile", + "mak": "Makefile", + "make": "Makefile", + "markdown": "Markdown", + "md": "Markdown", + "mkdown": "Markdown", + "mkd": "Markdown", + "mathematica": "Mathematica", + "mma": "Mathematica", + "wl": "Mathematica", + "matlab": "Matlab", + "maxima": "Maxima", + "mel": "Maya Embedded Language", + "mercury": "Mercury", + "mips": "MIPS Assembler", + "mipsasm": "MIPS Assembler", + "mint": "Mint", + "mirc": "mIRC Scripting Language", + "mrc": "mIRC Scripting Language", + "mizar": "Mizar", + "mkb": "MKB", + "mlir": "MLIR", + "mojolicious": "Mojolicious", + "monkey": "Monkey", + "moonscript": "Moonscript", + "moon": "Moonscript", + "motoko": "Motoko", + "mo": "Motoko", + "n1ql": "N1QL", + "nsis": "NSIS", + "never": "Never", + "nginx": "Nginx", + "nginxconf": "Nginx", + "nim": "Nim", + "nimrod": "Nim", + "nix": "Nix", + "oak": "Oak", + "ocl": "Object Constraint Language", + "ocaml": "OCaml", + "ml": "OCaml", + "objectivec": "Objective C", + "mm": "Objective C", + "objc": "Objective C", + "obj-c": "Objective C", + "obj-c++": "Objective C", + "objective-c++": "Objective C", + "glsl": "OpenGL Shading Language", + "openscad": "OpenSCAD", + "scad": "OpenSCAD", + "ruleslanguage": "Oracle Rules Language", + "oxygene": "Oxygene", + "pf": "PF", + "pf.conf": "PF", + "php": "PHP", + "papyrus": "Papyrus", + "psc": "Papyrus", + "parser3": "Parser3", + "perl": "Perl", + "pl": "Perl", + "pm": "Perl", + "pine": "Pine Script", + "pinescript": "Pine Script", + "plaintext": "Plaintext", + "txt": "Plaintext", + "text": "Plaintext", + "pony": "Pony", + "pgsql": "PostgreSQL & PL/pgSQL", + "postgres": "PostgreSQL & PL/pgSQL", + "postgresql": "PostgreSQL & PL/pgSQL", + "powershell": "PowerShell", + "ps": "PowerShell", + "ps1": "PowerShell", + "processing": "Processing", + "prolog": "Prolog", + "properties": "Properties", + "proto": "Protocol Buffers", + "protobuf": "Protocol Buffers", + "puppet": "Puppet", + "pp": "Puppet", + "python": "Python", + "py": "Python", + "gyp": "Python", + "profile": "Python profiler results", + "python-repl": "Python REPL", + "pycon": "Python REPL", + "qsharp": "Q#", + "k": "Q", + "kdb": "Q", + "qml": "QML", + "r": "R", + "cshtml": "Razor CSHTML", + "razor": "Razor CSHTML", + "razor-cshtml": "Razor CSHTML", + "reasonml": "ReasonML", + "re": "ReasonML", + "redbol": "Rebol & Red", + "rebol": "Rebol & Red", + "red": "Rebol & Red", + "red-system": "Rebol & Red", + "rib": "RenderMan RIB", + "rsl": "RenderMan RSL", + "risc": "RiScript", + "riscript": "RiScript", + "graph": "Roboconf", + "instances": "Roboconf", + "robot": "Robot Framework", + "rf": "Robot Framework", + "rpm-specfile": "RPM spec files", + "rpm": "RPM spec files", + "spec": "RPM spec files", + "rpm-spec": "RPM spec files", + "specfile": "RPM spec files", + "ruby": "Ruby", + "rb": "Ruby", + "gemspec": "Ruby", + "podspec": "Ruby", + "thor": "Ruby", + "irb": "Ruby", + "rust": "Rust", + "rs": "Rust", + "rvt": "RVT Script", + "rvt-script": "RVT Script", + "SAS": "SAS", + "sas": "SAS", + "scss": "SCSS", + "sql": "SQL", + "p21": "STEP Part 21", + "step": "STEP Part 21", + "stp": "STEP Part 21", + "scala": "Scala", + "scheme": "Scheme", + "scilab": "Scilab", + "sci": "Scilab", + "sfz": "SFZ", + "shexc": "Shape Expressions", + "shell": "Shell", + "console": "Shell", + "smali": "Smali", + "smalltalk": "Smalltalk", + "st": "Smalltalk", + "sml": "SML", + "solidity": "Solidity", + "sol": "Solidity", + "spl": "Splunk SPL", + "stan": "Stan", + "stanfuncs": "Stan", + "stata": "Stata", + "iecst": "Structured Text", + "scl": "Structured Text", + "stl": "Structured Text", + "structured-text": "Structured Text", + "stylus": "Stylus", + "styl": "Stylus", + "subunit": "SubUnit", + "supercollider": "Supercollider", + "sc": "Supercollider", + "svelte": "Svelte", + "swift": "Swift", + "tcl": "Tcl", + "tk": "Tcl", + "terraform": "Terraform (HCL)", + "tf": "Terraform (HCL)", + "hcl": "Terraform (HCL)", + "tap": "Test Anything Protocol", + "thrift": "Thrift", + "toit": "Toit", + "tp": "TP", + "tsql": "Transact-SQL", + "twig": "Twig", + "craftcms": "Twig", + "typescript": "TypeScript", + "ts": "TypeScript", + "tsx": "TypeScript", + "mts": "TypeScript", + "cts": "TypeScript", + "unicorn-rails-log": "Unicorn Rails log", + "vbnet": "VB.Net", + "vb": "VB.Net", + "vba": "VBA", + "vbscript": "VBScript", + "vbs": "VBScript", + "vhdl": "VHDL", + "vala": "Vala", + "verilog": "Verilog", + "v": "Verilog", + "vim": "Vim Script", + "xsharp": "X#", + "xs": "X#", + "prg": "X#", + "axapta": "X++", + "x++": "X++", + "x86asm": "x86 Assembly", + "xl": "XL", + "tao": "XL", + "xquery": "XQuery", + "xpath": "XQuery", + "xq": "XQuery", + "xqm": "XQuery", + "yml": "YAML", + "yaml": "YAML", + "zenscript": "ZenScript", + "zs": "ZenScript", + "zephir": "Zephir", + "zep": "Zephir" +} diff --git a/packages/backend/src/language-detection/snippets-mocks.ts b/packages/backend/src/language-detection/snippets-mocks.ts new file mode 100644 index 0000000..0645265 --- /dev/null +++ b/packages/backend/src/language-detection/snippets-mocks.ts @@ -0,0 +1,28 @@ +export const pyCodeSnippet = ` + import pandas as pd + import requests + from requests import post + print(pd.read_csv('1.csv')) + HEADERS = { + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9,ru;q=0.8', + 'authorization': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnROYW1lIjoid2ViYXBwLXYzMSIsInNjb3BlIjoic3RhdGljLWNvbnRlbnQtYXBpLGN1cmF0aW9uLWFwaSxuZXh4LWNvbnRlbnQtYXBpLXYzMSx3ZWJhcHAtYXBpIn0.mbuG9wS9Yf5q6PqgR4fiaRFIagiHk9JhwoKES7ksVX4', + } + res1 = requests.get('http://pbom.dev/', headers=HEADERS) + res2 = requests.get('http://ox.security/', allow_redirects=True) + res3 = requests.get('https://megalinter.io/', verify=False) +`; + +export const tsCodeSnippet = ` +import iconsMap from "devicon/devicon.json"; +export const resolveIcon = (languageName: string): string | undefined => { + const lcName = languageName.toLowerCase(); + const devIconInfo = iconsMap.find((l) => l.name === lcName); + + if (devIconInfo) { + return devIconInfo.versions.svg[0]; + } + + return undefined; +}; +`; diff --git a/packages/backend/src/megalinter/megalinter-types.ts b/packages/backend/src/megalinter/megalinter-types.ts index dd5b181..38f359c 100644 --- a/packages/backend/src/megalinter/megalinter-types.ts +++ b/packages/backend/src/megalinter/megalinter-types.ts @@ -95,13 +95,21 @@ export interface LinterCompleteMessage extends BaseMessage { export interface MegalinterErrorMessage extends BaseMessage { messageType: typeof MessageType.ServerError; message: string; - errorCode: string; + errorCode: MegalinterErrorCode; errorDetails: { error: string; }; requestId: string; } +export enum MegalinterErrorCode { + MissingAnalysisType = "missingAnalysisType", + GitClone = "gitCloneError", + UploadFileNotFound = "uploadedFileNotFound", + SnippetGuessError = "snippetGuessError", + SnippetBuildError = "snippetBuildError", +} + export interface RawLinter { descriptorId: string; linterId: string; diff --git a/packages/backend/src/megalinter/parsers/parse-errors.test.ts b/packages/backend/src/megalinter/parsers/parse-errors.test.ts new file mode 100644 index 0000000..90f1742 --- /dev/null +++ b/packages/backend/src/megalinter/parsers/parse-errors.test.ts @@ -0,0 +1,47 @@ +import { MegalinterErrorMessage, MessageType } from "../megalinter-types"; +import { parseMegalinterError } from "./parse-errors"; + +const storeMock = { + set: jest.fn(), + get: jest.fn().mockImplementation(() => ({ linters: [{ name: "devskim" }] })), + subscribe: jest.fn(), +}; + +describe("parse-errors", () => { + test("parseMegalinterError", () => { + const linterErrorMessage = { + messageType: MessageType.ServerError, + message: "Some error message", + errorCode: "gitCloneError", + errorDetails: { + error: "Some error details", + }, + requestId: "123", + }; + parseMegalinterError( + linterErrorMessage as MegalinterErrorMessage, + storeMock + ); + + expect(storeMock.set).toBeCalledWith({ + analysisError: { + errorCode: "gitCloneError", + errorMessage: "Some error message", + errorDetails: "Some error details", + }, + }); + }); + + test("parseMegalinterError should fail gracefully", () => { + const linterErrorMessage = {}; + + try { + parseMegalinterError( + linterErrorMessage as MegalinterErrorMessage, + storeMock + ); + } catch (err) { + expect(err.message).toBe("Unable to parse megalinter error message"); + } + }); +}); diff --git a/packages/backend/src/megalinter/parsers/parse-errors.ts b/packages/backend/src/megalinter/parsers/parse-errors.ts new file mode 100644 index 0000000..d2a2c2e --- /dev/null +++ b/packages/backend/src/megalinter/parsers/parse-errors.ts @@ -0,0 +1,23 @@ +import { ReportStore } from "../../stores/be-report-store"; +import { logger } from "../../utils/logger"; +import { MegalinterErrorMessage } from "../megalinter-types"; + +export const parseMegalinterError = ( + msg: MegalinterErrorMessage, + reportStore: ReportStore +) => { + try { + const { errorCode, message, errorDetails } = msg; + const analysisError = { + errorCode, + errorMessage: message, + errorDetails: errorDetails.error, + }; + reportStore.set({ + analysisError, + }); + } catch (err) { + logger.megalinter.error(err); + throw new Error("Unable to parse megalinter error message"); + } +}; diff --git a/packages/backend/src/megalinter/parsers/parse-linter-status.test.ts b/packages/backend/src/megalinter/parsers/parse-linter-status.test.ts index 9ed0cff..16f9ada 100644 --- a/packages/backend/src/megalinter/parsers/parse-linter-status.test.ts +++ b/packages/backend/src/megalinter/parsers/parse-linter-status.test.ts @@ -9,7 +9,7 @@ const storeMock = { }; describe("parse-linter-status", () => { - test("createWSServer", () => { + test("parseLinterStatus", () => { const linterCompleteMessage = { linterStatus: LinterStatus.Success, linterId: "devskim", diff --git a/packages/backend/src/megalinter/parsers/parser.ts b/packages/backend/src/megalinter/parsers/parser.ts index 76e642a..f514055 100644 --- a/packages/backend/src/megalinter/parsers/parser.ts +++ b/packages/backend/src/megalinter/parsers/parser.ts @@ -3,10 +3,12 @@ import { BaseMessage, LinterCompleteMessage, MegalinterCompleteMessage, + MegalinterErrorMessage, MegalinterStartMessage, MessageType, } from "../megalinter-types"; import { parseDetails } from "./parse-details"; +import { parseMegalinterError } from "./parse-errors"; import { parseLinterStatus } from "./parse-linter-status"; import { parseMegalinterComplete } from "./parse-megalinter-complete"; import { parseMegalinterStart } from "./parse-megalinter-start"; @@ -25,6 +27,9 @@ export const parseMessage = (msg: BaseMessage, reportStore: ReportStore) => { parseLinterStatus(msg as LinterCompleteMessage, reportStore); runSarif(msg as LinterCompleteMessage, reportStore); break; + case MessageType.ServerError: + parseMegalinterError(msg as MegalinterErrorMessage, reportStore); + break; } parsePackages(msg, reportStore); diff --git a/packages/backend/src/stores/be-report-store.test.ts b/packages/backend/src/stores/be-report-store.test.ts index a364c6c..e2df2ac 100644 --- a/packages/backend/src/stores/be-report-store.test.ts +++ b/packages/backend/src/stores/be-report-store.test.ts @@ -8,6 +8,7 @@ describe("report-store", () => { expect(getStore("123").get().requestId).toEqual("123"); expect(getStore("123").get().status).toEqual(AnalysisStatus.Created); expect(getStore("123").get().score).toEqual(0); + expect(getStore("123").get().analysisError).toEqual(undefined); expect(getStore("123")).toEqual(reportStore); }); }); diff --git a/packages/backend/src/stores/be-report-store.ts b/packages/backend/src/stores/be-report-store.ts index 8926afa..7773b24 100644 --- a/packages/backend/src/stores/be-report-store.ts +++ b/packages/backend/src/stores/be-report-store.ts @@ -5,7 +5,7 @@ import { addStore } from "./stores-map"; export const createReportStore = (requestId: string) => { logger.stores.log(`Creating report store for requestId: "${requestId}"`); - + const reportStore = createStore({ requestId, resourceType: undefined, @@ -16,8 +16,12 @@ export const createReportStore = (requestId: string) => { repoDetails: undefined, fileDetails: undefined, score: 0, + analysisError: undefined, }); + + // save the store instance for later use addStore(requestId, reportStore); + return reportStore; }; @@ -25,4 +29,5 @@ export type InitialReportStoreState = Pick< ReportState, "requestId" | "status" | "score" >; + export type ReportStore = Store; diff --git a/packages/backend/src/transport/http/http-file-upload-handler.test.ts b/packages/backend/src/transport/http/http-file-upload-handler.test.ts new file mode 100644 index 0000000..8355365 --- /dev/null +++ b/packages/backend/src/transport/http/http-file-upload-handler.test.ts @@ -0,0 +1,22 @@ +import { + FileUploader, + createFileUploadHandler, +} from "./http-file-upload-handler"; + +const memStorageMock = jest.fn(() => ({})); +const singleFuncMock = jest.fn(() => jest.fn()); +const uploader = jest.fn(() => ({ single: singleFuncMock })); +uploader["memoryStorage"] = memStorageMock; + +describe("http-file-upload-handler", () => { + test("createFileUploadHandler", () => { + const handler = createFileUploadHandler( + uploader as unknown as FileUploader + ); + + expect(memStorageMock).toBeCalled(); + expect(uploader).toBeCalledWith({ storage: {} }); + expect(singleFuncMock).toBeCalledWith("file"); + expect(typeof handler).toBe("function"); + }); +}); diff --git a/packages/backend/src/transport/http/http-file-upload-handler.ts b/packages/backend/src/transport/http/http-file-upload-handler.ts new file mode 100644 index 0000000..8647c0e --- /dev/null +++ b/packages/backend/src/transport/http/http-file-upload-handler.ts @@ -0,0 +1,12 @@ +import { RequestHandler } from "express"; + +export const createFileUploadHandler = (uploader: FileUploader): RequestHandler => { + const storage = uploader.memoryStorage(); + const upload = uploader({ storage }); + return upload.single("file"); +}; + +export type FileUploader = { + memoryStorage(): object; + (options: { storage: object }); +}; diff --git a/packages/backend/src/transport/http-server.ts b/packages/backend/src/transport/http/http-server.ts similarity index 69% rename from packages/backend/src/transport/http-server.ts rename to packages/backend/src/transport/http/http-server.ts index 9c61842..003a8eb 100644 --- a/packages/backend/src/transport/http-server.ts +++ b/packages/backend/src/transport/http/http-server.ts @@ -4,9 +4,14 @@ import express, { Request, Response } from "express"; import multer from "multer"; import path from "node:path"; import { Analysis, AnalysisStatus, FileAnalysis } from "shared-types"; -import { createAnalysis } from "../actions/create-analysis"; -import { getStore } from "../stores/stores-map"; -import { logger } from "../utils/logger"; +import { createAnalysis } from "../../actions/create-analysis"; +import { detectLanguage } from "../../language-detection/language-detect"; +import { getStore } from "../../stores/stores-map"; +import { logger } from "../../utils/logger"; +import { + FileUploader, + createFileUploadHandler, +} from "./http-file-upload-handler"; export const startHttpServer = ({ host, port }: HttpServerOptions) => { const app = express(); @@ -28,9 +33,9 @@ export const startHttpServer = ({ host, port }: HttpServerOptions) => { // add analysis route app.post( "/analysis", - createFileUploadHandler(), + createFileUploadHandler(multer as FileUploader), async (req: Request, res: Response) => { - logger.transport.log("Receiver new analysis request"); + logger.transport.log("Received new analysis request"); const file = req.file; let action = req.body as Analysis; if (file) { @@ -60,6 +65,20 @@ export const startHttpServer = ({ host, port }: HttpServerOptions) => { } }); + app.post("/detect", async (req: Request, res: Response) => { + const { snippet } = req.body; + + try { + const language = await detectLanguage(snippet); + language && res.json(language); + } catch (err) { + logger.transport.error(err.message); + res.status(500).send(); + } + + res.status(200).send(); + }); + app.listen(port, host, () => { logger.transport.log(`HTTP server is running on ${host}:${port}`); }); @@ -69,9 +88,3 @@ interface HttpServerOptions { host: string; port: number; } - -const createFileUploadHandler = () => { - const storage = multer.memoryStorage(); - const upload = multer({ storage: storage }); - return upload.single("file"); -}; diff --git a/packages/shared-types/src/analysis-types.ts b/packages/shared-types/src/analysis-types.ts index 322ac4a..d65548b 100644 --- a/packages/shared-types/src/analysis-types.ts +++ b/packages/shared-types/src/analysis-types.ts @@ -1,3 +1,4 @@ +import { ProgrammingLanguage } from "./language-types"; import { OneOfValues } from "./typescript-types"; export interface Analysis { @@ -18,6 +19,7 @@ export interface RepoAnalysis extends Analysis { export interface SnippetAnalysis extends Analysis { inputType: typeof AnalysisType.Snippet; snippet: string; + language?: ProgrammingLanguage; } export interface FileAnalysis extends Analysis { diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 6ced1db..0a34791 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,3 +1,4 @@ export * from "./analysis-types"; +export * from "./language-types"; export * from "./report-types"; export * from "./typescript-types"; diff --git a/packages/shared-types/src/language-types.ts b/packages/shared-types/src/language-types.ts new file mode 100644 index 0000000..3d6daf8 --- /dev/null +++ b/packages/shared-types/src/language-types.ts @@ -0,0 +1,5 @@ +export interface ProgrammingLanguage { + id: string; + name?: string; + icon?: string; +} diff --git a/packages/shared-types/src/report-types.ts b/packages/shared-types/src/report-types.ts index fc53d57..c5ec357 100644 --- a/packages/shared-types/src/report-types.ts +++ b/packages/shared-types/src/report-types.ts @@ -1,3 +1,4 @@ +import { ProgrammingLanguage } from "."; import { AnalysisStatus } from "./analysis-types"; import { OneOfValues } from "./typescript-types"; @@ -11,10 +12,14 @@ export interface ReportState { repoDetails?: RepoDetails; fileDetails?: FileDetails; score: number; + analysisError?: { + errorCode?: string; + errorMessage?: string; + errorDetails?: string; + }; + language?: ProgrammingLanguage; } - - export interface RepoDetails { languages: ReportLanguage[]; readmeUrl: string;