Last active
April 19, 2025 06:17
-
-
Save raspberrypisig/f9ff762bedde4bd920e1abaddbe9fd8a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const std = @import("std"); | |
const json = std.json; | |
const Allocator = std.mem.Allocator; | |
const StringHashMap = std.StringHashMap; | |
const ArrayList = std.ArrayList; | |
// --- Data Structures Mirroring JSON Schema --- | |
// Corresponds to TypeScript's ParameterKind | |
pub const ParameterKind = enum { | |
singular, | |
plus, | |
star, | |
pub fn fromString(s: []const u8) !ParameterKind { | |
if (std.mem.eql(u8, s, "singular")) { | |
return .singular; | |
} else if (std.mem.eql(u8, s, "plus")) { | |
return .plus; | |
} else if (std.mem.eql(u8, s, "star")) { | |
return .star; | |
} else { | |
return error.InvalidParameterKind; | |
} | |
} | |
}; | |
// Corresponds to TypeScript's Parameter | |
pub const Parameter = struct { | |
default_value: ?json.Value = null, // Renamed 'default' as it's a keyword. Using json.Value for unknown type. | |
export: bool, | |
kind: ParameterKind, | |
name: []const u8, | |
// Custom parsing logic needed because 'default' is a keyword and 'kind' is an enum | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, // To be set by the caller | |
.custom_options = .{ | |
.map_field_name = mapFieldName, | |
.parse_field_fn = parseField, | |
}, | |
.ignore_unknown_fields = true, // Be lenient like the TS version | |
}; | |
fn mapFieldName(field_name: []const u8) []const u8 { | |
if (std.mem.eql(u8, field_name, "default")) { | |
return "default_value"; | |
} | |
return field_name; | |
} | |
fn parseField( | |
field_value: *json.Value, | |
field_name: []const u8, | |
field_ptr: anytype, | |
options: json.ParseOptions, | |
stream: *json.TokenStream, | |
) !bool { | |
_ = stream; // Not needed for this custom parser | |
if (std.mem.eql(u8, field_name, "kind")) { | |
const kind_str = try json.parse(json.Value, field_value.*, options); | |
if (kind_str != .String) return error.ExpectedStringForKind; | |
const KindEnum = @TypeOf(field_ptr.*); | |
field_ptr.* = try KindEnum.fromString(kind_str.String); | |
return true; // Field handled | |
} | |
return false; // Use default parsing for other fields | |
} | |
}; | |
// Corresponds to TypeScript's Dependency | |
pub const Dependency = struct { | |
arguments: ArrayList(json.Value), // Using json.Value for unknown[] | |
recipe: []const u8, | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, | |
.ignore_unknown_fields = true, | |
}; | |
}; | |
// Corresponds to TypeScript's Recipe | |
pub const Recipe = struct { | |
name: []const u8, | |
doc: ?[]const u8, | |
body: ArrayList(json.Value), // Using json.Value for unknown[] | |
dependencies: ArrayList(Dependency), | |
parameters: ArrayList(Parameter), | |
priors: usize, | |
private: bool, | |
quiet: bool, | |
shebang: bool, | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, | |
.ignore_unknown_fields = true, | |
}; | |
}; | |
// Corresponds to TypeScript's Alias | |
pub const Alias = struct { | |
name: []const u8, | |
target: []const u8, | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, | |
.ignore_unknown_fields = true, | |
}; | |
}; | |
// Corresponds to TypeScript's Binding | |
pub const Binding = struct { | |
name: []const u8, | |
export: bool, | |
value: json.Value, // Using json.Value for unknown | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, | |
.ignore_unknown_fields = true, | |
}; | |
}; | |
// Corresponds to TypeScript's Justfile interface | |
pub const Justfile = struct { | |
aliases: StringHashMap(Alias), | |
assignments: StringHashMap(Binding), | |
recipes: StringHashMap(Recipe), | |
// Need custom parsing for the maps | |
pub fn jsonParse(allocator: Allocator, source: anytype, options: json.ParseOptions) !Justfile { | |
var value_tree = try json.parseFromSlice(json.Value, allocator, source, .{ | |
.allocator = allocator, | |
.max_value_depth = 10, // Adjust as needed | |
}); | |
defer value_tree.deinit(); | |
if (value_tree.value != .Object) { | |
return error.TopLevelNotObject; | |
} | |
var object_map = value_tree.value.Object; | |
var self = Justfile{ | |
.aliases = StringHashMap(Alias).init(allocator), | |
.assignments = StringHashMap(Binding).init(allocator), | |
.recipes = StringHashMap(Recipe).init(allocator), | |
}; | |
errdefer { | |
self.aliases.deinit(); | |
self.assignments.deinit(); | |
self.recipes.deinit(); | |
} | |
// Parse Aliases | |
if (object_map.get("aliases")) |aliases_val| { | |
if (aliases_val != .Object) return error.AliasesNotObject; | |
var aliases_iter = aliases_val.Object.iterator(); | |
while (try aliases_iter.next()) |entry| { | |
var alias_options = Alias.json_options; | |
alias_options.allocator = allocator; | |
const alias = try json.parse(Alias, entry.value_ptr.*, alias_options); | |
// Key needs to be duplicated as it's owned by the value_tree | |
const key_copy = try allocator.dupe(u8, entry.key_ptr.*); | |
errdefer allocator.free(key_copy); | |
try self.aliases.put(key_copy, alias); | |
} | |
} | |
// Parse Assignments | |
if (object_map.get("assignments")) |assignments_val| { | |
if (assignments_val != .Object) return error.AssignmentsNotObject; | |
var assignments_iter = assignments_val.Object.iterator(); | |
while (try assignments_iter.next()) |entry| { | |
var binding_options = Binding.json_options; | |
binding_options.allocator = allocator; | |
const binding = try json.parse(Binding, entry.value_ptr.*, binding_options); | |
const key_copy = try allocator.dupe(u8, entry.key_ptr.*); | |
errdefer allocator.free(key_copy); | |
try self.assignments.put(key_copy, binding); | |
} | |
} | |
// Parse Recipes | |
if (object_map.get("recipes")) |recipes_val| { | |
if (recipes_val != .Object) return error.RecipesNotObject; | |
var recipes_iter = recipes_val.Object.iterator(); | |
while (try recipes_iter.next()) |entry| { | |
var recipe_options = Recipe.json_options; | |
recipe_options.allocator = allocator; | |
var parameter_options = Parameter.json_options; // Need to set allocator for nested types too | |
parameter_options.allocator = allocator; | |
var dependency_options = Dependency.json_options; | |
dependency_options.allocator = allocator; | |
// Need to provide allocators down the chain - std.json doesn't automatically inherit | |
// This is a bit verbose; a helper function could abstract this. | |
const recipe = try json.parseWithOptions(Recipe, entry.value_ptr.*, recipe_options, .{ | |
.allocator = allocator, // Pass allocator explicitly for nested structures | |
.custom_options_map = &.{ // Map nested types to their options | |
.{ @TypeOf(Parameter), ¶meter_options }, | |
.{ @TypeOf(Dependency), &dependency_options }, | |
}, | |
}); | |
const key_copy = try allocator.dupe(u8, entry.key_ptr.*); | |
errdefer allocator.free(key_copy); | |
try self.recipes.put(key_copy, recipe); | |
} | |
} | |
return self; | |
} | |
// Remember to call deinit when done with the Justfile instance | |
pub fn deinit(self: *Justfile, allocator: Allocator) void { | |
// Deallocate strings owned by json.Value in Binding.value, Parameter.default_value, Recipe.body, Dependency.arguments | |
var assignment_iter = self.assignments.valueIterator(); | |
while (assignment_iter.next()) |binding| binding.value.deinit(allocator); | |
var recipe_iter = self.recipes.valueIterator(); | |
while (recipe_iter.next()) |recipe| { | |
for (recipe.body.items) |item| item.deinit(allocator); | |
recipe.body.deinit(allocator); | |
for (recipe.dependencies.items) |dep| { | |
for (dep.arguments.items) |arg| arg.deinit(allocator); | |
dep.arguments.deinit(allocator); | |
} | |
recipe.dependencies.deinit(allocator); | |
for (recipe.parameters.items) |param| { | |
if (param.default_value) |dv| dv.deinit(allocator); | |
} | |
recipe.parameters.deinit(allocator); | |
} | |
// Deallocate the hash maps themselves (keys were allocated in jsonParse) | |
self.aliases.deinitKeys(allocator); | |
self.assignments.deinitKeys(allocator); | |
self.recipes.deinitKeys(allocator); | |
} | |
}; | |
// --- Parsing Function --- | |
/// Parses the JSON output from `just --dump --dump-format json`. | |
/// Takes ownership of the input `json_string` if it's mutable, | |
/// otherwise copies if necessary depending on the allocator. | |
pub fn parseJustfileDump(allocator: Allocator, json_string: []const u8) !Justfile { | |
// Default options - allow trailing commas, comments etc. | |
const options = json.ParseOptions{ | |
.allocator = allocator, | |
.comment_handling = .ignore, | |
.trailing_comma = true, | |
}; | |
const justfile = try Justfile.jsonParse(allocator, json_string, options); | |
return justfile; | |
} | |
// --- Example Usage --- | |
pub fn main() !void { | |
// Example JSON string (replace with actual output from `just --dump ...`) | |
const json_dump = | |
\\{ | |
\\ "aliases": { | |
\\ "a": { "name": "a", "target": "actual-recipe" } | |
\\ }, | |
\\ "assignments": { | |
\\ "VAR": { "name": "VAR", "export": false, "value": "hello" } | |
\\ }, | |
\\ "recipes": { | |
\\ "actual-recipe": { | |
\\ "name": "actual-recipe", | |
\\ "doc": "Does a thing.", | |
\\ "body": ["echo \"running...\""], | |
\\ "dependencies": [], | |
\\ "parameters": [ | |
\\ { "name": "arg1", "kind": "singular", "export": false, "default": null }, | |
\\ { "name": "rest", "kind": "plus", "export": true, "default": ["default_val"] } | |
\\ ], | |
\\ "priors": 0, | |
\\ "private": false, | |
\\ "quiet": false, | |
\\ "shebang": false | |
\\ }, | |
\\ "private-recipe": { | |
\\ "name": "private-recipe", "doc": null, "body": [], "dependencies": [], "parameters": [], | |
\\ "priors": 0, "private": true, "quiet": false, "shebang": false | |
\\ } | |
\\ } | |
\\} | |
; | |
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
defer _ = gpa.deinit(); | |
const allocator = gpa.allocator(); | |
const justfile = try parseJustfileDump(allocator, json_dump); | |
// Remember to deallocate when done! | |
defer justfile.deinit(allocator); | |
// Now you can access the parsed data | |
std.debug.print("Parsed Justfile:\n", .{}); | |
if (justfile.recipes.get("actual-recipe")) |recipe| { | |
std.debug.print(" Recipe 'actual-recipe':\n", .{}); | |
std.debug.print(" Doc: {?s}\n", .{recipe.doc}); | |
std.debug.print(" Private: {}\n", .{recipe.private}); | |
std.debug.print(" Parameters:\n", .{}); | |
for (recipe.parameters.items) |param| { | |
std.debug.print(" - Name: {s}, Kind: {s}, Export: {}\n", .{ | |
param.name, @tagName(param.kind), param.export, | |
}); | |
// You'd need more logic to inspect param.default_value (json.Value) | |
} | |
} | |
if (justfile.aliases.get("a")) |alias| { | |
std.debug.print(" Alias 'a':\n", .{}); | |
std.debug.print(" Target: {s}\n", .{alias.target}); | |
} | |
if (justfile.assignments.get("VAR")) |binding| { | |
std.debug.print(" Assignment 'VAR':\n", .{}); | |
std.debug.print(" Export: {}\n", .{binding.export}); | |
// You'd need more logic to inspect binding.value (json.Value) | |
std.debug.print(" Value (JSON): {any}\n", .{binding.value}); | |
} | |
} | |
// --- Helper Function Placeholders (Not Implemented) --- | |
// pub fn getJustfilePath(allocator: Allocator, tokens: [][]const u8) !?[]const u8 { | |
// // TODO: Implement logic similar to the TypeScript version | |
// // using string searching/slicing. | |
// _ = allocator; | |
// _ = tokens; | |
// return null; | |
// } | |
// pub fn getJustfileDumpCommand(allocator: Allocator, justfilePath: ?[]const u8) ![]u8 { | |
// // TODO: Implement logic similar to the TypeScript version | |
// // using std.fmt.allocPrint | |
// _ = allocator; | |
// _ = justfilePath; | |
// return error.NotImplemented; | |
// } | |
// // Define FigSuggestion struct if needed | |
// pub const FigSuggestion = struct { | |
// name: []const u8, | |
// displayName: ?[]const u8 = null, | |
// description: ?[]const u8 = null, | |
// icon: ?[]const u8 = null, | |
// // ... other Fig fields | |
// }; | |
// pub fn getRecipeSuggestions(allocator: Allocator, justfile: Justfile, showRecipeParameters: bool) !ArrayList(FigSuggestion) { | |
// // TODO: Implement logic similar to the TypeScript version | |
// _ = allocator; | |
// _ = justfile; | |
// _ = showRecipeParameters; | |
// return error.NotImplemented; | |
// } | |
// pub fn getRecipeUsage(allocator: Allocator, recipe: Recipe) ![]u8 { | |
// // TODO: Implement logic similar to the TypeScript version | |
// _ = allocator; | |
// _ = recipe; | |
// return error.NotImplemented; | |
// } | |
// pub const RecipeArityMapping = struct { | |
// recipeArity: StringHashMap(usize), | |
// maxArity: usize, | |
// | |
// pub fn deinit(self: *RecipeArityMapping, allocator: Allocator) void { | |
// self.recipeArity.deinitKeys(allocator); // Assuming keys were allocated | |
// } | |
// }; | |
// | |
// pub fn getRecipeArityMap(allocator: Allocator, justfile: Justfile) !RecipeArityMapping { | |
// // TODO: Implement logic similar to the TypeScript version | |
// _ = allocator; | |
// _ = justfile; | |
// return error.NotImplemented; | |
// } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* The structure of the JSON data returned from dumping a justfile. | |
* | |
* This is not exhaustive; only the properties that this spec needs to care | |
* about are typed. There's a lot of properties where it's difficult to tell | |
* exactly how it'll be serialized without having too many unnecessary test | |
* cases. | |
*/ | |
interface Justfile { | |
aliases: Record<string, Alias>; | |
assignments: Record<string, Binding>; | |
recipes: Record<string, Recipe>; | |
} | |
interface Alias { | |
name: string; | |
target: string; | |
} | |
interface Recipe { | |
name: string; | |
doc: string | null; | |
body: unknown[]; | |
dependencies: Dependency[]; | |
parameters: Parameter[]; | |
priors: number; | |
private: boolean; | |
quiet: boolean; | |
shebang: boolean; | |
} | |
interface Dependency { | |
arguments: unknown[]; | |
recipe: string; | |
} | |
interface Parameter { | |
default: unknown; | |
export: boolean; | |
kind: ParameterKind; | |
name: string; | |
} | |
type ParameterKind = "singular" | "plus" | "star"; | |
interface Binding { | |
name: string; | |
export: boolean; | |
value: unknown; | |
} | |
/** | |
* Get the path of the justfile from an array of tokens. | |
* | |
* If no overriding flag is provided, `null` will be returned. | |
* | |
* Flags can be provided in any of these forms, and the regex to match this is | |
* about as ugly as you'd expect. | |
* - `-f name` | |
* - `-fname` | |
* - `-XYZfname` (short options before `f`, where `XYZ` are any non-f letters) | |
* - `-f=name` | |
* - `--justfile name` | |
* - `--justfile=name` | |
*/ | |
function getJustfilePath(tokens: string[]): string | null { | |
const flagRe = /^(-[A-Za-eg-z]*f=?|--justfile(?:=|$))/; | |
for (const [index, token] of tokens.entries()) { | |
const match = token.match(flagRe); | |
if (match === null) { | |
continue; | |
} | |
// Group 1 is the flag, up to and including the `=`. Everything after | |
// that is the path. If the path is "", then it's the next token. | |
const withoutOption = token.slice(match[1].length); | |
if (withoutOption === "") { | |
return tokens[index + 1]; | |
} | |
return withoutOption; | |
} | |
return null; | |
} | |
/** | |
* Get the command to dump the justfile at the given path, or let `just` handle | |
* searching for the file if the path is null. | |
*/ | |
function getJustfileDumpCommand(justfilePath: string | null): string { | |
return justfilePath | |
? `just --unstable --dump --dump-format json --justfile '${justfilePath}'` | |
: `just --unstable --dump --dump-format json`; | |
} | |
/** | |
* Get a `Fig.Suggestion[]` containing a suggestion for each recipe and alias. | |
* | |
* If the optional `showRecipeParameters` prop is `true`, the recipe display | |
* names will include additional text describing the recipe's parameters, in | |
* the same style as Fig's autocomplete. (eg. `name <arg>` instead of `name`) | |
*/ | |
function getRecipeSuggestions( | |
justfile: Justfile, | |
{ showRecipeParameters = false } = {} | |
): Fig.Suggestion[] { | |
const suggestions: Fig.Suggestion[] = []; | |
for (const [name, recipe] of Object.entries(justfile.recipes)) { | |
if (recipe.private) { | |
continue; | |
} | |
suggestions.push({ | |
name, | |
displayName: showRecipeParameters ? getRecipeUsage(recipe) : name, | |
description: recipe.doc ?? "Recipe", | |
icon: "fig://icon?type=command", | |
}); | |
} | |
// Now the aliases. Like the git aliases, these don't list their usage. | |
// Also like with the git spec, these use the commandkey icon. | |
for (const [name, alias] of Object.entries(justfile.aliases)) { | |
suggestions.push({ | |
name, | |
description: `Alias for '${alias.target}'`, | |
icon: "fig://icon?type=commandkey", | |
}); | |
} | |
return suggestions; | |
} | |
/** | |
* Get a string that is the usage of a recipe, in the same style as Fig's | |
* options and arguments. | |
* | |
* For example, `test <FILTER>`, , `echo [ARGS...]` | |
*/ | |
function getRecipeUsage(recipe: Recipe): string { | |
const parts = [recipe.name]; | |
for (const parameter of recipe.parameters) { | |
// Fig sanitizes things like "<NAME>", so this has to be encoded | |
if (parameter.kind === "singular") { | |
parts.push(`<${parameter.name}>`); | |
} else if (parameter.kind === "plus") { | |
parts.push(`<${parameter.name}...>`); | |
} else if (parameter.kind === "star") { | |
parts.push(`[${parameter.name}...]`); | |
} else { | |
console.error(`Unreachable: unknown kind '${parameter.kind}'`); | |
} | |
} | |
return parts.join(" "); | |
} | |
interface RecipeArityMapping { | |
recipeArity: Map<string, number>; | |
maxArity: number; | |
} | |
/** | |
* Get a `Map` of recipe name to its arity, where variadic recipes are set | |
* to `Infinity`. | |
*/ | |
function getRecipeArityMap(justfile: Justfile): RecipeArityMapping { | |
const recipeArity = new Map<string, number>(); | |
// Will be updated as the recipes are added to the map | |
let maxArity = 0; | |
for (const [name, recipe] of Object.entries(justfile.recipes)) { | |
const params = recipe.parameters; | |
let arity = params.length; | |
// A recipe can only be variadic if it takes at least one parameter, and | |
// the final parameter is not "singular" (must be "star" or "plus") | |
if (arity > 0 && params[params.length - 1].kind !== "singular") { | |
arity = Infinity; | |
} | |
// The arity has been calculated, update the running maximum too! | |
if (maxArity < arity) { | |
maxArity = arity; | |
} | |
recipeArity.set(name, arity); | |
} | |
// The recipes are in the map, now add the aliases. Since the target recipe | |
// added, it's safe to pull out its arity and assign that without checking. | |
for (const [name, alias] of Object.entries(justfile.aliases)) { | |
const arity = recipeArity.get(alias.target) as number; | |
recipeArity.set(name, arity); | |
} | |
return { recipeArity, maxArity }; | |
} | |
/** | |
* Generator script to dump the current justfile as JSON. | |
* | |
* If the user has provided -f/--justfile, that file will be used. Otherwise, | |
* `just` will handle searching for it. | |
*/ | |
const dumpJustfile: Fig.Generator["script"] = (tokens) => { | |
const path = getJustfilePath(tokens); | |
return getJustfileDumpCommand(path); | |
}; | |
/** | |
* Process the output of dumping a justfile as JSON. If JSON parsing | |
* failed, for any reason, `null` is returned and the error is logged. | |
*/ | |
function processJustfileDump(out: string): Justfile | null { | |
try { | |
return JSON.parse(out) as Justfile; | |
} catch (e) { | |
console.error(e); | |
return null; | |
} | |
} | |
const completionSpec: Fig.Spec = { | |
name: "just", | |
description: "Just a command runner", | |
options: [ | |
{ | |
name: ["--help", "-h"], | |
description: "Print help information", | |
}, | |
{ | |
name: "--changelog", | |
description: "Print the changelog", | |
}, | |
{ | |
name: "--check", | |
description: "Run --fmt in 'check' mode", | |
dependsOn: ["--unstable", "--fmt"], | |
}, | |
{ | |
name: "--choose", | |
description: "Select one or more recipes to run using another program", | |
}, | |
{ | |
name: "--chooser", | |
description: "Override the binary invoked by --choose", | |
args: { | |
name: "program", | |
}, | |
}, | |
{ | |
name: "--color", | |
description: "Print colorful output", | |
args: { | |
suggestions: [ | |
{ name: "auto", icon: "fig://icon?type=string" }, | |
{ name: "always", icon: "fig://icon?type=string" }, | |
{ name: "never", icon: "fig://icon?type=string" }, | |
], | |
}, | |
}, | |
{ | |
name: ["-c", "--command"], | |
description: | |
"Run an arbitrary command with the working directory, .env, overrides, and exports", | |
args: { | |
name: "command", | |
isCommand: true, | |
}, | |
}, | |
{ | |
name: "--completions", | |
description: "Print shell completions", | |
args: { | |
name: "shell", | |
suggestions: [ | |
{ name: "zsh", icon: "fig://icon?type=string" }, | |
{ name: "bash", icon: "fig://icon?type=string" }, | |
{ name: "fish", icon: "fig://icon?type=string" }, | |
{ name: "powershell", icon: "fig://icon?type=string" }, | |
{ name: "elvish", icon: "fig://icon?type=string" }, | |
], | |
}, | |
}, | |
{ | |
name: "--clear-shell-args", | |
description: "Clear shell arguments", | |
}, | |
{ | |
name: "--dry-run", | |
description: "Print what just would do, without doing it", | |
}, | |
{ | |
name: "--dump", | |
description: "Print justfile", | |
}, | |
{ | |
name: "--dotenv-filename", | |
description: "Use a file with this name instead of .env", | |
args: { | |
name: "name", | |
}, | |
}, | |
{ | |
name: "--dotenv-path", | |
description: | |
"Load the environment file from a path instead of searching for one", | |
}, | |
{ | |
name: "--dump-format", | |
description: "Specify the format for dumping the justfile", | |
dependsOn: ["--dump"], | |
args: { | |
name: "format", | |
suggestions: [ | |
{ | |
name: "just", | |
icon: "fig://icon?type=string", | |
}, | |
{ | |
name: "json", | |
icon: "fig://icon?type=string", | |
description: "This value requires --unstable", | |
}, | |
], | |
}, | |
}, | |
{ | |
name: ["-e", "--edit"], | |
description: | |
"Edit the justfile with $VISUAL or $EDITOR, falling back to vim", | |
}, | |
{ | |
name: "--evaluate", | |
description: "Evaluate and print all variables", | |
}, | |
{ | |
name: "--fmt", | |
description: "Format and overwrite the justfile", | |
dependsOn: ["--unstable"], | |
}, | |
{ | |
name: "--highlight", | |
description: "Highlight echoed recipe lines in bold", | |
exclusiveOn: ["--no-highlight"], | |
}, | |
{ | |
name: "--init", | |
description: "Initialize a new justfile", | |
}, | |
{ | |
name: ["-f", "--justfile"], | |
description: "Use a specific justfile", | |
args: { | |
name: "file", | |
template: "filepaths", | |
}, | |
}, | |
{ | |
name: ["-l", "--list"], | |
description: "List available recipes and their arguments", | |
}, | |
{ | |
name: "--list-heading", | |
description: "Print this text before the list", | |
args: { | |
name: "text", | |
}, | |
}, | |
{ | |
name: "--list-prefix", | |
description: "Print this text before each list item", | |
args: { | |
name: "text", | |
}, | |
}, | |
{ | |
name: "--no-dotenv", | |
description: "Don't load the environment file", | |
}, | |
{ | |
name: "--no-highlight", | |
description: "Don't highlight echoed recipe lines", | |
exclusiveOn: ["--highlight"], | |
}, | |
{ | |
name: ["-q", "--quiet"], | |
description: "Suppress all output", | |
}, | |
{ | |
name: "--set", | |
description: "Override a variable with a value", | |
args: [ | |
{ | |
name: "variable", | |
generators: { | |
script: dumpJustfile, | |
postProcess: (out) => { | |
const justfile = processJustfileDump(out); | |
if (justfile === null) { | |
return []; | |
} | |
return Object.keys(justfile.assignments).map((name) => ({ | |
name, | |
icon: "fig://icon?type=string", | |
})); | |
}, | |
}, | |
}, | |
{ | |
name: "value", | |
description: "The string value the variable will be set to", | |
}, | |
], | |
}, | |
{ | |
name: "--shell", | |
description: "Invoke this shell to run recipes", | |
args: { | |
name: "shell", | |
default: "sh", | |
}, | |
}, | |
{ | |
name: "--shell-arg", | |
description: "Invoke the shell with this as an argument", | |
args: { | |
name: "argument", | |
default: "-cu", | |
}, | |
}, | |
{ | |
name: "--shell-command", | |
description: | |
"Invoke --command with the shell used to run recipe lines and backticks", | |
}, | |
{ | |
name: ["-s", "--show"], | |
description: "Show information about a recipe", | |
args: { | |
name: "recipe", | |
generators: { | |
script: dumpJustfile, | |
postProcess: (out) => { | |
const justfile = processJustfileDump(out); | |
if (justfile === null) { | |
return []; | |
} | |
return getRecipeSuggestions(justfile); | |
}, | |
}, | |
}, | |
}, | |
{ | |
name: "--summary", | |
description: "List names of available recipes", | |
}, | |
{ | |
name: ["-u", "--unsorted"], | |
description: "Return list and summary entries in source order", | |
}, | |
{ | |
name: "--unstable", | |
description: "Enable unstable features", | |
}, | |
{ | |
name: "--variables", | |
description: "List names of variables", | |
}, | |
{ | |
name: ["-v", "--verbose"], | |
description: "Use verbose output", | |
}, | |
{ | |
name: ["-V", "--version"], | |
description: "Print version information", | |
}, | |
{ | |
name: ["-d", "--working-directory"], | |
description: "Use this as the working directory", | |
dependsOn: ["--justfile"], | |
args: { | |
template: "folders", | |
}, | |
}, | |
], | |
args: { | |
// This would normally say "recipes" but because this is also used | |
// for the recipes' arguments it needs to be more generic. | |
name: "args", | |
isVariadic: true, | |
isOptional: true, | |
optionsCanBreakVariadicArg: false, | |
generators: { | |
script: dumpJustfile, | |
postProcess: (out, tokens) => { | |
const justfile = processJustfileDump(out); | |
if (justfile === null) { | |
return []; | |
} | |
const { recipeArity, maxArity } = getRecipeArityMap(justfile); | |
const indicesToCheck = Math.min(maxArity, tokens.length - 2); | |
// Loop backwards over the tokens until you find a recipe name. If the | |
// recipe takes more arguments than the number of tokens that have been | |
// checked, then the final token is one of its arguments. | |
for (let checked = 0; checked < indicesToCheck; checked++) { | |
const index = tokens.length - 2 - checked; | |
const token = tokens[index]; | |
const arity = recipeArity.get(token); | |
// Not a recipe, keep going... | |
if (arity === undefined) { | |
continue; | |
} | |
// Found a recipe. If it takes more args than the number of tokens | |
// that have been checked, then we're in one of its arguments. | |
if (arity > checked) { | |
return []; | |
} else { | |
break; | |
} | |
} | |
return getRecipeSuggestions(justfile, { | |
showRecipeParameters: true, | |
}); | |
}, | |
}, | |
}, | |
}; | |
export default completionSpec; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const std = @import("std"); | |
const json = std.json; | |
const Allocator = std.mem.Allocator; | |
const StringHashMap = std.StringHashMap; | |
const ArrayList = std.ArrayList; | |
// --- Data Structures Mirroring JSON Schema --- | |
// Corresponds to TypeScript's ParameterKind | |
pub const ParameterKind = enum { | |
singular, | |
plus, | |
star, | |
pub fn fromString(s: []const u8) !ParameterKind { | |
if (std.mem.eql(u8, s, "singular")) { | |
return .singular; | |
} else if (std.mem.eql(u8, s, "plus")) { | |
return .plus; | |
} else if (std.mem.eql(u8, s, "star")) { | |
return .star; | |
} else { | |
return error.InvalidParameterKind; | |
} | |
} | |
}; | |
// Corresponds to TypeScript's Parameter | |
pub const Parameter = struct { | |
default_value: ?json.Value = null, // Renamed 'default' as it's a keyword. Using json.Value for unknown type. | |
export: bool, | |
kind: ParameterKind, | |
name: []const u8, | |
// Custom parsing logic needed because 'default' is a keyword and 'kind' is an enum | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, // To be set by the caller | |
.custom_options = .{ | |
.map_field_name = mapFieldName, | |
.parse_field_fn = parseField, | |
}, | |
.ignore_unknown_fields = true, // Be lenient like the TS version | |
}; | |
fn mapFieldName(field_name: []const u8) []const u8 { | |
if (std.mem.eql(u8, field_name, "default")) { | |
return "default_value"; | |
} | |
return field_name; | |
} | |
fn parseField( | |
field_value: *json.Value, | |
field_name: []const u8, | |
field_ptr: anytype, | |
options: json.ParseOptions, | |
stream: *json.TokenStream, | |
) !bool { | |
_ = stream; // Not needed for this custom parser | |
if (std.mem.eql(u8, field_name, "kind")) { | |
const kind_str = try json.parse(json.Value, field_value.*, options); | |
if (kind_str != .String) return error.ExpectedStringForKind; | |
const KindEnum = @TypeOf(field_ptr.*); | |
field_ptr.* = try KindEnum.fromString(kind_str.String); | |
return true; // Field handled | |
} | |
return false; // Use default parsing for other fields | |
} | |
}; | |
// Corresponds to TypeScript's Dependency | |
pub const Dependency = struct { | |
arguments: ArrayList(json.Value), // Using json.Value for unknown[] | |
recipe: []const u8, | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, | |
.ignore_unknown_fields = true, | |
}; | |
}; | |
// Corresponds to TypeScript's Recipe | |
pub const Recipe = struct { | |
name: []const u8, | |
doc: ?[]const u8, | |
body: ArrayList(json.Value), // Using json.Value for unknown[] | |
dependencies: ArrayList(Dependency), | |
parameters: ArrayList(Parameter), | |
priors: usize, | |
private: bool, | |
quiet: bool, | |
shebang: bool, | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, | |
.ignore_unknown_fields = true, | |
}; | |
}; | |
// Corresponds to TypeScript's Alias | |
pub const Alias = struct { | |
name: []const u8, | |
target: []const u8, | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, | |
.ignore_unknown_fields = true, | |
}; | |
}; | |
// Corresponds to TypeScript's Binding | |
pub const Binding = struct { | |
name: []const u8, | |
export: bool, | |
value: json.Value, // Using json.Value for unknown | |
pub const json_options = json.ParseOptions{ | |
.allocator = null, | |
.ignore_unknown_fields = true, | |
}; | |
}; | |
// Corresponds to TypeScript's Justfile interface | |
pub const Justfile = struct { | |
aliases: StringHashMap(Alias), | |
assignments: StringHashMap(Binding), | |
recipes: StringHashMap(Recipe), | |
// Need custom parsing for the maps | |
pub fn jsonParse(allocator: Allocator, source: anytype, options: json.ParseOptions) !Justfile { | |
var value_tree = try json.parseFromSlice(json.Value, allocator, source, .{ | |
.allocator = allocator, | |
.max_value_depth = 10, // Adjust as needed | |
}); | |
defer value_tree.deinit(); | |
if (value_tree.value != .Object) { | |
return error.TopLevelNotObject; | |
} | |
var object_map = value_tree.value.Object; | |
var self = Justfile{ | |
.aliases = StringHashMap(Alias).init(allocator), | |
.assignments = StringHashMap(Binding).init(allocator), | |
.recipes = StringHashMap(Recipe).init(allocator), | |
}; | |
errdefer { | |
self.aliases.deinit(); | |
self.assignments.deinit(); | |
self.recipes.deinit(); | |
} | |
// Parse Aliases | |
if (object_map.get("aliases")) |aliases_val| { | |
if (aliases_val != .Object) return error.AliasesNotObject; | |
var aliases_iter = aliases_val.Object.iterator(); | |
while (try aliases_iter.next()) |entry| { | |
var alias_options = Alias.json_options; | |
alias_options.allocator = allocator; | |
const alias = try json.parse(Alias, entry.value_ptr.*, alias_options); | |
// Key needs to be duplicated as it's owned by the value_tree | |
const key_copy = try allocator.dupe(u8, entry.key_ptr.*); | |
errdefer allocator.free(key_copy); | |
try self.aliases.put(key_copy, alias); | |
} | |
} | |
// Parse Assignments | |
if (object_map.get("assignments")) |assignments_val| { | |
if (assignments_val != .Object) return error.AssignmentsNotObject; | |
var assignments_iter = assignments_val.Object.iterator(); | |
while (try assignments_iter.next()) |entry| { | |
var binding_options = Binding.json_options; | |
binding_options.allocator = allocator; | |
const binding = try json.parse(Binding, entry.value_ptr.*, binding_options); | |
const key_copy = try allocator.dupe(u8, entry.key_ptr.*); | |
errdefer allocator.free(key_copy); | |
try self.assignments.put(key_copy, binding); | |
} | |
} | |
// Parse Recipes | |
if (object_map.get("recipes")) |recipes_val| { | |
if (recipes_val != .Object) return error.RecipesNotObject; | |
var recipes_iter = recipes_val.Object.iterator(); | |
while (try recipes_iter.next()) |entry| { | |
var recipe_options = Recipe.json_options; | |
recipe_options.allocator = allocator; | |
var parameter_options = Parameter.json_options; // Need to set allocator for nested types too | |
parameter_options.allocator = allocator; | |
var dependency_options = Dependency.json_options; | |
dependency_options.allocator = allocator; | |
// Need to provide allocators down the chain - std.json doesn't automatically inherit | |
// This is a bit verbose; a helper function could abstract this. | |
const recipe = try json.parseWithOptions(Recipe, entry.value_ptr.*, recipe_options, .{ | |
.allocator = allocator, // Pass allocator explicitly for nested structures | |
.custom_options_map = &.{ // Map nested types to their options | |
.{ @TypeOf(Parameter), ¶meter_options }, | |
.{ @TypeOf(Dependency), &dependency_options }, | |
}, | |
}); | |
const key_copy = try allocator.dupe(u8, entry.key_ptr.*); | |
errdefer allocator.free(key_copy); | |
try self.recipes.put(key_copy, recipe); | |
} | |
} | |
return self; | |
} | |
// Remember to call deinit when done with the Justfile instance | |
pub fn deinit(self: *Justfile, allocator: Allocator) void { | |
// Deallocate strings owned by json.Value in Binding.value, Parameter.default_value, Recipe.body, Dependency.arguments | |
var assignment_iter = self.assignments.valueIterator(); | |
while (assignment_iter.next()) |binding| binding.value.deinit(allocator); | |
var recipe_iter = self.recipes.valueIterator(); | |
while (recipe_iter.next()) |recipe| { | |
for (recipe.body.items) |item| item.deinit(allocator); | |
recipe.body.deinit(allocator); | |
for (recipe.dependencies.items) |dep| { | |
for (dep.arguments.items) |arg| arg.deinit(allocator); | |
dep.arguments.deinit(allocator); | |
} | |
recipe.dependencies.deinit(allocator); | |
for (recipe.parameters.items) |param| { | |
if (param.default_value) |dv| dv.deinit(allocator); | |
} | |
recipe.parameters.deinit(allocator); | |
} | |
// Deallocate the hash maps themselves (keys were allocated in jsonParse) | |
self.aliases.deinitKeys(allocator); | |
self.assignments.deinitKeys(allocator); | |
self.recipes.deinitKeys(allocator); | |
} | |
}; | |
// --- Parsing Function --- | |
/// Parses the JSON output from `just --dump --dump-format json`. | |
/// Takes ownership of the input `json_string` if it's mutable, | |
/// otherwise copies if necessary depending on the allocator. | |
pub fn parseJustfileDump(allocator: Allocator, json_string: []const u8) !Justfile { | |
// Default options - allow trailing commas, comments etc. | |
const options = json.ParseOptions{ | |
.allocator = allocator, | |
.comment_handling = .ignore, | |
.trailing_comma = true, | |
}; | |
const justfile = try Justfile.jsonParse(allocator, json_string, options); | |
return justfile; | |
} | |
// --- Example Usage --- | |
pub fn main() !void { | |
// Example JSON string (replace with actual output from `just --dump ...`) | |
const json_dump = | |
\\{ | |
\\ "aliases": { | |
\\ "a": { "name": "a", "target": "actual-recipe" } | |
\\ }, | |
\\ "assignments": { | |
\\ "VAR": { "name": "VAR", "export": false, "value": "hello" } | |
\\ }, | |
\\ "recipes": { | |
\\ "actual-recipe": { | |
\\ "name": "actual-recipe", | |
\\ "doc": "Does a thing.", | |
\\ "body": ["echo \"running...\""], | |
\\ "dependencies": [], | |
\\ "parameters": [ | |
\\ { "name": "arg1", "kind": "singular", "export": false, "default": null }, | |
\\ { "name": "rest", "kind": "plus", "export": true, "default": ["default_val"] } | |
\\ ], | |
\\ "priors": 0, | |
\\ "private": false, | |
\\ "quiet": false, | |
\\ "shebang": false | |
\\ }, | |
\\ "private-recipe": { | |
\\ "name": "private-recipe", "doc": null, "body": [], "dependencies": [], "parameters": [], | |
\\ "priors": 0, "private": true, "quiet": false, "shebang": false | |
\\ } | |
\\ } | |
\\} | |
; | |
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
defer _ = gpa.deinit(); | |
const allocator = gpa.allocator(); | |
const justfile = try parseJustfileDump(allocator, json_dump); | |
// Remember to deallocate when done! | |
defer justfile.deinit(allocator); | |
// Now you can access the parsed data | |
std.debug.print("Parsed Justfile:\n", .{}); | |
if (justfile.recipes.get("actual-recipe")) |recipe| { | |
std.debug.print(" Recipe 'actual-recipe':\n", .{}); | |
std.debug.print(" Doc: {?s}\n", .{recipe.doc}); | |
std.debug.print(" Private: {}\n", .{recipe.private}); | |
std.debug.print(" Parameters:\n", .{}); | |
for (recipe.parameters.items) |param| { | |
std.debug.print(" - Name: {s}, Kind: {s}, Export: {}\n", .{ | |
param.name, @tagName(param.kind), param.export, | |
}); | |
// You'd need more logic to inspect param.default_value (json.Value) | |
} | |
} | |
if (justfile.aliases.get("a")) |alias| { | |
std.debug.print(" Alias 'a':\n", .{}); | |
std.debug.print(" Target: {s}\n", .{alias.target}); | |
} | |
if (justfile.assignments.get("VAR")) |binding| { | |
std.debug.print(" Assignment 'VAR':\n", .{}); | |
std.debug.print(" Export: {}\n", .{binding.export}); | |
// You'd need more logic to inspect binding.value (json.Value) | |
std.debug.print(" Value (JSON): {any}\n", .{binding.value}); | |
} | |
} | |
// --- Helper Function Placeholders (Not Implemented) --- | |
// pub fn getJustfilePath(allocator: Allocator, tokens: [][]const u8) !?[]const u8 { | |
// // TODO: Implement logic similar to the TypeScript version | |
// // using string searching/slicing. | |
// _ = allocator; | |
// _ = tokens; | |
// return null; | |
// } | |
// pub fn getJustfileDumpCommand(allocator: Allocator, justfilePath: ?[]const u8) ![]u8 { | |
// // TODO: Implement logic similar to the TypeScript version | |
// // using std.fmt.allocPrint | |
// _ = allocator; | |
// _ = justfilePath; | |
// return error.NotImplemented; | |
// } | |
// // Define FigSuggestion struct if needed | |
// pub const FigSuggestion = struct { | |
// name: []const u8, | |
// displayName: ?[]const u8 = null, | |
// description: ?[]const u8 = null, | |
// icon: ?[]const u8 = null, | |
// // ... other Fig fields | |
// }; | |
// pub fn getRecipeSuggestions(allocator: Allocator, justfile: Justfile, showRecipeParameters: bool) !ArrayList(FigSuggestion) { | |
// // TODO: Implement logic similar to the TypeScript version | |
// _ = allocator; | |
// _ = justfile; | |
// _ = showRecipeParameters; | |
// return error.NotImplemented; | |
// } | |
// pub fn getRecipeUsage(allocator: Allocator, recipe: Recipe) ![]u8 { | |
// // TODO: Implement logic similar to the TypeScript version | |
// _ = allocator; | |
// _ = recipe; | |
// return error.NotImplemented; | |
// } | |
// pub const RecipeArityMapping = struct { | |
// recipeArity: StringHashMap(usize), | |
// maxArity: usize, | |
// | |
// pub fn deinit(self: *RecipeArityMapping, allocator: Allocator) void { | |
// self.recipeArity.deinitKeys(allocator); // Assuming keys were allocated | |
// } | |
// }; | |
// | |
// pub fn getRecipeArityMap(allocator: Allocator, justfile: Justfile) !RecipeArityMapping { | |
// // TODO: Implement logic similar to the TypeScript version | |
// _ = allocator; | |
// _ = justfile; | |
// return error.NotImplemented; | |
// } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment