## Support
### FAQs
Please check the [FAQ](https://sassoftware.github.io/vscode-sas-extension/faq) page for some common questions.
### SAS Programming Documentation
[SAS Programming documentation](https://go.documentation.sas.com/doc/en/pgmsascdc/v_048/lepg/titlepage.htm)
### SAS Communities
For usage questions, tips, and workarounds, interact with other SAS users to ask questions and get answers on the [SAS Programmers Community site](https://communities.sas.com/t5/SAS-Programming/bd-p/programming).
### SAS Technical Support
SAS Technical Support provides standard support for the current release of the [SAS Extension for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=SAS.sas-lsp) available through the [Visual Studio Marketplace](https://marketplace.visualstudio.com/items), in accordance with the [Policies for All SAS Products | SAS Support](https://support.sas.com/en/technical-support/services-policies/policies-for-sas-products.html). If you have been unable to solve a problem or find answers using self‑help resources, open a case in the [Customer Service Portal](https://service.sas.com/csm) to get technical support for the SAS Extension for VS Code.
### Reporting Issues
Submit a [GitHub issue](https://github.com/sassoftware/vscode-sas-extension/issues) for tracking bugs, feature requests, or questions regarding open‑source contributions.
## Contributing to the SAS Extension
We welcome your contributions! Please read [CONTRIBUTING.md](/CONTRIBUTING.md) for details on how to submit contributions to this project.
## License
This project is subject to the Apache License Version 2.0, a copy of which is included as [LICENSE](LICENSE)
================================================
FILE: SUPPORT.md
================================================
## Support
Support is provided through multiple channels, depending on your situation:
- For usage questions, tips, and workarounds, interact with other SAS users to ask questions and get answers on the [SAS Programmers Community site](https://communities.sas.com/t5/SAS-Programming/bd-p/programming).
- SAS Technical Support provides standard support for the current release of the [SAS Extension for Visual Studio Code](https://marketplace.visualstudio.com/items?itemName=SAS.sas-lsp) available through the [Visual Studio Marketplace](https://marketplace.visualstudio.com), in accordance with the [Policies for All SAS Products | SAS Support](https://support.sas.com/en/technical-support/services-policies/policies-for-sas-products.html). If you have been unable to solve a problem or find answers using self‑help resources, open a case in the [Customer Service Portal](https://service.sas.com/csm) to get technical support for the SAS Extension for VS Code.
- Submit a [GitHub issue](https://github.com/sassoftware/vscode-sas-extension/issues) for tracking bugs, feature requests, or questions regarding open‑source contributions.
================================================
FILE: client/package.json
================================================
{
"name": "sas-lsp-client",
"description": "VS Code client for SAS language server",
"author": "SAS Institute Inc.",
"license": "Apache-2.0",
"version": "0.0.1",
"publisher": "SAS",
"engines": {
"vscode": "^1.89.0"
},
"dependencies": {
"ag-grid-community": "^35.2.1",
"ag-grid-react": "^35.2.1",
"axios": "^1.15.2",
"highlight.js": "^11.11.1",
"katex": "^0.16.45",
"marked": "^17.0.5",
"marked-katex-extension": "^5.1.7",
"media-typer": "^1.1.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"ssh2": "^1.17.0",
"uuid": "^14.0.0",
"vscode-languageclient": "^10.0.0-next.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ssh2": "^1.15.5",
"@types/uuid": "^11.0.0",
"@types/vscode": "1.82.0",
"@types/vscode-notebook-renderer": "^1.72.4",
"@vscode/test-electron": "^2.5.2"
}
}
================================================
FILE: client/src/browser/extension.ts
================================================
// Copyright © 2022, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { ExtensionContext, Uri, commands } from "vscode";
import { LanguageClientOptions } from "vscode-languageclient";
import { LanguageClient } from "vscode-languageclient/browser";
let client: LanguageClient;
// this method is called when vs code is activated
export function activate(context: ExtensionContext): void {
commands.executeCommand("setContext", "SAS.hideRunMenuItem", true);
// Options to control the language client
const clientOptions: LanguageClientOptions = {
// Register the server for sas file
documentSelector: [{ language: "sas" }],
};
client = createWorkerLanguageClient(context, clientOptions);
client.start();
}
function createWorkerLanguageClient(
context: ExtensionContext,
clientOptions: LanguageClientOptions,
) {
// Create a worker. The worker main file implements the language server.
const serverMain = Uri.joinPath(
context.extensionUri,
"server/dist/browser/server.js",
);
const worker = new Worker(serverMain.toString());
// create the language server client to communicate with the server running in the worker
return new LanguageClient(
"sas-lsp",
"SAS Language Server",
worker,
clientOptions,
);
}
export function deactivate(): Thenable\s*.*)/;
const match = codeLine.match(capturingRegExp);
return match !== null && match.groups !== undefined
? {
lineNumber: parseInt(match.groups.lineNum),
code: match.groups.code,
}
: null;
}
function getLastElement(arr: T[]): T | null {
const lastIndex = arr.length - 1;
return lastIndex >= 0 ? arr[lastIndex] : null;
}
/* there are two kind of beginning log lines indicating new problem
1) problem with this kind of beginning has location indicator in one of previous log line.
"22" will be extracted as problem number in below log line.
"ERROR 22-322: Syntax error, expecting one of the following: ;, CANCEL, "
2) problem with this kind of beginning has no location indicator and it is located to the previous closest source code.
"undefined" will be returned in below log line, which means a general problem location will be used.
"WARNING: Variable POP_100 not found in data set WORK.UNIVOUT."
*/
function getProblemNumberFromProblemLogLine(
logLine: string,
): string | undefined {
const regExp = /(?<=^(warning|error)\s+)\d+(?=-\d+:\s.*)/gi;
const match = logLine.match(regExp);
return match?.[0] ?? undefined;
}
function processLocations(
line: string,
from: "indicator" | "problemNumber",
): ProblemLocation[] | null {
switch (from) {
case "indicator":
return processLocationsFromIndicatorLine(line);
case "problemNumber":
return processLocationFromProblemNumberLine(line);
}
}
function processLocationsFromIndicatorLine(
indicatorLogLine: string,
): ProblemLocation[] | null {
// example: " ---- --- --"
const regExp = /[-_]+/g;
const locations = Array.from(indicatorLogLine.matchAll(regExp), (match) => ({
startColumn: match.index,
endColumn: match.index + match[0].length,
}));
return locations.length === 0 ? null : locations;
}
// problem number log line example:
// 22 79
function processLocationFromProblemNumberLine(
problemNumberLine: string,
): ProblemLocation[] | null {
const regExp = /\d+/g;
const locations: ProblemLocation[] = Array.from(
problemNumberLine.matchAll(regExp),
(match): ProblemLocation => ({
startColumn: match.index,
problemNumber: match[0],
}),
);
return locations.length === 0 ? null : locations;
}
function reviseLocationsFromIndicatorLine(
indicatorLogLine: string,
baseLocations: ProblemLocation[],
): ProblemLocation[] {
const arr = Array.from(indicatorLogLine);
baseLocations.forEach((location) => {
arr[location.startColumn] = "=";
});
const regExp = /=[-_]*/g;
const locations = Array.from(arr.join("").matchAll(regExp), (match) => ({
startColumn: match.index,
endColumn: match.index + match[0].length,
}));
return locations.length === 0 ? null : locations;
}
function processRawLocationDescs(
rawLocationDescs: RawLocationDesc[],
offset: LocationOffset,
): ProblemLocation[] {
const locations: ProblemLocation[] = [];
rawLocationDescs.forEach((rawLocationDesc) =>
locations.push(...processRawLocationDesc(rawLocationDesc, offset)),
);
return locations;
}
function processSourceCodeLines(
sourceCodeLines: string[],
columnOffset: number,
): { lineNumber?: number; columnCorrection?: number } {
if (!sourceCodeLines || sourceCodeLines.length === 0) {
return {};
}
const lineNumber = decomposeCodeLogLine(sourceCodeLines[0]).lineNumber;
// the source code log line may be separated into multiple lines, and the error occurred at the latter line,
// in this case, must take account of the previous lines length to make start column correct.
const columnCorrection = sourceCodeLines.reduce(
(accumulator, line, index, lines) => {
// it is possible the beginning and end spaces are trimmed, which makes the column correction wrong.
// so in such case, 1 additional column (suppose only one space between two lines) will be considered.
const isLastLineEndWithSpace =
index === 0 || lines[index - 1].endsWith(" ");
const isCurrentLineStartWithSpace = lines[index]
.substring(columnOffset)
.startsWith(" ");
// excludes the length of last line
const currentLineLength =
lines.length - index === 1 ? 0 : line.length - columnOffset;
return (
accumulator +
currentLineLength +
(!(isLastLineEndWithSpace || isCurrentLineStartWithSpace) ? 1 : 0)
);
},
0,
);
return { lineNumber, columnCorrection };
}
function processRawLocationDesc(
rawLocationDesc: RawLocationDesc,
offset: LocationOffset,
): ProblemLocation[] {
const { lineNumber, columnCorrection } = processSourceCodeLines(
rawLocationDesc.sourceCodeLines,
offset.columnOffset,
);
if (lineNumber === undefined || columnCorrection === undefined) {
return [];
}
const locations: ProblemLocation[] = [];
const groups = rawLocationDesc.rawLocationGroups;
groups.forEach((group) => {
locations.push(...processRawLocationGroup(group));
});
locations.map((location) => {
location.lineNumber = lineNumber;
location.startColumn =
location.startColumn - offset.columnOffset + columnCorrection;
location.endColumn =
location.endColumn - offset.columnOffset + columnCorrection;
});
return locations;
}
/*
to handle the case below:
87 data CUSTOMERS (label="Customer data for geocoding");
88 infile datalines dlm=#' dlm=#' dlm=#;
_ _
24 24
24 24
ERROR 24-322: Variable name is not valid.
ERROR 24-2: Invalid value for the DLM option.
89 length address $ 24 city $ 24 state $ 2;
adjust the item order in list make the items which have same startColumn placed continuously
*/
function adjustAppearanceOrder(
locations: ProblemLocation[],
): ProblemLocation[] {
if (locations.length < 3) {
return locations;
}
const findAllMatched = (startColumn: number, problemNumber: string) => {
const found = [];
locations.map((location, index) => {
if (
location &&
location.startColumn === startColumn &&
location.problemNumber === problemNumber
) {
locations[index] = undefined;
found.push(location);
}
});
return found;
};
const orderedLocations: ProblemLocation[] = [];
locations.forEach((location, index) => {
if (!location) {
return;
}
orderedLocations.push(location);
locations[index] = undefined;
const found = findAllMatched(location.startColumn, location.problemNumber);
orderedLocations.push(...found);
});
return orderedLocations;
}
function processRawLocationGroup(
group: RawLocationGroup,
): ProblemLocation[] | null {
if (
!group.indicatorLine ||
!group.problemNumberLines ||
group.problemNumberLines.length === 0
) {
return null;
}
let indicatorLocations = processLocations(group.indicatorLine, "indicator");
const problemNumberLocations = processLocations(
group.problemNumberLines[0],
"problemNumber",
);
// to handle this case:
// _____
// 1 22
if (problemNumberLocations.length > indicatorLocations.length) {
indicatorLocations = reviseLocationsFromIndicatorLine(
group.indicatorLine,
problemNumberLocations,
);
}
// TODO
if (problemNumberLocations.length < indicatorLocations.length) {
// is it possible to have such case:
// - -
// 221
// is it possible to have such case, below includes two problems:
// ---
// 221
}
const locations: ProblemLocation[] = [];
group.problemNumberLines.forEach((line) => {
const locationsWithProblemNumber = processLocations(line, "problemNumber");
locationsWithProblemNumber.forEach((location1) => {
const found = indicatorLocations.find(
(location2) => location2.startColumn === location1.startColumn,
);
if (found) {
locations.push({
startColumn: found.startColumn,
endColumn: found.endColumn,
problemNumber: location1.problemNumber,
});
}
});
});
return adjustAppearanceOrder(locations);
}
function processGeneralLocation(
sourceCodeLines: string[],
offset: LocationOffset,
): ProblemLocation | null {
if (!sourceCodeLines || sourceCodeLines.length === 0 || !offset) {
return null;
}
const lineNumber = decomposeCodeLogLine(sourceCodeLines[0]).lineNumber;
const wholeLine = sourceCodeLines.reduce(
(accumulator, line) => accumulator + line.substring(offset.columnOffset),
);
const startColumn = getFirstCharacterIndex(wholeLine) - offset.columnOffset;
const endColumn =
startColumn + wholeLine.substring(startColumn).trim().length - 1;
return { lineNumber, startColumn, endColumn };
}
function getFirstCharacterIndex(logLine: string): number {
if (logLine.trim() === "") {
return -1;
}
/*
below are 3 cases that source code may be presented in log lines.
8 ...
8 ! ...
12! ...
*/
const regExp = /(?<=^\d+\s*!?\s+)[^\s!]/;
const match = logLine.match(regExp);
return match === null ? -1 : match.index;
}
/*
below are 3 cases that source code may be presented in log lines.
8 ...
8 ! ...
8! ...
*/
export function isSourceCodeLineAfterLineWrapping(logLine: string): boolean {
return /^(?\d+)\s*!\s(?\s*.*)/.test(logLine);
}
================================================
FILE: client/src/components/logViewer/index.ts
================================================
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import {
DocumentSemanticTokensProvider,
OutputChannel,
SemanticTokensBuilder,
l10n,
window,
} from "vscode";
import type { OnLogFn } from "../../connection";
import { useLogStore, useRunStore } from "../../store";
import { logSelectors, runSelectors } from "../../store/selectors";
import {
clearLogOnExecutionStart,
showLogOnExecutionFinish,
showLogOnExecutionStart,
} from "../utils/settings";
const { setProducedExecutionLogOutput } = useLogStore.getState();
let outputChannel: OutputChannel;
let data: string[] = [];
let fileName = "";
export const legend = {
tokenTypes: ["error", "warning", "note"],
tokenModifiers: [],
};
export const LogTokensProvider: DocumentSemanticTokensProvider = {
provideDocumentSemanticTokens: (document) => {
if (document.getText() === "") {
data = [];
}
const tokensBuilder = new SemanticTokensBuilder(legend);
for (let i = 0; i < data.length; i++) {
if (legend.tokenTypes.includes(data[i])) {
tokensBuilder.push(document.lineAt(i).range, data[i]);
}
}
return tokensBuilder.build();
},
};
/**
* Handles log lines generated for the SAS session startup.
* @param logs array of log lines to write.
*/
export const appendSessionLogFn: OnLogFn = (logLines) => {
appendLogLines(logLines);
};
/**
* Handles log lines generated for the SAS session execution.
* @param logs array of log lines to write.
*/
export const appendExecutionLogFn: OnLogFn = (logLines) => {
appendLogLines(logLines);
if (!useLogStore.getState().producedExecutionOutput) {
setProducedExecutionLogOutput(true);
}
};
export const appendLogToken = (type: string): void => {
data.push(type);
};
export const setFileName = (name: string) => {
fileName = name;
};
const appendLogLines: OnLogFn = (logs) => {
if (!outputChannel) {
const name = clearLogOnExecutionStart()
? l10n.t("SAS Log: {name}", { name: fileName })
: l10n.t("SAS Log");
outputChannel = window.createOutputChannel(name, "sas-log");
}
for (const line of logs) {
line.line
.trimEnd()
.split("\n")
.forEach((text) => {
appendLogToken(line.type);
outputChannel.appendLine(text);
});
}
};
useLogStore.subscribe(
logSelectors.selectProducedExecutionOutput,
(producedOutput, prevProducedOutput) => {
if (producedOutput && !prevProducedOutput) {
if (showLogOnExecutionStart()) {
outputChannel?.show(true);
}
}
},
);
useRunStore.subscribe(
runSelectors.selectIsExecutingCode,
(isExecuting, prevIsExecuting) => {
if (
!isExecuting &&
prevIsExecuting &&
useLogStore.getState().producedExecutionOutput
) {
if (showLogOnExecutionFinish()) {
outputChannel?.show(true);
}
} else if (isExecuting && !prevIsExecuting) {
setProducedExecutionLogOutput(false);
if (
clearLogOnExecutionStart() &&
outputChannel &&
useRunStore.getState().isUserExecuting
) {
outputChannel.dispose();
outputChannel = undefined;
data = [];
}
}
},
);
================================================
FILE: client/src/components/logViewer/logParser.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { LogLine } from "../../connection";
import {
LocationOffset,
Problem,
ProblemProcessor,
decomposeCodeLogLine,
isSourceCodeLineAfterLineWrapping,
} from "./ProblemProcessor";
export function parseLog(
logs: LogLine[],
logStartFlag: string,
): [Problem[], string[]] {
if (logs.length === 0 || logStartFlag.trim() === "") {
return [[], []];
}
// logs cleaning
const latestLogs = getTheLatestLogs(logs, logStartFlag);
const problemRelatedLogs = getProblemRelatedLogs(latestLogs);
let problemProcessor: ProblemProcessor = new ProblemProcessor();
let offset: LocationOffset;
const problems: Problem[] = [];
const codeLines: string[] = [];
problemRelatedLogs.forEach((logLine) => {
if (isProblemTypeLog(logLine)) {
if (isNewProblemLogLine(logLine.line)) {
problemProcessor.addProblemLogLine(logLine);
return;
}
if (isLocationIndicatorLogLine(logLine.line)) {
problemProcessor.addLocationIndicatorLogLine(logLine);
return;
}
if (isProblemNumberLogLine(logLine.line)) {
problemProcessor.addProblemNumberLogLine(logLine);
return;
}
problemProcessor.appendProblemLogLine(logLine);
return;
} else {
codeLines.push(logLine.line);
if (!isValidSourceCodeLog(logLine)) {
return;
}
const currentSourceCodeLine = logLine.line;
if (!offset) {
offset = calculateLocationOffset(currentSourceCodeLine, logStartFlag);
}
const isWrappedLine = isSourceCodeLineAfterLineWrapping(
currentSourceCodeLine,
);
const previousSourceCodeLines = problemProcessor.getSourceCodeLines();
const isSameAsPrevious = areSameLines(
currentSourceCodeLine,
previousSourceCodeLines[previousSourceCodeLines.length - 1],
);
if (problemProcessor.isReady() && !isSameAsPrevious) {
problems.push(...problemProcessor.processProblems(offset));
const unclaimedLocations = problemProcessor.getUnclaimedLocations();
problemProcessor = isWrappedLine
? new ProblemProcessor(previousSourceCodeLines, unclaimedLocations)
: new ProblemProcessor(undefined, unclaimedLocations);
}
problemProcessor.setSourceCodeLine(currentSourceCodeLine);
}
});
if (problemProcessor.isReady()) {
problems.push(...problemProcessor.processProblems(offset));
}
problemProcessor = null;
return [problems, cleanCodeLines(codeLines)];
}
function getTheLatestLogs(logs: LogLine[], firstCodeLine: string): LogLine[] {
let beginningIndex = -1;
logs.forEach((logLine, index) => {
if (logLine.type !== "source") {
return;
}
const code = decomposeCodeLogLine(logLine.line)?.code ?? null;
if (code !== null && firstCodeLine === code.trim()) {
beginningIndex = index;
}
});
return beginningIndex === -1 ? [] : logs.slice(beginningIndex);
}
function getProblemRelatedLogs(logs: LogLine[]): LogLine[] {
return logs.filter((logLine) => {
return ["error", "warning", "source"].includes(logLine.type);
});
}
function calculateLocationOffset(
codeLogLine: string,
firstCodeLine: string,
): { columnOffset: number; lineOffset: number } {
const codeInfo = decomposeCodeLogLine(codeLogLine);
// there may be a log kept when finishing running selected code,
// the kept log will be sent out in following running code.
// that log line number should not be used for calculating line offset.
if (codeInfo === null || firstCodeLine !== codeInfo.code.trim()) {
return { lineOffset: -1, columnOffset: -1 };
}
const lineOffset = codeInfo.lineNumber;
const columnOffset = codeLogLine.indexOf(firstCodeLine);
return { columnOffset, lineOffset };
}
function isProblemTypeLog(logLine: LogLine): boolean {
return logLine.type === "error" || logLine.type === "warning";
}
function isSourceTypeLog(logLine: LogLine): boolean {
return logLine.type === "source";
}
function isEmptyCodeLogLine(logLine: string): boolean {
return /^\d+\s*$/.test(logLine);
}
/* there are two kind of beginning log lines indicating new problem
1) problem with this kind of beginning has location indicator in one of previous log line.
"ERROR 22-322: Syntax error, expecting one of the following: ;, CANCEL, "
2) problem with this kind of beginning has no location indicator and it is located to the previous closest source code
"WARNING: Variable POP_100 not found in data set WORK.UNIVOUT."
*/
function isNewProblemLogLine(line: string): boolean {
return /^(?error|warning)(?\s*\d+-\d+)?:\s(?.*)/i.test(
line,
);
}
/*
the continuous hyphens/underscores means a location indicator.
in below logs, the 2nd & 5th lines are location indicator log lines.
below are part of logs:
18 call call symputx('mac', quote(strip(emple)));
------- -
22 79
68
----
251
*/
function isLocationIndicatorLogLine(logLine: string): boolean {
return /^(?\s+)(?[-_]+\s*)+$/.test(logLine);
}
/*
the number below the continuous hyphens/underscores means a problem number.
in below logs, the 3rd & 4th & 6th lines are problem number log lines.
below are part of logs:
18 call call symputx('mac', quote(strip(emple)));
------- -
22 79
68
----
251
*/
function isProblemNumberLogLine(line: string): boolean {
return /^(?\s+)(?\d+\s*)+$/.test(line);
}
function isValidSourceCodeLog(logLine: LogLine): boolean {
return isSourceTypeLog(logLine) && !isEmptyCodeLogLine(logLine.line);
}
function areSameLines(line1: string, line2: string) {
return line1 === line2;
}
// keep each line number only once
function cleanCodeLines(codeLines: string[]): string[] {
if (codeLines.length === 0) {
return codeLines;
}
let previousLineNumber = -1;
const result = [];
codeLines.forEach((line) => {
const lineNumber = decomposeCodeLogLine(line)?.lineNumber ?? -1;
if (lineNumber === previousLineNumber) {
return;
} else {
previousLineNumber = lineNumber;
result.push(line);
}
});
return result;
}
================================================
FILE: client/src/components/logViewer/sasDiagnostics.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import {
Diagnostic,
DiagnosticCollection,
DiagnosticSeverity,
Disposable,
Range,
Uri,
commands,
languages,
window,
workspace,
} from "vscode";
import { LogLine, OnLogFn } from "../../connection";
import { useRunStore } from "../../store";
import { runSelectors } from "../../store/selectors";
import { SASCodeDocument } from "../utils/SASCodeDocument";
import { isShowProblemsFromSASLogEnabled } from "../utils/settings";
import { DiagnosticCodeActionProvider } from "./DiagnosticCodeActionProvider";
import { Problem } from "./ProblemProcessor";
import { parseLog } from "./logParser";
export const diagnosticSource = "sas log";
let diagnosticCollection: DiagnosticCollection;
enum DiagnosticCommands {
IgnoreCommand = "SAS.diagnostic.ignore",
IgnoreAllWarningCommand = "SAS.diagnostic.ignoreAllWarning",
IgnoreAllErrorCommand = "SAS.diagnostic.ignoreAllError",
IgnoreAllCommand = "SAS.diagnostic.ignoreAll",
}
function ignore(diagnosticsToRemove: Diagnostic[], uri: Uri): void {
const diagnostics = getSasDiagnosticCollection().get(uri);
const newDiagnostics = diagnostics.filter((diagnostic) => {
return !diagnosticsToRemove.includes(diagnostic);
});
getSasDiagnosticCollection().set(uri, newDiagnostics);
}
function ignoreAll(uri: Uri, severity?: DiagnosticSeverity): void {
if (severity === undefined) {
getSasDiagnosticCollection().delete(uri);
} else {
const diagnostics = getSasDiagnosticCollection().get(uri);
const newDiagnostics = diagnostics.filter((diagnostic) => {
return diagnostic.severity !== severity;
});
getSasDiagnosticCollection().set(uri, newDiagnostics);
}
}
function updateDiagnosticUri(oldUri: Uri, newUri: Uri): void {
const diagnosticCollection = getSasDiagnosticCollection();
const diagnostics = diagnosticCollection.get(oldUri);
diagnosticCollection.delete(oldUri);
diagnosticCollection.set(newUri, diagnostics);
}
function getSasDiagnosticCollection(): DiagnosticCollection {
if (diagnosticCollection === undefined) {
diagnosticCollection = languages.createDiagnosticCollection("sas");
}
return diagnosticCollection;
}
async function updateDiagnostics(
logs: LogLine[],
codeDoc: SASCodeDocument,
): Promise {
if (!isShowProblemsFromSASLogEnabled()) {
getSasDiagnosticCollection().clear();
return;
}
const [problems, codeLinesInLog] = parseLog(
logs,
codeDoc.wrappedCodeLineAt(0),
);
await updateProblemLocation(problems, codeDoc, codeLinesInLog);
const problemsWithValidLocation = problems.filter((problem) => {
const { lineNumber, startColumn, endColumn } = problem;
return lineNumber * startColumn * endColumn >= 0;
});
const diagnostics = constructDiagnostics(problemsWithValidLocation);
getSasDiagnosticCollection().set(
Uri.parse(codeDoc.getUri()),
diagnostics.length > 0 ? diagnostics : undefined,
);
}
async function updateProblemLocation(
problems: Problem[],
codeDoc: SASCodeDocument,
codeLinesInLog: string[],
) {
for (const problem of problems) {
const { lineNumber, startColumn, endColumn } = problem;
const {
lineNumber: actualLineNumber,
startColumn: actualStartColumn,
endColumn: actualEndColumn,
} = await codeDoc.getLocationInRawCode(
{
lineNumber,
startColumn,
endColumn,
},
codeLinesInLog,
);
problem.lineNumber = actualLineNumber;
problem.startColumn = actualStartColumn;
problem.endColumn = actualEndColumn;
}
}
function constructDiagnostics(problems: Problem[]): Diagnostic[] {
const diagnostics = problems.map((problem) => {
const { lineNumber, startColumn, endColumn, message, type } = problem;
const range = new Range(lineNumber, startColumn, lineNumber, endColumn);
const diagnostic = new Diagnostic(
range,
message,
type === "error" ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning,
);
diagnostic.source = diagnosticSource;
return diagnostic;
});
return diagnostics;
}
function generateLogFn(
codeDoc: SASCodeDocument,
originLogFn?: OnLogFn,
): OnLogFn {
const uri = codeDoc.getUri();
if (uri === undefined || uri.trim() === "") {
return originLogFn;
}
const receivedLogs = [];
const additionalLogFn: OnLogFn = (logs) => {
receivedLogs.push(...logs);
};
const unsubscribe = useRunStore.subscribe(
runSelectors.selectIsExecutingCode,
(isExecuting) => {
if (!isExecuting) {
updateDiagnostics(receivedLogs, codeDoc);
unsubscribe();
}
},
);
return (logs) => {
originLogFn?.(logs);
additionalLogFn(logs);
};
}
function getSubscriptions(): Disposable[] {
return [
getSasDiagnosticCollection(),
commands.registerCommand(DiagnosticCommands.IgnoreCommand, ignore),
commands.registerCommand(
DiagnosticCommands.IgnoreAllWarningCommand,
ignoreAll,
),
commands.registerCommand(
DiagnosticCommands.IgnoreAllErrorCommand,
ignoreAll,
),
commands.registerCommand(DiagnosticCommands.IgnoreAllCommand, ignoreAll),
languages.registerCodeActionsProvider(
"sas",
new DiagnosticCodeActionProvider(),
),
workspace.onDidRenameFiles((e) => {
e.files.forEach((file) => updateDiagnosticUri(file.oldUri, file.newUri));
}),
workspace.onDidDeleteFiles((e) => {
e.files.forEach((uri) => {
ignoreAll(uri);
});
}),
workspace.onDidSaveTextDocument((e) => {
// the new file
const uri = window.activeTextEditor.document.uri;
const isNewFileSaved =
uri.scheme === "untitled" && e.languageId === "sas";
if (isNewFileSaved) {
// clear diagnostics on new file
ignoreAll(uri);
// if the new file is saved, e indicates the file which new file saved to.
// no matter if it override a existing file, it is ok to clear its diagnostics.
ignoreAll(e.uri);
}
}),
workspace.onDidCloseTextDocument((e) => {
const uri = e.uri;
// if the new file is saved, the onDidSaveTextDocument is invoked, then this is invoked.
// if the new file is not saved, only this is invoked.
const isNewFileClosed = uri.scheme === "untitled";
if (isNewFileClosed) {
ignoreAll(uri);
}
}),
];
}
export const sasDiagnostic = {
DiagnosticCommands,
generateLogFn,
getSubscriptions,
updateDiagnosticUri,
ignoreAll,
};
================================================
FILE: client/src/components/notebook/Controller.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as vscode from "vscode";
import { getSession } from "../../connection";
import { SASCodeDocument } from "../utils/SASCodeDocument";
import { getCodeDocumentConstructionParameters } from "../utils/SASCodeDocumentHelper";
import { Deferred, deferred } from "../utils/deferred";
export class NotebookController {
readonly controllerId = "sas-notebook-controller-id";
readonly notebookType = "sas-notebook";
readonly label = "SAS Notebook";
readonly supportedLanguages = ["sas", "sql", "python", "r"];
private readonly _controller: vscode.NotebookController;
private _executionOrder = 0;
private _interrupted: Deferred | undefined;
constructor() {
this._controller = vscode.notebooks.createNotebookController(
this.controllerId,
this.notebookType,
this.label,
);
this._controller.supportedLanguages = this.supportedLanguages;
this._controller.supportsExecutionOrder = true;
this._controller.executeHandler = this._execute.bind(this);
this._controller.interruptHandler = this._interrupt.bind(this);
}
dispose(): void {
this._controller.dispose();
}
private async _execute(cells: vscode.NotebookCell[]): Promise {
this._interrupted = undefined;
try {
const session = getSession();
await session.setup();
} catch (err) {
vscode.window.showErrorMessage(
err.response?.data ? JSON.stringify(err.response.data) : err.message,
);
return;
}
for (const cell of cells) {
await this._doExecution(cell);
}
}
private async _doExecution(cell: vscode.NotebookCell): Promise {
if (this._interrupted) {
return;
}
const execution = this._controller.createNotebookCellExecution(cell);
execution.executionOrder = ++this._executionOrder;
execution.start(Date.now()); // Keep track of elapsed time to execute cell.
execution.clearOutput();
const session = getSession();
session.onExecutionLogFn = (logLines) => {
logs = logs.concat(logLines);
};
const parameters = getCodeDocumentConstructionParameters(cell.document);
const codeDoc = new SASCodeDocument(parameters);
let logs = [];
try {
const result = await session.run(codeDoc.getWrappedCode());
execution.replaceOutput([
new vscode.NotebookCellOutput([
...(result.html5?.length
? [
vscode.NotebookCellOutputItem.text(
result.html5,
"application/vnd.sas.ods.html5",
),
]
: []),
vscode.NotebookCellOutputItem.json(
logs,
"application/vnd.sas.compute.log.lines",
),
]),
]);
execution.end(true, Date.now());
} catch (error) {
execution.replaceOutput([
new vscode.NotebookCellOutput([
vscode.NotebookCellOutputItem.error(error),
]),
]);
execution.end(false, Date.now());
if (!this._interrupted) {
this._interrupted = deferred();
}
}
if (this._interrupted) {
this._interrupted.resolve();
}
}
private _interrupt() {
if (this._interrupted) {
return;
}
this._interrupted = deferred();
vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: vscode.l10n.t("Cancelling job..."),
},
() => this._interrupted.promise,
);
const session = getSession();
session.cancel?.();
}
}
================================================
FILE: client/src/components/notebook/Serializer.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as vscode from "vscode";
import { TextDecoder, TextEncoder } from "util";
interface RawNotebookCell {
language: string;
value: string;
kind: vscode.NotebookCellKind;
outputs?: {
items: {
data: string;
mime: string;
}[];
}[];
}
export class NotebookSerializer implements vscode.NotebookSerializer {
private readonly _decoder = new TextDecoder();
private readonly _encoder = new TextEncoder();
async deserializeNotebook(content: Uint8Array): Promise {
const contents = this._decoder.decode(content);
let raw: RawNotebookCell[];
try {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
raw = JSON.parse(contents);
} catch {
raw = [];
}
const cells = raw.map((item) => {
const cell = new vscode.NotebookCellData(
item.kind,
item.value,
item.language,
);
if (item.outputs) {
cell.outputs = item.outputs.map(
(output) =>
new vscode.NotebookCellOutput(
output.items.map((item) =>
vscode.NotebookCellOutputItem.text(item.data, item.mime),
),
),
);
}
return cell;
});
return new vscode.NotebookData(cells);
}
async serializeNotebook(data: vscode.NotebookData): Promise {
const contents: RawNotebookCell[] = [];
for (const cell of data.cells) {
const content: RawNotebookCell = {
kind: cell.kind,
language: cell.languageId,
value: cell.value,
};
if (cell.outputs) {
content.outputs = cell.outputs.map((output) => ({
items: output.items.map((item) => ({
data: this._decoder.decode(item.data),
mime: item.mime,
})),
}));
}
contents.push(content);
}
return this._encoder.encode(JSON.stringify(contents));
}
}
================================================
FILE: client/src/components/notebook/exporters/index.ts
================================================
// Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { Uri, l10n, window, workspace } from "vscode";
import type { LanguageClient } from "vscode-languageclient/node";
import path from "path";
import { exportToHTML } from "./toHTML";
import { exportToSAS } from "./toSAS";
export const exportNotebook = async (client: LanguageClient) => {
const notebook = window.activeNotebookEditor?.notebook;
if (!notebook) {
return;
}
// Show QuickPick for export format selection
const formatChoices = [
{
label: l10n.t("HTML"),
description: l10n.t("Export as HTML file"),
format: "html" as const,
extension: "html",
},
{
label: l10n.t("SAS Code"),
description: l10n.t("Export as SAS program file"),
format: "sas" as const,
extension: "sas",
},
];
const formatChoice = await window.showQuickPick(formatChoices, {
placeHolder: l10n.t("Select export format"),
ignoreFocusOut: true,
});
if (!formatChoice) {
return;
}
// Show save dialog with appropriate file extension
const defaultFileName =
path.basename(notebook.uri.path, ".sasnb") + `.${formatChoice.extension}`;
const filters: { [name: string]: string[] } = {};
filters[formatChoice.extension.toUpperCase()] = [formatChoice.extension];
const uri = await window.showSaveDialog({
filters,
defaultUri: Uri.parse(defaultFileName),
});
if (!uri) {
return;
}
try {
let content: string | Uint8Array;
// Generate content based on selected format
switch (formatChoice.format) {
case "html":
content = await exportToHTML(notebook, client);
await workspace.fs.writeFile(uri, new TextEncoder().encode(content));
break;
case "sas":
content = exportToSAS(notebook);
await workspace.fs.writeFile(uri, new TextEncoder().encode(content));
break;
}
window.showInformationMessage(
l10n.t("Notebook exported to {0}", uri.fsPath),
);
} catch (error) {
window.showErrorMessage(
l10n.t("Failed to export notebook: {0}", error.message || error),
);
}
};
export const saveOutput = async () => {
const notebook = window.activeNotebookEditor?.notebook;
const activeCell = window.activeNotebookEditor?.selection?.start;
if (!notebook || activeCell === undefined) {
return;
}
const cell = notebook.cellAt(activeCell);
if (!cell) {
return;
}
let odsItem = null;
let logItem = null;
for (const output of cell.outputs) {
if (!odsItem) {
odsItem = output.items.find(
(item) => item.mime === "application/vnd.sas.ods.html5",
);
}
if (!logItem) {
logItem = output.items.find(
(item) => item.mime === "application/vnd.sas.compute.log.lines",
);
}
if (odsItem && logItem) {
break;
}
}
const choices: Array<{
label: string;
outputType: "html" | "log";
}> = [];
if (odsItem) {
choices.push({
label: l10n.t("Save ODS HTML"),
outputType: "html",
});
}
if (logItem) {
choices.push({
label: l10n.t("Save Log"),
outputType: "log",
});
}
const exportChoice = await window.showQuickPick(choices, {
placeHolder: l10n.t("Choose output type to save"),
ignoreFocusOut: true,
});
if (!exportChoice) {
return;
}
let content = "";
let fileExtension = "";
let fileName = "";
try {
if (exportChoice.outputType === "html" && odsItem) {
content = odsItem.data.toString();
fileExtension = "html";
fileName = `${path.basename(notebook.uri.path, ".sasnb")}_${l10n.t("output")}_${
activeCell + 1
}.html`;
} else if (exportChoice.outputType === "log" && logItem) {
const logs: Array<{ line: string; type: string }> = JSON.parse(
logItem.data.toString(),
);
content = logs.map((log) => log.line).join("\n");
fileExtension = "log";
fileName = `${path.basename(notebook.uri.path, ".sasnb")}_${l10n.t("output")}_${
activeCell + 1
}.log`;
}
} catch (error) {
window.showErrorMessage(
l10n.t("Failed to extract output content." + error),
);
return;
}
const filters: { [name: string]: string[] } = {};
filters[fileExtension.toUpperCase()] = [fileExtension];
const uri = await window.showSaveDialog({
filters,
defaultUri: Uri.parse(fileName),
});
if (!uri) {
return;
}
await workspace.fs.writeFile(uri, new TextEncoder().encode(content));
window.showInformationMessage(l10n.t("Saved to {0}", uri.fsPath));
};
================================================
FILE: client/src/components/notebook/exporters/templates/dark.css
================================================
body {
background-color: #03233a;
color: #cccccc;
--border-color: #b0b7bb;
--highlight-background-color: #9fb6c633;
}
.markdown-cell a:link {
color: #3794ff;
}
.hljs {
background: #021727;
color: #dddfe4;
}
/* Syntax highlighting */
.hljs-operator,
.hljs-punctuation {
color: #dddfe4;
}
.hljs-keyword {
color: #4398f9;
}
.hljs-comment {
color: #97c03f;
}
.hljs-string {
color: #f17e70;
}
.hljs-number {
color: #54b6a4;
}
.hljs-title {
color: #92c3fc;
font-weight: bold;
}
.hljs-built_in {
color: #faaa6b;
}
/* SAS Syntax */
.sas-syntax-sep {
color: #dddfe4;
}
.sas-syntax-keyword,
.sas-syntax-macro-keyword {
color: #4398f9;
}
.sas-syntax-sec-keyword,
.sas-syntax-proc-name {
color: #92c3fc;
font-weight: bold;
}
.sas-syntax-comment,
.sas-syntax-macro-comment {
color: #97c03f;
}
.sas-syntax-macro-ref,
.sas-syntax-macro-sec-keyword {
color: #dddfe4;
font-weight: bold;
}
.sas-syntax-macro-keyword-param {
color: #97c6fc;
}
.sas-syntax-cards-data {
color: #faaa6b;
}
.sas-syntax-string {
color: #f17e70;
}
.sas-syntax-date,
.sas-syntax-time,
.sas-syntax-dt,
.sas-syntax-bitmask {
color: #54b6a4;
font-weight: bold;
}
.sas-syntax-namelit {
color: #f17e70;
font-weight: bold;
}
.sas-syntax-hex,
.sas-syntax-numeric {
color: #54b6a4;
font-weight: bold;
}
.sas-syntax-format {
color: #54b6a4;
}
.log-line.sas-log-error {
color: #ff4d4d;
}
.log-line.sas-log-warning {
color: #ffb829;
}
.log-line.sas-log-note {
color: #2fa8fe;
}
================================================
FILE: client/src/components/notebook/exporters/templates/default.html
================================================
${content}
================================================
FILE: client/src/components/notebook/exporters/templates/light.css
================================================
body {
color: #3b3b3b;
--border-color: #b0b7bb;
--highlight-background-color: #818b981f;
}
.hljs {
background: #fff;
}
/* Syntax highlighting */
.hljs-operator,
.hljs-punctuation {
color: #1b1d22;
}
.hljs-keyword {
color: #3578c5;
}
.hljs-comment {
color: #647f29;
}
.hljs-string {
color: #8f4238;
}
.hljs-number {
color: #3c8275;
}
.hljs-title {
color: #224c7c;
font-weight: bold;
}
.hljs-built_in {
color: #aa0d91;
}
/* SAS Syntax */
.sas-syntax-sep {
color: #1b1d22;
}
.sas-syntax-keyword,
.sas-syntax-macro-keyword {
color: #3578c5;
}
.sas-syntax-sec-keyword,
.sas-syntax-proc-name {
color: #224c7c;
font-weight: bold;
}
.sas-syntax-comment,
.sas-syntax-macro-comment {
color: #647f29;
}
.sas-syntax-macro-ref,
.sas-syntax-macro-sec-keyword {
color: #1b1d22;
font-weight: bold;
}
.sas-syntax-macro-keyword-param {
color: #054894;
}
.sas-syntax-cards-data {
color: #ad6531;
}
.sas-syntax-string {
color: #8f4238;
}
.sas-syntax-date,
.sas-syntax-time,
.sas-syntax-dt,
.sas-syntax-bitmask {
color: #3c8275;
font-weight: bold;
}
.sas-syntax-namelit {
color: #8f4238;
font-weight: bold;
}
.sas-syntax-hex,
.sas-syntax-numeric {
color: #3c8275;
font-weight: bold;
}
.sas-syntax-format {
color: #3c8275;
}
.log-line.sas-log-error {
color: #ff0000;
}
.log-line.sas-log-warning {
color: #8f5f00;
}
.log-line.sas-log-note {
color: #0000ff;
}
================================================
FILE: client/src/components/notebook/exporters/toHTML.ts
================================================
// Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import {
ColorThemeKind,
NotebookCell,
NotebookCellKind,
NotebookCellOutput,
NotebookDocument,
TextDocument,
window,
} from "vscode";
import {
LanguageClient,
SemanticTokensRequest,
} from "vscode-languageclient/node";
import { readFileSync } from "fs";
import hljs from "highlight.js/lib/core";
import python from "highlight.js/lib/languages/python";
import r from "highlight.js/lib/languages/r";
import sql from "highlight.js/lib/languages/sql";
import { marked } from "marked";
import markedKatex from "marked-katex-extension";
import path from "path";
import type { LogLine } from "../../../connection";
import { includeLogInNotebookExport } from "../../utils/settings";
const templatesDir = path.resolve(__dirname, "../notebook/exporters/templates");
hljs.registerLanguage("python", python);
hljs.registerLanguage("r", r);
hljs.registerLanguage("sql", sql);
// Configure marked options
marked.setOptions({
breaks: false,
gfm: false,
});
// Enable KaTeX extension for marked
marked.use(markedKatex());
export const exportToHTML = async (
notebook: NotebookDocument,
client: LanguageClient,
) => {
const cells = notebook.getCells();
let template = readFileSync(`${templatesDir}/default.html`).toString();
const isDark =
window.activeColorTheme.kind === ColorThemeKind.Dark ||
window.activeColorTheme.kind === ColorThemeKind.HighContrast;
const theme = readFileSync(
`${templatesDir}/${isDark ? "dark" : "light"}.css`,
).toString();
// Read KaTeX CSS from templates directory (copied from node_modules during build)
const katexCss = readFileSync(`${templatesDir}/katex.css`).toString();
template = template.replace("${katex}", katexCss);
template = template.replace("${theme}", theme);
template = template.replace("${content}", await exportCells(cells, client));
return template;
};
const exportCells = async (cells: NotebookCell[], client: LanguageClient) => {
let result = "";
for (const cell of cells) {
if (cell.kind === NotebookCellKind.Markup) {
result += markdownToHTML(cell.document) + "\n";
} else {
result += (await codeToHTML(cell.document, client)) + "\n";
if (cell.outputs.length > 0) {
for (const output of cell.outputs) {
if (includeLogInNotebookExport()) {
result += logToHTML(output) + "\n";
}
result += odsToHTML(output) + "\n";
}
}
}
}
return result;
};
const markdownToHTML = (doc: TextDocument) => {
let text = doc.getText();
text = normalizeDisplayMathBlocks(text);
return `
${marked.parse(text)}
`;
};
/**
* Normalize display math blocks in markdown text.
*
* Ensures each $$...$$ display math block is on its own lines with blank
* lines before and after, and trims leading/trailing whitespace inside the
* delimiters while preserving internal newlines.
*
* @example
* Inline: "This is $$ x^2 $$ in text." -> "This is\n\n$$\n x^2\n$$\n\n"
*
* @example
* Display: "Text$$\\frac{1}{2}$$More" -> "Text\n\n$$\n\\frac{1}{2}\n$$\n\nMore"
*/
const normalizeDisplayMathBlocks = (input: string) =>
input.replace(/\$\$([\s\S]*?)\$\$/g, (_match, content) => {
const trimmedContent = content.trim();
return `\n\n$$\n${trimmedContent}\n$$\n\n`;
});
const codeToHTML = async (doc: TextDocument, client: LanguageClient) => {
let result = "";
if (doc.languageId === "sas") {
result = await SASToHTML(doc, client);
} else {
result = hljs.highlight(doc.getText(), {
language: doc.languageId,
}).value;
}
return `
${result}
${doc.languageId}
`;
};
const SASToHTML = async (doc: TextDocument, client: LanguageClient) => {
const result = [];
const tokens = (
await client.sendRequest(SemanticTokensRequest.type, {
textDocument: {
uri: doc.uri.toString(),
},
})
).data;
const legend =
client.initializeResult.capabilities.semanticTokensProvider.legend
.tokenTypes;
let tokenIndex = 0;
let token =
tokenIndex + 4 < tokens.length
? {
line: tokens[tokenIndex],
startChar: tokens[tokenIndex + 1],
length: tokens[tokenIndex + 2],
tokenType: tokens[tokenIndex + 3],
}
: null;
for (let line = 0; line < doc.lineCount; line++) {
const lineText = doc.lineAt(line).text;
const parts = [];
let end = 0;
while (token && token.line === line) {
parts.push(lineText.slice(end, token.startChar));
end = token.startChar + token.length;
parts.push(
`${lineText.slice(
token.startChar,
end,
)}`,
);
tokenIndex += 5;
token =
tokenIndex + 4 < tokens.length
? {
line: tokens[tokenIndex] + token.line,
startChar:
tokens[tokenIndex + 1] +
(tokens[tokenIndex] > 0 ? 0 : token.startChar),
length: tokens[tokenIndex + 2],
tokenType: tokens[tokenIndex + 3],
}
: null;
}
parts.push(lineText.slice(end));
result.push(parts.join(""));
}
return result.join("\n");
};
const odsToHTML = (output: NotebookCellOutput) => {
const ods = output.items.find(
(item) => item.mime === "application/vnd.sas.ods.html5",
);
if (ods) {
const html = ods.data.toString();
const style = html.slice(
html.indexOf("") + 8,
);
const content = html.slice(
html.indexOf(""),
);
return `
${style}
`;
}
return "";
};
const logToHTML = (output: NotebookCellOutput) => {
const logItem = output.items.find(
(item) => item.mime === "application/vnd.sas.compute.log.lines",
);
if (logItem) {
const logs: LogLine[] = JSON.parse(logItem.data.toString());
return `
${logs
.map(
(line) => `${line.line}`,
)
.join("\n")}
`;
}
return "";
};
================================================
FILE: client/src/components/notebook/exporters/toSAS.ts
================================================
// Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { NotebookCell, NotebookDocument } from "vscode";
export const exportToSAS = (notebook: NotebookDocument) =>
notebook
.getCells()
.map((cell) => exportCell(cell) + "\n")
.join("\n");
const exportCell = (cell: NotebookCell) => {
const text = cell.document.getText();
switch (cell.document.languageId) {
case "sas":
return text;
case "python":
return wrapPython(text);
case "r":
return wrapR(text);
case "sql":
return wrapSQL(text);
case "markdown":
return `/*\n${text}\n*/`;
}
};
const wrapSQL = (code: string) => {
if (!code.trimEnd().endsWith(";")) {
code = `${code};`;
}
return `proc sql;
${code}
quit;`;
};
const wrapPython = (code: string) => `proc python;
submit;
${code}
endsubmit;
run;`;
const wrapR = (code: string) => `proc r;
submit;
${code}
endsubmit;
run;`;
================================================
FILE: client/src/components/notebook/renderers/HTMLRenderer.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import type { ActivationFunction } from "vscode-notebook-renderer";
/**
* Replace the last occurrence of a substring
*/
function replaceLast(
base: string,
searchValue: string,
replaceValue: string,
): string {
const index = base.lastIndexOf(searchValue);
if (index < 0) {
return base;
}
return (
base.slice(0, index) + replaceValue + base.slice(index + searchValue.length)
);
}
export const activate: ActivationFunction = () => ({
renderOutputItem(data, element) {
const html = data.text();
let shadow = element.shadowRoot;
if (!shadow) {
shadow = element.attachShadow({ mode: "open" });
}
shadow.innerHTML = replaceLast(
// it's not a whole webview, body not allowed
html.replace("",
"",
);
},
});
================================================
FILE: client/src/components/notebook/renderers/LogRenderer.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import type { ActivationFunction } from "vscode-notebook-renderer";
import type { LogLine } from "../../../connection";
const colorMap = {
error: "var(--vscode-editorError-foreground)",
warning: "var(--vscode-editorWarning-foreground)",
note: "var(--vscode-editorInfo-foreground)",
};
export const activate: ActivationFunction = () => ({
renderOutputItem(data, element) {
const root = document.createElement("div");
root.style.whiteSpace = "pre";
root.style.fontFamily = "var(--vscode-editor-font-family)";
const logs: LogLine[] = data.json();
for (const line of logs) {
const color = colorMap[line.type];
const div = document.createElement("div");
div.innerText = line.line;
if (color) {
div.style.color = color;
}
root.append(div);
}
element.replaceChildren(root);
},
});
================================================
FILE: client/src/components/profile.ts
================================================
// Copyright © 2022-2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import {
ConfigurationTarget,
QuickPickOptions,
commands,
l10n,
window,
workspace,
} from "vscode";
import { readFileSync } from "fs";
export const EXTENSION_CONFIG_KEY = "SAS";
export const EXTENSION_DEFINE_PROFILES_CONFIG_KEY = "connectionProfiles";
export const EXTENSION_PROFILES_CONFIG_KEY = "profiles";
export const EXTENSION_ACTIVE_PROFILE_CONFIG_KEY = "activeProfile";
enum ConnectionOptions {
SAS9COM = "SAS 9.4 (local)",
SAS9IOM = "SAS 9.4 (remote - IOM)",
SAS9SSH = "SAS 9.4 (remote - SSH)",
SASViya = "SAS Viya",
}
const CONNECTION_PICK_OPTS: string[] = [
ConnectionOptions.SASViya,
ConnectionOptions.SAS9SSH,
ConnectionOptions.SAS9IOM,
ConnectionOptions.SAS9COM,
];
/**
* The default compute context that will be used to create a SAS session.
*/
export const DEFAULT_COMPUTE_CONTEXT = "SAS Job Execution compute context";
export const DEFAULT_SSH_PORT = "22";
export const DEFAULT_IOM_PORT = "8591";
/**
* Dictionary is a type that maps a generic object with a string key.
*/
export type Dictionary = {
[key: string]: T;
};
/**
* Enum that represents the authentication type for a profile.
*/
export enum AuthType {
AuthCode = "authorization_code",
Error = "error",
}
/**
* Enum that represents the connection type for a profile.
*/
export enum ConnectionType {
COM = "com",
IOM = "iom",
Rest = "rest",
SSH = "ssh",
}
/**
* Profile is an interface that represents a users profile. Currently
* supports two different authentication flows, token and password
* flow with the clientId and clientSecret.
*
* Direct connect is also supported where a server is already started with
* a static serverId. Setting serverId in the profile indicates that a connection
* to that specific server with Id will be created. This overrides the context
* value. Normally this option should not be set by the user since it is most likely
* being set by an automated process.
*/
export interface ViyaProfile extends BaseProfile, ProfileWithFileRootOptions {
connectionType: ConnectionType.Rest;
endpoint: string;
clientId?: string;
clientSecret?: string;
context?: string;
serverId?: string;
}
export interface SSHProfile extends BaseProfile {
connectionType: ConnectionType.SSH;
host: string;
saspath: string;
port: number;
username: string;
privateKeyFilePath?: string;
}
export interface COMProfile extends BaseProfile, ProfileWithFileRootOptions {
connectionType: ConnectionType.COM;
host: string;
}
export interface IOMProfile extends BaseProfile, ProfileWithFileRootOptions {
connectionType: ConnectionType.IOM;
host: string;
username: string;
port: number;
}
export type Profile = ViyaProfile | SSHProfile | COMProfile | IOMProfile;
export enum AutoExecType {
File = "file",
Line = "line",
}
export type AutoExec = AutoExecLine | AutoExecFile;
export interface AutoExecLine {
type: AutoExecType.Line;
line: string;
}
export interface AutoExecFile {
type: AutoExecType.File;
filePath: string;
}
export interface BaseProfile {
sasOptions?: string[];
autoExec?: AutoExec[];
}
export interface ProfileWithFileRootOptions {
fileNavigationCustomRootPath?: string;
fileNavigationRoot?: "CUSTOM" | "SYSTEM" | "USER";
}
export const toAutoExecLines = (autoExec: AutoExec[]): string[] => {
const lines: string[] = [];
for (const item of autoExec) {
switch (item.type) {
case AutoExecType.Line:
lines.push(item.line);
break;
case AutoExecType.File:
lines.push(...toAutoExecLinesFromPaths(item.filePath));
break;
default:
break;
}
}
return lines;
};
/**
* Reads content from the given string paths.
* Content is read sequentially from each path starting at the zeroth path,
* appending each content line into the output array.
*
* If there is an error reading a file in the paths array, then
* the file is skipped and content is not added.
* @param paths string array of paths to read content from.
* @returns string array of lines
*/
const toAutoExecLinesFromPaths = (filePath: string): string[] => {
const lines: string[] = [];
try {
const content = readFileSync(filePath, "utf8").split(/\n|\r\n/);
lines.push(...content);
} catch (e) {
const err: Error = e;
console.warn(
`Error reading file: ${filePath}, error: ${err.message}, skipping...`,
);
}
return lines;
};
/**
* Profile detail is an interface that encapsulates the name of the profile
* with the {@link Profile}.
*/
export interface ProfileDetail {
name: string;
profile: Profile;
}
/**
* Profile validation is an interface that represents the validation
* information from a profile needed when making a SAS connection.
*/
export interface ProfileValidation {
type: AuthType;
error: string;
data?: string;
profile: Profile;
}
/**
* ProfileConfig manages a list of {@link Profile}s that are located in vscode settings.
* Connection Profiles are designed to keep track of multiple
* configurations of SAS Connections.
*/
export class ProfileConfig {
/**
* Helper function to migrate legacy profiles without a connection type.
*/
async migrateLegacyProfiles() {
const profiles = this.getAllProfiles();
if (profiles) {
for (const key in profiles) {
const profile = profiles[key];
if (profile.connectionType === undefined) {
profile.connectionType = ConnectionType.Rest;
await this.upsertProfile(key, profile);
}
if (
profile.connectionType === ConnectionType.Rest &&
/\/$/.test(profile.endpoint)
) {
profile.endpoint = profile.endpoint.replace(/\/$/, "");
await this.upsertProfile(key, profile);
}
}
}
}
/**
* Validates settings.json to confirm that SAS.connectionProfiles exists
* as a key, and updates it, if the setting does not exists
*
* @returns Boolean for pass or fail
*/
validateSettings(): boolean {
const profileList: Dictionary = workspace
.getConfiguration(EXTENSION_CONFIG_KEY)
.get(EXTENSION_DEFINE_PROFILES_CONFIG_KEY)[EXTENSION_PROFILES_CONFIG_KEY];
if (!profileList) {
workspace.getConfiguration(EXTENSION_CONFIG_KEY).update(
EXTENSION_DEFINE_PROFILES_CONFIG_KEY,
{
activeProfile: "",
profiles: {},
},
ConfigurationTarget.Global,
);
return false;
}
return true;
}
/**
* Get the active profile from the vscode settings.
*
* @returns String name to the active profile
*/
getActiveProfile(): string {
if (!this.validateSettings()) {
return "";
}
const activeProfile: string = workspace
.getConfiguration(EXTENSION_CONFIG_KEY)
.get(EXTENSION_DEFINE_PROFILES_CONFIG_KEY)[
EXTENSION_ACTIVE_PROFILE_CONFIG_KEY
];
return activeProfile;
}
/**
* Gets all profiles from the vscode settings.
*
* @returns Dictionary of profiles
*/
getAllProfiles(): Dictionary {
if (!this.validateSettings()) {
return {};
}
const profileList: Dictionary = workspace
.getConfiguration(EXTENSION_CONFIG_KEY)
.get(EXTENSION_DEFINE_PROFILES_CONFIG_KEY)[EXTENSION_PROFILES_CONFIG_KEY];
return profileList;
}
/**
* Update VSCode settings with profile dictionary
*
* @param profileDict {@link Dictionary} the value for the key
*/
async updateProfileSetting(profileDict: Dictionary): Promise {
const currentActiveProfile = this.getActiveProfile();
const profiles = {
activeProfile: currentActiveProfile,
profiles: profileDict,
};
await workspace
.getConfiguration(EXTENSION_CONFIG_KEY)
.update(
EXTENSION_DEFINE_PROFILES_CONFIG_KEY,
profiles,
ConfigurationTarget.Global,
);
}
/**
* Update VSCode settings with active profile
*
* @param activeProfileParam {@link String} the value for the key
*/
async updateActiveProfileSetting(activeProfileParam: string): Promise {
const profileList = this.getAllProfiles();
const profiles = {
activeProfile: activeProfileParam,
profiles: profileList,
};
if (activeProfileParam in profileList) {
commands.executeCommand("SAS.close", true);
} else {
profiles.activeProfile = "";
}
await workspace
.getConfiguration(EXTENSION_CONFIG_KEY)
.update(
EXTENSION_DEFINE_PROFILES_CONFIG_KEY,
profiles,
ConfigurationTarget.Global,
);
}
/**
* Determines the number of profiles found in settings
*
* @returns number of profiles found in vscode settings
*/
length(): number {
return Object.keys(this.getAllProfiles()).length;
}
/**
* Retreives the list of profile names.
*
* @returns List of profile names
*/
listProfile(): string[] {
return Object.keys(this.getAllProfiles());
}
/**
* Retrieves the {@link Profile} by name from the profile configuration. If the profile
* is not found by name, a default {@link Profile} will be generated and returned.
*
* @param name {@link String} of the profile name
* @returns Profile object
*/
getProfileByName(name: string): T {
const profileList = this.getAllProfiles();
if (name in profileList) {
/* eslint-disable @typescript-eslint/consistent-type-assertions*/
return profileList[name] as T;
}
return undefined;
}
/**
* Retrieves the {@link ProfileDetail} of the active profile set in the profile
* configurations.
*
* @returns Optional ProfileDetail
*/
getActiveProfileDetail(): ProfileDetail | undefined {
const activeProfileName = this.getActiveProfile();
const profileList = this.getAllProfiles();
if (activeProfileName in profileList) {
const profile = { ...profileList[activeProfileName] };
if (
profile.connectionType === ConnectionType.Rest &&
/\/$/.test(profile.endpoint)
) {
profile.endpoint = profile.endpoint.replace(/\/$/, "");
}
const detail: ProfileDetail = {
name: activeProfileName,
profile,
};
return detail;
} else {
return undefined;
}
}
/**
* Upsert allows for add or update the new {@link Profile} into vscode settings.
*
* @param name {@link String} of the name of the profile
* @param profile {@link Profile} object
*/
async upsertProfile(name: string, profile: Profile): Promise {
const profileList = this.getAllProfiles();
// Cannot mutate VSCode Config Object, create a clone and add that to settings.json
const newProfileList = JSON.parse(JSON.stringify(profileList));
newProfileList[name] = profile;
await this.updateProfileSetting(newProfileList);
}
/**
* Deletes a profile from the vscode settings.
*
* @param name {@link String} of the name of the profile
*/
async deleteProfile(name: string): Promise {
const profileList = this.getAllProfiles();
if (name in profileList) {
// Cannot mutate VSCode Config Object, create a clone and add that to settings.json
const newProfileList = JSON.parse(JSON.stringify(profileList));
delete newProfileList[name];
await this.updateProfileSetting(newProfileList);
if (name === this.getActiveProfile()) {
await this.updateActiveProfileSetting("");
}
}
}
/**
* Validates if the {@link ProfileDetail} meets the requirements needed for authentication
* and returns back the authentication type.
*
* The validation process calculates the authentication flow by what is detailed in the
* {@link ProfileDetail}. If the conditions to calculate the authentication flow are not
* meet, then an error is provided in the {@link ProfileValidation}.
*
* @param profileDetail
* @returns ProfileValidation object
*/
validateProfile(profileDetail?: ProfileDetail): ProfileValidation {
const pv: ProfileValidation = {
type: AuthType.Error,
error: "",
profile: undefined,
};
//Validate active profile, return early if not valid
if (!profileDetail?.profile) {
pv.error = l10n.t("No Active Profile");
return pv;
}
const profile: Profile = profileDetail.profile;
if (profile.connectionType === undefined) {
pv.error = l10n.t("Missing connectionType in active profile.");
return pv;
}
if (profile.connectionType === ConnectionType.Rest) {
if (!profile.endpoint) {
pv.error = l10n.t("Missing endpoint in active profile.");
return pv;
}
} else if (profile.connectionType === ConnectionType.SSH) {
if (!profile.host) {
pv.error = l10n.t("Missing host in active profile.");
return pv;
}
if (!profile.port) {
pv.error = l10n.t("Missing port in active profile.");
return pv;
}
if (!profile.saspath) {
pv.error = l10n.t("Missing sas path in active profile.");
return pv;
}
if (!profile.username) {
pv.error = l10n.t("Missing username in active profile.");
return pv;
}
}
pv.profile = profileDetail.profile;
pv.type = AuthType.AuthCode;
return pv;
}
/**
* Requests users input on updating or adding a new profile.
*
* @param name the {@link String} representation of the name of the profile
*/
async prompt(name: string): Promise {
const profile: Profile = this.getProfileByName(name);
// Cannot mutate VSCode Config Object, create a clone and upsert
let profileClone = { ...profile };
if (!profile) {
profileClone = {
connectionType: ConnectionType.Rest,
endpoint: undefined,
};
}
const inputConnectionType: string = await createInputQuickPick(
CONNECTION_PICK_OPTS,
ProfilePromptType.ConnectionType,
);
if (inputConnectionType === undefined) {
return;
}
profileClone.connectionType = mapQuickPickToEnum(inputConnectionType);
if (profileClone.connectionType === ConnectionType.Rest) {
profileClone.endpoint = await createInputTextBox(
ProfilePromptType.Endpoint,
profileClone.endpoint,
);
if (!profileClone.endpoint) {
return;
}
profileClone.endpoint = profileClone.endpoint.replace(/\/$/, "");
profileClone.context = await createInputTextBox(
ProfilePromptType.ComputeContext,
profileClone.context || DEFAULT_COMPUTE_CONTEXT,
);
if (profileClone.context === undefined) {
return;
}
if (
profileClone.context === "" ||
profileClone.context === DEFAULT_COMPUTE_CONTEXT
) {
delete profileClone.context;
}
profileClone.clientId = await createInputTextBox(
ProfilePromptType.ClientId,
profileClone.clientId,
);
if (profileClone.clientId === undefined) {
return;
}
if (profileClone.clientId === "") {
delete profileClone.clientId;
}
if (profileClone.clientId) {
profileClone.clientSecret = await createInputTextBox(
ProfilePromptType.ClientSecret,
profileClone.clientSecret,
);
if (profileClone.clientSecret === undefined) {
return;
}
}
await this.upsertProfile(name, profileClone);
} else if (profileClone.connectionType === ConnectionType.SSH) {
profileClone.host = await createInputTextBox(
ProfilePromptType.Host,
profileClone.host,
);
if (!profileClone.host) {
return;
}
profileClone.saspath = await createInputTextBox(
ProfilePromptType.SASPath,
profileClone.saspath,
);
if (profileClone.saspath === undefined) {
return;
}
profileClone.username = await createInputTextBox(
ProfilePromptType.Username,
profileClone.username,
);
if (profileClone.username === undefined) {
return;
}
profileClone.port = parseInt(
await createInputTextBox(ProfilePromptType.Port, DEFAULT_SSH_PORT),
);
if (isNaN(profileClone.port)) {
return;
}
const keyPath = await createInputTextBox(
ProfilePromptType.PrivateKeyFilePath,
profileClone.privateKeyFilePath,
);
if (keyPath) {
profileClone.privateKeyFilePath = keyPath;
}
await this.upsertProfile(name, profileClone);
} else if (profileClone.connectionType === ConnectionType.COM) {
profileClone.sasOptions = [];
profileClone.host = "localhost"; //once remote support rolls out this should be set via prompting
await this.upsertProfile(name, profileClone);
} else if (profileClone.connectionType === ConnectionType.IOM) {
profileClone.sasOptions = [];
profileClone.host = await createInputTextBox(
ProfilePromptType.Host,
profileClone.host,
);
if (!profileClone.host) {
return;
}
profileClone.port = parseInt(
await createInputTextBox(ProfilePromptType.Port, DEFAULT_IOM_PORT),
);
if (isNaN(profileClone.port)) {
return;
}
profileClone.username = await createInputTextBox(
ProfilePromptType.Username,
profileClone.username,
);
if (profileClone.username === undefined) {
return;
}
await this.upsertProfile(name, profileClone);
}
}
/**
* Retrieves the remote target associated with the active profile. For SSH profiles, the host
* value is used. For Viya, the endpoint value is used.
* @param profileName - a profile name to retrieve.
* @returns
*/
remoteTarget(profileName: string): string {
const activeProfile = this.getProfileByName(profileName);
switch (activeProfile.connectionType) {
case ConnectionType.SSH:
case ConnectionType.COM:
case ConnectionType.IOM:
return activeProfile.host;
case ConnectionType.Rest:
return activeProfile.endpoint;
}
}
}
/**
* Define an object to represent the values needed for prompting a window.showInputBox
*/
export interface ProfilePrompt {
title: string;
placeholder: string;
description: string;
}
/**
* An enum representing the types of prompts that can be returned for window.showInputBox
*/
export enum ProfilePromptType {
Profile = 0,
NewProfile,
ClientId,
Endpoint,
ComputeContext,
ClientSecret,
ConnectionType,
Host,
SASPath,
Port,
Username,
PrivateKeyFilePath,
}
/**
* An interface that will map an enum of {@link ProfilePromptType} to an interface of {@link ProfilePrompt}.
*/
export type ProfilePromptInput = {
[key in ProfilePromptType]: ProfilePrompt;
};
/**
* Retrieves the {@link ProfilePrompt} by the enum {@link ProfilePromptType}
*
* @param type {@link ProfilePromptType}
* @returns ProfilePrompt object
*/
export function getProfilePrompt(type: ProfilePromptType): ProfilePrompt {
return input[type];
}
/**
* Helper method to generate a window.ShowInputBox with using a defined set of {@link ProfilePrompt}s.
*
* @param profilePromptType {@link ProfilePromptType}
* @param defaultValue the {@link String} of the default value that will be represented in the input box. Defaults to null
* @param maskValue the {@link boolean} if the input value will be masked
* @param username the {@link String} of the SAS User ID
* @returns Thenable<{@link String}> of the users input
*/
export async function createInputTextBox(
profilePromptType: ProfilePromptType,
defaultValue: string | undefined = null,
maskValue = false,
): Promise {
const profilePrompt = getProfilePrompt(profilePromptType);
const entered = await window.showInputBox({
title: profilePrompt.title,
placeHolder: profilePrompt.placeholder,
prompt: profilePrompt.description,
password: maskValue,
value: defaultValue,
ignoreFocusOut: true,
});
return entered;
}
/**
* Helper method to generate a window.ShowInputQuickPick using a defined set of {@link ProfilePrompt}s.
* @param items list of selectable options to bind to the quickpick.
* @param profilePromptType {@link ProfilePromptType}
* @returns Thenable<{@link String}> of the users input
*/
export async function createInputQuickPick(
items: readonly string[] | Thenable = [],
profilePromptType: ProfilePromptType,
): Promise {
const profilePrompt = getProfilePrompt(profilePromptType);
const options: QuickPickOptions = {
title: profilePrompt.title,
placeHolder: profilePrompt.placeholder,
ignoreFocusOut: true,
canPickMany: false,
};
const entered = await window.showQuickPick(items, options);
return entered;
}
/**
* Mapped {@link ProfilePrompt} to an enum of {@link ProfilePromptType}.
*/
const input: ProfilePromptInput = {
[ProfilePromptType.Profile]: {
title: l10n.t("Switch Current SAS Profile"),
placeholder: l10n.t("Select a SAS connection profile"),
description: "",
},
[ProfilePromptType.NewProfile]: {
title: l10n.t("New SAS Connection Profile Name"),
placeholder: l10n.t("Enter connection name"),
description: l10n.t(
"You can also specify connection profile using the settings.json file.",
),
},
[ProfilePromptType.Endpoint]: {
title: l10n.t("SAS Viya Server"),
placeholder: l10n.t("Enter the URL"),
description: l10n.t(
"Enter the URL for the SAS Viya server. An example is https://example.sas.com.",
),
},
[ProfilePromptType.ComputeContext]: {
title: l10n.t("SAS Compute Context"),
placeholder: l10n.t("Enter the SAS compute context"),
description: l10n.t("Enter the SAS compute context."),
},
[ProfilePromptType.ClientId]: {
title: l10n.t("Client ID"),
placeholder: l10n.t("Enter a client ID"),
description: l10n.t(
"Enter the registered client ID. An example is myapp.client.",
),
},
[ProfilePromptType.ClientSecret]: {
title: l10n.t("Client Secret"),
placeholder: l10n.t("Enter a client secret"),
description: l10n.t(
"Enter secret for client ID. An example is myapp.secret.",
),
},
[ProfilePromptType.ConnectionType]: {
title: l10n.t("Connection Type"),
placeholder: l10n.t("Select a Connection Type"),
description: l10n.t("Select a Connection Type."),
},
[ProfilePromptType.Host]: {
title: l10n.t("SAS 9 Server"),
placeholder: l10n.t("Enter the server name"),
description: l10n.t("Enter the name of the SAS 9 server."),
},
[ProfilePromptType.SASPath]: {
title: l10n.t("Server Path"),
placeholder: l10n.t("Enter the server path"),
description: l10n.t("Enter the server path of the SAS Executable."),
},
[ProfilePromptType.Port]: {
title: l10n.t("Port Number"),
placeholder: l10n.t("Enter a port number"),
description: l10n.t("Enter a port number."),
},
[ProfilePromptType.Username]: {
title: l10n.t("SAS Server Username"),
placeholder: l10n.t("Enter your username"),
description: l10n.t("Enter your SAS server username."),
},
[ProfilePromptType.PrivateKeyFilePath]: {
title: l10n.t("Private Key File Path (optional)"),
placeholder: l10n.t("Enter the local private key file path"),
description: l10n.t("To use the SSH Agent or a password, leave blank."),
},
};
/**
* Helper function to map the quick pick item selection to a well known {@link ConnectionType}.
* @param connectionTypePickInput - string value of one of the quick pick option inputs
* @returns {@link ConnectionType}
*/
function mapQuickPickToEnum(connectionTypePickInput: string): ConnectionType {
/*
Having a translation layer here allows the profile types to potentially evolve separately from the
underlying technology used to implement the connection. Down the road its quite possible to have
more than one selectable quick pick input that uses the same underlying connection methods..
*/
switch (connectionTypePickInput) {
case ConnectionOptions.SASViya:
return ConnectionType.Rest;
case ConnectionOptions.SAS9SSH:
return ConnectionType.SSH;
case ConnectionOptions.SAS9COM:
return ConnectionType.COM;
case ConnectionOptions.SAS9IOM:
return ConnectionType.IOM;
default:
return undefined;
}
}
================================================
FILE: client/src/components/tasks/SasTaskProvider.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import {
CustomExecution,
EventEmitter,
ProviderResult,
Pseudoterminal,
Task,
TaskProvider,
TaskScope,
l10n,
} from "vscode";
import { hasRunningTask } from "../../commands/run";
import {
Execute,
SAS_TASK_TYPE,
SasTaskDefinition,
SasTaskNames,
TaskInfo,
runSasFileTask,
} from "./SasTasks";
export class SasTaskProvider implements TaskProvider {
provideTasks(): ProviderResult {
return [generateTask(SasTaskNames.RunSasFile, runSasFileTask)];
}
resolveTask(task: Task): ProviderResult {
if (task.definition.task === SasTaskNames.RunSasFile) {
return generateTask(task, runSasFileTask);
}
}
}
export class SasPseudoterminal implements Pseudoterminal {
private messageEmitter = new EventEmitter();
private closeEmitter = new EventEmitter();
constructor(
private execute: Execute,
private taskInfo: TaskInfo,
) {}
public onDidWrite = this.messageEmitter.event;
public onDidClose? = this.closeEmitter.event;
public open(): void {
if (hasRunningTask()) {
this.messageEmitter.fire(
"There is running task, please try again after that is complete.\n",
);
this.closeEmitter.fire(1);
} else {
this.executeTask();
}
}
public close(): void {
this.closeEmitter.fire(0);
}
public handleInput?(data): void {
// press ctrl + c to cancel executing task.
if (data === "") {
this.messageEmitter.fire("Task is cancelled.");
this.closeEmitter.fire(1);
}
}
private async executeTask(): Promise {
return new Promise(() => {
this.execute(this.messageEmitter, this.taskInfo, this.closeEmitter)
.then(() => {
this.messageEmitter.fire(l10n.t("Task is complete.") + "\r\n\r\n");
this.closeEmitter.fire(0);
})
.catch((reason) => {
this.messageEmitter.fire(reason.message + "\r\n\r\n");
this.messageEmitter.fire(l10n.t("Task is cancelled.") + "\r\n\r\n");
this.closeEmitter.fire(1);
});
});
}
}
function generateTask(task: string | Task, execute: Execute) {
const definition =
typeof task === "object"
? task.definition
: {
type: SAS_TASK_TYPE,
task: task,
};
return new Task(
definition,
TaskScope.Workspace,
definition.task,
SAS_TASK_TYPE,
new CustomExecution(async (taskDefinition: SasTaskDefinition) => {
return new SasPseudoterminal(execute, {
definition: taskDefinition,
label: typeof task === "object" ? task.name : task,
});
}),
);
}
================================================
FILE: client/src/components/tasks/SasTasks.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { EventEmitter, TaskDefinition, l10n, window, workspace } from "vscode";
import { isAbsolute } from "path";
import { runTask } from "../../commands/run";
import { SASCodeDocument } from "../utils/SASCodeDocument";
import { getCodeDocumentConstructionParameters } from "../utils/SASCodeDocumentHelper";
export const SAS_TASK_TYPE = "sas";
export enum SasTaskNames {
// Run the sas file indicated in the "file" property. if preamble or postamble provided, wrapping the sas code in the file with them.
// If this task is called as predefined task or custom task without file or blank file name provided,
// the code to run will be the selected code in active editor or be the active file code if there is no code selected.
RunSasFile = "Run sas file",
}
export interface SasTaskDefinition extends TaskDefinition {
task: string;
file?: string;
preamble?: string;
postamble?: string;
}
export interface TaskInfo {
definition: SasTaskDefinition;
label: string;
}
export type Execute = (
messageEmitter: EventEmitter,
taskInfo: TaskInfo,
closeEmitter: EventEmitter,
) => Promise;
export async function runSasFileTask(
messageEmitter: EventEmitter,
taskInfo: TaskInfo,
closeEmitter: EventEmitter,
) {
const {
definition: { file, preamble, postamble },
label,
} = taskInfo;
const textDocument = await getTextDocumentFromFile(file);
const parameters = getCodeDocumentConstructionParameters(textDocument, {
selections:
file === undefined || file.trim() === ""
? window.activeTextEditor?.selections
: undefined,
preamble,
postamble,
});
const codeDoc = new SASCodeDocument(parameters);
return runTask(codeDoc, messageEmitter, closeEmitter, label);
}
async function getTextDocumentFromFile(file: string | undefined) {
if (file === undefined || file.trim() === "") {
return window.activeTextEditor.document;
} else if (isAbsolute(file)) {
return await workspace.openTextDocument(file);
} else {
const uri = (await workspace.findFiles(file))[0];
if (uri === undefined) {
throw new Error(l10n.t("Cannot find file: {file}", { file }));
} else {
return await workspace.openTextDocument(uri);
}
}
}
================================================
FILE: client/src/components/utils/SASCodeDocument.ts
================================================
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import {
ProblemLocation,
decomposeCodeLogLine,
} from "../logViewer/ProblemProcessor";
export interface SASCodeDocumentParameters {
languageId: string;
code: string;
selectedCode: string;
uri?: string;
fileName?: string;
selections?: ReadonlyArray<{
start: { line: number; character: number };
end: { line: number; character: number };
}>;
preamble?: string;
postamble?: string;
outputHtml?: boolean;
htmlStyle?: string;
uuid?: string;
checkKeyword: (LineNumber: number, ...keywords: string[]) => Promise;
}
type LineNumber = number;
type Offset = { lineOffset: number; columnOffset: number };
export class SASCodeDocument {
// there may be many selected raw code and they can be not continuous in editor.
// this field provides a offset map for selected raw code in wrapped code and in whole raw code.
private offsetMap: Map;
public constructor(private parameters: SASCodeDocumentParameters) {}
public getWrappedCode(): string {
const code = this.getRawCode();
return this.codeIsEmpty(code) ? "" : this.wrapCode(code);
}
public getUri(): string {
return this.parameters.uri;
}
public getFileName(): string {
return this.parameters.fileName;
}
public wrappedCodeLineAt(lineNumber: number) {
return this.getWrappedCode().split("\n")[lineNumber];
}
public async getLocationInRawCode(
locationFromLog: ProblemLocation,
codeLinesInLog: string[],
): Promise {
if (this.offsetMap === undefined) {
await this.constructOffsetMap(codeLinesInLog);
}
const {
lineNumber: lineNumberInLog,
startColumn: startColumnInLog,
endColumn: endColumnInLog,
} = locationFromLog;
const offset = this.offsetMap.get(lineNumberInLog);
if (offset) {
const { lineOffset: lineNumberInRaw, columnOffset } = offset;
return {
lineNumber: lineNumberInRaw,
startColumn: startColumnInLog + columnOffset,
endColumn: endColumnInLog + columnOffset,
};
}
const firstLineNumber = this.offsetMap.keys().next().value;
const lastLineNumber = Array.from(this.offsetMap.keys()).pop() ?? 0;
// if the problem occurs before the first raw code line,
// then re-locate it at the first character in the first raw code line.
if (lineNumberInLog < firstLineNumber) {
return {
lineNumber: 0,
startColumn: 0,
endColumn: 1,
};
}
// if the problem occurs after the last raw code line,
// then re-located it at the last character in the last raw code line.
if (lineNumberInLog > lastLineNumber) {
const codeLinesInRaw = this.getRawCode().split("\n");
const count = codeLinesInRaw[codeLinesInRaw.length - 1].length;
let lastCharacterIndex = count === 0 ? 0 : count - 1;
if (this.offsetMap.size === 1) {
lastCharacterIndex = this.parameters.selections[0].end.character - 1;
}
const lineNumberInRaw =
this.offsetMap.get(lastLineNumber)?.lineOffset ?? 0;
return {
lineNumber: lineNumberInRaw,
startColumn: lastCharacterIndex,
endColumn: lastCharacterIndex + 1,
};
}
// the problem occurs in imported source code,
// re-locate it at the nearest previous raw code line.
const nearestPreviousLineNumberInLog = Array.from(this.offsetMap.keys())
.reverse()
.find((lineNumber) => lineNumberInLog > lineNumber);
const nearestOffset = this.offsetMap.get(nearestPreviousLineNumberInLog);
return {
lineNumber: nearestOffset.lineOffset,
startColumn: 0,
endColumn: 1,
};
}
private codeIsEmpty(code: string): boolean {
return code.trim() === "";
}
private wrapCodeWithSASProgramFileName(code: string): string {
let fileName = this.parameters.fileName;
if (fileName === undefined) {
return code;
} else {
fileName = fileName.replace(/[('")]/g, "%$&");
const wrapped =
"%let _SASPROGRAMFILE = %nrquote(%nrstr(" + fileName + "));\n" + code;
return wrapped;
}
}
private wrapCodeWithPreambleAndPostamble(code: string): string {
return (
(this.parameters?.preamble ? this.parameters?.preamble + "\n" : "") +
code +
(this.parameters?.postamble ? "\n" + this.parameters?.postamble : "")
);
}
private wrapCodeWithOutputHtml(code: string): string {
if (this.parameters.outputHtml) {
const htmlStyle = this.parameters.htmlStyle.trim();
const htmlStyleOption = htmlStyle !== "" ? ` style=${htmlStyle}` : "";
const outputDestination = this.parameters?.uuid
? ` body="${this.parameters.uuid}.htm"`
: "";
return `title;footnote;ods _all_ close;
ods graphics on;
ods html5(id=vscode)${htmlStyleOption} options(bitmap_mode='inline' svg_mode='inline')${outputDestination};
${code}
;*';*";*/;run;quit;ods html5(id=vscode) close;
`;
} else {
return code;
}
}
private wrapSQL(code: string) {
return `proc sql;
${code}
;quit;`;
}
private wrapPython(code: string) {
return `proc python;
submit;
${code}
endsubmit;
run;`;
}
private wrapR(code: string) {
return `proc r;
submit;
${code}
endsubmit;
run;`;
}
private insertLogStartIndicator(code: string): string {
// add a comment line at the top of code,
// this comment line will be used as indicator to the beginning of log related with this code
return `/** LOG_START_INDICATOR **/
${code}`;
}
private wrapCode(code: string): string {
let wrapped = code;
if (this.parameters.languageId === "sql") {
wrapped = this.wrapSQL(wrapped);
}
if (this.parameters.languageId === "python") {
wrapped = this.wrapPython(wrapped);
}
if (this.parameters.languageId === "r") {
wrapped = this.wrapR(wrapped);
}
wrapped = this.wrapCodeWithSASProgramFileName(wrapped);
wrapped = this.wrapCodeWithPreambleAndPostamble(wrapped);
wrapped = this.wrapCodeWithOutputHtml(wrapped);
wrapped = this.insertLogStartIndicator(wrapped);
return wrapped;
}
// getWrappedCode() returns more code than raw code in editor, and addition code may be added at the beginning or end of raw code.
// this method return the position at which the raw code begins in wrapped code.
private getRawCodeBeginLineNumberInWrappedCode(): number {
const FRONT_LOCATOR = "LOCATOR-TO-MARK-THE-BEGIN-OF-USER-CODE";
const codeWithLocator = FRONT_LOCATOR + this.getRawCode();
const wrapped = this.wrapCode(codeWithLocator);
return wrapped
.split("\n")
.findIndex((line) => line.includes(FRONT_LOCATOR));
}
// return selected code line array, which is in {lineNumber, column, code} format.
private constructCodeLinesInRaw(): {
lineNumber: LineNumber;
column: number;
code: string;
}[] {
const codeLines = this.getRawCode().split("\n");
let index = -1;
const codeLinesInRaw = [];
this.parameters.selections.forEach((selection) => {
const { start, end } = selection;
for (let lineNumber = start.line; lineNumber <= end.line; lineNumber++) {
index++;
codeLinesInRaw[index] = {
lineNumber,
column: lineNumber === start.line ? start.character : 0,
code: codeLines[index],
};
}
});
return codeLinesInRaw;
}
private getNextValidCodeLineInLog(
codeLines: string[],
start: number,
): { code: string; lineNumber: number; index: number } {
let index = start;
let { code, lineNumber } = decomposeCodeLogLine(codeLines[index]);
while (
index < codeLines.length &&
// code not included in the source file starts with "+" in the log.
code.trim().startsWith("+")
) {
({ code, lineNumber } = decomposeCodeLogLine(codeLines[++index]));
}
return { code, lineNumber, index };
}
private async constructOffsetMap(codeLinesInLog: string[]): Promise {
const codeLinesInRaw = this.constructCodeLinesInRaw();
let indexInRaw = 0;
let codeLineInRaw: string;
let lineNumberInRaw: number;
let columnInRaw: number;
let indexInLog = this.getRawCodeBeginLineNumberInWrappedCode();
let codeLineInLog: string;
let lineNumberInLog: number;
let lastValidLineNumberInLog: number;
let inInteractiveBlock = false;
this.offsetMap = new Map();
while (
indexInRaw < codeLinesInRaw.length &&
indexInLog < codeLinesInLog.length
) {
({
code: codeLineInRaw,
lineNumber: lineNumberInRaw,
column: columnInRaw,
} = codeLinesInRaw[indexInRaw]);
if (inInteractiveBlock) {
let index = indexInRaw;
let lineInfo = codeLinesInRaw[++index];
while (
!(await this.parameters.checkKeyword(
// this.parameters.uri,
// lineInfo.code,
lineInfo.lineNumber,
"endinteractive",
)) &&
index < codeLinesInRaw.length
) {
lineInfo = codeLinesInRaw[++index];
}
if (index < codeLinesInRaw.length) {
({
code: codeLineInRaw,
lineNumber: lineNumberInRaw,
column: columnInRaw,
} = codeLinesInRaw[++index]);
indexInRaw = index;
inInteractiveBlock = false;
}
}
({
code: codeLineInLog,
lineNumber: lineNumberInLog,
index: indexInLog,
} = this.getNextValidCodeLineInLog(codeLinesInLog, indexInLog));
// The line numbers in the source code within the log should be continuous.
// but if encountering datasets following a datalines statement or %INC statement,
// the line numbers will not be continuous.
// for datalines-like statements, it will skip the number of dataset lines.
// for %INC-like statements, it will continue without skip
const delta =
lastValidLineNumberInLog === undefined
? 1
: lineNumberInLog - lastValidLineNumberInLog;
if (
delta > 1 &&
// if the code line in log can be found in raw,
// think of the discontinuous line number is caused by %INC-like statements,
// otherwise it is from datalines-like statements and need to skip lines in raw.
!isSameOrStartsWith(codeLineInRaw.trim(), codeLineInLog.trim())
) {
indexInRaw += delta - 1;
({
code: codeLineInRaw,
lineNumber: lineNumberInRaw,
column: columnInRaw,
} = codeLinesInRaw[indexInRaw]);
}
if (!isSameOrStartsWith(codeLineInRaw.trim(), codeLineInLog.trim())) {
const match = this.getMatchedCodeLineInLog(
codeLineInRaw,
codeLinesInLog,
indexInLog,
);
lineNumberInLog = match.lineNumber;
indexInLog = match.index;
}
const offset = { lineOffset: lineNumberInRaw, columnOffset: columnInRaw };
this.offsetMap.set(lineNumberInLog, offset);
lastValidLineNumberInLog = lineNumberInLog;
inInteractiveBlock = await this.parameters.checkKeyword(
// this.parameters.uri,
// codeLineInRaw,
lineNumberInRaw,
"interactive",
"i",
);
indexInRaw++;
indexInLog++;
}
}
private getMatchedCodeLineInLog(
codeLineInRaw: string,
codeLinesInLog: string[],
start: number,
): { code: string; lineNumber: number; index: number } | null {
let validCodeLine = { code: "", lineNumber: -1, index: start };
let indexInLog = start;
do {
validCodeLine = this.getNextValidCodeLineInLog(
codeLinesInLog,
indexInLog++,
);
} while (
!isSameOrStartsWith(codeLineInRaw.trim(), validCodeLine.code.trim()) &&
indexInLog < codeLinesInLog.length
);
return validCodeLine.index >= codeLinesInLog.length ? null : validCodeLine;
}
// priority return selected code, otherwise, return whole code.
private getRawCode(): string {
return this.parameters.selectedCode.trim() === ""
? this.parameters.code
: this.parameters.selectedCode;
}
}
function isSameOrStartsWith(base: string, target: string): boolean {
return target === "" ? base === target : base.startsWith(target);
}
================================================
FILE: client/src/components/utils/SASCodeDocumentHelper.ts
================================================
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import {
ColorThemeKind,
Hover,
Position,
Selection,
TextDocument,
commands,
window,
workspace,
} from "vscode";
import { v4 } from "uuid";
import { profileConfig } from "../../commands/profile";
import { ConnectionType } from "../profile";
import { SASCodeDocumentParameters } from "./SASCodeDocument";
import { getHtmlStyle, isOutputHtmlEnabled } from "./settings";
export function getCodeDocumentConstructionParameters(
textDocument: TextDocument,
addition?: {
selections?: ReadonlyArray;
preamble?: string;
postamble?: string;
},
): SASCodeDocumentParameters {
// TODO #810 This is a temporary solution to prevent creating an excessive
// number of result files for viya connections.
// This todo will be cleaned up with remaining work in #810.
const uuid = connectionTypeIsNotRest() ? v4() : undefined;
return {
languageId: textDocument.languageId,
code: textDocument.getText(),
selectedCode: getSelectedCode(textDocument, addition?.selections),
uri: textDocument.uri.toString(),
fileName: getFileName(textDocument),
selections: getCodeSelections(addition?.selections, textDocument),
preamble: addition?.preamble,
postamble: addition?.postamble,
htmlStyle: getHtmlStyleValue(),
outputHtml: isOutputHtmlEnabled(),
uuid,
checkKeyword: async (lineNumber: number, ...keywords: string[]) => {
const codeLines = textDocument.getText().split("\n");
const codeLine = codeLines[lineNumber];
const regExp = new RegExp(`\\b(${keywords.join("|")})\\b`, "gi");
const matches = Array.from(codeLine.matchAll(regExp));
if (matches.length === 0) {
return false;
}
for (const match of matches) {
const [actualHover]: Hover[] = await commands.executeCommand(
"vscode.executeHoverProvider",
textDocument.uri,
new Position(lineNumber, match.index),
);
if (actualHover !== undefined) {
return true;
}
}
return false;
},
};
}
function getSelectedCode(
textDocument: TextDocument,
selections?: ReadonlyArray,
): string {
if (selectionsAreNotEmpty(selections)) {
return selections
.map((selection) => {
return textDocument.getText(selection);
})
.join("\n");
} else {
return "";
}
}
function connectionTypeIsNotRest(): boolean {
const activeProfile = profileConfig.getActiveProfileDetail();
return (
activeProfile &&
activeProfile.profile.connectionType !== ConnectionType.Rest
);
}
function selectionsAreNotEmpty(
selections: ReadonlyArray | undefined,
): boolean {
return (
selections?.length > 1 ||
// the single cursor (if it is not in a selection) is always treated as a selection in Monaco Editor
(selections?.length === 1 && !selections[0].isEmpty)
);
}
function getHtmlStyleValue(): string {
const htmlStyleSetting = getHtmlStyle();
if (htmlStyleSetting === "(auto)") {
// get the results.html.custom.style object from user settings
const customStyle =
workspace.getConfiguration("SAS").get<{
light?: string;
dark?: string;
highContrast?: string;
highContrastLight?: string;
}>("results.html.custom.style") || {};
switch (window.activeColorTheme.kind) {
case ColorThemeKind.Light:
return customStyle.light || "Illuminate";
case ColorThemeKind.Dark:
return customStyle.dark || "Ignite";
case ColorThemeKind.HighContrast:
return customStyle.highContrast || "HighContrast";
case ColorThemeKind.HighContrastLight:
return customStyle.highContrastLight || "Illuminate";
default:
return "";
}
} else if (htmlStyleSetting === "(server default)") {
return "";
} else {
return htmlStyleSetting;
}
}
// if no valid selection, return whole text as only selection
function getCodeSelections(
selections: ReadonlyArray,
textDocument: TextDocument,
): ReadonlyArray | undefined {
if (selectionsAreNotEmpty(selections)) {
const codeSelections: Selection[] = selections.filter(
(selection) => !selection.isEmpty,
);
return codeSelections;
} else {
const lastLine = textDocument.lineCount - 1;
const lastCharacter = textDocument.lineAt(lastLine).text.length;
return [
new Selection(new Position(0, 0), new Position(lastLine, lastCharacter)),
];
}
}
function getFileName(textDocument: TextDocument): string {
// Extract the query parameters
const params = new URL(decodeURIComponent(textDocument.uri.toString()))
.searchParams;
let pathName: string;
// Massage file path value
const scheme = textDocument.uri?.scheme;
if (scheme === "sasServer" && params.has("id")) {
const id = params.get("id");
// Viya - server file
// id = /compute/sessions//files/~fs~studiodev~fs~myprogram.sas
// result = /studiodev/myprogram.sas
if (/^\/compute\/sessions\/\w+(-\w+)+\/files\/~fs~[^/]+$/.test(id)) {
pathName = `${id}`.split("/").pop().replace(/~fs~/g, "/");
}
if (!pathName) {
// IOM - server file
// id = C:\\Users\\sasdemo\\Documents\\My SAS Files\\9.4\\myfolder\\myprogram.sas
pathName = id;
}
}
// Local files will default to utilizing the fileName.
// fileName = c:\\Development\\VSCODE\\files\\myprogram.sas
// We could consider utilizing the "sasContent" scheme id value but this
// is the internal uri in the form of /files/files/ which would need to
// potentially get translated to a readable path name. Also it doesn't work well
// with the _SASPROGRAMDIR variable as it assumes folder structure.
return pathName ?? textDocument.fileName ?? textDocument.uri?.fsPath;
}
================================================
FILE: client/src/components/utils/deferred.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
export interface Deferred {
promise: Promise;
resolve: (value: T | PromiseLike) => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reject: (reason?: any) => void;
}
export function deferred() {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const deferred = {} as Deferred;
deferred.promise = new Promise((resolve, reject) => {
deferred.resolve = resolve;
deferred.reject = reject;
});
return deferred;
}
================================================
FILE: client/src/components/utils/settings.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { workspace } from "vscode";
export function isOutputHtmlEnabled(): boolean {
return !!workspace.getConfiguration("SAS").get("results.html.enabled");
}
export function getHtmlStyle(): string {
return workspace.getConfiguration("SAS").get("results.html.style");
}
export function isSideResultEnabled(): string {
return workspace.getConfiguration("SAS").get("results.sideBySide");
}
export function isSinglePanelEnabled(): string {
return workspace.getConfiguration("SAS").get("results.singlePanel");
}
export function showLogOnExecutionStart(): boolean {
return workspace.getConfiguration("SAS").get("log.showOnExecutionStart");
}
export function showLogOnExecutionFinish(): boolean {
return workspace.getConfiguration("SAS").get("log.showOnExecutionFinish");
}
export function clearLogOnExecutionStart(): boolean {
return workspace.getConfiguration("SAS").get("log.clearOnExecutionStart");
}
export function isShowProblemsFromSASLogEnabled(): boolean {
return workspace.getConfiguration("SAS").get("problems.log.enabled");
}
export function includeLogInNotebookExport(): boolean {
return workspace.getConfiguration("SAS").get("notebook.export.includeLog");
}
================================================
FILE: client/src/components/utils/throttle.ts
================================================
// Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
/**
* Run tasks with limited concurrency
* @param tasks array of tasks to run
* @param limit limit of tasks that can run in parallel
* @returns a promise like `Promise.all`
*/
export function throttle(tasks: Array<() => Promise>, limit: number) {
const total = tasks.length;
const results: T[] = Array(total);
let count = 0;
return new Promise((resolve, reject) => {
function run() {
const index = total - tasks.length;
if (index === total) {
if (count === total) {
resolve(results);
}
return;
}
const task = tasks.shift();
task().then((result) => {
results[index] = result;
++count;
run();
}, reject);
}
Array(limit).fill(0).forEach(run);
});
}
================================================
FILE: client/src/components/utils/treeViewSelections.ts
================================================
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { TreeItem, TreeView } from "vscode";
/**
* Gets the selected items from a tree view based on the current selection state.
*
* If an item is present and is not part of selections, return the item. Otherwise,
* return the selections.
*
* @param treeView - The VS Code TreeView instance
* @param item - The item that was clicked/activated
* @returns An array of selected items
*/
export function treeViewSelections(
treeView: TreeView,
item: T | undefined,
): T[] {
if (item) {
const itemIsInSelection = treeView.selection.some(
({ id }) => id === item.id,
);
if (itemIsInSelection) {
return [...treeView.selection];
}
return [item];
}
return [...treeView.selection];
}
================================================
FILE: client/src/connection/index.ts
================================================
// Copyright © 2022-2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { l10n } from "vscode";
import {
AuthType,
ConnectionType,
ProfileConfig,
ViyaProfile,
toAutoExecLines,
} from "../components/profile";
import { getSession as getITCSession } from "./itc";
import { ITCProtocol } from "./itc/types";
import { Config as RestConfig, getSession as getRestSession } from "./rest";
import {
Error2 as ComputeError,
LogLine as ComputeLogLine,
LogLineTypeEnum as ComputeLogLineTypeEnum,
} from "./rest/api/compute";
import { Session } from "./session";
import { getSession as getSSHSession } from "./ssh";
let profileConfig: ProfileConfig;
export type ErrorRepresentation = ComputeError;
export type LogLine = ComputeLogLine;
export type LogLineTypeEnum = ComputeLogLineTypeEnum;
export type OnLogFn = (logs: LogLine[]) => void;
export interface RunResult {
html5?: string;
title?: string;
}
export interface BaseConfig {
sasOptions?: string[];
autoExecLines?: string[];
}
export function getSession(): Session {
if (!profileConfig) {
profileConfig = new ProfileConfig();
}
// retrieve active & valid profile
const activeProfile = profileConfig.getActiveProfileDetail();
const validProfile = profileConfig.validateProfile(activeProfile);
if (validProfile.type === AuthType.Error) {
throw new Error(validProfile.error);
}
switch (validProfile.profile?.connectionType) {
case ConnectionType.Rest:
return getRestSession(toRestConfig(validProfile.profile));
case ConnectionType.SSH:
return getSSHSession(validProfile.profile);
case ConnectionType.COM:
return getITCSession(validProfile.profile, ITCProtocol.COM);
case ConnectionType.IOM:
return getITCSession(validProfile.profile, ITCProtocol.IOMBridge);
default:
throw new Error(
l10n.t("Invalid connectionType. Check Profile settings."),
);
}
}
/**
* Translates a {@link ViyaProfile} interface to a {@link RestConfig} interface.
* @param profile an input {@link ViyaProfile} to translate.
* @returns RestConfig instance derived from the input profile.
*/
function toRestConfig(profile: ViyaProfile): RestConfig {
const mapped: RestConfig = profile;
if (profile.autoExec) {
mapped.autoExecLines = toAutoExecLines(profile.autoExec);
}
return mapped;
}
================================================
FILE: client/src/connection/itc/CodeRunner.ts
================================================
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { commands } from "vscode";
import { v4 } from "uuid";
import { ITCSession } from ".";
import { LogLine, getSession } from "..";
import { useRunStore } from "../../store";
import { Session } from "../session";
import { extractTextBetweenTags } from "../util";
let wait: Promise | undefined;
export async function executeRawCode(code: string): Promise {
const randomId = v4();
const startTag = `<${randomId}>`;
const endTag = `${randomId}>`;
const task = () =>
_runCode(
async (session) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
await (session as ITCSession).execute(
`Write-Host "${startTag}"\n${code}\nWrite-Host "${endTag}"\n`,
);
},
startTag,
endTag,
);
wait = wait ? wait.then(task) : task();
return wait;
}
export async function runCode(
code: string,
startTag: string = "",
endTag: string = "",
): Promise {
const task = () =>
_runCode(
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
async (session) => await (session as ITCSession).run(code, true),
startTag,
endTag,
);
wait = wait ? wait.then(task) : task();
return wait;
}
async function _runCode(
runCallback: (session: Session) => void,
startTag: string = "",
endTag: string = "",
): Promise {
// If we're already executing code, lets wait for it
// to finish up.
let unsubscribe;
if (useRunStore.getState().isExecutingCode) {
await new Promise((resolve) => {
unsubscribe = useRunStore.subscribe(
(state) => state.isExecutingCode,
(isExecutingCode) => !isExecutingCode && resolve(true),
);
});
}
const { setIsExecutingCode } = useRunStore.getState();
setIsExecutingCode(true, false);
commands.executeCommand("setContext", "SAS.running", true);
const session = getSession();
let logText = "";
const onExecutionLogFn = session.onExecutionLogFn;
const outputLines = [];
const addLine = (logLines: LogLine[]) => {
outputLines.push(...logLines.map(({ line }) => line));
};
try {
await session.setup(true);
// Lets capture output to use it on
session.onExecutionLogFn = addLine;
await runCallback(session);
const logOutput = outputLines.filter((line) => line.trim()).join("");
logText = extractTextBetweenTags(logOutput, startTag, endTag);
} finally {
unsubscribe && unsubscribe();
// Lets update our session to write to the log
session.onExecutionLogFn = onExecutionLogFn;
setIsExecutingCode(false);
commands.executeCommand("setContext", "SAS.running", false);
}
return logText;
}
================================================
FILE: client/src/connection/itc/ItcLibraryAdapter.ts
================================================
// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { l10n } from "vscode";
import type { SortModelItem } from "ag-grid-community";
import { ChildProcessWithoutNullStreams } from "child_process";
import { onRunError } from "../../commands/run";
import {
LibraryAdapter,
LibraryItem,
TableData,
TableQuery,
TableRow,
} from "../../components/LibraryNavigator/types";
import { ColumnCollection, TableInfo } from "../rest/api/compute";
import { getColumnIconType } from "../util";
import { executeRawCode, runCode } from "./CodeRunner";
import { Config } from "./types";
class ItcLibraryAdapter implements LibraryAdapter {
protected hasEstablishedConnection: boolean = false;
protected shellProcess: ChildProcessWithoutNullStreams;
protected pollingForLogResults: boolean = false;
protected log: string[] = [];
protected endTag: string = "";
protected outputFinished: boolean = false;
protected config: Config;
public async connect(): Promise {
this.hasEstablishedConnection = true;
}
public async setup(): Promise {
if (this.hasEstablishedConnection) {
return;
}
await this.connect();
}
public async deleteTable(item: LibraryItem): Promise {
const code = `
proc datasets library=${item.library} nolist nodetails; delete ${item.name}; run;
`;
await this.runCode(code);
}
public async getColumns(item: LibraryItem): Promise {
const code = `
$runner.GetColumns("${item.library}", "${item.name}")
`;
const output = await executeRawCode(code);
const rawColumns = JSON.parse(output);
const columns = rawColumns.map((column) => ({
...column,
type: getColumnIconType(column),
}));
return {
items: columns,
count: -1,
};
}
public async getLibraries(): Promise<{
items: LibraryItem[];
count: number;
}> {
const code = `
$runner.GetLibraries()
`;
const output = await executeRawCode(code);
const rawLibraries = JSON.parse(output).libraries;
const libraries = rawLibraries.map((row: string[]) => {
const [libName, readOnlyValue] = row;
return {
type: "library",
uid: libName,
id: libName,
name: libName,
readOnly: readOnlyValue === "yes",
};
});
return {
items: libraries,
count: -1,
};
}
public async getRows(
item: LibraryItem,
start: number,
limit: number,
sortModel: SortModelItem[],
query: TableQuery | undefined,
): Promise {
const { rows: rawRowValues, count } = await this.getDatasetInformation(
item,
start,
limit,
sortModel,
query,
);
const rows = rawRowValues.map((line, idx: number): TableRow => {
const rowData = [`${start + idx + 1}`].concat(line);
return { cells: rowData };
});
return {
rows,
count,
};
}
public async getRowsAsCSV(
item: LibraryItem,
start: number,
limit: number,
): Promise {
// We only need the columns for the first page of results
const columns =
start === 0
? {
columns: ["INDEX"].concat(
(await this.getColumns(item)).items.map((column) => column.name),
),
}
: {};
const { rows } = await this.getRows(item, start, limit, [], undefined);
rows.unshift(columns);
// Fetching csv doesn't rely on count. Instead, we get the count
// upfront via getTableRowCount
return { rows, count: -1 };
}
public async getTableRowCount(
item: LibraryItem,
): Promise<{ rowCount: number; maxNumberOfRowsToRead: number }> {
const code = `
proc sql;
SELECT COUNT(1) into: COUNT FROM ${item.library}.${item.name};
quit;
%put &COUNT ;
`;
const output = await this.runCode(code, "", " ");
const rowCount = parseInt(output.replace(/[^0-9]/g, ""), 10);
return { rowCount, maxNumberOfRowsToRead: 100 };
}
public async getTables(item: LibraryItem): Promise<{
items: LibraryItem[];
count: number;
}> {
const code = `
$runner.GetTables("${item.name}")
`;
const output = await executeRawCode(code);
const rawTables = JSON.parse(output).tables;
const tables = rawTables.map((table: string): LibraryItem => {
return {
type: "table",
uid: `${item.name!}.${table}`,
id: table,
name: table,
library: item.name,
readOnly: item.readOnly,
};
});
return { items: tables, count: -1 };
}
protected async getDatasetInformation(
item: LibraryItem,
start: number,
limit: number,
sortModel: SortModelItem[],
query: TableQuery | undefined,
): Promise<{ rows: Array; count: number }> {
const sortString = sortModel
.map((col) => `${col.colId} ${col.sort}`)
.join(",");
const code = `
$runner.GetDatasetRecords("${item.library}","${item.name}", ${start}, ${limit}, "${sortString}", '${query ? JSON.stringify(query) : ""}')
`;
const output = await executeRawCode(code);
try {
return JSON.parse(output);
} catch (e) {
console.warn("Failed to load table data with error", e);
console.warn("Raw output", output);
throw new Error(
l10n.t(
"An error was encountered when loading table data. This usually happens when a table is too large or the data couldn't be processed. See console for more details.",
),
);
}
}
public async getTableInfo(item: LibraryItem): Promise {
const basicInfo: TableInfo = {
bookmarkLength: 0, // Not available in vtable
columnCount: 0,
compressionRoutine: "",
creationTimeStamp: "",
encoding: "",
engine: "",
extendedType: "",
label: "",
libref: item.library,
logicalRecordCount: 0,
modifiedTimeStamp: "",
name: item.name,
physicalRecordCount: 0,
recordLength: 0,
rowCount: 0,
type: "DATA",
};
try {
// Use the PowerShell GetTableInfo function which queries sashelp.vtable
const code = `
$runner.GetTableInfo("${item.library}", "${item.name}")
`;
const output = await executeRawCode(code);
const tableInfo = JSON.parse(output);
return {
...basicInfo,
columnCount: tableInfo.columnCount || basicInfo.columnCount,
compressionRoutine:
tableInfo.compressionRoutine || basicInfo.compressionRoutine,
creationTimeStamp:
tableInfo.creationTimeStamp || basicInfo.creationTimeStamp,
encoding: tableInfo.encoding || basicInfo.encoding,
engine: tableInfo.engine || basicInfo.engine,
extendedType: tableInfo.extendedType || basicInfo.extendedType,
label: tableInfo.label || basicInfo.label,
libref: tableInfo.libref || basicInfo.libref,
logicalRecordCount: tableInfo.rowCount || basicInfo.logicalRecordCount,
modifiedTimeStamp:
tableInfo.modifiedTimeStamp || basicInfo.modifiedTimeStamp,
name: tableInfo.name || basicInfo.name,
physicalRecordCount:
tableInfo.rowCount || basicInfo.physicalRecordCount,
recordLength: tableInfo.recordLength || basicInfo.recordLength,
rowCount: tableInfo.rowCount || basicInfo.rowCount,
type: tableInfo.type || basicInfo.type,
};
} catch (error) {
console.warn("Failed to get table info:", error);
// If anything fails, return basic info
return basicInfo;
}
}
protected async executionHandler(
callback: () => Promise,
): Promise {
try {
return await callback();
} catch (e) {
onRunError(e);
return "";
}
}
protected async runCode(
code: string,
startTag: string = "",
endTag: string = "",
): Promise {
return this.executionHandler(() => runCode(code, startTag, endTag));
}
protected async executeRawCode(code: string): Promise {
return this.executionHandler(() => executeRawCode(code));
}
}
export default ItcLibraryAdapter;
================================================
FILE: client/src/connection/itc/ItcServerAdapter.ts
================================================
// Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { FileType, Uri, workspace } from "vscode";
import { v4 } from "uuid";
import { onRunError } from "../../commands/run";
import {
Messages,
SAS_SERVER_ROOT_FOLDER,
SAS_SERVER_ROOT_FOLDERS,
SERVER_FOLDER_ID,
} from "../../components/ContentNavigator/const";
import {
ContentAdapter,
ContentItem,
RootFolderMap,
} from "../../components/ContentNavigator/types";
import {
ContextMenuAction,
ContextMenuProvider,
convertStaticFolderToContentItem,
createStaticFolder,
homeDirectoryNameAndType,
sortedContentItems,
} from "../../components/ContentNavigator/utils";
import { getGlobalStorageUri } from "../../components/ExtensionContext";
import { ProfileWithFileRootOptions } from "../../components/profile";
import { getLink, getResourceId, getSasServerUri } from "../rest/util";
import { executeRawCode } from "./CodeRunner";
import { PowershellResponse, ScriptActions } from "./types";
import { getDirectorySeparator } from "./util";
class ItcServerAdapter implements ContentAdapter {
protected sessionId: string;
private rootFolders: RootFolderMap;
private contextMenuProvider: ContextMenuProvider;
public constructor(
protected readonly fileNavigationCustomRootPath: ProfileWithFileRootOptions["fileNavigationCustomRootPath"],
protected readonly fileNavigationRoot: ProfileWithFileRootOptions["fileNavigationRoot"],
) {
this.rootFolders = {};
this.contextMenuProvider = new ContextMenuProvider(
[
ContextMenuAction.CreateChild,
ContextMenuAction.Delete,
ContextMenuAction.Update,
ContextMenuAction.CopyPath,
ContextMenuAction.AllowDownload,
],
{
[ContextMenuAction.CopyPath]: (item) => item.id !== SERVER_FOLDER_ID,
},
);
}
/* The following methods are needed for favorites, which are not applicable to sas server */
public async addChildItem(): Promise {
throw new Error("Method not implemented");
}
public async addItemToFavorites(): Promise {
throw new Error("Method not implemented");
}
public removeItemFromFavorites(): Promise {
throw new Error("Method not implemented");
}
public getRootFolder(): ContentItem | undefined {
return undefined;
}
/* The following is needed for creating a flow, which isn't supported on sas server */
public async getParentOfItem(
item: ContentItem,
): Promise {
const parent = await this.getItemAtPath(item.parentFolderUri);
if (!parent) {
return undefined;
}
return parent;
}
public async getFolderPathForItem(): Promise {
return "";
}
public async connect(): Promise {
return;
}
public connected(): boolean {
return true;
}
public async createNewFolder(
parentItem: ContentItem,
folderName: string,
): Promise