diff --git a/package.json b/package.json index f5885259..ba601363 100644 --- a/package.json +++ b/package.json @@ -191,6 +191,7 @@ "istanbul": "^0.4.5", "lint-staged": "^7.3.0", "mocha": "^2.3.3", + "prettier": "^2.3.1", "remap-istanbul": "^0.8.4", "tslint": "^4.0.2", "typescript": "^3.5.1", diff --git a/src/extension.ts b/src/extension.ts index f98049b3..3a8cc850 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,23 +10,27 @@ import { FORTRAN_FREE_FORM_ID, EXTENSION_ID } from './lib/helper' import { FortranLangServer, checkForLangServer } from './lang-server' import { LoggingService } from './services/logging-service' import * as pkg from '../package.json' +import { Config } from './services/config' -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { const loggingService = new LoggingService() - const extensionConfig = vscode.workspace.getConfiguration(EXTENSION_ID) + const extensionConfig = new Config( + vscode.workspace.getConfiguration(EXTENSION_ID), + loggingService + ) loggingService.logInfo(`Extension Name: ${pkg.displayName}`) loggingService.logInfo(`Extension Version: ${pkg.version}`) - if (extensionConfig.get('linterEnabled', true)) { - let linter = new FortranLintingProvider(loggingService) + if (await extensionConfig.get('linterEnabled', true)) { + let linter = new FortranLintingProvider(loggingService, extensionConfig) linter.activate(context.subscriptions) vscode.languages.registerCodeActionsProvider(FORTRAN_FREE_FORM_ID, linter) } else { loggingService.logInfo('Linter is not enabled') } - if (extensionConfig.get('provideCompletion', true)) { + if (await extensionConfig.get('provideCompletion', true)) { let completionProvider = new FortranCompletionProvider(loggingService) vscode.languages.registerCompletionItemProvider( FORTRAN_FREE_FORM_ID, @@ -36,14 +40,14 @@ export function activate(context: vscode.ExtensionContext) { loggingService.logInfo('Completion Provider is not enabled') } - if (extensionConfig.get('provideHover', true)) { + if (await extensionConfig.get('provideHover', true)) { let hoverProvider = new FortranHoverProvider(loggingService) vscode.languages.registerHoverProvider(FORTRAN_FREE_FORM_ID, hoverProvider) } else { loggingService.logInfo('Hover Provider is not enabled') } - if (extensionConfig.get('provideSymbols', true)) { + if (await extensionConfig.get('provideSymbols', true)) { let symbolProvider = new FortranDocumentSymbolProvider() vscode.languages.registerDocumentSymbolProvider( FORTRAN_FREE_FORM_ID, diff --git a/src/features/linter-provider.ts b/src/features/linter-provider.ts index c1ac11a0..7a2bad9b 100644 --- a/src/features/linter-provider.ts +++ b/src/features/linter-provider.ts @@ -6,16 +6,23 @@ import { getIncludeParams, LANGUAGE_ID } from '../lib/helper' import * as vscode from 'vscode' import { LoggingService } from '../services/logging-service' +import { Config } from '../services/config' +import { EnvironmentVariables } from '../services/utils' + +const ERROR_REGEX: RegExp = + /^([a-zA-Z]:\\)*([^:]*):([0-9]+):([0-9]+):\s+(.*)\s+.*?\s+(Error|Warning|Fatal Error):\s(.*)$/gm + +const knownModNames = ['mpi'] export default class FortranLintingProvider { - constructor(private loggingService: LoggingService) {} + constructor( + private loggingService: LoggingService, + private _config: Config + ) {} private diagnosticCollection: vscode.DiagnosticCollection - private doModernFortranLint(textDocument: vscode.TextDocument) { - const errorRegex: RegExp = - /^([a-zA-Z]:\\)*([^:]*):([0-9]+):([0-9]+):\s+(.*)\s+.*?\s+(Error|Warning|Fatal Error):\s(.*)$/gm - + private async doModernFortranLint(textDocument: vscode.TextDocument) { if ( textDocument.languageId !== LANGUAGE_ID || textDocument.uri.scheme !== 'file' @@ -24,9 +31,9 @@ export default class FortranLintingProvider { } let decoded = '' - let diagnostics: vscode.Diagnostic[] = [] - let command = this.getGfortranPath() - let argList = this.constructArgumentList(textDocument) + + let command = await this.getGfortranPath() + let argList = await this.constructArgumentList(textDocument) let filePath = path.parse(textDocument.fileName).dir @@ -37,70 +44,82 @@ export default class FortranLintingProvider { * * see also: https://gcc.gnu.org/onlinedocs/gcc/Environment-Variables.html */ - const env = process.env - env.LC_ALL = 'C' + const env: EnvironmentVariables = { ...process.env, LC_ALL: 'C' } if (process.platform === 'win32') { // Windows needs to know the path of other tools if (!env.Path.includes(path.dirname(command))) { env.Path = `${path.dirname(command)}${path.delimiter}${env.Path}` } } - let childProcess = cp.spawn(command, argList, { cwd: filePath, env: env }) + this.loggingService.logInfo( + `executing linter command ${command} ${argList.join(' ')}` + ) + let gfortran = cp.spawn(command, argList, { cwd: filePath, env }) - if (childProcess.pid) { - childProcess.stdout.on('data', (data: Buffer) => { + if (gfortran && gfortran.pid) { + gfortran!.stdout!.on('data', (data: Buffer) => { decoded += data }) - childProcess.stderr.on('data', (data) => { + gfortran!.stderr!.on('data', (data) => { decoded += data }) - childProcess.stderr.on('end', () => { - let matchesArray: string[] - while ((matchesArray = errorRegex.exec(decoded)) !== null) { - let elements: string[] = matchesArray.slice(1) // get captured expressions - let startLine = parseInt(elements[2]) - let startColumn = parseInt(elements[3]) - let type = elements[5] // error or warning - let severity = - type.toLowerCase() === 'warning' - ? vscode.DiagnosticSeverity.Warning - : vscode.DiagnosticSeverity.Error - let message = elements[6] - let range = new vscode.Range( - new vscode.Position(startLine - 1, startColumn), - new vscode.Position(startLine - 1, startColumn) - ) - let diagnostic = new vscode.Diagnostic(range, message, severity) - diagnostics.push(diagnostic) - } - - this.diagnosticCollection.set(textDocument.uri, diagnostics) + gfortran!.stderr.on('end', () => { + this.reportErrors(decoded, textDocument) }) - childProcess.stdout.on('close', (code) => { + gfortran.stdout.on('close', (code) => { console.log(`child process exited with code ${code}`) }) } else { - childProcess.on('error', (err: any) => { + gfortran.on('error', (err: any) => { if (err.code === 'ENOENT') { vscode.window.showErrorMessage( - "gfortran can't found on path, update your settings with a proper path or disable the linter." + "gfortran executable can't be found at the provided path, update your settings with a proper path or disable the linter." ) } }) } } - private constructArgumentList(textDocument: vscode.TextDocument): string[] { - let options = vscode.workspace.rootPath - ? { cwd: vscode.workspace.rootPath } - : undefined + reportErrors(errors: string, textDocument: vscode.TextDocument) { + let diagnostics: vscode.Diagnostic[] = [] + let matchesArray: string[] + while ((matchesArray = ERROR_REGEX.exec(errors)) !== null) { + let elements: string[] = matchesArray.slice(1) // get captured expressions + let startLine = parseInt(elements[2]) + let startColumn = parseInt(elements[3]) + let type = elements[5] // error or warning + let severity = + type.toLowerCase() === 'warning' + ? vscode.DiagnosticSeverity.Warning + : vscode.DiagnosticSeverity.Error + let message = elements[6] + const [isModError, modName] = isModuleMissingErrorMessage(message) + // skip error from known mod names + if (isModError && knownModNames.includes(modName)) { + continue + } + let range = new vscode.Range( + new vscode.Position(startLine - 1, startColumn), + new vscode.Position(startLine - 1, startColumn) + ) + + let diagnostic = new vscode.Diagnostic(range, message, severity) + diagnostics.push(diagnostic) + } + + this.diagnosticCollection.set(textDocument.uri, diagnostics) + } + + private async constructArgumentList( + textDocument: vscode.TextDocument + ): Promise { let args = [ '-fsyntax-only', '-cpp', '-fdiagnostics-show-option', - ...this.getLinterExtraArgs(), + ...(await this.getLinterExtraArgs()), ] - let includePaths = this.getIncludePaths() + let includePaths = await this.getIncludePaths() let extensionIndex = textDocument.fileName.lastIndexOf('.') let fileNameWithoutExtension = textDocument.fileName.substring( @@ -164,21 +183,34 @@ export default class FortranLintingProvider { this.command.dispose() } - private getIncludePaths(): string[] { - let config = vscode.workspace.getConfiguration('fortran') - let includePaths: string[] = config.get('includePaths', []) - + private async getIncludePaths(): Promise { + let includePaths: string[] = await this._config.get('includePaths', []) + this.loggingService.logInfo(`using include paths "${includePaths}"`) return includePaths } - private getGfortranPath(): string { - let config = vscode.workspace.getConfiguration('fortran') - const gfortranPath = config.get('gfortranExecutable', 'gfortran') - this.loggingService.logInfo(`using gfortran executable: ${gfortranPath}`) + + private async getGfortranPath(): Promise { + const gfortranPath = await this._config.get( + 'gfortranExecutable', + 'gfortran' + ) + this.loggingService.logInfo(`using gfortran executable: "${gfortranPath}"`) return gfortranPath } - private getLinterExtraArgs(): string[] { - let config = vscode.workspace.getConfiguration('fortran') - return config.get('linterExtraArgs', ['-Wall']) + private getLinterExtraArgs(): Promise { + return this._config.get('linterExtraArgs', ['-Wall']) + } +} + +function isModuleMissingErrorMessage( + message: string +): [boolean, string | null] { + const result = /^Cannot open module file '(\w+).mod' for reading/.exec( + message + ) + if (result) { + return [true, result[1]] } + return [false, null] } diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 00000000..8a8544e2 --- /dev/null +++ b/src/services/config.ts @@ -0,0 +1,240 @@ +import * as vscode from 'vscode' +import { LoggingService } from './logging-service' +import { + EnvironmentVariables, + errorToString, + fixPaths, + mergeEnvironment, + normalizeEnvironmentVarname, + replaceAll, +} from './utils' + +interface VariableExpansionOptions { + envOverride?: Record + recursive: boolean + vars: Record + doNotSupportCommands?: boolean +} + +export class Config { + constructor( + private _config: vscode.WorkspaceConfiguration, + private log: LoggingService + ) {} + get( + section: string, + defaultValue?: T + ): Promise | undefined { + const value = this._config.get(section, defaultValue) + if (Array.isArray(value)) { + // @ts-ignore + return Promise.all( + value.map((value) => + expandValue(value, this.log, this.getExpandOptions()) + ) + ) + } + return expandValue(value, this.log, this.getExpandOptions()) + } + + getExpandOptions(): VariableExpansionOptions { + const [rootFolder] = vscode.workspace.workspaceFolders ?? [] + return { + envOverride: {}, + recursive: false, + doNotSupportCommands: false, + vars: { + workspaceFolder: rootFolder?.uri.fsPath, + }, + } + } +} + +export async function expandValue( + value: any, + log: LoggingService, + opts: VariableExpansionOptions +) { + if (typeof value === 'string') { + return expandString(value, log, opts) + } + return value +} + +/** + * Replace ${variable} references in the given string with their corresponding + * values. + * @param instr The input string + * @param opts Options for the expansion process + * @returns A string with the variable references replaced + */ +export async function expandString( + tmpl: string, + log: LoggingService, + opts: VariableExpansionOptions +) { + if (!tmpl) { + return tmpl + } + + const MAX_RECURSION = 10 + let result = tmpl + let didReplacement = false + + let i = 0 + do { + // TODO: consider a full circular reference check? + const expansion = await expandStringHelper(result, log, opts) + result = expansion.result + didReplacement = expansion.didReplacement + i++ + } while (i < MAX_RECURSION && opts.recursive && didReplacement) + + if (i === MAX_RECURSION) { + log.logInfo( + 'Reached max string expansion recursion. Possible circular reference.' + ) + } + + return replaceAll(result, '${dollar}', '$') +} + +export async function expandStringHelper( + tmpl: string, + log: LoggingService, + opts: VariableExpansionOptions +) { + const envPreNormalize = opts.envOverride + ? opts.envOverride + : (process.env as EnvironmentVariables) + const env = mergeEnvironment(envPreNormalize) + const repls = opts.vars + + // We accumulate a list of substitutions that we need to make, preventing + // recursively expanding or looping forever on bad replacements + const subs = new Map() + + const var_re = /\$\{(\w+)\}/g + let mat: RegExpMatchArray | null = null + while ((mat = var_re.exec(tmpl))) { + const full = mat[0] + const key = mat[1] + if (key !== 'dollar') { + // Replace dollar sign at the very end of the expanding process + const repl = repls[key] + if (!repl) { + log.logWarning(`Invalid variable reference ${full} in string: ${tmpl}`) + } else { + subs.set(full, repl) + } + } + } + + // Regular expression for variable value (between the variable suffix and the next ending curly bracket): + // .+? matches any character (except line terminators) between one and unlimited times, + // as few times as possible, expanding as needed (lazy) + const varValueRegexp = '.+?' + const env_re = RegExp(`\\$\\{env:(${varValueRegexp})\\}`, 'g') + while ((mat = env_re.exec(tmpl))) { + const full = mat[0] + const varname = mat[1] + const repl = fixPaths(env[normalizeEnvironmentVarname(varname)]) || '' + subs.set(full, repl) + } + + const env_re2 = RegExp(`\\$\\{env\\.(${varValueRegexp})\\}`, 'g') + while ((mat = env_re2.exec(tmpl))) { + const full = mat[0] + const varname = mat[1] + const repl = fixPaths(env[normalizeEnvironmentVarname(varname)]) || '' + subs.set(full, repl) + } + + const env_re3 = RegExp(`\\$env\\{(${varValueRegexp})\\}`, 'g') + while ((mat = env_re3.exec(tmpl))) { + const full = mat[0] + const varname = mat[1] + const repl = fixPaths(env[normalizeEnvironmentVarname(varname)]) || '' + subs.set(full, repl) + } + + const penv_re = RegExp(`\\$penv\\{(${varValueRegexp})\\}`, 'g') + while ((mat = penv_re.exec(tmpl))) { + const full = mat[0] + const varname = mat[1] + const repl = + fixPaths(process.env[normalizeEnvironmentVarname(varname)] || '') || '' + subs.set(full, repl) + } + + const vendor_re = RegExp(`\\$vendor\\{(${varValueRegexp})\\}`, 'g') + while ((mat = vendor_re.exec(tmpl))) { + const full = mat[0] + const varname = mat[1] + const repl = + fixPaths(process.env[normalizeEnvironmentVarname(varname)] || '') || '' + subs.set(full, repl) + } + + if ( + vscode.workspace.workspaceFolders && + vscode.workspace.workspaceFolders.length > 0 + ) { + const folder_re = RegExp( + `\\$\\{workspaceFolder:(${varValueRegexp})\\}`, + 'g' + ) + + mat = folder_re.exec(tmpl) + while (mat) { + const full = mat[0] + const folderName = mat[1] + const f = vscode.workspace.workspaceFolders.find( + (folder) => + folder.name.toLocaleLowerCase() === folderName.toLocaleLowerCase() + ) + if (f) { + subs.set(full, f.uri.fsPath) + } else { + log.logWarning(`workspace folder ${folderName} not found`) + } + mat = folder_re.exec(tmpl) + } + } + + const command_re = RegExp(`\\$\\{command:(${varValueRegexp})\\}`, 'g') + while ((mat = command_re.exec(tmpl))) { + if (opts.doNotSupportCommands) { + log.logWarning(`Commands are not supported for string: ${tmpl}`) + break + } + const full = mat[0] + const command = mat[1] + if (subs.has(full)) { + continue // Don't execute commands more than once per string + } + try { + const command_ret = await vscode.commands.executeCommand( + command, + opts.vars.workspaceFolder + ) + subs.set(full, `${command_ret}`) + } catch (e) { + log.logWarning( + `Exception while executing command ${command} for string: ${tmpl} ${errorToString( + e + )}` + ) + } + } + + let final_str = tmpl + let didReplacement = false + subs.forEach((value, key) => { + if (value !== key) { + final_str = replaceAll(final_str, key, value) + didReplacement = true + } + }) + return { result: final_str, didReplacement } +} diff --git a/src/services/utils.ts b/src/services/utils.ts new file mode 100644 index 00000000..e73abf9d --- /dev/null +++ b/src/services/utils.ts @@ -0,0 +1,62 @@ +export type EnvironmentVariables = Record +export function errorToString(e: any): string { + if (e.stack) { + // e.stack has both the message and the stack in it. + return `\n\t${e.stack}` + } + return `\n\t${e.toString()}` +} + +export function replaceAll(str: string, needle: string, what: string) { + const pattern = escapeStringForRegex(needle) + const re = new RegExp(pattern, 'g') + return str.replace(re, what) +} + +/** + * Escape a string so it can be used as a regular expression + */ +export function escapeStringForRegex(str: string): string { + return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, '\\$1') +} + +export function mergeEnvironment( + ...env: EnvironmentVariables[] +): EnvironmentVariables { + return env.reduce((acc, vars) => { + if (process.platform === 'win32') { + // Env vars on windows are case insensitive, so we take the ones from + // active env and overwrite the ones in our current process env + const norm_vars = Object.getOwnPropertyNames( + vars + ).reduce((acc2, key: string) => { + acc2[normalizeEnvironmentVarname(key)] = vars[key] + return acc2 + }, {}) + return { ...acc, ...norm_vars } + } else { + return { ...acc, ...vars } + } + }, {}) +} + +export function normalizeEnvironmentVarname(varname: string) { + return process.platform === 'win32' ? varname.toUpperCase() : varname +} + +/** + * Fix slashes in Windows paths for CMake + * @param str The input string + * @returns The modified string with fixed paths + */ +export function fixPaths(str: string) { + const fix_paths = /[A-Z]:(\\((?![<>:\"\/\\|\?\*]).)+)*\\?(?!\\)/gi + let pathmatch: RegExpMatchArray | null = null + let newstr = str + while ((pathmatch = fix_paths.exec(str))) { + const pathfull = pathmatch[0] + const fixslash = pathfull.replace(/\\/g, '/') + newstr = newstr.replace(pathfull, fixslash) + } + return newstr +} diff --git a/yarn.lock b/yarn.lock index 556c253e..b0c1087a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2325,6 +2325,11 @@ prepend-http@^1.0.1: resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc" integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw= +prettier@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.1.tgz#76903c3f8c4449bc9ac597acefa24dc5ad4cbea6" + integrity sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA== + pretty-format@^23.6.0: version "23.6.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-23.6.0.tgz#5eaac8eeb6b33b987b7fe6097ea6a8a146ab5760"