diff --git a/src/codeAction/codeActionProvider.ts b/src/codeAction/codeActionProvider.ts index 29616b10..778e81fd 100644 --- a/src/codeAction/codeActionProvider.ts +++ b/src/codeAction/codeActionProvider.ts @@ -1,5 +1,7 @@ +import { codeActionProvider as configProvider } from "@src/features/config"; import { codeActionProvider as envProvider } from "@src/features/env"; import { codeActionProvider as inertiaProvider } from "@src/features/inertia"; +import { codeActionProvider as translationProvider } from "@src/features/translation"; import { codeActionProvider as viewProvider } from "@src/features/view"; import * as vscode from "vscode"; import { CodeActionProviderFunction } from ".."; @@ -8,6 +10,8 @@ const providers: CodeActionProviderFunction[] = [ envProvider, inertiaProvider, viewProvider, + translationProvider, + configProvider, ]; export class CodeActionProvider implements vscode.CodeActionProvider { diff --git a/src/diagnostic/index.ts b/src/diagnostic/index.ts index b2de380f..1ca7bb6c 100644 --- a/src/diagnostic/index.ts +++ b/src/diagnostic/index.ts @@ -1,10 +1,6 @@ +import { AutocompleteParsingResult } from "@src/types"; import { Diagnostic, DiagnosticSeverity, Range, Uri } from "vscode"; -export type DiagnosticCodeWithTarget = { - value: DiagnosticCode; - target: Uri; -}; - export type DiagnosticCode = | "appBinding" | "asset" @@ -20,17 +16,21 @@ export type DiagnosticCode = | "view" | "storage_disk"; -export type NotFoundCode = DiagnosticCode | DiagnosticCodeWithTarget; +export class DiagnosticWithContext extends Diagnostic { + context?: AutocompleteParsingResult.ContextValue; +} export const notFound = ( descriptor: string, match: string, range: Range, - code: NotFoundCode, -): Diagnostic => ({ + code: DiagnosticCode, + context?: AutocompleteParsingResult.ContextValue, +): DiagnosticWithContext => ({ message: `${descriptor} [${match}] not found.`, severity: DiagnosticSeverity.Warning, range, source: "Laravel Extension", code, + context, }); diff --git a/src/features/config.ts b/src/features/config.ts index 51b493eb..189e7709 100644 --- a/src/features/config.ts +++ b/src/features/config.ts @@ -1,15 +1,18 @@ -import { notFound, NotFoundCode } from "@src/diagnostic"; +import { openFile } from "@src/commands"; +import { notFound } from "@src/diagnostic"; import AutocompleteResult from "@src/parser/AutocompleteResult"; import { getConfigPathByName, getConfigs } from "@src/repositories/configs"; import { config } from "@src/support/config"; import { findHoverMatchesInDoc } from "@src/support/doc"; +import { getIndentNumber } from "@src/support/indent"; import { detectedRange, detectInDoc } from "@src/support/parser"; import { wordMatchRegex } from "@src/support/patterns"; import { projectPath } from "@src/support/project"; -import { contract, facade } from "@src/support/util"; +import { contract, facade, withLineFragment } from "@src/support/util"; import { AutocompleteParsingResult } from "@src/types"; import * as vscode from "vscode"; import { + CodeActionProviderFunction, CompletionProvider, FeatureTag, HoverProvider, @@ -167,18 +170,119 @@ export const diagnosticProvider = ( return null; } - const pathToFile = getConfigPathByName(param.value); + return notFound( + "Config", + param.value, + detectedRange(param), + "config", + ); + }, + ); +}; - const code: NotFoundCode = pathToFile - ? { - value: "config", - target: vscode.Uri.file(projectPath(pathToFile)), - } - : "config"; +export const codeActionProvider: CodeActionProviderFunction = async ( + diagnostic: vscode.Diagnostic, + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + token: vscode.CancellationToken, +): Promise => { + if (diagnostic.code !== "config") { + return []; + } - return notFound("Config", param.value, detectedRange(param), code); - }, + const missingVar = document.getText(diagnostic.range); + + if (!missingVar) { + return []; + } + + const actions = await Promise.all([addToFile(diagnostic, missingVar)]); + + return actions.filter((action) => action !== null); +}; + +const addToFile = async ( + diagnostic: vscode.Diagnostic, + missingVar: string, +): Promise => { + const edit = new vscode.WorkspaceEdit(); + + const config = getConfigs().items.configs.find( + (c) => c.name === missingVar, ); + + if (config) { + return null; + } + + const configPath = getConfigPathByName(missingVar); + + if (!configPath) { + return null; + } + + const fileName = configPath.path.split("/").pop()?.replace(".php", ""); + + if (!fileName) { + return null; + } + + // Remember that Laravel config keys can go to subfolders, for example: foo.bar.baz.example + // can be: foo/bar.php with a key "baz.example" but also foo/bar/baz.php with a key "example" + const countNestedKeys = + missingVar.substring(missingVar.indexOf(`${fileName}.`)).split(".") + .length - 1; + + // Case when a user tries to add a new config key to an existing key that is not an array + if (!configPath.line && countNestedKeys > 1) { + return null; + } + + const configContents = await vscode.workspace.fs.readFile( + vscode.Uri.file(configPath.path), + ); + + const lineNumberFromConfig = configPath.line + ? Number(configPath.line) - 1 + : undefined; + + const lineNumber = + lineNumberFromConfig ?? + configContents + .toString() + .split("\n") + .findIndex((line) => line.startsWith("];")); + + if (lineNumber === -1) { + return null; + } + + const key = missingVar.split(".").pop(); + + const indent = " ".repeat((getIndentNumber("php") ?? 4) * countNestedKeys); + + const finalValue = `${indent}'${key}' => '',\n`; + + edit.insert( + vscode.Uri.file(configPath.path), + new vscode.Position(lineNumber, 0), + finalValue, + ); + + const action = new vscode.CodeAction( + "Add config to the file", + vscode.CodeActionKind.QuickFix, + ); + + action.edit = edit; + action.command = openFile( + configPath.path, + lineNumber, + finalValue.length - 3, + ); + action.diagnostics = [diagnostic]; + + return action; }; export const completionProvider: CompletionProvider = { diff --git a/src/features/translation.ts b/src/features/translation.ts index f97b4aa1..affaaf6b 100644 --- a/src/features/translation.ts +++ b/src/features/translation.ts @@ -1,4 +1,5 @@ -import { notFound, NotFoundCode } from "@src/diagnostic"; +import { openFile } from "@src/commands"; +import { DiagnosticWithContext, notFound } from "@src/diagnostic"; import AutocompleteResult from "@src/parser/AutocompleteResult"; import { getTranslationItemByName, @@ -8,13 +9,24 @@ import { } from "@src/repositories/translations"; import { config } from "@src/support/config"; import { findHoverMatchesInDoc } from "@src/support/doc"; +import { getIndentNumber } from "@src/support/indent"; import { detectedRange, detectInDoc } from "@src/support/parser"; import { wordMatchRegex } from "@src/support/patterns"; import { projectPath, relativePath } from "@src/support/project"; -import { contract, createIndexMapping, facade } from "@src/support/util"; +import { + contract, + createIndexMapping, + facade, + withLineFragment, +} from "@src/support/util"; import { AutocompleteParsingResult } from "@src/types"; import * as vscode from "vscode"; -import { FeatureTag, HoverProvider, LinkProvider } from ".."; +import { + CodeActionProviderFunction, + FeatureTag, + HoverProvider, + LinkProvider, +} from ".."; const toFind: FeatureTag = [ { @@ -191,28 +203,184 @@ export const diagnosticProvider = ( return null; } - const pathToFile = getTranslationPathByName( - param.value, - getLang(item as AutocompleteParsingResult.MethodCall), - ); - - const code: NotFoundCode = pathToFile - ? { - value: "translation", - target: vscode.Uri.file(projectPath(pathToFile)), - } - : "translation"; - return notFound( "Translation", param.value, detectedRange(param), - code, + "translation", + item, ); }, ); }; +export const codeActionProvider: CodeActionProviderFunction = async ( + diagnostic: DiagnosticWithContext, + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + token: vscode.CancellationToken, +): Promise => { + if (diagnostic.code !== "translation") { + return []; + } + + const missingVar = document.getText(diagnostic.range); + + if (!missingVar) { + return []; + } + + const actions = await Promise.all([ + addToPhpFile(diagnostic, missingVar), + addToJsonFile(diagnostic, missingVar), + ]); + + return actions.filter((action) => action !== null); +}; + +const addToJsonFile = async ( + diagnostic: DiagnosticWithContext, + missingVar: string, +): Promise => { + const edit = new vscode.WorkspaceEdit(); + + const translation = getTranslationItemByName(missingVar); + + if (translation) { + return null; + } + + const lang = + getLang(diagnostic.context as AutocompleteParsingResult.MethodCall) ?? + getTranslations().items.default; + + const translationPath = getTranslations().items.paths.find( + (path) => !path.startsWith("vendor/") && path.endsWith(`${lang}.json`), + ); + + if (!translationPath) { + return null; + } + + const translationContents = await vscode.workspace.fs.readFile( + vscode.Uri.file(projectPath(translationPath)), + ); + + const lines = translationContents.toString().split("\n"); + + const lineNumber = lines.findIndex((line) => line.startsWith("}")); + + if (lineNumber === -1) { + return null; + } + + const indent = " ".repeat(getIndentNumber("json") ?? 4); + + const finalValue = `${indent}"${missingVar}": ""\n`; + + edit.insert( + vscode.Uri.file(projectPath(translationPath)), + new vscode.Position(lineNumber - 1, lines[lineNumber - 1].length), + ",", + ); + + edit.insert( + vscode.Uri.file(projectPath(translationPath)), + new vscode.Position(lineNumber, 0), + finalValue, + ); + + const action = new vscode.CodeAction( + "Add translation to the JSON file", + vscode.CodeActionKind.QuickFix, + ); + + action.edit = edit; + action.command = openFile( + projectPath(translationPath), + lineNumber, + finalValue.length - 2, + ); + action.diagnostics = [diagnostic]; + + return action; +}; + +const addToPhpFile = async ( + diagnostic: DiagnosticWithContext, + missingVar: string, +): Promise => { + const edit = new vscode.WorkspaceEdit(); + + const translation = getTranslationItemByName(missingVar); + + if (translation) { + return null; + } + + const translationPath = getTranslationPathByName( + missingVar, + getLang(diagnostic.context as AutocompleteParsingResult.MethodCall), + ); + + if (!translationPath) { + return null; + } + + const countNestedKeys = missingVar.split(".").length - 1; + + // Case when a user tries to add a new translation key to an existing key that is not an array + if (!translationPath.line && countNestedKeys > 1) { + return null; + } + + const translationContents = await vscode.workspace.fs.readFile( + vscode.Uri.file(translationPath.path), + ); + + const lineNumberFromConfig = translationPath.line + ? translationPath.line - 1 + : undefined; + + const lineNumber = + lineNumberFromConfig ?? + translationContents + .toString() + .split("\n") + .findIndex((line) => line.startsWith("];")); + + if (lineNumber === -1) { + return null; + } + + const key = missingVar.split(".").pop(); + + const indent = " ".repeat((getIndentNumber("php") ?? 4) * countNestedKeys); + + const finalValue = `${indent}'${key}' => '',\n`; + + edit.insert( + vscode.Uri.file(translationPath.path), + new vscode.Position(lineNumber, 0), + finalValue, + ); + + const action = new vscode.CodeAction( + "Add translation to the PHP file", + vscode.CodeActionKind.QuickFix, + ); + + action.edit = edit; + action.command = openFile( + translationPath.path, + lineNumber, + finalValue.length - 3, + ); + action.diagnostics = [diagnostic]; + + return action; +}; + export const completionProvider = { tags() { return toFind; diff --git a/src/repositories/configs.ts b/src/repositories/configs.ts index a7d2f00d..d23d2d5b 100644 --- a/src/repositories/configs.ts +++ b/src/repositories/configs.ts @@ -1,3 +1,4 @@ +import { projectPath } from "@src/support/project"; import { repository } from "."; import { Config } from ".."; import { runInLaravel, template } from "../support/php"; @@ -7,23 +8,75 @@ interface ConfigGroupResult { paths: string[]; } -export const getConfigPathByName = (match: string): string | undefined => { - const filePath = match.replace(/\.[^.]+$/, ""); - - for (const tryPath of [ - filePath.replaceAll(".", "/"), - filePath.replace(/^([^.]+)\..*$/, "$1"), - ]) { - const configPath = getConfigs().items.paths.find((path) => { - return ( - !path.startsWith("vendor/") && path.endsWith(`${tryPath}.php`) - ); - }); - - if (configPath) { - return configPath; +interface ConfigPath { + path: string; + line?: string | null; +} + +export const getConfigByName = (name: string): Config | undefined => { + return getConfigs().items.configs.find((item) => item.name === name); +}; + +export const getParentConfigByName = (match: string): Config | undefined => { + const name = match.match(/^(.+\..+)\./)?.[0]; + + if (!name) { + return undefined; + } + + return getConfigs().items.configs.find((config) => + new RegExp(`^${name}[^.]*$`).test(config.name), + ); +}; + +export const getConfigPathByName = (match: string): ConfigPath | undefined => { + // Firstly, we try to get the parent Config, because it has a path and a line + const parentItem = getParentConfigByName(match); + + let path = parentItem?.file; + + // If the path is not found (because, for example, config file is empty), + // we try to find the path by the file name + if (!path) { + const fileName = match.replace(/\.[^.]+$/, ""); + + // We have to check every possible subfolder, for example: foo.bar.baz.example + // can be: foo/bar.php with a key "baz.example" but also foo/bar/baz.php with a key "example" + const parts = fileName.split("."); + const subfolderPaths = parts + .slice(1) + .map((_, i) => + ( + parts.slice(0, i + 2).join("/") + + "." + + parts.slice(i + 2).join(".") + ).replace(/^([^.]+)\..*$/, "$1"), + ) + .reverse(); + + for (const tryPath of [ + ...subfolderPaths, + fileName.replace(/^([^.]+)\..*$/, "$1"), + ]) { + path = getConfigs().items.paths.find((path) => { + return ( + !path.startsWith("vendor/") && + path.endsWith(`${tryPath}.php`) + ); + }); + + if (path) { + break; + } } } + + return path + ? { + path: projectPath(path), + line: parentItem?.line, + } + : undefined; }; export const getConfigs = repository({ diff --git a/src/repositories/translations.ts b/src/repositories/translations.ts index a55b3263..1740bb17 100644 --- a/src/repositories/translations.ts +++ b/src/repositories/translations.ts @@ -35,6 +35,11 @@ interface TranslationGroupPhpResult { languages: string[]; } +interface TranslationPath { + path: string; + line?: number; +} + let dirsToWatch: string[] | null = null; const load = () => { @@ -78,20 +83,58 @@ export const getTranslationItemByName = ( return getTranslations().items.translations[match.replaceAll("\\", "")]; }; +export const getParentTranslationItemByName = ( + match: string, +): TranslationItem | undefined => { + const name = match.match(/^(.+\..+)\./)?.[0]; + + if (!name) { + return undefined; + } + + const parentName = Object.keys(getTranslations().items.translations).find( + (key) => key.startsWith(name.replaceAll("\\", "")), + ); + + return parentName ? getTranslationItemByName(parentName) : undefined; +}; + export const getTranslationPathByName = ( match: string, - lang: string | undefined, -): string | undefined => { + lang?: string | undefined, +): TranslationPath | undefined => { lang = lang ?? getTranslations().items.default; - const fileName = match.replace(/^.*::/, "").replace(/^([^.]+)\..*$/, "$1"); - - return getTranslations().items.paths.find((path) => { - return ( - !path.startsWith("vendor/") && - path.endsWith(`${lang}/${fileName}.php`) - ); - }); + // Firstly, we try to get the parent TranslationItem, because it has a path and a line + const parentItem = getParentTranslationItemByName(match); + + let path = parentItem?.[lang]?.path; + + // If the path is not found (because, for example, translation file is empty), + // we try to find the path by the file name + if (!path) { + const fileName = match + .replace(/^.*::/, "") + .replace(/^([^.]+)\..*$/, "$1"); + + path = getTranslations().items.paths.find((path) => { + return ( + !path.startsWith("vendor/") && + path.endsWith(`${lang}/${fileName}.php`) + ); + }); + + if (path) { + path = projectPath(path); + } + } + + return path + ? { + path: path, + line: parentItem?.[lang]?.line, + } + : undefined; }; export const getTranslations = repository({ diff --git a/src/support/indent.ts b/src/support/indent.ts new file mode 100644 index 00000000..85abd69a --- /dev/null +++ b/src/support/indent.ts @@ -0,0 +1,32 @@ +import * as vscode from "vscode"; + +export function getIndentNumber(extension: string): number | undefined { + /** + * {@link vscode.TextEditorOptions} + */ + const config = vscode.workspace.getConfiguration("editor", { + languageId: extension, + }); + + const insertSpaces = config.get( + "insertSpaces", + ); + + if (insertSpaces !== true) { + return undefined; + } + + const indentSize = config.get("indentSize"); + + if (typeof indentSize === "number") { + return indentSize; + } + + const tabSize = config.get("tabSize"); + + if (typeof tabSize === "number") { + return tabSize; + } + + return undefined; +} diff --git a/src/support/util.ts b/src/support/util.ts index 72325cc3..8a5f48f7 100644 --- a/src/support/util.ts +++ b/src/support/util.ts @@ -18,6 +18,12 @@ export const indent = (text: string = "", repeat: number = 1): string => { return "\t" + text; }; +export const withLineFragment = ( + line: number | string | undefined | null, +): { fragment?: string } => { + return line ? { fragment: `L${line}` } : {}; +}; + export const trimQuotes = (text: string): string => text.substring(1, text.length - 1);