@jutge.org/toolkit 4.4.1 → 4.4.5

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jutge.org/toolkit",
3
3
  "description": "Toolkit to prepare problems for Jutge.org",
4
- "version": "4.4.1",
4
+ "version": "4.4.5",
5
5
  "homepage": "https://jutge.org",
6
6
  "author": {
7
7
  "name": "Jutge.org",
@@ -69,6 +69,7 @@
69
69
  "boxen": "^8.0.1",
70
70
  "bun-types": "^1.3.7",
71
71
  "chalk": "^5.6.2",
72
+ "chokidar": "^5.0.0",
72
73
  "cli-highlight": "^2.1.11",
73
74
  "commander": "^14.0.2",
74
75
  "dayjs": "^1.11.19",
package/toolkit/lint.ts CHANGED
@@ -1,63 +1,65 @@
1
1
  import { Command } from '@commander-js/extra-typings'
2
2
  import chalk from 'chalk'
3
- import { lintDirectories, type LintIssue } from '../lib/lint'
3
+ import { lintDirectories, type LintIssue, type LintResult } from '../lib/lint'
4
+ import tui from '../lib/tui'
5
+ import { nothing } from '../lib/utils'
4
6
 
5
- function formatIssue(issue: LintIssue): string {
7
+ export function formatLintIssue(issue: LintIssue): string {
6
8
  const prefix = issue.severity === 'error' ? chalk.red('error') : chalk.yellow('warning')
7
9
  const path = issue.path ? chalk.gray(` (${issue.path})`) : ''
8
10
  return ` ${prefix} ${issue.code}: ${issue.message}${path}`
9
11
  }
10
12
 
11
- export const lintCmd = new Command('lint')
12
- .summary('Lint a problem directory')
13
- .description(
14
- 'Check problem.yml/handler.yml schema, required files present, naming conventions, statement structure, sample vs public test consistency, etc.',
15
- )
16
- .argument('[directories...]', 'problem directories to lint (default: current directory)')
17
- .option('-d, --directory <path>', 'problem directory when no arguments given', '.')
18
- .action(async (directories: string[], { directory }) => {
19
- const dirs = directories.length > 0 ? directories : [directory]
20
- const results = await lintDirectories(dirs)
21
-
22
- if (results.length === 0) {
23
- console.log(chalk.yellow('No problem directories found (looked for handler.yml in the given path(s)).'))
24
- return
25
- }
26
-
13
+ export async function printLintResults(results: LintResult[], directories: string[]): Promise<{ hasError: boolean }> {
27
14
  let hasError = false
28
- let hasWarning = false
29
-
30
- for (const result of results) {
31
- const errors = result.issues.filter((i) => i.severity === 'error')
32
- const warnings = result.issues.filter((i) => i.severity === 'warning')
33
- if (errors.length > 0) hasError = true
34
- if (warnings.length > 0) hasWarning = true
15
+ for (const result of results) {
16
+ const errors = result.issues.filter((i) => i.severity === 'error')
17
+ if (errors.length > 0) hasError = true
35
18
 
36
- const dirLabel = result.directory === dirs[0] && results.length === 1 ? result.directory : result.directory
37
- if (result.issues.length === 0) {
38
- console.log(chalk.green('✓'), dirLabel, chalk.gray('— no issues'))
39
- } else {
40
- console.log()
41
- console.log(chalk.bold(dirLabel))
19
+ const dirLabel = result.directory === directories[0] && results.length === 1 ? result.directory : result.directory
20
+ if (result.issues.length === 0) {
21
+ tui.print(chalk.green('✓') + ' ' + dirLabel + ' ' + chalk.gray('— no issues'))
22
+ } else {
23
+ tui.print()
24
+ await tui.section(`Linting ${dirLabel}`, async () => {
25
+ await nothing()
42
26
  for (const issue of result.issues) {
43
- console.log(formatIssue(issue))
27
+ tui.print(formatLintIssue(issue))
44
28
  }
45
- }
29
+ })
30
+ }
31
+ }
32
+
33
+ if (results.length > 1) {
34
+ tui.print()
35
+ const totalErrors = results.reduce((s, r) => s + r.issues.filter((i) => i.severity === 'error').length, 0)
36
+ const totalWarnings = results.reduce((s, r) => s + r.issues.filter((i) => i.severity === 'warning').length, 0)
37
+ if (totalErrors > 0 || totalWarnings > 0) {
38
+ tui.gray(
39
+ `Total: ${totalErrors} error(s), ${totalWarnings} warning(s) across ${results.length} problem(s)`,
40
+ )
46
41
  }
42
+ }
43
+
44
+ return { hasError }
45
+ }
46
+
47
+ export const lintCmd = new Command('lint')
48
+ .summary('Lint a problem directory')
49
+
50
+ .argument('[directories...]', 'problem directories to lint (default: current directory)')
51
+ .option('-d, --directory <path>', 'problem directory when no arguments given', '.')
52
+
53
+ .action(async (directories: string[], { directory }) => {
54
+ const dirs = directories.length > 0 ? directories : [directory]
55
+ const results = await lintDirectories(dirs)
47
56
 
48
- if (results.length > 1) {
49
- console.log()
50
- const totalErrors = results.reduce((s, r) => s + r.issues.filter((i) => i.severity === 'error').length, 0)
51
- const totalWarnings = results.reduce((s, r) => s + r.issues.filter((i) => i.severity === 'warning').length, 0)
52
- if (totalErrors > 0 || totalWarnings > 0) {
53
- console.log(
54
- chalk.gray(
55
- `Total: ${totalErrors} error(s), ${totalWarnings} warning(s) across ${results.length} problem(s)`,
56
- ),
57
- )
58
- }
57
+ if (results.length === 0) {
58
+ tui.warning('No problem directories found (looked for handler.yml in the given path(s)).')
59
+ return
59
60
  }
60
61
 
62
+ const { hasError } = await printLintResults(results, dirs)
61
63
  if (hasError) {
62
64
  process.exitCode = 1
63
65
  }
package/toolkit/make.ts CHANGED
@@ -1,9 +1,16 @@
1
1
  import { Command } from '@commander-js/extra-typings'
2
- import { join, resolve } from 'path'
2
+ import chokidar from 'chokidar'
3
+ import { basename, dirname, join, resolve } from 'path'
3
4
  import { findRealDirectories } from '../lib/helpers'
4
- import { newMaker } from '../lib/maker'
5
+ import { lintDirectories } from '../lib/lint'
6
+ import { printLintResults } from './lint'
7
+ import { newMaker, type Maker } from '../lib/maker'
8
+ import type { Problem } from '../lib/problem'
5
9
  import tui from '../lib/tui'
6
10
  import { nothing, projectDir } from '../lib/utils'
11
+ import chalk from 'chalk'
12
+
13
+ const WATCH_DEBOUNCE_MS = 300
7
14
 
8
15
  export const makeCmd = new Command('make')
9
16
  .description('Make problem')
@@ -13,8 +20,12 @@ export const makeCmd = new Command('make')
13
20
  .option('-i, --ignore-errors', 'ignore errors on a directory and continue processing', false)
14
21
  .option('-e, --only-errors', 'only show errors at the final summary', false)
15
22
  .option('-p, --problem_nm <problem_nm>', 'problem nm', 'DRAFT')
23
+ .option('-w, --watch', 'watch for changes and rebuild incrementally (under development)', false)
16
24
 
17
- .action(async (tasks, { directories, ignoreErrors, onlyErrors, problem_nm }) => {
25
+ .action(async (tasks, { directories, ignoreErrors, onlyErrors, problem_nm, watch }) => {
26
+ if (watch) {
27
+ tasks = ['all']
28
+ }
18
29
  if (tasks.length === 0) {
19
30
  tasks = ['all']
20
31
  }
@@ -32,38 +43,61 @@ export const makeCmd = new Command('make')
32
43
 
33
44
  const realDirectories = await findRealDirectories(directories)
34
45
 
46
+ if (watch && realDirectories.length > 1) {
47
+ tui.warning('With --watch only the first directory is watched')
48
+ }
49
+ const watchDirectory = watch && realDirectories.length > 0 ? realDirectories[0]! : null
50
+
35
51
  for (const directory of realDirectories) {
52
+ if (watch && directory !== watchDirectory) continue
53
+
36
54
  try {
37
55
  tui.title(`Making problem in directory ${tui.hyperlink(directory, resolve(directory))}`)
38
56
 
39
57
  const maker = await newMaker(directory, problem_nm)
40
58
 
41
- // If tasks include 'all', run makeProblem
42
- if (tasks.includes('all')) {
43
- await maker.makeProblem()
44
- } else {
45
- // Run specific tasks
46
- if (tasks.includes('info')) {
47
- // already done in maker initialization
48
- }
49
- if (tasks.includes('exe')) {
50
- await maker.makeExecutables()
59
+ if (watch) {
60
+ const handler = maker.problem.handler.handler
61
+ if (handler === 'quiz' || handler === 'game') {
62
+ tui.warning('--watch has no effect for quiz or game problems')
63
+ await maker.makeProblem()
64
+ continue
51
65
  }
52
- if (tasks.includes('cor')) {
53
- await maker.makeCorrectOutputs()
54
- }
55
- if (tasks.includes('pdf')) {
56
- await maker.makePdfStatements()
57
- }
58
- if (tasks.includes('txt') || tasks.includes('html') || tasks.includes('md')) {
59
- await maker.makeFullTextualStatements(
60
- tasks.filter((t) => ['txt', 'html', 'md'].includes(t)) as Array<'txt' | 'html' | 'md'>,
61
- )
62
- await maker.makeShortTextualStatements(
63
- tasks.filter((t) => ['txt', 'html', 'md'].includes(t)) as Array<'txt' | 'html' | 'md'>,
64
- )
66
+ }
67
+
68
+ if (!watch) {
69
+ // If tasks include 'all', run makeProblem
70
+ if (tasks.includes('all')) {
71
+ await maker.makeProblem()
72
+ } else {
73
+ // Run specific tasks
74
+ if (tasks.includes('info')) {
75
+ // already done in maker initialization
76
+ }
77
+ if (tasks.includes('exe')) {
78
+ await maker.makeExecutables()
79
+ }
80
+ if (tasks.includes('cor')) {
81
+ await maker.makeCorrectOutputs()
82
+ }
83
+ if (tasks.includes('pdf')) {
84
+ await maker.makePdfStatements()
85
+ }
86
+ if (tasks.includes('txt') || tasks.includes('html') || tasks.includes('md')) {
87
+ await maker.makeFullTextualStatements(
88
+ tasks.filter((t) => ['txt', 'html', 'md'].includes(t)) as Array<'txt' | 'html' | 'md'>,
89
+ )
90
+ await maker.makeShortTextualStatements(
91
+ tasks.filter((t) => ['txt', 'html', 'md'].includes(t)) as Array<'txt' | 'html' | 'md'>,
92
+ )
93
+ }
65
94
  }
66
95
  }
96
+
97
+ if (watch && directory === watchDirectory) {
98
+ await runWatch(maker)
99
+ return
100
+ }
67
101
  } catch (error) {
68
102
  const errorMessage = error instanceof Error ? error.message : String(error)
69
103
 
@@ -94,3 +128,281 @@ export const makeCmd = new Command('make')
94
128
  }
95
129
  })
96
130
  })
131
+
132
+ async function runWatch(maker: Maker): Promise<void> {
133
+ const directory = maker.problem.directory
134
+ const goldenSolution = maker.problem.goldenSolution
135
+
136
+ const pending = {
137
+ inp: new Set<string>(),
138
+ golden: false,
139
+ alternatives: new Set<string>(),
140
+ statementTex: false,
141
+ ymlSchema: false,
142
+ changedPaths: new Set<string>(),
143
+ }
144
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null
145
+ let running = false
146
+
147
+ function scheduleRun() {
148
+ if (debounceTimer) clearTimeout(debounceTimer)
149
+ debounceTimer = setTimeout(() => {
150
+ void flush()
151
+ }, WATCH_DEBOUNCE_MS)
152
+ }
153
+
154
+ async function flush() {
155
+ debounceTimer = null
156
+ if (
157
+ running ||
158
+ (pending.inp.size === 0 &&
159
+ !pending.golden &&
160
+ pending.alternatives.size === 0 &&
161
+ !pending.statementTex &&
162
+ !pending.ymlSchema)
163
+ ) {
164
+ return
165
+ }
166
+ const inp = new Set(pending.inp)
167
+ const golden = pending.golden
168
+ const alternatives = new Set(pending.alternatives)
169
+ const statementTex = pending.statementTex
170
+ const ymlSchema = pending.ymlSchema
171
+ const changedPaths = new Set(pending.changedPaths)
172
+ pending.inp.clear()
173
+ pending.golden = false
174
+ pending.alternatives.clear()
175
+ pending.statementTex = false
176
+ pending.ymlSchema = false
177
+ pending.changedPaths.clear()
178
+ running = true
179
+ try {
180
+ if (changedPaths.size > 0) {
181
+ tui.print()
182
+ tui.print(chalk.gray('Changed:') + ' ' + [...changedPaths].sort().join(', '))
183
+ tui.print()
184
+ }
185
+ if (ymlSchema) {
186
+ const results = await lintDirectories([directory])
187
+ if (results.length > 0) {
188
+ await printLintResults(results, [directory])
189
+ }
190
+ }
191
+ const problem: Problem = maker.problem
192
+ await (problem.reloadTestcases as () => Promise<void>)()
193
+ await (problem.reloadSolutions as () => Promise<void>)()
194
+ if (golden && problem.goldenSolution) {
195
+ await maker.makeGoldenExecutable()
196
+ await maker.makeCorrectOutputs()
197
+ await maker.remakeStatements()
198
+ return
199
+ }
200
+ const currentGolden = problem.goldenSolution
201
+ for (const testcase of inp) {
202
+ try {
203
+ await maker.makeCorrectOutputForTestcase(testcase)
204
+ } catch (e) {
205
+ tui.error(e instanceof Error ? e.message : String(e))
206
+ }
207
+ }
208
+ const hasSampleInp = [...inp].some((t) => t.includes('sample'))
209
+ if (hasSampleInp && inp.size > 0) {
210
+ await maker.remakeStatements()
211
+ }
212
+ for (const program of alternatives) {
213
+ if (program === currentGolden) continue
214
+ try {
215
+ await maker.verifyCandidate(program)
216
+ } catch (e) {
217
+ tui.error(e instanceof Error ? e.message : String(e))
218
+ }
219
+ }
220
+ if (inp.size > 0 && currentGolden) {
221
+ const changedTestcases = [...inp]
222
+ for (const solution of problem.solutions) {
223
+ if (solution === currentGolden) continue
224
+ try {
225
+ await maker.verifyCandidate(solution, changedTestcases, { skipCompile: true })
226
+ } catch (e) {
227
+ tui.error(e instanceof Error ? e.message : String(e))
228
+ }
229
+ }
230
+ }
231
+ if (statementTex) {
232
+ await maker.remakeStatements()
233
+ }
234
+ } finally {
235
+ running = false
236
+ printIdleMessage()
237
+ }
238
+ }
239
+
240
+ function printIdleMessage() {
241
+ tui.print()
242
+ tui.title(`Watching ${directory}`)
243
+ tui.print('╭───╮ ╭───╮ ╭───╮ ╭───╮ ')
244
+ tui.print(
245
+ '│ ' +
246
+ chalk.bold('A') +
247
+ ' │ │ ' +
248
+ chalk.bold('L') +
249
+ ' │ │ ' +
250
+ chalk.bold('H') +
251
+ ' │ │ ' +
252
+ chalk.bold('Q') +
253
+ ' │ ',
254
+ )
255
+ tui.print('╰───╯ ╰───╯ ╰───╯ ╰───╯ ')
256
+ tui.print(' ♻️ 🔍 ❓ 🚫')
257
+ tui.print(
258
+ ' ' +
259
+ chalk.blue('All') +
260
+ ' ' +
261
+ chalk.blue('Lint') +
262
+ ' ' +
263
+ chalk.blue('Help') +
264
+ ' ' +
265
+ chalk.blue('Quit') +
266
+ ' ',
267
+ )
268
+ tui.print(chalk.gray('Waiting for changes or keypress...'))
269
+ }
270
+
271
+ const watchDir = resolve(directory)
272
+ const watcher = chokidar.watch(watchDir, {
273
+ ignoreInitial: true,
274
+ awaitWriteFinish: { stabilityThreshold: 100 },
275
+ })
276
+
277
+ function onFileEvent(path: string) {
278
+ const parent = resolve(dirname(path))
279
+ if (parent !== watchDir) return
280
+ const name = basename(path)
281
+ pending.changedPaths.add(path)
282
+ if (name.endsWith('.inp')) {
283
+ pending.inp.add(name.replace(/\.inp$/, ''))
284
+ } else if (goldenSolution && name === goldenSolution) {
285
+ pending.golden = true
286
+ } else if (name.startsWith('solution.')) {
287
+ if (name === goldenSolution) {
288
+ pending.golden = true
289
+ } else {
290
+ pending.alternatives.add(name)
291
+ }
292
+ } else if (/^problem\.\w+\.tex$/.test(name)) {
293
+ pending.statementTex = true
294
+ } else if (
295
+ name === 'handler.yml' ||
296
+ name === 'problem.yml' ||
297
+ /^problem\.\w+\.yml$/.test(name)
298
+ ) {
299
+ pending.ymlSchema = true
300
+ }
301
+ scheduleRun()
302
+ }
303
+
304
+ watcher.on('add', onFileEvent)
305
+ watcher.on('change', onFileEvent)
306
+
307
+ printIdleMessage()
308
+
309
+ await new Promise<void>((resolveExit, rejectExit) => {
310
+ function printHelp() {
311
+ tui.print()
312
+ tui.print('Under watch mode, the toolkit automatically rebuilds the necessary files in the problem directory when you make changes to your files.')
313
+ tui.print(
314
+ [
315
+ 'Key bindings:',
316
+ '',
317
+ ' A Make all (full rebuild from scratch)',
318
+ ' L Run lint to check issues in the problem directory',
319
+ ' H Show this help',
320
+ ' Q Quit watch mode',
321
+ '',
322
+ ].join('\n'),
323
+
324
+ )
325
+ tui.print('The watch mode is under development. Please report any issues to the developers.')
326
+ printIdleMessage()
327
+ }
328
+
329
+ const onKey = (key: Buffer | string) => {
330
+ const k = typeof key === 'string' ? key : key.toString()
331
+ if (k === 'q' || k === 'Q' || k === '\x03') {
332
+ cleanup()
333
+ tui.print()
334
+ tui.success('Watch mode stopped')
335
+ resolveExit()
336
+ return
337
+ }
338
+ if (k === 'h' || k === 'H') {
339
+ printHelp()
340
+ return
341
+ }
342
+ if (k === 'a' || k === 'A') {
343
+ if (debounceTimer) {
344
+ clearTimeout(debounceTimer)
345
+ debounceTimer = null
346
+ }
347
+ pending.inp.clear()
348
+ pending.golden = false
349
+ pending.alternatives.clear()
350
+ pending.statementTex = false
351
+ pending.ymlSchema = false
352
+ pending.changedPaths.clear()
353
+ void (async () => {
354
+ running = true
355
+ try {
356
+ await maker.makeProblem()
357
+ } catch (e) {
358
+ tui.error(e instanceof Error ? e.message : String(e))
359
+ } finally {
360
+ running = false
361
+ printIdleMessage()
362
+ }
363
+ })()
364
+ }
365
+ if (k === 'l' || k === 'L') {
366
+ void (async () => {
367
+ running = true
368
+ try {
369
+ const results = await lintDirectories([directory])
370
+ if (results.length === 0) {
371
+ tui.warning('No problem directories found (looked for handler.yml in the given path(s)).')
372
+ } else {
373
+ await printLintResults(results, [directory])
374
+ }
375
+ } catch (e) {
376
+ tui.error(e instanceof Error ? e.message : String(e))
377
+ } finally {
378
+ running = false
379
+ printIdleMessage()
380
+ }
381
+ })()
382
+ }
383
+ }
384
+
385
+ function cleanup() {
386
+ void watcher.close()
387
+ process.stdin.removeListener('data', onKey)
388
+ if (typeof process.stdin.setRawMode === 'function') {
389
+ process.stdin.setRawMode(false)
390
+ }
391
+ process.stdin.pause()
392
+ }
393
+
394
+ process.stdin.resume()
395
+ if (typeof process.stdin.setRawMode === 'function') {
396
+ process.stdin.setRawMode(true)
397
+ }
398
+ process.stdin.on('data', (key) => {
399
+ onKey(key)
400
+ })
401
+ process.on('SIGINT', () => {
402
+ cleanup()
403
+ tui.print()
404
+ tui.success('Watch stopped')
405
+ resolveExit()
406
+ })
407
+ })
408
+ }