diff --git a/package.json b/package.json index 3948a51d..aafefbac 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,10 @@ { "id": "laravel.wrapWithHelper.submenu", "label": "Wrap selection with helpers" + }, + { + "id": "laravel.artisanMake.submenu", + "label": "New Laravel File..." } ], "menus": { @@ -94,6 +98,165 @@ "command": "laravel.namespace.generate", "when": "resourceLangId == php", "group": "laravel" + }, + { + "command": "laravel.artisan.make", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.cast", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.channel", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.class", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.command", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.component", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.controller", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.enum", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.event", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.exception", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.factory", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.interface", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.job", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.job-middleware", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.listener", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.livewire", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.mail", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.middleware", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.migration", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.model", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.notification", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.observer", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.policy", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.provider", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.request", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.resource", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.rule", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.scope", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.seeder", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.test", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.trait", + "when": "false", + "group": "laravel" + }, + { + "command": "laravel.artisan.make.view", + "when": "false", + "group": "laravel" } ], "editor/context": [ @@ -118,6 +281,170 @@ "group": "laravel" } ], + "explorer/context": [ + { + "submenu": "laravel.artisanMake.submenu", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)(app|src|database|resource(s?)|test(s?))(\\/|\\\\|$)/i", + "group": "navigation" + } + ], + "laravel.artisanMake.submenu": [ + { + "command": "laravel.artisan.make.cast", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Cast(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.channel", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Broadcasting(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.class", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)(app|src)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.command", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Console(\\/|\\\\)Command(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.component", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)View(s?)(\\/|\\\\)Component(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.controller", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Controller(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.enum", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)(Enum|ValueObject)(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.event", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Event(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.exception", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Exception(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.factory", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Database(\\/|\\\\)Factor(y|ies)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.interface", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)(app|src)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.job", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Job(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.job-middleware", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Job(s?)(\\/|\\\\)Middleware(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.listener", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Listener(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.livewire", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Livewire(\\/|\\\\)Component(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.mail", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Mail(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.middleware", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Http(\\/|\\\\)Middleware(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.migration", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Database(\\/|\\\\)Migration(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.model", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Model(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.notification", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Notification(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.observer", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Observer(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.policy", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Polic(y|ies)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.provider", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Provider(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.request", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Http(\\/|\\\\)Request(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.resource", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Http(\\/|\\\\)Resource(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.rule", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Rule(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.scope", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Scope(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.seeder", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Database(\\/|\\\\)Seed(er?)(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.test", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)Test(s?)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.trait", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)(app|src)(\\/|\\\\|$)/i", + "group": "navigation" + }, + { + "command": "laravel.artisan.make.view", + "when": "explorerResourceIsFolder && resourcePath =~ /(\\/|\\\\)View(s?)(\\/|\\\\|$)/i", + "group": "navigation" + } + ], "laravel.wrapWithHelper.submenu": [ { "command": "laravel.wrapWithHelper.dd" @@ -218,6 +545,166 @@ "command": "laravel.namespace.generate", "title": "Generate namespace", "category": "Laravel" + }, + { + "command": "laravel.artisan.make", + "title": "New file", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.cast", + "title": "New Cast...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.channel", + "title": "New Channel...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.class", + "title": "New Class...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.command", + "title": "New Console Command...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.component", + "title": "New View Component...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.controller", + "title": "New Controller...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.enum", + "title": "New Enum...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.event", + "title": "New Event...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.exception", + "title": "New Exception...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.factory", + "title": "New Factory...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.interface", + "title": "New Interface...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.job", + "title": "New Job...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.job-middleware", + "title": "New Job Middleware...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.listener", + "title": "New Event Listener...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.livewire", + "title": "New Livewire Component...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.mail", + "title": "New Mail...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.middleware", + "title": "New Middleware...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.migration", + "title": "New Migration...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.model", + "title": "New Model...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.notification", + "title": "New Notification...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.observer", + "title": "New Observer...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.policy", + "title": "New Policy...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.provider", + "title": "New Service Provider...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.request", + "title": "New Form Request...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.resource", + "title": "New Resource...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.rule", + "title": "New Validation Rule...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.scope", + "title": "New Scope...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.seeder", + "title": "New Database Seeder...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.test", + "title": "New Test...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.trait", + "title": "New Trait...", + "category": "Laravel" + }, + { + "command": "laravel.artisan.make.view", + "title": "New View...", + "category": "Laravel" } ], "configuration": { diff --git a/src/commands/artisanMake.ts b/src/commands/artisanMake.ts new file mode 100644 index 00000000..3165548b --- /dev/null +++ b/src/commands/artisanMake.ts @@ -0,0 +1,393 @@ +import { + Argument, + ArgumentType, + Command, + getArtisanMakeCommands, + Option, + SubCommand, +} from "@src/repositories/artisanMakeCommands"; +import { artisan } from "@src/support/php"; +import { getWorkspaceFolders } from "@src/support/project"; +import { ucfirst } from "@src/support/str"; +import { escapeNamespace } from "@src/support/util"; +import path from "path"; +import * as vscode from "vscode"; +import { openFileCommand } from "."; +import { getNamespace } from "./generateNamespace"; + +const EndSelection = "End Selection"; + +const getValueForArgumentType = async ( + value: string, + argumentType: ArgumentType | undefined, + workspaceFolder: vscode.WorkspaceFolder, + uri: vscode.Uri, +): Promise => { + switch (argumentType) { + case "namespaceOrPath": + case "namespace": + // User can input a relative path, for example: NewFolder\NewFile + // or NewFolder/NewFile, so we need to convert it to a new Uri + const newUri = vscode.Uri.joinPath(uri, value); + + const fileName = path.parse(newUri.fsPath).name; + + // Always try to get the full namespace because it supports + // projects with modular architecture + let namespace = await getNamespace(workspaceFolder, newUri); + + if (!namespace && argumentType === "namespaceOrPath") { + return getValueForArgumentType( + value, + "path", + workspaceFolder, + uri, + ); + } + + namespace = namespace ? (namespace += `\\${fileName}`) : value; + + return escapeNamespace(namespace.replaceAll("/", "\\").trim()); + + case "path": + // OS path separators + return path.normalize(value.replaceAll("\\", "/")).trim(); + + default: + return value.trim(); + } +}; + +const validateInput = (input: string, field: string): boolean => { + if (input === "") { + vscode.window.showWarningMessage(`${field} is required`); + + return false; + } + + if (/\s/.test(input)) { + vscode.window.showWarningMessage(`${field} cannot contain spaces`); + + return false; + } + + return true; +}; + +const getUserArguments = async ( + commandArguments: Argument[], + workspaceFolder: vscode.WorkspaceFolder, + uri: vscode.Uri, +): Promise | undefined> => { + const userArguments: Record = {}; + + for (const argument of commandArguments) { + let input = undefined; + + while (!input) { + input = await vscode.window.showInputBox({ + prompt: argument.description, + }); + + // Exit when the user press ESC + if (input === undefined) { + return; + } + + if (!validateInput(input, `Argument ${argument.name}`)) { + input = undefined; + } + } + + userArguments[argument.name] = await getValueForArgumentType( + input, + argument.type, + workspaceFolder, + uri, + ); + } + + return userArguments; +}; + +const getArgumentsAsString = (userArguments: Record) => + Object.values(userArguments).join(" "); + +const getUserOptions = async ( + commandOptions: Option[] | undefined, + userArguments: Record, +): Promise | undefined> => { + const userOptions: Record = {}; + + if (!commandOptions?.length) { + return userOptions; + } + + let pickOptions = commandOptions.map((option) => ({ + label: `${option.name} ${option.description}`, + command: option.name, + })); + + while (true) { + const choice = await vscode.window.showQuickPick( + [ + { + label: EndSelection, + command: EndSelection, + }, + ...pickOptions, + ], + { + placeHolder: "Select an option or end selection...", + }, + ); + + // Exit when the user press ESC + if (choice === undefined) { + return; + } + + if (choice.command === EndSelection) { + break; + } + + let value = undefined; + + const option = commandOptions.find( + (option) => option.name === choice.command, + ); + + if (option?.type === "select" && option?.options) { + const optionsChoice = await vscode.window.showQuickPick( + Object.entries(option.options()).map(([key, value]) => ({ + label: key, + command: value, + })), + ); + + // Once again if the user cancels the selection by pressing ESC + if (optionsChoice === undefined) { + continue; + } + + value = optionsChoice.command; + } + + if (option?.type === "input") { + let input = undefined; + + while (!input) { + let _default = undefined; + + if (typeof option.default === "string") { + _default = option.default; + } + + if (typeof option.default === "function") { + _default = option.default(...Object.values(userArguments)); + } + + input = await vscode.window.showInputBox({ + prompt: option.description, + value: _default ?? "", + }); + + // Exit when the user press ESC + if (input === undefined) { + break; + } + + if (!validateInput(input, `Value for ${option.name}`)) { + input = undefined; + } + } + + // Once again if the user cancels the selection by pressing ESC + if (input === undefined) { + continue; + } + + value = input; + } + + userOptions[choice.command] = value ?? choice.command; + + // Remove the option from the list + pickOptions.splice(pickOptions.indexOf(choice), 1); + + if (!pickOptions.length) { + break; + } + } + + return userOptions; +}; + +const getOptionsAsString = (userOptions: Record) => + Object.entries(userOptions) + .map(([key, value]) => (key !== value ? `${key}=${value}` : key)) + .join(" "); + +const getPathFromOutput = ( + output: string, + command: SubCommand, + workspaceFolder: vscode.WorkspaceFolder, + uri: vscode.Uri, +): string | undefined => { + let paths; + + // Unfortunately, Livewire has own output format for make:livewire + if (command === "livewire") { + paths = output.match(/CLASS:\s+(.*)/); + + if (paths?.[1]) { + return path.join(workspaceFolder.uri.fsPath, paths?.[1]); + } + } + + paths = output.match(/\[(.*?)\]/g)?.map((path) => path.slice(1, -1)); + + if (!paths) { + return; + } + + // If artisan command creates multiple files, we have to find the right one, for example: + // + // INFO Test [tests/Feature/Http/Controllers/NewControllerTest.php] created successfully. + // INFO Controller [app/Http/Controllers/NewController.php] created successfully. + const outputPath = paths + // Windows always returns absolute paths, Linux returns relative. We have to normalize the paths + .map((_path) => + path.isAbsolute(_path) + ? path.relative(workspaceFolder.uri.fsPath, _path) + : _path, + ) + .map((_path) => path.join(workspaceFolder.uri.fsPath, _path)) + .find((_path) => _path.startsWith(uri.fsPath)); + + if (!outputPath) { + return; + } + + return outputPath; +}; + +const getWorkspaceFolder = ( + uri: vscode.Uri | undefined, +): vscode.WorkspaceFolder | undefined => { + let workspaceFolder = undefined; + + // Case when the user uses VSCode explorer/context (click on a folder in explorer) + if (uri) { + workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); + + if (workspaceFolder) { + return workspaceFolder; + } + } + + // Case when the user uses VSCode command palette (click on "Laravel: Create new file") + // and some file is open in the editor + const editor = vscode.window.activeTextEditor; + + if (editor) { + const fileUri = editor.document.uri; + + workspaceFolder = vscode.workspace.getWorkspaceFolder(fileUri); + + if (workspaceFolder) { + return workspaceFolder; + } + } + + // Case when the user uses VSCode command palette (click on "Laravel: Create new file") + // and no file is open in the editor + return getWorkspaceFolders()?.[0]; +}; + +export const artisanMakeCommandNameSubCommandName = (command: SubCommand) => + `laravel.artisan.make.${command}`; + +export const artisanMakeOpenSubmenuCommand = async () => { + const choice = await vscode.window.showQuickPick( + getArtisanMakeCommands() + .filter((command) => command.submenu) + .map((command) => { + const name = ucfirst(command.name); + + return { + label: `New ${name}...`, + command: artisanMakeCommandNameSubCommandName(command.name), + }; + }), + { + placeHolder: "Select file type...", + }, + ); + + if (choice) { + vscode.commands.executeCommand(choice.command); + } +}; + +export const artisanMakeCommand = async ( + command: Command, + uri?: vscode.Uri | undefined, +) => { + const workspaceFolder = getWorkspaceFolder(uri); + + if (!workspaceFolder) { + vscode.window.showErrorMessage("Cannot detect active workspace"); + + return; + } + + uri ??= vscode.Uri.joinPath(workspaceFolder.uri); + + const userArguments = await getUserArguments( + command.arguments, + workspaceFolder, + uri, + ); + + if (!userArguments) { + return; + } + + const userOptions = await getUserOptions(command.options, userArguments); + + if (!userOptions) { + return; + } + + const userArgumentsAsString = getArgumentsAsString(userArguments); + const userOptionsAsString = getOptionsAsString(userOptions); + + const output = await artisan( + `make:${command.name} ${userArgumentsAsString} ${userOptionsAsString}`, + workspaceFolder.uri.fsPath, + ); + + const error = output.match(/ERROR\s+(.*)/); + + if (error?.[1]) { + vscode.window.showErrorMessage(error[1]); + } + + const outputPath = getPathFromOutput( + output, + command.name, + workspaceFolder, + uri, + ); + + if (!outputPath) { + vscode.window.showErrorMessage( + "Failed to get the path of the new file", + ); + + return; + } + + openFileCommand(vscode.Uri.file(outputPath), 1, 1); +}; diff --git a/src/commands/generateNamespace.ts b/src/commands/generateNamespace.ts index 189ea8cc..087b4c4f 100644 --- a/src/commands/generateNamespace.ts +++ b/src/commands/generateNamespace.ts @@ -34,6 +34,48 @@ const getPsr4Autoloads = async ( }; }; +export const getNamespace = async ( + workspaceFolder: vscode.WorkspaceFolder, + fileUri: vscode.Uri, +): Promise => { + const composerPath = vscode.Uri.joinPath( + workspaceFolder.uri, + "composer.json", + ); + + const autoloads = await getPsr4Autoloads(composerPath); + + const namespaces: Namespace[] = Object.entries(autoloads) + .map(([namespace, path]) => ({ namespace, path })) + // We need to sort by length, because we need to check longer paths first, for example: + // + // "psr-4": { + // "App\\": "app/", + // "App\\AnotherNamespace\\": "app/anotherPath/", + // } + // + // Otherwise, the system will first find the shorter one, which also matches + .sort((a, b) => b.path.length - a.path.length); + + const findNamespace = namespaces.find((namespace) => + fileUri.path.startsWith( + `${workspaceFolder.uri.path}/${namespace.path}`, + ), + ); + + if (!findNamespace) { + return; + } + + return ( + findNamespace.namespace + + fileUri.path + .replace(`${workspaceFolder.uri.path}/${findNamespace.path}`, "") + .replace(/\/?[^\/]+$/, "") + .replace(/\//g, "\\") + ).replace(/\\$/, ""); +}; + const getNamespaceReplacement = ( newNamespace: string, content: string, @@ -88,45 +130,14 @@ export const generateNamespaceCommand = async () => { return; } - const composerPath = vscode.Uri.joinPath( - workspaceFolder.uri, - "composer.json", - ); - - const autoloads = await getPsr4Autoloads(composerPath); - - const namespaces: Namespace[] = Object.entries(autoloads) - .map(([namespace, path]) => ({ namespace, path })) - // We need to sort by length, because we need to check longer paths first, for example: - // - // "psr-4": { - // "App\\": "app/", - // "App\\AnotherNamespace\\": "app/anotherPath/", - // } - // - // Otherwise, the system will first find the shorter one, which also matches - .sort((a, b) => b.path.length - a.path.length); - - const findNamespace = namespaces.find((namespace) => - fileUri.path.startsWith( - `${workspaceFolder.uri.path}/${namespace.path}`, - ), - ); + const newNamespace = await getNamespace(workspaceFolder, fileUri); - if (!findNamespace) { + if (!newNamespace) { vscode.window.showErrorMessage("Failed to find a matching namespace"); return; } - const newNamespace = ( - findNamespace.namespace + - fileUri.path - .replace(`${workspaceFolder.uri.path}/${findNamespace.path}`, "") - .replace(/\/?[^\/]+$/, "") - .replace(/\//g, "\\") - ).replace(/\\$/, ""); - const doc = editor.document; const text = doc.getText(); diff --git a/src/commands/generatedRegisteredCommands.ts b/src/commands/generatedRegisteredCommands.ts index 4f5da2d4..4f43496e 100644 --- a/src/commands/generatedRegisteredCommands.ts +++ b/src/commands/generatedRegisteredCommands.ts @@ -14,4 +14,36 @@ export type RegisteredCommand = | "laravel.refactorSelectedHtmlClassToBladeDirective" | "laravel.refactorAllHtmlClassesToBladeDirectives" | "laravel.namespace.generate" + | "laravel.artisan.make" + | "laravel.artisan.make.cast" + | "laravel.artisan.make.channel" + | "laravel.artisan.make.class" + | "laravel.artisan.make.command" + | "laravel.artisan.make.component" + | "laravel.artisan.make.controller" + | "laravel.artisan.make.enum" + | "laravel.artisan.make.event" + | "laravel.artisan.make.exception" + | "laravel.artisan.make.factory" + | "laravel.artisan.make.interface" + | "laravel.artisan.make.job" + | "laravel.artisan.make.job-middleware" + | "laravel.artisan.make.listener" + | "laravel.artisan.make.livewire" + | "laravel.artisan.make.mail" + | "laravel.artisan.make.middleware" + | "laravel.artisan.make.migration" + | "laravel.artisan.make.model" + | "laravel.artisan.make.notification" + | "laravel.artisan.make.observer" + | "laravel.artisan.make.policy" + | "laravel.artisan.make.provider" + | "laravel.artisan.make.request" + | "laravel.artisan.make.resource" + | "laravel.artisan.make.rule" + | "laravel.artisan.make.scope" + | "laravel.artisan.make.seeder" + | "laravel.artisan.make.test" + | "laravel.artisan.make.trait" + | "laravel.artisan.make.view" | "laravel.open"; diff --git a/src/commands/pint.ts b/src/commands/pint.ts index d36f40a0..8154e0c6 100644 --- a/src/commands/pint.ts +++ b/src/commands/pint.ts @@ -1,4 +1,4 @@ -import { fixFilePath } from "@src/support/php"; +import { fixFilePath, getCommand } from "@src/support/php"; import { statusBarError, statusBarSuccess, @@ -37,7 +37,10 @@ const runPintCommand = ( return; } - const command = `"${pintPath}" ${args}`.trim(); + const phpPath = getCommand(); + + // Without php at the beginning, it won't work on Windows + const command = `${phpPath} "${pintPath}" ${args}`.trim(); cp.exec( command, diff --git a/src/extension.ts b/src/extension.ts index a8f536bf..e1b3edfe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,11 @@ import { LanguageClient } from "vscode-languageclient/node"; import { bladeSpacer } from "./blade/bladeSpacer"; import { initClient } from "./blade/client"; import { commandName, openFileCommand } from "./commands"; +import { + artisanMakeCommand, + artisanMakeCommandNameSubCommandName, + artisanMakeOpenSubmenuCommand, +} from "./commands/artisanMake"; import { generateNamespaceCommand } from "./commands/generateNamespace"; import { pintCommands, @@ -29,6 +34,7 @@ import { wrapSelectionCommand, wrapWithHelperCommands, } from "./commands/wrapWithHelper"; +import { getArtisanMakeCommands } from "./repositories/artisanMakeCommands"; import { configAffected } from "./support/config"; import { collectDebugInfo } from "./support/debug"; import { @@ -257,6 +263,16 @@ export async function activate(context: vscode.ExtensionContext) { htmlClassToBladeDirectiveCommands.all, refactorAllHtmlClassesToBladeDirectives, ), + vscode.commands.registerCommand( + commandName("laravel.artisan.make"), + artisanMakeOpenSubmenuCommand, + ), + ...getArtisanMakeCommands().map((command) => { + return vscode.commands.registerCommand( + artisanMakeCommandNameSubCommandName(command.name), + (uri: vscode.Uri) => artisanMakeCommand(command, uri), + ); + }), ); collectDebugInfo(); diff --git a/src/repositories/artisanMakeCommands.ts b/src/repositories/artisanMakeCommands.ts new file mode 100644 index 00000000..e1a9cd18 --- /dev/null +++ b/src/repositories/artisanMakeCommands.ts @@ -0,0 +1,712 @@ +import { kebab } from "@src/support/str"; +import { escapeNamespace } from "@src/support/util"; +import { getModels } from "./models"; + +export interface Argument { + name: string; + type?: ArgumentType | undefined; + description?: string; +} + +export interface Option { + name: string; + type?: OptionType | undefined; + options?: () => Record; + default?: ((...args: string[]) => string) | string; + description?: string; +} + +export interface Command { + name: SubCommand; + submenu?: boolean; + arguments: [Argument, ...Argument[]]; + options?: Option[]; +} + +type OptionType = "select" | "input"; + +export type ArgumentType = "namespaceOrPath" | "namespace" | "path"; + +export type SubCommand = + | "cast" + | "channel" + | "class" + | "command" + | "component" + | "controller" + | "model" + | "enum" + | "event" + | "exception" + | "factory" + | "interface" + | "job" + | "job-middleware" + | "listener" + | "livewire" + | "mail" + | "middleware" + | "migration" + | "notification" + | "observer" + | "policy" + | "provider" + | "request" + | "resource" + | "rule" + | "scope" + | "seeder" + | "service" + | "test" + | "trait" + | "view"; + +const forceOption: Option = { + name: "--force", + description: "Create the class even if the cast already exists", +}; + +const testOptions: Option[] = [ + { + name: "--test", + description: "Generate an accompanying Test test for the class", + }, + { + name: "--pest", + description: "Generate an accompanying Pest test for the class", + }, + { + name: "--phpunit", + description: "Generate an accompanying PHPUnit test for the class", + }, +]; + +const commands: Command[] = [ + { + name: "cast", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the cast class", + }, + ], + options: [ + forceOption, + { + name: "--inbound", + description: "Generate an inbound cast class", + }, + ], + }, + { + name: "channel", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the channel", + }, + ], + options: [forceOption], + }, + { + name: "class", + submenu: false, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the class", + }, + ], + options: [ + forceOption, + { + name: "--invokable", + description: "Generate a single method, invokable class", + }, + ], + }, + { + name: "command", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the command", + }, + ], + options: [forceOption, ...testOptions], + }, + { + name: "component", + submenu: true, + arguments: [ + { + name: "name", + type: "namespaceOrPath", + description: "The name of the component", + }, + ], + options: [ + forceOption, + { + name: "--path", + type: "input", + default: "components", + description: + "The location where the component view should be created", + }, + { + name: "--inline", + description: "Create a component that renders an inline view", + }, + { + name: "--view", + description: "Create an anonymous component with only a view", + }, + ...testOptions, + ], + }, + { + name: "controller", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the controller", + }, + ], + options: [ + forceOption, + { + name: "--invokable", + description: "Generate a single method, invokable class", + }, + { + name: "--api", + description: + "Exclude the create and edit methods from the controller", + }, + { + name: "--model", + type: "select", + options: () => getModelClassnames(), + description: + "Generate a resource controller for the given model", + }, + { + name: "--resource", + description: "Generate a resource controller class", + }, + { + name: "--requests", + description: + "Generate FormRequest classes for store and update", + }, + { + name: "--singleton", + description: "Generate a singleton resource controller class", + }, + { + name: "--creatable", + description: + "Indicate that a singleton resource should be creatable", + }, + ...testOptions, + ], + }, + { + name: "enum", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the enum", + }, + ], + options: [ + forceOption, + { + name: "--string", + description: "Generate a string backed enum.", + }, + { + name: "--int", + description: "Generate an integer backed enum.", + }, + ], + }, + { + name: "event", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the event", + }, + ], + options: [forceOption], + }, + { + name: "exception", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the exception", + }, + ], + options: [ + forceOption, + { + name: "--render", + description: "Create the exception with an empty render method", + }, + { + name: "--report", + description: "Create the exception with an empty report method", + }, + ], + }, + { + name: "factory", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the factory", + }, + ], + options: [ + { + name: "--model", + type: "select", + options: () => getModelClassnames(), + description: "The name of the model", + }, + ], + }, + { + name: "interface", + submenu: false, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the interface", + }, + ], + options: [forceOption], + }, + { + name: "job", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the job", + }, + ], + options: [ + forceOption, + { + name: "--sync", + description: "Indicates that job should be synchronous", + }, + ...testOptions, + ], + }, + { + name: "job-middleware", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the job middleware", + }, + ], + options: [forceOption, ...testOptions], + }, + { + name: "listener", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the listener", + }, + ], + options: [ + forceOption, + { + name: "--queued", + description: "Indicates that listener should be queued", + }, + ...testOptions, + ], + }, + { + name: "livewire", + submenu: true, + arguments: [ + { + name: "name", + type: "path", + description: "The name of the Livewire component", + }, + ], + options: [ + forceOption, + { + name: "--inline", + description: "Create a component that renders an inline view", + }, + ...testOptions, + ], + }, + { + name: "mail", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the mail", + }, + ], + options: [ + forceOption, + { + name: "--markdown", + description: "Create a new Markdown template for the mailable", + type: "input", + default: (name: string): string => + kebab( + name + .replaceAll("\\\\", "/") + .split("/") + .slice(-2) + .join("/"), + ), + }, + { + name: "--view", + description: "Create a new Blade template for the mailable", + type: "input", + default: (name: string): string => + kebab( + name + .replaceAll("\\\\", "/") + .split("/") + .slice(-2) + .join("/"), + ), + }, + ...testOptions, + ], + }, + { + name: "middleware", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the middleware", + }, + ], + options: [forceOption], + }, + { + name: "migration", + submenu: true, + arguments: [ + { + name: "name", + type: "path", + description: "The name of the migration", + }, + ], + options: [ + { + name: "--create", + type: "input", + description: "The table to be created", + }, + { + name: "--table", + type: "input", + description: "The table to migrate", + }, + ], + }, + { + name: "model", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the model", + }, + ], + options: [ + forceOption, + { + name: "--all", + description: + "Generate a migration, seeder, factory, policy, resource controller, and form request classes for the model", + }, + { + name: "--controller", + description: "Create a new controller for the model", + }, + { + name: "--factory", + description: "Create a new factory for the model", + }, + { + name: "--migration", + description: "Create a new migration file for the model", + }, + { + name: "--morph-pivot", + description: + "Indicates if the generated model should be a custom polymorphic intermediate table model", + }, + { + name: "--policy", + description: "Create a new policy for the model", + }, + { + name: "--seed", + description: "Create a new seeder for the model", + }, + { + name: "--pivot", + description: + "Indicates if the generated model should be a custom intermediate table model", + }, + { + name: "--resource", + description: + "Indicates if the generated controller should be a resource controller", + }, + { + name: "--api", + description: + "Indicates if the generated controller should be an API resource controller", + }, + { + name: "--requests", + description: + "Create new form request classes and use them in the resource controller", + }, + ...testOptions, + ], + }, + { + name: "notification", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the notification", + }, + ], + options: [ + forceOption, + { + name: "--markdown", + description: + "Create a new Markdown template for the notification", + }, + ...testOptions, + ], + }, + { + name: "observer", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the observer", + }, + ], + options: [ + forceOption, + { + name: "--model", + type: "select", + options: () => getModelClassnames(), + description: "The model that the observer applies to", + }, + ], + }, + { + name: "policy", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the policy", + }, + ], + options: [ + forceOption, + { + name: "--model", + type: "select", + options: () => getModelClassnames(), + description: "The model that the policy applies to", + }, + { + name: "--guard", + type: "input", + description: "The guard that the policy relies on", + }, + ], + }, + { + name: "provider", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the service provider", + }, + ], + options: [forceOption], + }, + { + name: "request", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the request", + }, + ], + options: [forceOption], + }, + { + name: "resource", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the resource", + }, + ], + options: [ + forceOption, + { + name: "--collection", + description: "Create a resource collection", + }, + ], + }, + { + name: "scope", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the scope", + }, + ], + options: [forceOption], + }, + { + name: "seeder", + submenu: true, + arguments: [ + { + name: "name", + type: "path", + description: "The name of the seeder", + }, + ], + }, + { + name: "test", + submenu: true, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the test", + }, + ], + options: [ + forceOption, + { + name: "--unit", + description: "Create a unit test", + }, + { + name: "--pest", + description: "Create a Pest test", + }, + { + name: "--phpunit", + description: "Create a PHPUnit test", + }, + ], + }, + { + name: "trait", + submenu: false, + arguments: [ + { + name: "name", + type: "namespace", + description: "The name of the trait", + }, + ], + options: [forceOption], + }, + { + name: "view", + submenu: true, + arguments: [ + { + name: "name", + type: "path", + description: "The name of the view", + }, + ], + options: [forceOption, ...testOptions], + }, +]; + +const getModelClassnames = (): Record => { + return Object.fromEntries( + Object.entries(getModels().items).map(([_, model]) => [ + model.class, + escapeNamespace(model.class), + ]), + ); +}; + +export const getArtisanMakeCommands = () => commands; diff --git a/src/support/php.ts b/src/support/php.ts index 72002dca..a5722a00 100644 --- a/src/support/php.ts +++ b/src/support/php.ts @@ -271,6 +271,10 @@ const getHashedFile = (code: string) => { return fixFilePath(hashedFile); }; +export const getCommand = (): string => { + return getCommandTemplate().replace('"{code}"', "").trim(); +}; + export const getCommandTemplate = (): string => { return config("phpCommand", "") || getDefaultPhpCommand(); }; @@ -317,14 +321,23 @@ export const runPhp = ( }); }; -export const artisan = (command: string): Promise => { - const fullCommand = projectPath("artisan") + " " + command; +export const artisan = ( + command: string, + workspaceFolder?: string | undefined, +): Promise => { + const phpPath = getCommand(); + + // Without php at the beginning, it doesn't work on Windows + const fullCommand = `${phpPath} artisan ${command}`.trim(); + + // Support for multi-root workspaces + workspaceFolder ??= getWorkspaceFolders()[0]?.uri?.fsPath; return new Promise((resolve, error) => { cp.exec( fullCommand, { - cwd: getWorkspaceFolders()[0]?.uri?.fsPath, + cwd: workspaceFolder, }, (err, stdout, stderr) => { if (err === null) { diff --git a/src/support/str.ts b/src/support/str.ts new file mode 100644 index 00000000..acfdba2f --- /dev/null +++ b/src/support/str.ts @@ -0,0 +1,8 @@ +export const kebab = (str: string): string => + str + .replace(/([a-z])([A-Z])/g, "$1-$2") + .replace(/[\s_]+/g, "-") + .toLowerCase(); + +export const ucfirst = (str: string): string => + str.charAt(0).toUpperCase() + str.slice(1); diff --git a/src/support/util.ts b/src/support/util.ts index 72325cc3..55bcefb1 100644 --- a/src/support/util.ts +++ b/src/support/util.ts @@ -146,3 +146,16 @@ export const createIndexMapping = ( }, }; }; + +export const escapeNamespace = (namespace: string): string => { + if ( + ["linux", "openbsd", "sunos", "darwin"].some((unixPlatforms) => + os.platform().includes(unixPlatforms), + ) + ) { + // We need to escape backslashes because finally it will be a part of CLI command + return namespace.replace(/(?