Skip to content

Instantly share code, notes, and snippets.

@raspberrypisig
Last active April 19, 2025 06:17
Show Gist options
  • Save raspberrypisig/f9ff762bedde4bd920e1abaddbe9fd8a to your computer and use it in GitHub Desktop.
Save raspberrypisig/f9ff762bedde4bd920e1abaddbe9fd8a to your computer and use it in GitHub Desktop.
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;
// }
/**
* 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(`&lt;${parameter.name}&gt;`);
} else if (parameter.kind === "plus") {
parts.push(`&lt;${parameter.name}...&gt;`);
} 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;
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