@rbxts/centurion-ui 1.0.7 → 2.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +60 -15
  2. package/centurion.luau +10 -0
  3. package/package.json +13 -23
  4. package/src/analyse/cursor.luau +25 -0
  5. package/src/analyse/highlight.luau +91 -0
  6. package/src/analyse/init.luau +318 -0
  7. package/src/analyse/leventine.luau +298 -0
  8. package/src/analyse/match.luau +39 -0
  9. package/src/analyse/suggest.luau +41 -0
  10. package/src/app/header.luau +128 -0
  11. package/src/app/history.luau +258 -0
  12. package/src/app/init.luau +699 -0
  13. package/src/app/suggestions.luau +419 -0
  14. package/src/button.luau +13 -0
  15. package/src/components/background.luau +37 -0
  16. package/src/components/container.luau +33 -0
  17. package/src/components/corner.luau +11 -0
  18. package/src/components/divider.luau +28 -0
  19. package/src/components/flex.luau +108 -0
  20. package/src/components/gap.luau +25 -0
  21. package/src/components/image.luau +33 -0
  22. package/src/components/padding.luau +59 -0
  23. package/src/components/stroke.luau +20 -0
  24. package/src/components/text.luau +57 -0
  25. package/src/components/textbox.luau +186 -0
  26. package/src/fade.luau +23 -0
  27. package/src/init.luau +142 -0
  28. package/src/input.luau +16 -0
  29. package/src/px.luau +68 -0
  30. package/src/rich.luau +14 -0
  31. package/src/state.luau +33 -0
  32. package/src/theme.luau +70 -0
  33. package/src/ts/index.d.ts +60 -0
  34. package/src/ts/init.luau +5 -0
  35. package/src/types.luau +85 -0
  36. package/vide/init.luau +8 -0
  37. package/vide/src/action.luau +25 -0
  38. package/vide/src/apply.luau +162 -0
  39. package/vide/src/batch.luau +23 -0
  40. package/vide/src/branch.luau +40 -0
  41. package/vide/src/changed.luau +18 -0
  42. package/vide/src/cleanup.luau +43 -0
  43. package/vide/src/context.luau +74 -0
  44. package/vide/src/create.luau +107 -0
  45. package/vide/src/defaults.luau +113 -0
  46. package/vide/src/derive.luau +18 -0
  47. package/vide/src/effect.luau +12 -0
  48. package/vide/src/flags.luau +12 -0
  49. package/vide/src/graph.luau +321 -0
  50. package/vide/src/implicit_effect.luau +125 -0
  51. package/vide/src/indexes.luau +121 -0
  52. package/vide/src/init.luau +10 -0
  53. package/vide/src/lib.luau +120 -0
  54. package/vide/src/mount.luau +11 -0
  55. package/vide/src/read.luau +5 -0
  56. package/vide/src/root.luau +35 -0
  57. package/vide/src/show.luau +36 -0
  58. package/vide/src/source.luau +31 -0
  59. package/vide/src/spring.luau +328 -0
  60. package/vide/src/switch.luau +114 -0
  61. package/vide/src/timeout.luau +27 -0
  62. package/vide/src/untrack.luau +25 -0
  63. package/vide/src/values.luau +131 -0
  64. package/out/app/centurion-app.d.ts +0 -3
  65. package/out/app/centurion-app.luau +0 -86
  66. package/out/app/index.d.ts +0 -32
  67. package/out/app/init.luau +0 -105
  68. package/out/components/history/history-line.d.ts +0 -9
  69. package/out/components/history/history-line.luau +0 -88
  70. package/out/components/history/history-list.d.ts +0 -11
  71. package/out/components/history/history-list.luau +0 -67
  72. package/out/components/history/index.d.ts +0 -1
  73. package/out/components/history/init.luau +0 -7
  74. package/out/components/index.d.ts +0 -2
  75. package/out/components/init.luau +0 -10
  76. package/out/components/suggestions/badge.d.ts +0 -16
  77. package/out/components/suggestions/badge.luau +0 -40
  78. package/out/components/suggestions/index.d.ts +0 -1
  79. package/out/components/suggestions/init.luau +0 -7
  80. package/out/components/suggestions/main-suggestion.d.ts +0 -9
  81. package/out/components/suggestions/main-suggestion.luau +0 -232
  82. package/out/components/suggestions/suggestion-list.d.ts +0 -8
  83. package/out/components/suggestions/suggestion-list.luau +0 -96
  84. package/out/components/suggestions/suggestions.d.ts +0 -2
  85. package/out/components/suggestions/suggestions.luau +0 -64
  86. package/out/components/suggestions/types.d.ts +0 -6
  87. package/out/components/suggestions/types.luau +0 -2
  88. package/out/components/suggestions/util.d.ts +0 -1
  89. package/out/components/suggestions/util.luau +0 -22
  90. package/out/components/terminal/command.d.ts +0 -6
  91. package/out/components/terminal/command.luau +0 -74
  92. package/out/components/terminal/index.d.ts +0 -1
  93. package/out/components/terminal/init.luau +0 -7
  94. package/out/components/terminal/suggestion.d.ts +0 -26
  95. package/out/components/terminal/suggestion.luau +0 -205
  96. package/out/components/terminal/terminal-text-field/index.d.ts +0 -1
  97. package/out/components/terminal/terminal-text-field/init.luau +0 -7
  98. package/out/components/terminal/terminal-text-field/suggestion.d.ts +0 -5
  99. package/out/components/terminal/terminal-text-field/suggestion.luau +0 -124
  100. package/out/components/terminal/terminal-text-field/terminal-text-field.d.ts +0 -13
  101. package/out/components/terminal/terminal-text-field/terminal-text-field.luau +0 -273
  102. package/out/components/terminal/terminal.d.ts +0 -2
  103. package/out/components/terminal/terminal.luau +0 -304
  104. package/out/components/ui/frame.d.ts +0 -17
  105. package/out/components/ui/frame.luau +0 -32
  106. package/out/components/ui/group.d.ts +0 -15
  107. package/out/components/ui/group.luau +0 -29
  108. package/out/components/ui/layer.d.ts +0 -8
  109. package/out/components/ui/layer.luau +0 -26
  110. package/out/components/ui/outline.d.ts +0 -15
  111. package/out/components/ui/outline.luau +0 -116
  112. package/out/components/ui/padding.d.ts +0 -10
  113. package/out/components/ui/padding.luau +0 -19
  114. package/out/components/ui/scrolling-frame.d.ts +0 -14
  115. package/out/components/ui/scrolling-frame.luau +0 -44
  116. package/out/components/ui/text-field.d.ts +0 -12
  117. package/out/components/ui/text-field.luau +0 -60
  118. package/out/components/ui/text.d.ts +0 -17
  119. package/out/components/ui/text.luau +0 -61
  120. package/out/constants/options.d.ts +0 -2
  121. package/out/constants/options.luau +0 -27
  122. package/out/constants/text.d.ts +0 -3
  123. package/out/constants/text.luau +0 -9
  124. package/out/constants/util.d.ts +0 -1
  125. package/out/constants/util.luau +0 -7
  126. package/out/hooks/use-client.d.ts +0 -2
  127. package/out/hooks/use-client.luau +0 -17
  128. package/out/hooks/use-event.d.ts +0 -14
  129. package/out/hooks/use-event.luau +0 -58
  130. package/out/hooks/use-px.d.ts +0 -19
  131. package/out/hooks/use-px.luau +0 -78
  132. package/out/index.d.ts +0 -2
  133. package/out/init.luau +0 -10
  134. package/out/palette.d.ts +0 -47
  135. package/out/palette.luau +0 -42
  136. package/out/store.d.ts +0 -12
  137. package/out/store.luau +0 -26
  138. package/out/types.d.ts +0 -44
  139. package/out/types.luau +0 -2
  140. package/out/utils/string.d.ts +0 -5
  141. package/out/utils/string.luau +0 -25
