Skip to content

Commit

Permalink
feat: Add files auto import! By configuring a filesAutoImport setti…
Browse files Browse the repository at this point in the history
…ng you can now setup robust auto imports for .svg, .styles or any other extension!
  • Loading branch information
zardoy committed Feb 4, 2024
1 parent 0e061d3 commit 1c0edd3
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 5 deletions.
24 changes: 24 additions & 0 deletions src/configurationType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,30 @@ export type Configuration = {
* @default false
*/
declareMissingPropertyQuickfixOtherFiles: boolean
/**
* @default {}
*/
filesAutoImport: {
[ext: string]: {
/**
* Override import path (default is "$path")
*/
importPath?: string
/**
* Start phrase that will trigger search for available files import
*/
prefix: string
/**
* @default camel
*/
nameCasing?: 'camel' | 'pascal' | 'constant' | 'snake'
/**
* @default $name
*/
nameTransform?: string
iconPost?: string
}
}
}

// scrapped using search editor. config: caseInsensitive, context lines: 0, regex: const fix\w+ = "[^ ]+"
Expand Down
19 changes: 17 additions & 2 deletions typescript/src/completionEntryDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,29 @@ export default function completionEntryDetails(
const sourceFile = program?.getSourceFile(fileName)
if (!program || !sourceFile) return

const { documentationOverride, documentationAppend, detailPrepend } = prevCompletionsMap[entryName] ?? {}
const { documentationOverride, documentationAppend, detailPrepend, textChanges } = prevCompletionsMap[entryName] ?? {}
if (documentationOverride) {
return {
const prior: ts.CompletionEntryDetails = {
name: entryName,
kind: ts.ScriptElementKind.alias,
kindModifiers: '',
displayParts: typeof documentationOverride === 'string' ? [{ kind: 'text', text: documentationOverride }] : documentationOverride,
}
if (textChanges) {
prior.codeActions = [
// ...(prior.codeActions ?? []),
{
description: 'Includes Text Changes',
changes: [
{
fileName,
textChanges,
},
],
},
]
}
return prior
}
let prior = languageService.getCompletionEntryDetails(
fileName,
Expand Down
146 changes: 146 additions & 0 deletions typescript/src/completions/filesAutoImport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { camelCase, pascalCase, snakeCase, constantCase } from 'change-case'
import { Configuration } from '../types'
import { nodeModules } from '../utils'
import { sharedCompletionContext } from './sharedContext'

export default () => {
const { c, prior, languageService, languageServiceHost, node, sourceFile, prevCompletionsMap } = sharedCompletionContext
// todo better web support?
if (!node || !languageServiceHost.readDirectory || !nodeModules?.path) return
const filesAutoImport = c('filesAutoImport')
const included: Array<{ ext: string; item: Configuration['filesAutoImport'][string] }> = []
const currentText = node.getText()
for (const [ext, item] of Object.entries(filesAutoImport)) {
if (currentText.startsWith(item.prefix)) included.push({ ext, item })
}
// if (!included.length) return
const root = languageServiceHost.getCurrentDirectory()
// const fileRelative = nodeModules.path.relative(root, sourceFile.fileName)
const collected = [] as string[]
const MAX_ITERATIONS = 200
let iter = 0
const collectFiles = (dir: string) => {
iter++
if (iter > MAX_ITERATIONS) {
console.error('[essentials plugin filesAutoImport] Max iterations reached')
return
}
const files = nodeModules!.fs.readdirSync(dir, { withFileTypes: true })
for (const file of files) {
if (file.isDirectory()) {
if (
file.name === 'node_modules' ||
file.name.startsWith('.') ||
file.name.startsWith('out') ||
file.name.startsWith('build') ||
file.name.startsWith('dist')
)
continue
collectFiles(nodeModules!.path.join(dir, file.name))
} else if (file.isFile()) {
// const ext = nodeModules!.path.extname(file.name)
// if (included.some(i => i.ext === ext)) files.push(nodeModules!.path.join(dir, file.name))
collected.push(nodeModules!.path.relative(root, nodeModules!.path.join(dir, file.name)))
}
}
}
collectFiles(root)

const lastImport = sourceFile.statements.filter(ts.isImportDeclaration).at(-1)

// const directory = languageServiceHost.readDirectory(root, undefined, undefined, undefined, 1)
const completions: Array<{
name: string
insertText: string
addImport: string
detail: string
description: string
sort: number
}> = []
for (const { ext, item } of included) {
const files = collected.filter(f => f.endsWith(ext))
for (const file of files) {
const fullPath = nodeModules.path.join(root, file)
const relativeToFile = nodeModules.path.relative(nodeModules.path.dirname(sourceFile.fileName), fullPath).replaceAll('\\', '/')
const lastModified = nodeModules.fs.statSync(fullPath).mtime
const lastModifiedFormatted = timeDifference(Date.now(), lastModified.getTime())
const importPath = (item.importPath ?? '$path').replaceAll('$path', relativeToFile)
const casingFn = {
camel: camelCase,
pascal: pascalCase,
snake: snakeCase,
constant: constantCase,
}
const name =
item.prefix + casingFn[item.nameCasing ?? 'camel']((item.nameTransform ?? '$name').replaceAll('$name', nodeModules.path.basename(file, ext)))
if (prior.entries.some(e => e.name === name)) continue
completions.push({
name,
insertText: name,
sort: Date.now() - lastModified.getTime(),
detail: `${item.iconPost?.replaceAll('$path', relativeToFile) ?? '📄'} ${lastModifiedFormatted}`,
description: importPath,
addImport: `import ${name} from '${importPath}'`,
})
}
}

const prependImport = lastImport ? '\n' : ''
const entries = completions.map(({ name, insertText, detail, sort, addImport, description }): ts.CompletionEntry => {
prevCompletionsMap[name] = {
textChanges: [
{
newText: `${prependImport}${addImport}`,
span: {
start: lastImport?.end ?? 0,
length: 0,
},
},
],
documentationOverride: description,
}
return {
kind: ts.ScriptElementKind.variableElement,
name,
insertText,
sortText: `${sort}`,
labelDetails: {
description: detail,
},
// description,
}
})
return entries
}

function timeDifference(current, previous) {
const msPerMinute = 60 * 1000
const msPerHour = msPerMinute * 60
const msPerDay = msPerHour * 24
const msPerMonth = msPerDay * 30
const msPerYear = msPerDay * 365

const elapsed = current - previous

if (elapsed < msPerMinute) {
return `${Math.round(elapsed / 1000)} sec ago`
}

if (elapsed < msPerHour) {
return `${Math.round(elapsed / msPerMinute)} min ago`
}

if (elapsed < msPerDay) {
return `${Math.round(elapsed / msPerHour)} h ago`
}

if (elapsed < msPerMonth) {
return `${Math.round(elapsed / msPerDay)} days ago`
}

if (elapsed < msPerYear) {
return `${Math.round(elapsed / msPerMonth)} months ago`
}

return `${Math.round(elapsed / msPerYear)} years ago`
}
2 changes: 1 addition & 1 deletion typescript/src/completions/sharedContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ export const sharedCompletionContext = {} as unknown as Readonly<{
preferences: ts.UserPreferences
fullText: string
typeChecker: ts.TypeChecker
// languageServiceHost: ts.LanguageServiceHost
languageServiceHost: ts.LanguageServiceHost
}>
6 changes: 5 additions & 1 deletion typescript/src/completionsAtPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import localityBonus from './completions/localityBonus'
import functionCompletions from './completions/functionCompletions'
import staticHintSuggestions from './completions/staticHintSuggestions'
import typecastCompletions from './completions/typecastCompletions'
import filesAutoImport from './completions/filesAutoImport'

export type PrevCompletionMap = Record<
string,
Expand All @@ -42,7 +43,7 @@ export type PrevCompletionMap = Record<
detailPrepend?: string
documentationAppend?: string
range?: [number, number]
// textChanges?: ts.TextChange[]
textChanges?: ts.TextChange[]
}
>
export type PrevCompletionsAdditionalData = {
Expand All @@ -63,6 +64,7 @@ export const getCompletionsAtPosition = (
options: ts.GetCompletionsAtPositionOptions | undefined,
c: GetConfig,
languageService: ts.LanguageService,
languageServiceHost: ts.LanguageServiceHost,
scriptSnapshot: ts.IScriptSnapshot,
formatOptions: ts.FormatCodeSettings | undefined,
additionalData: { scriptKind: ts.ScriptKind; compilerOptions: ts.CompilerOptions },
Expand Down Expand Up @@ -148,6 +150,7 @@ export const getCompletionsAtPosition = (
prior: prior!,
fullText: sourceFile.getFullText(),
typeChecker: program.getTypeChecker(),
languageServiceHost,
} satisfies typeof sharedCompletionContext)

if (node && !hasSuggestions && ensurePrior() && prior) {
Expand Down Expand Up @@ -376,6 +379,7 @@ export const getCompletionsAtPosition = (
}

if (!prior.isMemberCompletion) {
prior.entries = [...prior.entries, ...(filesAutoImport() ?? [])]
prior.entries = markOrRemoveGlobalCompletions(prior.entries, position, languageService, c) ?? prior.entries
}
if (exactNode) {
Expand Down
2 changes: 1 addition & 1 deletion typescript/src/decorateProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export const decorateLanguageService = (
if (!scriptSnapshot) return
const compilerOptions = languageServiceHost.getCompilationSettings()
try {
const result = getCompletionsAtPosition(fileName, position, options, c, languageService, scriptSnapshot, formatOptions, {
const result = getCompletionsAtPosition(fileName, position, options, c, languageService, languageServiceHost, scriptSnapshot, formatOptions, {
scriptKind,
compilerOptions,
})
Expand Down
1 change: 1 addition & 0 deletions typescript/test/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const getCompletionsAtPosition = (pos: number, { fileName = entrypoint, s
},
defaultConfigFunc,
languageService,
languageServiceHost,
languageServiceHost.getScriptSnapshot(entrypoint)!,
{
convertTabsToSpaces: false,
Expand Down

0 comments on commit 1c0edd3

Please sign in to comment.