diff --git a/src/extension.ts b/src/extension.ts index 7794fec..6072d3e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,7 +15,7 @@ export async function activate(context: vscode.ExtensionContext) { let globalState: GlobalState = { isFirstTimeRunningPlugin: true, elmJsonFiles: [], - cachedDocs: {}, + cachedDocs: new Map(), jumpToDocDetails: undefined } context.subscriptions.push({ diff --git a/src/features/jump-to-definition.ts b/src/features/jump-to-definition.ts index d76becf..013cbbb 100644 --- a/src/features/jump-to-definition.ts +++ b/src/features/jump-to-definition.ts @@ -45,15 +45,13 @@ const provider = (globalState: GlobalState) => { } type HandleJumpToLinksForImportsInput = { - document: vscode.TextDocument position: vscode.Position ast: ElmSyntax.Ast elmJsonFile: ElmJsonFile - packages: Packages } const handleJumpToLinksForImports = - async ({ document, position, ast, elmJsonFile, packages }: HandleJumpToLinksForImportsInput) + async ({ position, ast, elmJsonFile }: HandleJumpToLinksForImportsInput) : Promise => { for (let import_ of ast.imports) { @@ -128,10 +126,9 @@ const provider = (globalState: GlobalState) => { ast: ElmSyntax.Ast, doc: vscode.TextDocument elmJsonFile: ElmJsonFile - packages: Packages } - const handleJumpToLinksForDeclarations = async ({ position, ast, doc, elmJsonFile, packages }: HandleJumpToLinksForDeclarationsInput): Promise => { + const handleJumpToLinksForDeclarations = async ({ position, ast, doc, elmJsonFile }: HandleJumpToLinksForDeclarationsInput): Promise => { let { aliasMappingToModuleNames, explicitExposingValuesForImports, @@ -139,7 +136,7 @@ const provider = (globalState: GlobalState) => { } = ElmSyntax.getInitialPreludeMappings() const findImportedModuleNamesThatMightHaveExposedThisValue = (moduleName: string): string[] => { - let explicitMatches = explicitExposingValuesForImports[moduleName] || [] + let explicitMatches = explicitExposingValuesForImports.get(moduleName) ?? [] return explicitMatches.concat(hasUnknownImportsFromExposingAll) } @@ -315,7 +312,7 @@ const provider = (globalState: GlobalState) => { // would return "Html.Attributes" let parentModuleName = parentModules.join('.') - let aliases = aliasMappingToModuleNames[parentModuleName] || [] + let aliases = aliasMappingToModuleNames.get(parentModuleName) ?? [] let moduleNamesToCheck = [parentModuleName].concat(aliases) // Check local project files @@ -892,8 +889,12 @@ const provider = (globalState: GlobalState) => { if (import_.value.moduleAlias) { let alias = import_.value.moduleAlias.value[0] if (alias !== undefined) { - aliasMappingToModuleNames[alias] = aliasMappingToModuleNames[alias] || [] as string[] - (aliasMappingToModuleNames[alias] as any).push(moduleName) + const previous = aliasMappingToModuleNames.get(alias) + if (previous === undefined) { + aliasMappingToModuleNames.set(alias, [moduleName]) + } else { + previous.push(moduleName) + } } } @@ -910,8 +911,12 @@ const provider = (globalState: GlobalState) => { .map(node => ElmSyntax.toTopLevelExposeName(node.value)) for (let exportedName of namesOfExportedThings) { - explicitExposingValuesForImports[exportedName] = explicitExposingValuesForImports[exportedName] || [] as string[] - (explicitExposingValuesForImports[exportedName] as string[]).push(moduleName) + const previous = explicitExposingValuesForImports.get(exportedName) + if (previous === undefined) { + explicitExposingValuesForImports.set(exportedName, [moduleName]) + } else { + previous.push(moduleName) + } } if (isExposingAnyCustomVariants) { @@ -1014,15 +1019,14 @@ const provider = (globalState: GlobalState) => { } // Handle module imports - let packages = await sharedLogic.getMappingOfModuleNameToDocJsonFilepath(globalState, elmJsonFile) - matchingLocation = await handleJumpToLinksForImports({ document: doc, position, ast, elmJsonFile, packages }) + matchingLocation = await handleJumpToLinksForImports({ position, ast, elmJsonFile }) if (matchingLocation) { console.info('provideDefinition', `${Date.now() - start}ms`) return matchingLocation } // Handle module declarations - matchingLocation = await handleJumpToLinksForDeclarations({ position, ast, doc, elmJsonFile, packages }) + matchingLocation = await handleJumpToLinksForDeclarations({ position, ast, doc, elmJsonFile }) if (matchingLocation) { console.info('provideDefinition', `${Date.now() - start}ms`) return matchingLocation diff --git a/src/features/offline-package-docs.ts b/src/features/offline-package-docs.ts index 1c662a3..c8a0943 100644 --- a/src/features/offline-package-docs.ts +++ b/src/features/offline-package-docs.ts @@ -37,8 +37,8 @@ export const feature: Feature = ({ globalState, context }) => { let moduleNameNode = importNode.value.moduleName let range = SharedLogic.fromElmRange(moduleNameNode.range) let moduleName = moduleNameNode.value.join(".") - let docsJsonFsPath = packages[moduleName] - if (docsJsonFsPath) { + let docsJsonFsPath = packages.get(moduleName) + if (docsJsonFsPath !== undefined) { details.push({ range, docsJsonFsPath, @@ -258,8 +258,8 @@ export const feature: Feature = ({ globalState, context }) => { ) for (let moduleName of moduleNames) { - let docsJsonFsPath = packages[moduleName] - if (docsJsonFsPath) { + let docsJsonFsPath = packages.get(moduleName) + if (docsJsonFsPath !== undefined) { let typeOrValueName = await SharedLogic.doesModuleExposesValue( globalState, foo, @@ -343,8 +343,8 @@ export const feature: Feature = ({ globalState, context }) => { ) for (let moduleName of moduleNames) { - let docsJsonFsPath = packages[moduleName] - if (docsJsonFsPath) { + let docsJsonFsPath = packages.get(moduleName) + if (docsJsonFsPath !== undefined) { let typeOrValueName = await SharedLogic.doesModuleExposesValue( globalState, foo, diff --git a/src/features/shared/autodetect-elm-json.ts b/src/features/shared/autodetect-elm-json.ts index e663f26..2a762d4 100644 --- a/src/features/shared/autodetect-elm-json.ts +++ b/src/features/shared/autodetect-elm-json.ts @@ -7,7 +7,7 @@ import sharedLogic from './logic' export type GlobalState = { isFirstTimeRunningPlugin: boolean elmJsonFiles: ElmJsonFile[] - cachedDocs: Record + cachedDocs: Map jumpToDocDetails: JumpToDocDetails[] | undefined } @@ -51,7 +51,7 @@ export const run = async (globalState: GlobalState) => { let elmJsonFileUris = await vscode.workspace.findFiles('**/elm.json', '**/node_modules/**', 10) let possibleElmJsonFiles = await Promise.all(toElmJsonFiles({ elmJsonFileUris, settings })) globalState.elmJsonFiles = possibleElmJsonFiles.filter(sharedLogic.isDefined) - globalState.cachedDocs = {} + globalState.cachedDocs = new Map() console.info(`autodetectElmJson`, `${Date.now() - start}ms`) } diff --git a/src/features/shared/elm-json-file.ts b/src/features/shared/elm-json-file.ts index 336766a..168e81d 100644 --- a/src/features/shared/elm-json-file.ts +++ b/src/features/shared/elm-json-file.ts @@ -53,18 +53,18 @@ export type BinOp = { export const getDocumentationForElmPackage = async (globalState: GlobalState, fsPath: string): Promise => { - let cachedDocsForThisFsPath = globalState.cachedDocs[fsPath] + let cachedDocsForThisFsPath = globalState.cachedDocs.get(fsPath) - if (cachedDocsForThisFsPath) { + if (cachedDocsForThisFsPath !== undefined) { return cachedDocsForThisFsPath } else { try { let buffer = await vscode.workspace.fs.readFile(vscode.Uri.file(fsPath)) let contents = Buffer.from(buffer).toString('utf8') let json = JSON.parse(contents) - globalState.cachedDocs[fsPath] = json + globalState.cachedDocs.set(fsPath, json) return json - } catch (_) { + } catch { return [] } } diff --git a/src/features/shared/elm-to-ast/elm-syntax.ts b/src/features/shared/elm-to-ast/elm-syntax.ts index 1e0a4b0..4c94986 100644 --- a/src/features/shared/elm-to-ast/elm-syntax.ts +++ b/src/features/shared/elm-to-ast/elm-syntax.ts @@ -370,6 +370,9 @@ export type ModuleImportTracker = { findImportedModuleNamesForQualifiedValue: (moduleName: string) => string[] } +const toMap = (record: Record): Map => + new Map(Object.entries(record)) + // Need to build up a collection of which types and values // are being exposed by all imports. // (This will be useful later when jumping to definitions) @@ -393,12 +396,12 @@ type ImportAlias = string type ExposedValue = string type ModuleName = string type InitialPreludeData = { - explicitExposingValuesForImports: Record + explicitExposingValuesForImports: Map hasUnknownImportsFromExposingAll: ModuleName[] - aliasMappingToModuleNames: Record + aliasMappingToModuleNames: Map } export let getInitialPreludeMappings = (): InitialPreludeData => ({ - explicitExposingValuesForImports: { + explicitExposingValuesForImports: toMap({ 'List': ['List'], '(::)': ['List'], 'Maybe': ['Maybe'], @@ -412,12 +415,12 @@ export let getInitialPreludeMappings = (): InitialPreludeData => ({ 'Program': ['Platform'], 'Cmd': ['Platform.Cmd'], 'Sub': ['Platform.Sub'], - }, + }), hasUnknownImportsFromExposingAll: ['Basics'], - aliasMappingToModuleNames: { + aliasMappingToModuleNames: toMap({ 'Cmd': ['Platform.Cmd'], 'Sub': ['Platform.Sub'] - } + }) }) export const createModuleImportTracker = (ast: Ast): ModuleImportTracker => { @@ -435,8 +438,12 @@ export const createModuleImportTracker = (ast: Ast): ModuleImportTracker => { if (import_.value.moduleAlias) { let alias = import_.value.moduleAlias.value[0] if (alias !== undefined) { - aliasMappingToModuleNames[alias] = aliasMappingToModuleNames[alias] || [] as string[] - (aliasMappingToModuleNames[alias] as any).push(moduleName) + const previous = aliasMappingToModuleNames.get(alias) + if (previous === undefined) { + aliasMappingToModuleNames.set(alias, [moduleName]) + } else { + previous.push(moduleName) + } } } @@ -452,8 +459,12 @@ export const createModuleImportTracker = (ast: Ast): ModuleImportTracker => { .map(node => toTopLevelExposeName(node.value)) for (let exportedName of namesOfExportedThings) { - explicitExposingValuesForImports[exportedName] = explicitExposingValuesForImports[exportedName] || [] as string[] - (explicitExposingValuesForImports[exportedName] as string[]).push(moduleName) + const previous = explicitExposingValuesForImports.get(exportedName) + if (previous === undefined) { + explicitExposingValuesForImports.set(exportedName, [moduleName]) + } else { + previous.push(moduleName) + } } if (isExposingAnyCustomVariants) { @@ -467,11 +478,11 @@ export const createModuleImportTracker = (ast: Ast): ModuleImportTracker => { return { findImportedModuleNamesThatMightHaveExposedThisValue: (typeOrValueName: string): string[] => { - let explicitMatches = explicitExposingValuesForImports[typeOrValueName] || [] + let explicitMatches = explicitExposingValuesForImports.get(typeOrValueName) ?? [] return explicitMatches.concat(hasUnknownImportsFromExposingAll) }, findImportedModuleNamesForQualifiedValue: (moduleName: string): string[] => { - let aliases = aliasMappingToModuleNames[moduleName] || [] + let aliases = aliasMappingToModuleNames.get(moduleName) ?? [] let moduleNamesToCheck = [moduleName].concat(aliases) return moduleNamesToCheck } diff --git a/src/features/shared/logic.ts b/src/features/shared/logic.ts index 020147e..5a19eae 100644 --- a/src/features/shared/logic.ts +++ b/src/features/shared/logic.ts @@ -23,20 +23,20 @@ let findElmJsonFor = (globalState: AutodetectElmJson.GlobalState, uri: vscode.Ur } } -const getMappingOfModuleNameToDocJsonFilepath = async (globalState: AutodetectElmJson.GlobalState, elmJsonFile: ElmJsonFile): Promise> => { - let packages: { [key: string]: string } = {} +const getMappingOfModuleNameToDocJsonFilepath = async (globalState: AutodetectElmJson.GlobalState, elmJsonFile: ElmJsonFile): Promise> => { + const packages = new Map() const dependencies = elmJsonFile.dependencies - for (let dep of dependencies) { - let docs = await getDocumentationForElmPackage(globalState, dep.fsPath) - for (let doc of docs) { - packages[doc.name] = dep.fsPath + for (const dep of dependencies) { + const docs = await getDocumentationForElmPackage(globalState, dep.fsPath) + for (const doc of docs) { + packages.set(doc.name, dep.fsPath) } } return packages } -const findFirstOccurenceOfWordInFile = (word: string, rawJsonString: string): [number, number, number, number] | undefined => { +const findFirstOccurrenceOfWordInFile = (word: string, rawJsonString: string): [number, number, number, number] | undefined => { if (word && rawJsonString) { const regex = new RegExp(word, 'm') const match = rawJsonString.match(regex) @@ -104,7 +104,7 @@ export default { findElmJsonFor, fromElmRange, getMappingOfModuleNameToDocJsonFilepath, - findFirstOccurenceOfWordInFile, + findFirstOccurenceOfWordInFile: findFirstOccurrenceOfWordInFile, isDefined, doesModuleExposesValue, keepFilesThatExist diff --git a/src/features/type-driven-autocomplete.ts b/src/features/type-driven-autocomplete.ts index 4f54072..7e80567 100644 --- a/src/features/type-driven-autocomplete.ts +++ b/src/features/type-driven-autocomplete.ts @@ -21,22 +21,21 @@ export const feature: Feature = ({ globalState, context }) => { let range = new vscode.Range(line.range.start, position) let textBeforeCursor = document.getText(range) - let regex = /((?:[A-Z][_A-Za-z]+\.)+)$/ - let match = textBeforeCursor.match(regex) + let match = textBeforeCursor.match(autocompleteRegex) if (match) { - let packages: Record = {} + let packages = new Map() let moduleNameTheUserTyped = match[0].slice(0, -1) let aliasMap = getAliasesForCurrentFile(document) for (let [alias, moduleNames] of Object.entries(aliasMap)) { for (let moduleName of moduleNames) { - packages[alias] = packages[moduleName] || [] + packages.set(alias, packages.get(moduleName) ?? []) } } - let matchingAliasedModules = aliasMap[moduleNameTheUserTyped] - let moduleName = matchingAliasedModules && matchingAliasedModules[0] || moduleNameTheUserTyped + let matchingAliasedModules = aliasMap.get(moduleNameTheUserTyped) + let moduleName = matchingAliasedModules?.[0] ?? moduleNameTheUserTyped let elmStuffFolder = path.join(elmJson.projectFolder, 'elm-stuff', '0.19.1') let elmiFilepaths = await vscode.workspace.fs.readDirectory(vscode.Uri.file(elmStuffFolder)) @@ -66,16 +65,16 @@ export const feature: Feature = ({ globalState, context }) => { } for (let [moduleDoc, dependency] of packageModuleDocs) { - packages[moduleDoc.name] = toCompletionItems( + packages.set(moduleDoc.name, toCompletionItems( moduleDoc, allModuleNames, dependency.packageUserAndName - ) + )) } - let value = packages[moduleName] + let value = packages.get(moduleName) - if (value) { + if (value !== undefined) { console.info(`autocomplete`, `${Date.now() - start}ms`) return value } @@ -89,8 +88,12 @@ export const feature: Feature = ({ globalState, context }) => { return toCompletionItems(moduleDoc, allModuleNames) } } - } + // If you have typed `Json.` but there are no functions or types in that namespace, + // suggest `Decode` and `Encode` if appropriate. + console.info(`autocomplete`, `${Date.now() - start}ms`) + return moduleNameCompletions(moduleName, allModuleNames) + } } // return an empty array if the suggestions are not applicable @@ -221,25 +224,30 @@ const toModuleDoc = (ast: ElmSyntax.Ast): ModuleDoc => { // COMPLETION ITEMS const toCompletionItems = (moduleDoc: ModuleDoc, allModuleNames: string[], packageUserAndName?: string): vscode.CompletionItem[] => { - let subModules = Object.keys( - allModuleNames - .filter(name => name.startsWith(moduleDoc.name)) - .reduce((obj: Record, name: string) => { - let partAfterThisModule = name.slice(moduleDoc.name.length + 1).split('.') - if (partAfterThisModule[0]) { - obj[partAfterThisModule[0]] = true - } - return obj - }, {}) - ) return [ - ...subModules.map(toNamespaceCompletionItem(packageUserAndName)), + ...moduleNameCompletions(moduleDoc.name, allModuleNames, packageUserAndName), ...moduleDoc.aliases.map(toAliasCompletionItem(packageUserAndName)), ...moduleDoc.unions.flatMap(toUnionCompletionItems(packageUserAndName)), ...moduleDoc.values.map(toValueCompletionItem(packageUserAndName)), ] } +const moduleNameCompletions = (moduleName: string, allModuleNames: string[], packageUserAndName?: string): vscode.CompletionItem[] => { + const modulePrefix = `${moduleName}.` + const subModules = new Set() + + for (const moduleName of allModuleNames) { + if (moduleName.startsWith(modulePrefix)) { + const partAfterThisModule = moduleName.slice(modulePrefix.length).split('.')[0] + if (partAfterThisModule !== undefined) { + subModules.add(partAfterThisModule) + } + } + } + + return Array.from(subModules, toNamespaceCompletionItem(packageUserAndName)) +} + const toNamespaceCompletionItem = (packageUserAndName?: string) => (moduleName: string): vscode.CompletionItem => ({ label: { label: moduleName, @@ -257,16 +265,6 @@ const toAliasCompletionItem = (packageUserAndName?: string) => (alias: Alias): v documentation: new vscode.MarkdownString(alias.comment) }) -const toBinopCompletionItem = (packageUserAndName?: string) => (binop: BinOp): vscode.CompletionItem => ({ - label: { - label: binop.name, - description: packageUserAndName, - detail: simplifyAnnotation(binop.type) - }, - kind: vscode.CompletionItemKind.Operator, - documentation: new vscode.MarkdownString(binop.comment) -}) - const toUnionCompletionItems = (packageUserAndName?: string) => (union: Union): vscode.CompletionItem[] => { let unionName = [union.name, ...union.args].join(' ') let typeCompletionItem: vscode.CompletionItem = { @@ -305,38 +303,47 @@ const toValueCompletionItem = (packageUserAndName?: string) => (value: Value): v const simplifyAnnotation = (type: string): string => { if (!type) return '' - let simplifiedAnnotation = - type.replace(/(\b[A-Za-z]+\.)+/g, "") + const simplifiedAnnotation = + type.replace(moduleQualifierRegex, "") return ` : ${simplifiedAnnotation}` } type ModuleName = string +// Parts for these regexes are taken from here: https://github.com/rtfeldman/node-test-runner/blob/eedf853fc9b45afd73a0db72decebdb856a69771/lib/Parser.js#L234 +// +// Regular expression to match import statements. +// Note: This might match inside multiline comments and multiline strings. +const importRegex = /^import\s+(\p{Lu}[_\d\p{L}]*(?:\.\p{Lu}[_\d\p{L}]*)*)(?:\s+as\s+(\p{Lu}[_\d\p{L}]*))?/gmu +// Regular expression to match the `Module.Name.` part of `Module.Name.function`. +const moduleQualifierRegex = /(\b\p{Lu}[_\d\p{L}]*\.)+/gu +// Regular expression to match `Module.Name.` before the cursor, for triggering autocomplete. +const autocompleteRegex = /(?:\p{Lu}[_\d\p{L}]*\.)+$/u + // // This scans the file with a regex, so we can still provide // a good autocomplete experience, even for incomplete Elm files. // -const getAliasesForCurrentFile = (document: vscode.TextDocument): Record => { +const getAliasesForCurrentFile = (document: vscode.TextDocument): Map => { let code = document.getText() // Start with the two aliases implicitly included // in every Elm file: - let alias: Record = { - Cmd: ['Platform.Cmd'], - Sub: ['Platform.Sub'], - } - - // Regular expression to match import statements - let importRegex = /import\s([\w\.]+)(\sas\s(\w+))?/g - - let match; - while ((match = importRegex.exec(code)) !== null) { - let moduleName = match[1] - let aliasName = match[3] - if (moduleName && aliasName) { - alias[aliasName] = alias[aliasName] || [] - alias[aliasName]?.push(moduleName) + let alias = new Map([ + ['Cmd', ['Platform.Cmd']], + ['Sub', ['Platform.Sub']], + ]) + + for (const match of code.matchAll(importRegex)) { + const [, moduleName, aliasName] = match + if (moduleName !== undefined && aliasName !== undefined) { + const previous = alias.get(aliasName) + if (previous === undefined) { + alias.set(aliasName, [moduleName]) + } else { + previous.push(moduleName) + } } } return alias