package/README.md CHANGED
@@ -1,29 +1,74 @@
1
1
  <div align="center">
2
2
  <a href="https://centurion.paradoxum.dev/" target="_blank">
3
- <img src="https://raw.githubusercontent.com/paradoxuum/centurion/main/docs/src/assets/logo.webp" width="150" />
3
+ <img src="https://raw.githubusercontent.com/paradoxuum/centurion/main/docs/public/logo.svg" width="150" />
4
4
  </a>
5
5
 
6
- <h1>Centurion - UI</h1>
6
+ <h1>Centurion</h1>
7
7
 
8
- <p>
9
- <strong>A flexible and extensible command framework for roblox-ts</strong>
10
- </p>
11
-
12
- This package contains Centurion's user interface. To use it, you must
13
- also install [@rbxts/centurion](https://www.npmjs.com/package/@rbxts/centurion).
8
+ <p><strong>A flexible and extensible command framework for Roblox</strong></p>
14
9
 
15
10
  [![CI status][ci-badge]][ci-url]
16
11
  [![npm latest version][npm-latest-badge]][npm-latest-url]
17
- [![npm dev version][npm-next-badge]][npm-next-url]
12
+ [![MIT License][license-badge]][license-url]
18
13
 
19
- [npm-latest-badge]: https://img.shields.io/npm/v/%40rbxts%2Fcenturion?logo=npm&label=latest&color=green
14
+ [npm-latest-badge]: https://img.shields.io/npm/v/@rbxts/centurion.svg?style=for-the-badge&logo=npm
20
15
  [npm-latest-url]: https://www.npmjs.com/package/@rbxts/centurion/v/latest
21
- [npm-next-badge]: https://img.shields.io/npm/v/%40rbxts%2Fcenturion%2Fnext?logo=npm&label=next
22
- [npm-next-url]: https://www.npmjs.com/package/@rbxts/centurion/v/next
23
- [ci-badge]: https://github.com/paradoxuum/centurion/actions/workflows/ci.yml/badge.svg?branch=main
16
+ [ci-badge]: https://img.shields.io/github/actions/workflow/status/paradoxuum/centurion/ci.yml?style=for-the-badge&branch=main&logo=github
24
17
  [ci-url]: https://github.com/paradoxuum/centurion/actions/workflows/ci.yml
18
+ [license-badge]: https://img.shields.io/github/license/paradoxuum/centurion?style=for-the-badge
19
+ [license-url]: https://github.com/paradoxuum/centurion/blob/main/LICENSE.md
20
+
21
+ <img src="https://raw.githubusercontent.com/paradoxuum/centurion/main/docs/public/example.webp" alt="Example" style="border-radius: 4px;" />
25
22
 
26
- <a href="https://centurion.paradoxum.dev/">📖 Documentation</a>
23
+ [📖 **Documentation →**](https://centurion.paradoxum.dev/)
27
24
  </div>
28
25
 
29
- <img src="https://raw.githubusercontent.com/paradoxuum/centurion/main/public/banner.png" />
26
+ ## ✨ Features
27
+
28
+ - 🌙 **Luau and roblox-ts.** Written in Luau with full TypeScript typings, and available on both Wally and npm.
29
+
30
+ - ✏️ **Simple to define.** Register commands with a plain table, or use decorators and classes in roblox-ts.
31
+
32
+ - 🧩 **Typed arguments.** Built-in types for players, teams, numbers, colors, durations, vectors and more, with optional, multi-value and rest arguments. Register your own types with custom suggestions.
33
+
34
+ - 🔐 **Guards and permissions.** Guard commands with reusable checks, and control access with prioritised, inheritable roles.
35
+
36
+ - 🔄 **Client-server sync.** Players only receive the commands they're allowed to run, and stay up to date as commands or roles change.
37
+
38
+ - 💻 **Optional terminal UI.** A clean console with suggestions, fuzzy matching, tab completion, syntax highlighting and history. Use it as-is, theme it, or build your own.
39
+
40
+ ## 📦 Installation
41
+
42
+ ```toml
43
+ # wally.toml
44
+ [dependencies]
45
+ centurion = "paradoxuum/centurion@2.0.0"
46
+ centurion-ui = "paradoxuum/centurion-ui@2.0.0"
47
+ ```
48
+
49
+ ```bash
50
+ # roblox-ts
51
+ npm install @rbxts/centurion @rbxts/centurion-ui
52
+ ```
53
+
54
+ See the [documentation](https://centurion.paradoxum.dev/) to get started.
55
+
56
+ ## 💖 Attributions
57
+
58
+ - [🍀 Charm](https://github.com/littensy/charm): Inspiration for command syncing
59
+
60
+ - [💻 Conch](https://github.com/alicesaidhi/conch): UI utility components and text box state handling
61
+
62
+ - [🌿 Vide](https://github.com/centau/vide): Reactive UI library powering the terminal UI
63
+
64
+ ---
65
+
66
+ <p align="center">
67
+ This project is released under the <a href="https://github.com/paradoxuum/centurion/blob/main/LICENSE.md">MIT License</a>.
68
+ </p>
69
+
70
+ <div align="center">
71
+
72
+ [![MIT License](https://img.shields.io/github/license/paradoxuum/centurion?style=for-the-badge)](https://github.com/paradoxuum/centurion/blob/main/LICENSE.md)
73
+
74
+ </div>
package/centurion.luau ADDED
@@ -0,0 +1,10 @@
1
+ local centurion = require(script.Parent.Parent.centurion.src)
2
+
3
+ export type Argument = centurion.Argument
4
+ export type ArgumentRange = centurion.ArgumentRange
5
+ export type Command<T...> = centurion.Command<T...>
6
+ export type Response = centurion.Response
7
+ export type Result<T = unknown> = centurion.Result<T>
8
+ export type Token = centurion.Token
9
+
10
+ return centurion
package/package.json CHANGED
@@ -1,14 +1,20 @@
1
1
  {
2
2
  "name": "@rbxts/centurion-ui",
3
3
  "description": "Terminal UI for the Centurion command framework",
4
- "version": "1.0.7",
4
+ "version": "2.0.0-rc.1",
5
5
  "author": "paradoxuum",
6
6
  "license": "MIT",
7
- "main": "out/init.lua",
8
- "types": "out/index.d.ts",
7
+ "main": "src/ts/init.luau",
8
+ "types": "src/ts/index.d.ts",
9
9
  "files": [
10
- "out",
11
- "!**/*.tsbuildinfo",
10
+ "src",
11
+ "vide/src",
12
+ "vide/init.luau",
13
+ "*.luau",
14
+ "!src/stories",
15
+ "!wally.toml",
16
+ "!wally.lock",
17
+ "!default.project.json",
12
18
  "!**/__tests__"
13
19
  ],
14
20
  "keywords": [
@@ -26,24 +32,8 @@
26
32
  "publishConfig": {
27
33
  "access": "public"
28
34
  },
29
- "devDependencies": {
30
- "@rbxts/compiler-types": "3.0.0-types.0",
31
- "@rbxts/types": "^1.0.813",
32
- "roblox-ts": "3.0.0",
33
- "shx": "^0.3.4",
34
- "typescript": "5.5.3",
35
- "@rbxts/centurion": "^1.0.1"
36
- },
37
35
  "dependencies": {
38
- "@rbxts/services": "^1.5.5",
39
- "@rbxts/set-timeout": "^1.1.2",
40
- "@rbxts/vide": "^0.5.3"
41
- },
42
- "peerDependencies": {
43
- "@rbxts/centurion": "^1.0.1"
36
+ "@rbxts/centurion": "^2.0.0-rc.1"
44
37
  },
45
- "scripts": {
46
- "build": "shx rm -rf out && rbxtsc --verbose",
47
- "dev": "shx rm -rf out && rbxtsc -w"
48
- }
38
+ "scripts": {}
49
39
  }
@@ -0,0 +1,25 @@
1
+ --!strict
2
+
3
+ type Token = {
4
+ read span: vector,
5
+ }
6
+
7
+ -- finds the token the cursor is on
8
+ local function locate(tokens: { read [number]: Token }, cursor_pos: number): (number, boolean)
9
+ local before = 0
10
+ for i, token in tokens do
11
+ if cursor_pos >= token.span.x and cursor_pos <= token.span.y + 1 then
12
+ return i, false
13
+ end
14
+
15
+ if token.span.y + 1 < cursor_pos then
16
+ before = i
17
+ end
18
+ end
19
+
20
+ return before + 1, true
21
+ end
22
+
23
+ return {
24
+ locate = locate,
25
+ }
@@ -0,0 +1,91 @@
1
+ --!strict
2
+ local Players = game:GetService("Players")
3
+
4
+ local centurion = require("../../centurion")
5
+ local rich = require("../rich")
6
+ local types = require("../types")
7
+
8
+ type Kind = "command" | "argument" | "string" | "invalid"
9
+
10
+ local function is_valid(raw: string, arg: centurion.Argument): boolean
11
+ local ok, result = pcall(centurion.transform_arg, Players.LocalPlayer, arg, raw)
12
+ return ok and result.success
13
+ end
14
+
15
+ local function classify(text: string): { { span: vector, kind: Kind } }
16
+ local resolution = centurion.resolve_command(text)
17
+ local tokens = resolution.tokens
18
+ local cmds = centurion.registry.commands
19
+
20
+ local highlights = {}
21
+ local function add(token: centurion.Token, kind: Kind)
22
+ table.insert(highlights, { span = token.span, kind = kind })
23
+ end
24
+
25
+ local cmd_name = resolution.name
26
+ local cmd_index = resolution.name_end
27
+
28
+ if not cmd_name then
29
+ for _, token in tokens do
30
+ add(token, "invalid")
31
+ end
32
+ return highlights
33
+ end
34
+
35
+ for i = 1, cmd_index do
36
+ add(tokens[i], "command")
37
+ end
38
+
39
+ local cmd = cmds[cmd_name]
40
+ local args: { centurion.Argument } = if cmd.arguments then { cmd.arguments() } :: { centurion.Argument } else {}
41
+
42
+ local ranges, next_index = centurion.assign_args(args, #tokens - cmd_index)
43
+ for i, arg in args do
44
+ for j = ranges[i].first, ranges[i].last do
45
+ local token = tokens[cmd_index + j]
46
+ local raw = text:sub(token.span.x, token.span.y)
47
+
48
+ if not is_valid(raw, arg) then
49
+ add(token, "invalid")
50
+ elseif raw:match("^[\"']") then
51
+ add(token, "string")
52
+ else
53
+ add(token, "argument")
54
+ end
55
+ end
56
+ end
57
+
58
+ -- input past the last argument is rejected, including by commands that
59
+ -- take no arguments at all
60
+ for j = cmd_index + next_index, #tokens do
61
+ add(tokens[j], "invalid")
62
+ end
63
+
64
+ if resolution.unclosed_quote and #highlights > 0 then
65
+ highlights[#highlights].kind = "invalid"
66
+ end
67
+
68
+ return highlights
69
+ end
70
+
71
+ local function highlight(text: string, theme: types.Theme): string
72
+ local colors: { [Kind]: Color3 } = {
73
+ command = theme.highlight,
74
+ argument = theme.text,
75
+ string = theme.success,
76
+ invalid = theme.error,
77
+ }
78
+
79
+ local out = {}
80
+ local pos = 1
81
+ for _, item in classify(text) do
82
+ table.insert(out, rich.escape(text:sub(pos, item.span.x - 1)))
83
+ table.insert(out, rich.color(rich.escape(text:sub(item.span.x, item.span.y)), colors[item.kind]))
84
+ pos = item.span.y + 1
85
+ end
86
+ table.insert(out, rich.escape(text:sub(pos)))
87
+
88
+ return table.concat(out)
89
+ end
90
+
91
+ return highlight
@@ -0,0 +1,318 @@
1
+ --!strict
2
+ local Players = game:GetService("Players")
3
+
4
+ local centurion = require("../centurion")
5
+ local cursor = require("@self/cursor")
6
+ local match = require("@self/match")
7
+ local suggest = require("@self/suggest")
8
+ local types = require("./types")
9
+
10
+ local function analyse(text: string, cursor_pos: number, max_suggestions: number?): types.AnalysisResult
11
+ local resolution = centurion.resolve_command(text)
12
+ local tokens = resolution.tokens
13
+
14
+ if #tokens == 0 then
15
+ return {
16
+ result = {
17
+ success = true,
18
+ value = nil :: any,
19
+ },
20
+ suggestions = {},
21
+ }
22
+ end
23
+
24
+ local cmds = centurion.registry.commands
25
+
26
+ local cmd_name: string? = resolution.name
27
+ local cmd_index: number? = cmd_name and resolution.name_end or nil
28
+
29
+ local arg_tokens: { centurion.Token } = cmd_index and { select(2, unpack(tokens, cmd_index)) } or {}
30
+
31
+ local at_arg = false
32
+ if cmd_index and cursor_pos - 1 > tokens[cmd_index].span.y then
33
+ at_arg = true
34
+ end
35
+
36
+ local slot, inserting = cursor.locate(arg_tokens, cursor_pos)
37
+
38
+ local function process_arg(): types.AnalysisResult?
39
+ if not cmd_name then
40
+ return nil
41
+ end
42
+
43
+ if not cmd_index then
44
+ return nil
45
+ end
46
+
47
+ local cmd = cmds[cmd_name]
48
+ if not cmd then
49
+ return nil
50
+ end
51
+
52
+ local cmd_args: { centurion.Argument } = if cmd.arguments
53
+ then { cmd.arguments() } :: { centurion.Argument }
54
+ else {}
55
+ local arg_text = resolution.args
56
+
57
+ -- commands taking no arguments have nothing to suggest, but still
58
+ -- reject extra input
59
+ if #cmd_args == 0 then
60
+ if #arg_tokens == 0 then
61
+ return nil
62
+ end
63
+
64
+ return {
65
+ result = centurion.transform_args(Players.LocalPlayer, arg_text, cmd_args),
66
+ suggestions = {},
67
+ }
68
+ end
69
+
70
+ local transform_result = centurion.transform_args(Players.LocalPlayer, arg_text, cmd_args)
71
+
72
+ if not at_arg then
73
+ return {
74
+ result = transform_result,
75
+ suggestions = {},
76
+ }
77
+ end
78
+
79
+ -- assign tokens to arguments the same way execution does, counting the
80
+ -- token about to be typed if the cursor isn't on an existing one
81
+ local count = if inserting then #arg_tokens + 1 else #arg_tokens
82
+ local ranges = centurion.assign_args(cmd_args, count)
83
+
84
+ local arg_index: number? = nil
85
+ for i, range in ranges do
86
+ if slot >= range.first and slot <= range.last then
87
+ arg_index = i
88
+ break
89
+ end
90
+ end
91
+
92
+ -- past the last argument, which execution rejects
93
+ if not arg_index then
94
+ return {
95
+ result = transform_result,
96
+ suggestions = {},
97
+ }
98
+ end
99
+
100
+ local arg = cmd_args[arg_index]
101
+ local arg_type = centurion.registry.types[arg.type]
102
+ assert(arg_type, "Unknown argument type: " .. tostring(arg.type))
103
+
104
+ local input_token: centurion.Token
105
+ if inserting then
106
+ -- an empty span at the cursor, so a suggestion is inserted there
107
+ input_token = {
108
+ span = vector.create(cursor_pos, cursor_pos - 1, 0),
109
+ text = "",
110
+ quoted = false,
111
+ }
112
+ else
113
+ input_token = arg_tokens[slot]
114
+ end
115
+
116
+ if arg_type.kind == "list" then
117
+ local parsed_token = centurion.parse_text(text:sub(input_token.span.x, input_token.span.y), ",")
118
+
119
+ local at_list_token = false
120
+ for _, t in parsed_token.tokens do
121
+ if t.span.y + input_token.span.x - 1 >= cursor_pos - 1 then
122
+ at_list_token = true
123
+ input_token = {
124
+ span = vector.create(t.span.x + input_token.span.x - 1, t.span.y + input_token.span.x - 1, 0),
125
+ text = t.text,
126
+ quoted = t.quoted,
127
+ }
128
+ break
129
+ end
130
+ end
131
+
132
+ if not at_list_token and #parsed_token.tokens > 0 then
133
+ local last_token = parsed_token.tokens[#parsed_token.tokens]
134
+ input_token = {
135
+ span = vector.create(
136
+ last_token.span.y + input_token.span.x + 1,
137
+ last_token.span.y + input_token.span.x + 1,
138
+ 0
139
+ ),
140
+ text = "",
141
+ quoted = false,
142
+ }
143
+ end
144
+ end
145
+
146
+ local arg_suggestions: { string } = {}
147
+ if arg_type.suggestions then
148
+ arg_suggestions = table.clone(arg_type.suggestions(input_token.text, Players.LocalPlayer))
149
+ end
150
+
151
+ if arg.suggestions then
152
+ for _, suggestion in arg.suggestions do
153
+ table.insert(arg_suggestions, suggestion)
154
+ end
155
+ end
156
+
157
+ -- check for duplicates
158
+ local seen = {}
159
+ for i = #arg_suggestions, 1, -1 do
160
+ local suggestion = arg_suggestions[i]
161
+ if seen[suggestion] then
162
+ table.remove(arg_suggestions, i)
163
+ else
164
+ seen[suggestion] = true
165
+ end
166
+ end
167
+
168
+ local suggestions = {}
169
+ if #arg_suggestions > 0 then
170
+ local matches = suggest(input_token.text, arg_suggestions, max_suggestions)
171
+
172
+ for _, suggestion_text in matches do
173
+ local replacement = ""
174
+ local replacement_text = {}
175
+ for _, replacement_token in centurion.parse_text(suggestion_text, " ").tokens do
176
+ table.insert(replacement_text, replacement_token.text)
177
+ end
178
+
179
+ if #replacement_text == 1 then
180
+ replacement = replacement_text[1]
181
+ else
182
+ replacement = '"' .. table.concat(replacement_text, " ") .. '"'
183
+ end
184
+
185
+ local suggestion = {
186
+ kind = "argument",
187
+ name = suggestion_text,
188
+ matches = match(input_token.text, suggestion_text),
189
+
190
+ span = input_token.span,
191
+ replace = replacement,
192
+ } :: types.AnalysisSuggestion
193
+ table.insert(suggestions, suggestion)
194
+ end
195
+ end
196
+
197
+ return {
198
+ result = transform_result,
199
+ argument = {
200
+ index = arg_index,
201
+ type = arg.type,
202
+ kind = arg_type.kind,
203
+ },
204
+ suggestions = suggestions,
205
+ } :: types.AnalysisResult
206
+ end
207
+
208
+ local function process_command(): types.AnalysisResult
209
+ local to_match = {}
210
+ for k, _ in cmds do
211
+ if k == cmd_name then
212
+ continue
213
+ end
214
+ table.insert(to_match, k)
215
+ end
216
+
217
+ local token_text = {}
218
+ for _, token in tokens do
219
+ table.insert(token_text, token.text)
220
+ end
221
+
222
+ local query = table.concat(token_text, " ")
223
+ local matches = suggest(query, to_match, max_suggestions)
224
+ if cmd_name then
225
+ table.insert(matches, 1, cmd_name)
226
+ end
227
+
228
+ local result: centurion.Result<unknown>
229
+ if cmd_name then
230
+ result = {
231
+ success = true,
232
+ value = nil :: any,
233
+ }
234
+ else
235
+ result = {
236
+ success = false :: false,
237
+ error = "Unknown command",
238
+ }
239
+ end
240
+
241
+ local suggestions = {}
242
+ for _, cmd_match in matches do
243
+ local suggestion = {
244
+ kind = "command",
245
+ name = cmd_match,
246
+ description = cmds[cmd_match].description,
247
+ matches = match(query, cmd_match),
248
+
249
+ span = vector.create(1, tokens[#tokens].span.y, 0),
250
+ replace = cmd_match,
251
+ } :: types.AnalysisSuggestion
252
+ table.insert(suggestions, suggestion)
253
+ end
254
+
255
+ return {
256
+ result = result,
257
+ suggestions = suggestions,
258
+ } :: types.AnalysisResult
259
+ end
260
+
261
+ local analysis_result: centurion.Result<unknown>
262
+
263
+ local arg_result = process_arg()
264
+ local cmd_result = process_command()
265
+ if resolution.unclosed_quote then
266
+ analysis_result = {
267
+ success = false,
268
+ error = "Unclosed quote at position " .. tokens[#tokens].span.x,
269
+ }
270
+ else
271
+ if arg_result then
272
+ analysis_result = arg_result.result
273
+ else
274
+ analysis_result = cmd_result.result
275
+ end
276
+ end
277
+
278
+ local final_suggestions = cmd_result.suggestions
279
+ if at_arg and arg_result then
280
+ final_suggestions = arg_result.suggestions
281
+
282
+ -- a subcommand shares the position of its parent's first argument, so
283
+ -- both sets are offered there instead of the argument replacing them
284
+ if cmd_name and slot == 1 then
285
+ local prefix = cmd_name .. " "
286
+ local merged: { types.AnalysisSuggestion } = {}
287
+
288
+ for _, suggestion in cmd_result.suggestions do
289
+ if suggestion.name ~= cmd_name and suggestion.name:sub(1, #prefix) == prefix then
290
+ table.insert(merged, suggestion)
291
+ end
292
+ end
293
+
294
+ if #merged > 0 then
295
+ for _, suggestion in final_suggestions do
296
+ table.insert(merged, suggestion)
297
+ end
298
+
299
+ if max_suggestions then
300
+ for i = #merged, max_suggestions + 1, -1 do
301
+ merged[i] = nil
302
+ end
303
+ end
304
+
305
+ final_suggestions = merged
306
+ end
307
+ end
308
+ end
309
+
310
+ return {
311
+ result = analysis_result,
312
+ command = cmd_name,
313
+ argument = arg_result and arg_result.argument or nil,
314
+ suggestions = final_suggestions,
315
+ }
316
+ end
317
+
318
+ return analyse