Skip to content

Commit 7dfeb62

Browse files
committed
Expan on Laravel class creation feature
1 parent ff0a2a8 commit 7dfeb62

9 files changed

Lines changed: 727 additions & 133 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1010

1111
- Log viewer panel that tails `storage/logs/*.log` with log-level highlighting and click-through navigation from stack-trace frames to the referenced file and line. A status bar item opens it and shows a subtle dot when a log changes since it was last viewed.
1212
- Run Artisan Command palette entry. It lists the application's artisan commands with their descriptions, prompts for the arguments and options each command declares, and runs the result in the integrated terminal. The command list is cached per workspace with a refresh entry, and a `phpantom.phpPath` setting selects the PHP executable used to run it.
13-
- New Laravel Class palette entry. It lets you pick a class kind (model, controller, request, migration, job, and more), type a name, and generates the file via the matching `artisan make:*` command in the integrated terminal.
13+
- New Laravel Class generation. Pick a class kind (model, controller, request, migration, job, and more), type a name, and toggle the common `make:*` flags in the picker, and PHPantom generates the file via the matching `artisan make:*` command and opens it in the editor. It is available from the command palette and by right-clicking a folder in the explorer, where the namespace is pre-filled from the folder's PSR-4 mapping. When artisan cannot boot (no PHP, a broken checkout), the common kinds fall back to a bundled template written straight to disk.
1414
- Route list panel. It lists the application's routes (method, URI, name, action) sourced from `artisan route:list`, with a filter box that matches across all four columns and click-through from a route's action to the controller method that handles it. The panel re-sources routes when your route files change, and a refresh entry re-runs the command on demand.
1515
- Generate Model Annotations palette entry. It boots the Laravel application once to read each Eloquent model's column types from the live database connection, then writes them as `@property` docblocks on the model class so column access is fully typed. Model `$casts` are respected (a `datetime` cast becomes a Carbon type, a `bool` cast becomes `bool`), it prompts before overwriting hand-written annotations, and a Regenerate variant refreshes them after migrations run. The boot happens only when you ask for it; the language server stays purely static.
1616

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ PHP language support for Visual Studio Code powered by [PHPantom](https://github
2020
- **Blade templates.** Completion, diagnostics, and navigation in `.blade.php` files, with VS Code's native HTML completion and Emmet still active in the markup.
2121
- **Log viewer.** A panel that tails `storage/logs/*.log` with log-level highlighting and click-through on stack-trace frames. A status bar item shows a subtle dot when a log changes since you last looked.
2222
- **Artisan command runner.** Browse your application's artisan commands in a quick-pick, fill in their arguments and options, and run them in the integrated terminal.
23+
- **Laravel file generation.** Create a model, controller, request, migration, and more via `artisan make:*` from the command palette or by right-clicking a folder in the explorer, which pre-fills the namespace. Toggle the common `make:*` flags in the picker, and PHPantom opens the generated file. When artisan cannot boot, the common kinds fall back to a bundled template written straight to disk.
2324
- **Route list.** A panel listing your application's routes (method, URI, name, action) with a filter box and click-through from an action to the controller method. It refreshes as your route files change.
2425
- **Model annotations.** Generate `@property` docblocks on your Eloquent models from the live database schema, honouring `$casts`, so column access is fully typed. A one-time boot you trigger; the language server itself stays purely static.
2526

@@ -57,7 +58,7 @@ To use a custom binary, set `phpantom.serverPath` to the path of your `phpantom_
5758
- **PHPantom: Clear Downloaded Language Server.** Remove cached server binaries.
5859
- **PHPantom: Show Logs.** Open the log viewer for `storage/logs/*.log`.
5960
- **PHPantom: Run Artisan Command.** Pick an artisan command, fill in its arguments and options, and run it in the terminal.
60-
- **PHPantom: New Laravel Class...** Pick a class kind (model, controller, request, migration, job, and more), type a name, and PHPantom generates it via the matching `artisan make:*` command.
61+
- **PHPantom: New Laravel Class...** Pick a class kind (model, controller, request, migration, job, and more), type a name, toggle the common `make:*` flags, and PHPantom generates it via the matching `artisan make:*` command and opens the file. Also available by right-clicking a folder in the explorer, where the namespace is pre-filled from the folder. Falls back to a bundled template for the common kinds when artisan cannot boot.
6162
- **PHPantom: Show Route List.** Open the route list panel with filtering and click-through to controller methods.
6263
- **PHPantom: Generate Model Annotations.** Boot the app once to read your Eloquent models' column types from the database and write them as `@property` docblocks.
6364
- **PHPantom: Regenerate Model Annotations.** Refresh existing model annotations after migrations, without prompting.

package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@
140140
"command": "phpantom.regenerateModelAnnotations",
141141
"when": "workspaceFolderCount != 0"
142142
}
143+
],
144+
"explorer/context": [
145+
{
146+
"command": "phpantom.newLaravelClass",
147+
"when": "explorerResourceIsFolder && workspaceFolderCount != 0",
148+
"group": "navigation@100"
149+
}
143150
]
144151
},
145152
"configuration": {

src/artisan.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,77 @@ export function runArtisanInTerminal(context: ArtisanContext, args: string[]): v
184184
terminal.sendText(commandLine);
185185
}
186186

187+
/**
188+
* A command-line flag offered in the shared option picker. Both the Run Artisan
189+
* Command palette (options parsed from a command's definition) and Laravel file
190+
* generation (a curated set of `make:*` flags) collect options through the same
191+
* flow so the prompts stay consistent, per the extension roadmap.
192+
*/
193+
export interface FlagOption {
194+
/** The flag token including its leading dashes, e.g. `-m` or `--model`. */
195+
flag: string;
196+
/** Whether the flag takes a value (`--model=User`) rather than being a bare toggle. */
197+
acceptValue: boolean;
198+
/** Whether a value is mandatory once the flag is selected. */
199+
valueRequired: boolean;
200+
/** Human-readable description, shown in the quick-pick detail. */
201+
description: string;
202+
}
203+
204+
/**
205+
* Let the user toggle a set of flags via a multi-select quick-pick, prompting
206+
* for a value on flags that take one, and return the assembled command-line
207+
* tokens. Returns `undefined` when the user cancels a required value prompt (so
208+
* the caller aborts the whole run) and an empty list when there are no flags.
209+
*/
210+
export async function collectFlagOptions(
211+
title: string,
212+
options: FlagOption[]
213+
): Promise<string[] | undefined> {
214+
if (options.length === 0) {
215+
return [];
216+
}
217+
218+
const selected = await vscode.window.showQuickPick(
219+
options.map((option) => ({
220+
label: option.flag,
221+
description: option.acceptValue ? "takes a value" : undefined,
222+
detail: option.description || undefined,
223+
option
224+
})),
225+
{
226+
title,
227+
placeHolder: "Select options to include (optional)",
228+
canPickMany: true
229+
}
230+
);
231+
if (selected === undefined) {
232+
return undefined;
233+
}
234+
235+
const tokens: string[] = [];
236+
for (const { option } of selected) {
237+
if (!option.acceptValue) {
238+
tokens.push(option.flag);
239+
continue;
240+
}
241+
const value = await vscode.window.showInputBox({
242+
title,
243+
prompt: `Value for ${option.flag}${option.description ? `: ${option.description}` : ""}`,
244+
ignoreFocusOut: true,
245+
validateInput: (input) =>
246+
option.valueRequired && input.trim() === "" ? `${option.flag} requires a value.` : undefined
247+
});
248+
if (value === undefined) {
249+
return undefined;
250+
}
251+
const trimmed = value.trim();
252+
// A non-required value option toggled with no value passes as a bare flag.
253+
tokens.push(trimmed === "" ? option.flag : `${option.flag}=${trimmed}`);
254+
}
255+
return tokens;
256+
}
257+
187258
/** Forget a closed terminal so a later run recreates it. */
188259
export function disposeArtisanTerminal(terminal: vscode.Terminal): void {
189260
for (const [key, tracked] of artisanTerminals) {

src/artisanCommand.ts

Lines changed: 10 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as vscode from "vscode";
22
import {
33
ArtisanContext,
4+
collectFlagOptions,
45
disposeArtisanTerminal,
56
findArtisanContexts,
67
pickArtisanContext,
@@ -343,53 +344,20 @@ async function collectArguments(command: ArtisanCommand): Promise<string[] | und
343344
}
344345

345346
/**
346-
* Let the user toggle the command's options via a multi-select quick-pick,
347+
* Let the user toggle the command's options via the shared flag picker,
347348
* prompting for a value on options that take one. Returns `undefined` on
348349
* cancellation of a required value prompt.
349350
*/
350-
async function collectOptions(command: ArtisanCommand): Promise<string[] | undefined> {
351-
if (command.options.length === 0) {
352-
return [];
353-
}
354-
355-
const selected = await vscode.window.showQuickPick(
351+
function collectOptions(command: ArtisanCommand): Promise<string[] | undefined> {
352+
return collectFlagOptions(
353+
`artisan ${command.name}`,
356354
command.options.map((option) => ({
357-
label: option.name,
358-
description: option.acceptValue ? "takes a value" : undefined,
359-
detail: option.description || undefined,
360-
option
361-
})),
362-
{
363-
title: `artisan ${command.name}`,
364-
placeHolder: "Select options to include (optional)",
365-
canPickMany: true
366-
}
355+
flag: option.name,
356+
acceptValue: option.acceptValue,
357+
valueRequired: option.valueRequired,
358+
description: option.description
359+
}))
367360
);
368-
if (selected === undefined) {
369-
return undefined;
370-
}
371-
372-
const tokens: string[] = [];
373-
for (const { option } of selected) {
374-
if (!option.acceptValue) {
375-
tokens.push(option.name);
376-
continue;
377-
}
378-
const value = await vscode.window.showInputBox({
379-
title: `artisan ${command.name}`,
380-
prompt: `Value for ${option.name}${option.description ? `: ${option.description}` : ""}`,
381-
ignoreFocusOut: true,
382-
validateInput: (input) =>
383-
option.valueRequired && input.trim() === "" ? `${option.name} requires a value.` : undefined
384-
});
385-
if (value === undefined) {
386-
return undefined;
387-
}
388-
const trimmed = value.trim();
389-
// A non-required value option toggled with no value passes as a bare flag.
390-
tokens.push(trimmed === "" ? option.name : `${option.name}=${trimmed}`);
391-
}
392-
return tokens;
393361
}
394362

395363
/**

src/composer.ts

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import * as fs from "fs";
2+
import * as path from "path";
3+
4+
// ── composer.json PSR-4 autoload parsing ─────────────────────────────────────
5+
//
6+
// The Laravel file generation feature needs to map between directories,
7+
// namespaces, and file paths the way Laravel's own generators do: to pre-fill a
8+
// namespace from a clicked explorer folder, and to resolve where a generated
9+
// class file will land (for opening it, and for the bundled-template fallback
10+
// when artisan cannot boot). All three need the project's PSR-4 roots.
11+
//
12+
// This is deliberately a small, self-contained reader. It never runs the app,
13+
// and nothing it produces is fed back into the language server; it only informs
14+
// the extension's own generation UI.
15+
16+
/** A single PSR-4 autoload mapping: a namespace prefix and the directory it maps to. */
17+
export interface Psr4Root {
18+
/** The namespace prefix without a trailing separator, e.g. `App`. */
19+
namespace: string;
20+
/** The absolute directory the prefix maps to, e.g. `/project/app`. */
21+
directory: string;
22+
}
23+
24+
/**
25+
* Read the PSR-4 autoload roots declared in a folder's `composer.json`, merging
26+
* the `autoload` and `autoload-dev` sections. Returns an empty list when the
27+
* file is missing or unparsable, so callers degrade to no pre-fill rather than
28+
* failing. Roots are sorted with the longest namespace first so the most
29+
* specific prefix wins when several would match.
30+
*/
31+
export function loadPsr4Roots(folderFsPath: string): Psr4Root[] {
32+
let raw: string;
33+
try {
34+
raw = fs.readFileSync(path.join(folderFsPath, "composer.json"), "utf8");
35+
} catch {
36+
return [];
37+
}
38+
39+
let parsed: unknown;
40+
try {
41+
parsed = JSON.parse(raw);
42+
} catch {
43+
return [];
44+
}
45+
if (typeof parsed !== "object" || parsed === null) {
46+
return [];
47+
}
48+
49+
const record = parsed as Record<string, unknown>;
50+
const roots: Psr4Root[] = [];
51+
for (const section of ["autoload", "autoload-dev"]) {
52+
const auto = record[section];
53+
if (typeof auto !== "object" || auto === null) {
54+
continue;
55+
}
56+
const psr4 = (auto as Record<string, unknown>)["psr-4"];
57+
if (typeof psr4 !== "object" || psr4 === null) {
58+
continue;
59+
}
60+
for (const [namespace, directory] of Object.entries(psr4 as Record<string, unknown>)) {
61+
const dir = firstDirectory(directory);
62+
if (dir === undefined) {
63+
continue;
64+
}
65+
roots.push({
66+
namespace: namespace.replace(/\\+$/, ""),
67+
directory: path.resolve(folderFsPath, dir)
68+
});
69+
}
70+
}
71+
72+
roots.sort((a, b) => b.namespace.length - a.namespace.length);
73+
return roots;
74+
}
75+
76+
/** A PSR-4 mapping may point at a single directory or a list of them; take the first. */
77+
function firstDirectory(value: unknown): string | undefined {
78+
if (typeof value === "string") {
79+
return value;
80+
}
81+
if (Array.isArray(value)) {
82+
const first = value.find((entry) => typeof entry === "string");
83+
return typeof first === "string" ? first : undefined;
84+
}
85+
return undefined;
86+
}
87+
88+
/**
89+
* The namespace a directory lives in, according to the PSR-4 roots (e.g.
90+
* `/project/app/Models` → `App\Models`). Returns `undefined` when the directory
91+
* is not under any root. When several roots contain the directory, the deepest
92+
* (longest directory) wins so a nested root is preferred over its parent.
93+
*/
94+
export function directoryToNamespace(roots: Psr4Root[], dirFsPath: string): string | undefined {
95+
const target = path.resolve(dirFsPath);
96+
97+
let best: Psr4Root | undefined;
98+
for (const root of roots) {
99+
if (isInside(root.directory, target) && (!best || root.directory.length > best.directory.length)) {
100+
best = root;
101+
}
102+
}
103+
if (!best) {
104+
return undefined;
105+
}
106+
107+
const relative = path.relative(best.directory, target);
108+
if (relative === "") {
109+
return best.namespace;
110+
}
111+
const suffix = relative.split(path.sep).join("\\");
112+
return best.namespace ? `${best.namespace}\\${suffix}` : suffix;
113+
}
114+
115+
/**
116+
* The file a fully-qualified class name maps to under the PSR-4 roots (e.g.
117+
* `App\Models\Post` → `/project/app/Models/Post.php`). Returns `undefined` when
118+
* no root's namespace prefixes the class.
119+
*/
120+
export function fqnToFilePath(roots: Psr4Root[], fqn: string): string | undefined {
121+
const normalized = fqn.replace(/^\\+/, "");
122+
123+
for (const root of roots) {
124+
if (root.namespace === "") {
125+
return path.join(root.directory, `${normalized.split("\\").join(path.sep)}.php`);
126+
}
127+
const prefix = `${root.namespace}\\`;
128+
if (normalized.startsWith(prefix)) {
129+
const relative = normalized.slice(prefix.length).split("\\").join(path.sep);
130+
return path.join(root.directory, `${relative}.php`);
131+
}
132+
}
133+
return undefined;
134+
}
135+
136+
/**
137+
* The application's root namespace, i.e. the PSR-4 namespace mapped to the `app/`
138+
* directory (`App` in a default Laravel install). This mirrors Laravel's
139+
* `$app->getNamespace()`, which the `make:*` commands use to qualify names.
140+
* Falls back to `App` when no mapping points at `app/`.
141+
*/
142+
export function appRootNamespace(roots: Psr4Root[], folderFsPath: string): string {
143+
const appDir = path.resolve(folderFsPath, "app");
144+
for (const root of roots) {
145+
if (path.resolve(root.directory) === appDir) {
146+
return root.namespace;
147+
}
148+
}
149+
return "App";
150+
}
151+
152+
/** Whether `child` is `parent` itself or nested inside it. */
153+
function isInside(parent: string, child: string): boolean {
154+
const relative = path.relative(parent, child);
155+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
156+
}

src/extension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
4949

5050
registerArtisanCommands(context, outputChannel);
5151

52-
registerLaravelMakeCommands(context);
52+
registerLaravelMakeCommands(context, outputChannel);
5353

5454
registerModelAnnotationCommands(context, outputChannel);
5555

0 commit comments

Comments
 (0)