pll-python 0.2.0 → 0.5.0

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 (5) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +64 -14
  3. package/cli.cjs +2053 -1509
  4. package/package.json +5 -3
  5. package/worker.cjs +709 -524
package/cli.cjs CHANGED
@@ -24,722 +24,1032 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  ));
25
25
 
26
26
  // src/cli/main.ts
27
- var path7 = __toESM(require("node:path"));
27
+ var fs8 = __toESM(require("node:fs"));
28
+ var path8 = __toESM(require("node:path"));
29
+
30
+ // src/cli/args.ts
31
+ var import_node_util = require("node:util");
32
+
33
+ // src/core/errorText.ts
34
+ function errorText(err) {
35
+ return err instanceof Error ? err.message : String(err);
36
+ }
37
+
38
+ // src/cli/args.ts
39
+ function parseCommandLine(argv, options) {
40
+ const config = Object.fromEntries(
41
+ Object.entries(options).map(([name, { type, short }]) => [name, short ? { type, short } : { type }])
42
+ );
43
+ let line;
44
+ try {
45
+ line = (0, import_node_util.parseArgs)({ args: argv, options: config, allowPositionals: true, strict: true });
46
+ } catch (err) {
47
+ return { error: reworded(err, options, argv) };
48
+ }
49
+ for (const [name, value] of Object.entries(line.values)) {
50
+ if (value === "") {
51
+ return { error: `${typedFlag(name, options[name], argv)} needs ${options[name]?.needs ?? "a value"}` };
52
+ }
53
+ }
54
+ return line;
55
+ }
56
+ function subcommandIndex(argv, name, options) {
57
+ const takesValue = /* @__PURE__ */ new Set();
58
+ for (const [long, option] of Object.entries(options)) {
59
+ if (option.type !== "string") continue;
60
+ takesValue.add(`--${long}`);
61
+ if (option.short) takesValue.add(`-${option.short}`);
62
+ }
63
+ for (let i = 0; i < argv.length; i++) {
64
+ const arg = argv[i];
65
+ if (arg === "--") {
66
+ return argv[i + 1] === name ? i + 1 : -1;
67
+ }
68
+ if (!arg.startsWith("-") || arg === "-") {
69
+ return arg === name ? i : -1;
70
+ }
71
+ if (takesValue.has(arg)) i++;
72
+ }
73
+ return -1;
74
+ }
75
+ function typedFlag(long, option, argv) {
76
+ const typedLong = argv.some((arg) => arg === `--${long}` || arg.startsWith(`--${long}=`));
77
+ return option?.short && !typedLong ? `-${option.short}` : `--${long}`;
78
+ }
79
+ function reworded(err, options, argv) {
80
+ const message = errorText(err);
81
+ const code = err.code;
82
+ if (code === "ERR_PARSE_ARGS_UNKNOWN_OPTION") {
83
+ const flag = /^Unknown option '([^']+)'/.exec(message)?.[1];
84
+ return flag ? `unknown option ${flag}` : message;
85
+ }
86
+ const named = /^Option '(?:-(\w), )?--([\w-]+)/.exec(message);
87
+ if (code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE" && named) {
88
+ const long = named[2];
89
+ const option = options[long];
90
+ const flag = typedFlag(long, option, argv);
91
+ return option?.type === "string" ? `${flag} needs ${option.needs ?? "a value"}` : `${flag} does not take a value`;
92
+ }
93
+ return message;
94
+ }
28
95
 
29
96
  // src/cli/examplar.ts
97
+ var fs4 = __toESM(require("node:fs/promises"));
98
+ var path4 = __toESM(require("node:path"));
99
+
100
+ // src/cli/run.ts
30
101
  var fs3 = __toESM(require("node:fs/promises"));
31
102
  var path3 = __toESM(require("node:path"));
32
103
 
33
- // src/cli/run.ts
34
- var fs2 = __toESM(require("node:fs/promises"));
35
- var path2 = __toESM(require("node:path"));
104
+ // src/core/editDistance.ts
105
+ function editDistance(a, b) {
106
+ let twoBack = [];
107
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
108
+ for (let i = 1; i <= a.length; i++) {
109
+ const current = [i];
110
+ for (let j = 1; j <= b.length; j++) {
111
+ current[j] = Math.min(
112
+ previous[j] + 1,
113
+ current[j - 1] + 1,
114
+ previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
115
+ );
116
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
117
+ current[j] = Math.min(current[j], twoBack[j - 2] + 1);
118
+ }
119
+ }
120
+ twoBack = previous;
121
+ previous = current;
122
+ }
123
+ return previous[b.length];
124
+ }
36
125
 
37
- // src/common/analyzers/static/shadowingExplainer.ts
38
- function outerScopeNoun(kind) {
39
- switch (kind) {
40
- case "module":
41
- return "the file";
42
- case "function":
43
- case "lambda":
44
- return "an enclosing function";
45
- case "class":
46
- return "an enclosing class";
47
- case "comprehension":
48
- return "an enclosing comprehension";
49
- default:
50
- return "an outer scope";
126
+ // src/core/level.ts
127
+ var LEVEL_RAW = "raw";
128
+ var LEVEL_BEGINNER = "beginner";
129
+ var LEVEL_INTERMEDIATE = "intermediate";
130
+ var LEVEL_ADVANCED = "advanced";
131
+ var LEVEL_NAMES = [LEVEL_RAW, LEVEL_BEGINNER, LEVEL_INTERMEDIATE, LEVEL_ADVANCED];
132
+ var DEFAULT_LEVEL = LEVEL_RAW;
133
+ function isLevel(name) {
134
+ return LEVEL_NAMES.includes(name);
135
+ }
136
+ var HEADER_RE = /^#\s*level\s+([a-z]+)\s*$/;
137
+ function parseLevel(source) {
138
+ const lines = source.split(/\r?\n/);
139
+ for (const raw of lines) {
140
+ const line = raw.trim();
141
+ if (line.length === 0) {
142
+ continue;
143
+ }
144
+ const match = HEADER_RE.exec(line);
145
+ if (!match) {
146
+ return DEFAULT_LEVEL;
147
+ }
148
+ const name = match[1];
149
+ return isLevel(name) ? name : DEFAULT_LEVEL;
51
150
  }
151
+ return DEFAULT_LEVEL;
52
152
  }
53
- function explainShadowing(raw, level, fileName) {
54
- const name = raw.name_token ?? "this name";
55
- const outerLine = raw.outer_line_number ?? null;
56
- const outerNoun = outerScopeNoun(raw.outer_scope_kind);
57
- const knownOuterLine = outerLine !== null && outerLine > 0;
58
- const outerHint = knownOuterLine ? ` (first defined on line ${outerLine}, in ${outerNoun})` : ` in ${outerNoun}`;
59
- return {
60
- id: "shadowing",
61
- errorType: "Shadowing",
62
- message: raw.message,
63
- headline: `\`${name}\` is already defined${outerHint}.`,
64
- howToFix: [
65
- `Rename the inner \`${name}\` to something distinct (e.g. \`${name}_inner\`, \`local_${name}\`, or whatever describes its role).`,
66
- `Or, if you want to use the outer \`${name}\`, just read it directly without making a new variable.`
67
- ],
68
- fileName,
69
- lineNumber: raw.line_number,
70
- column: raw.column,
71
- nameToken: raw.name_token,
72
- severity: "error",
73
- raw: JSON.stringify(raw),
74
- origin: "static",
75
- level
76
- };
153
+ function levelHeaderProblem(source) {
154
+ const lines = source.split(/\r?\n/);
155
+ const valid = new RegExp(`^#\\s*level\\s+(?:${LEVEL_NAMES.join("|")})\\s*$`);
156
+ const joined = new RegExp(`^#\\s*level(${LEVEL_NAMES.join("|")})\\s*$`);
157
+ for (let i = 0; i < lines.length; i++) {
158
+ const line = lines[i].trim();
159
+ if (line.length === 0) {
160
+ continue;
161
+ }
162
+ const squashed = joined.exec(line);
163
+ if (squashed !== null) {
164
+ return {
165
+ line: i + 1,
166
+ message: `\`#level\` needs a space before \`${squashed[1]}\`: \`#level ${squashed[1]}\`.`,
167
+ howToFix: [`Write it as \`#level ${squashed[1]}\`, with a space.`]
168
+ };
169
+ }
170
+ const attempt = /^#\s*level\b(.*)$/.exec(line);
171
+ if (attempt === null) {
172
+ return misplacedHeader(lines, i, valid);
173
+ }
174
+ if (valid.test(line)) {
175
+ return null;
176
+ }
177
+ const named = attempt[1].trim();
178
+ if (named.length === 0) {
179
+ return {
180
+ line: i + 1,
181
+ message: `\`#level\` needs a level after it: ${levelList()}.`,
182
+ howToFix: [`Name one of ${levelList()}, or leave the line out.`]
183
+ };
184
+ }
185
+ const suggestion = closestLevel(named);
186
+ return {
187
+ line: i + 1,
188
+ message: `\`${named}\` is not a level` + (suggestion ? `. Did you mean \`${suggestion}\`?` : `. The levels are ${levelList()}.`),
189
+ howToFix: suggestion ? [`Write \`#level ${suggestion}\` on the first line, in lower case.`] : [`Name one of ${levelList()}, or leave the line out.`]
190
+ };
191
+ }
192
+ return null;
77
193
  }
78
- function explainShadowingBuiltin(raw, level, fileName) {
79
- const name = raw.name_token ?? "this name";
80
- const headline = `\`${name}\` is the name of a Python built-in.`;
81
- return {
82
- id: "shadowing-builtin",
83
- errorType: "Shadowing",
84
- message: raw.message,
85
- headline,
86
- howToFix: [
87
- `Pick a different name. Common patterns: \`my_${name}\`, \`${name}_value\`, \`${name}s\` (plural), or a more specific noun describing what this value represents.`
88
- ],
89
- fileName,
90
- lineNumber: raw.line_number,
91
- column: raw.column,
92
- nameToken: raw.name_token,
93
- severity: "error",
94
- raw: JSON.stringify(raw),
95
- origin: "static",
96
- level
97
- };
194
+ function misplacedHeader(lines, from, valid) {
195
+ const comments = commentLines(lines);
196
+ let underCode = false;
197
+ for (let i = from; i < lines.length; i++) {
198
+ const line = lines[i].trim();
199
+ if (line.length === 0) {
200
+ continue;
201
+ }
202
+ if (!comments[i]) {
203
+ underCode = true;
204
+ continue;
205
+ }
206
+ if (valid.test(line)) {
207
+ return {
208
+ line: i + 1,
209
+ message: `\`${line}\` only counts on the first line, so none of its checks ran.`,
210
+ howToFix: [
211
+ underCode ? "Move it to the very first line of the file, above the code." : "Move it to the very top of the file, above the comments.",
212
+ "Leave the line out altogether to run the file as ordinary Python."
213
+ ]
214
+ };
215
+ }
216
+ }
217
+ return null;
98
218
  }
99
- function libraryNoun(library) {
100
- switch (library) {
101
- case "image":
102
- return "the image library";
103
- case "table":
104
- return "the table library";
105
- case "reactor":
106
- return "the reactor library";
107
- default:
108
- return "the PLL libraries";
219
+ function commentLines(lines) {
220
+ const comments = [];
221
+ let open = null;
222
+ for (const line of lines) {
223
+ comments.push(open === null && line.trimStart().startsWith("#"));
224
+ let i = 0;
225
+ while (i < line.length) {
226
+ if (open !== null) {
227
+ const close = closingQuote(line, i, open);
228
+ if (close < 0) break;
229
+ i = close + open.length;
230
+ open = null;
231
+ continue;
232
+ }
233
+ const c = line[i];
234
+ if (c === "#") break;
235
+ if (c === '"' || c === "'") {
236
+ if (line.startsWith(c.repeat(3), i)) {
237
+ open = c.repeat(3);
238
+ i += 3;
239
+ continue;
240
+ }
241
+ const close = closingQuote(line, i + 1, c);
242
+ i = close < 0 ? line.length : close + 1;
243
+ continue;
244
+ }
245
+ i += 1;
246
+ }
109
247
  }
248
+ return comments;
110
249
  }
111
- function explainShadowingLibrary(raw, level, fileName) {
112
- const name = raw.name_token ?? "this name";
113
- const noun = libraryNoun(raw.library);
114
- const headline = `\`${name}\` is already defined by ${noun}.`;
115
- return {
116
- id: "shadowing-library",
117
- errorType: "Shadowing",
118
- message: raw.message,
119
- headline,
120
- howToFix: [
121
- `Pick a different name for your definition - for example \`my_${name}\`, \`${name}_value\`, or a word that describes what yours represents.`,
122
- `If you meant to use the library's \`${name}\`, call it directly instead of defining a new \`${name}\` of your own.`
123
- ],
124
- fileName,
125
- lineNumber: raw.line_number,
126
- column: raw.column,
127
- nameToken: raw.name_token,
128
- severity: "error",
129
- raw: JSON.stringify(raw),
130
- origin: "static",
131
- level
132
- };
250
+ function closingQuote(line, from, quote) {
251
+ for (let i = from; i < line.length; i++) {
252
+ if (line[i] === "\\") {
253
+ i += 1;
254
+ } else if (line.startsWith(quote, i)) {
255
+ return i;
256
+ }
257
+ }
258
+ return -1;
259
+ }
260
+ function levelList() {
261
+ return LEVEL_NAMES.map((name) => `\`${name}\``).join(", ");
262
+ }
263
+ function closestLevel(named) {
264
+ const lower = named.toLowerCase();
265
+ let best = null;
266
+ let bestDistance = Infinity;
267
+ for (const level of LEVEL_NAMES) {
268
+ const distance = editDistance(lower, level);
269
+ if (distance < bestDistance) {
270
+ best = level;
271
+ bestDistance = distance;
272
+ }
273
+ }
274
+ return best !== null && bestDistance <= Math.max(2, Math.floor(best.length / 3)) ? best : null;
275
+ }
276
+ function levelHasStaticChecks(level) {
277
+ return level === LEVEL_BEGINNER || level === LEVEL_INTERMEDIATE;
133
278
  }
134
279
 
135
- // src/common/analyzers/static/reassignmentExplainer.ts
136
- function explainReassignment(raw, level, fileName) {
137
- const name = raw.name_token ?? "this name";
138
- const firstLine = raw.first_line_number ?? null;
139
- const firstLineHint = firstLine !== null && firstLine > 0 ? ` (first assigned on line ${firstLine})` : "";
280
+ // src/run/finding.ts
281
+ function serializeFinding(finding) {
140
282
  return {
141
- id: "reassignment",
142
- errorType: "Reassignment",
143
- message: raw.message,
144
- headline: `\`${name}\` is already assigned${firstLineHint}.`,
145
- howToFix: level === "intermediate" ? [
146
- `If you want a different value, give it a different name (e.g. \`${name}_doubled\`, \`new_${name}\`, \`${name}2\`).`,
147
- `If you're trying to update a value (\`x = x + 1\`, accumulators, running totals), wrap the work in a function and rebind \`${name}\` from the function's return value: \`${name} = compute_${name}(...)\`.`
148
- ] : [
149
- `If you want a different value, give it a different name (e.g. \`${name}_doubled\`, \`new_${name}\`, \`${name}2\`).`,
150
- `If you're trying to update a value (\`x = x + 1\`, accumulators, running totals), use a built-in like \`sum(...)\`, \`max(...)\`, or a small helper function that returns the new value instead.`
151
- ],
152
- fileName,
153
- lineNumber: raw.line_number,
154
- column: raw.column,
155
- nameToken: raw.name_token,
156
- severity: "error",
157
- raw: JSON.stringify(raw),
158
- origin: "static",
159
- level
283
+ errorType: finding.errorType,
284
+ headline: finding.headline,
285
+ howToFix: [...finding.howToFix],
286
+ location: findingLocation(finding)
160
287
  };
161
288
  }
162
-
163
- // src/common/analyzers/static/disallowedKeywordExplainer.ts
164
- function explainDisallowedKeyword(raw, level, fileName) {
165
- const keyword = raw.keyword === "nonlocal" ? "nonlocal" : "global";
166
- const names = Array.isArray(raw.names) ? raw.names : [];
167
- const formattedNames = names.map((n) => `\`${n}\``).join(", ");
168
- const namesPhrase = formattedNames.length > 0 ? ` ${formattedNames}` : "";
169
- const headline = `\`${keyword}\` is not allowed at the ${level} level.`;
170
- const howToFix = [];
171
- if (keyword === "global") {
172
- howToFix.push(
173
- `Pass${namesPhrase || " the value"} into this function as an argument instead of declaring it \`global\`.`,
174
- `If the function needs to *change* a value, return the new value and let the caller decide what to do with it (e.g. \`x = update(x)\`).`
175
- );
176
- } else {
177
- howToFix.push(
178
- `Return${namesPhrase || " the new value"} from this inner function and have the outer function rebind it explicitly (e.g. \`x = inner(x)\`).`,
179
- `If the inner function only needs to *read* the outer name, just use it directly - you don't need \`nonlocal\` for that.`
180
- );
289
+ function findingLocation(finding) {
290
+ if (finding.lineNumber === null) {
291
+ return null;
292
+ }
293
+ if (finding.fileName === "<repl>" || finding.fileName === "<input>") {
294
+ return null;
181
295
  }
296
+ const column = finding.column;
297
+ const label = column !== null ? `${finding.fileName}:${finding.lineNumber}:${column + 1}` : `${finding.fileName}:${finding.lineNumber}`;
182
298
  return {
183
- id: "disallowed-keyword",
184
- errorType: "DisallowedKeyword",
185
- message: raw.message,
186
- headline,
187
- howToFix,
188
- fileName,
189
- lineNumber: raw.line_number,
190
- column: raw.column,
191
- nameToken: raw.name_token,
192
- severity: "error",
193
- raw: JSON.stringify(raw),
194
- origin: "static",
195
- level
299
+ fileName: finding.fileName,
300
+ line: finding.lineNumber,
301
+ column,
302
+ label
196
303
  };
197
304
  }
198
-
199
- // src/common/analyzers/static/registry.ts
200
- var explainers = {
201
- shadowing: explainShadowing,
202
- "shadowing-builtin": explainShadowingBuiltin,
203
- "shadowing-library": explainShadowingLibrary,
204
- reassignment: explainReassignment,
205
- "disallowed-keyword": explainDisallowedKeyword
206
- };
207
- function fallbackExplainer(raw, level, fileName) {
305
+ function formatFriendlyError(finding) {
306
+ const lines = [];
307
+ lines.push(`${finding.errorType}: ${finding.headline}`);
308
+ const location = findingLocation(finding);
309
+ if (location) {
310
+ lines.push(` at ${location.label}`);
311
+ }
312
+ if (finding.howToFix.length > 0) {
313
+ lines.push("");
314
+ lines.push("How to fix:");
315
+ for (const item of finding.howToFix) {
316
+ lines.push(` - ${item}`);
317
+ }
318
+ }
319
+ return lines;
320
+ }
321
+ function levelHeaderFinding(source, fileName, level) {
322
+ const problem = levelHeaderProblem(source);
323
+ if (problem === null) {
324
+ return null;
325
+ }
208
326
  return {
209
- id: raw.id,
210
- errorType: raw.error_type || "StaticError",
211
- message: raw.message,
212
- headline: raw.message,
213
- howToFix: [],
327
+ id: "level-header",
328
+ errorType: "Level",
329
+ headline: problem.message,
330
+ howToFix: problem.howToFix,
214
331
  fileName,
215
- lineNumber: raw.line_number,
216
- column: raw.column,
217
- nameToken: raw.name_token,
332
+ lineNumber: problem.line,
333
+ column: null,
334
+ nameToken: null,
218
335
  severity: "error",
219
- raw: JSON.stringify(raw),
220
336
  origin: "static",
221
337
  level
222
338
  };
223
339
  }
224
- function enrichStaticFindings(rawFindings, level, fileName) {
225
- return rawFindings.map((raw) => {
226
- const explainer = explainers[raw.id] ?? fallbackExplainer;
227
- return explainer(raw, level, fileName);
228
- });
340
+ function findRuntimeFinding(fileName, level, error) {
341
+ return { ...error.finding, fileName: error.finding.fileName ?? fileName, level };
229
342
  }
230
-
231
- // src/common/errors/pythonErrorParser.ts
232
- var NON_USER_FRAME = [
233
- "<exec>",
234
- "/pll_vendor",
235
- "site-packages",
236
- "/lib/python",
237
- "_pytest",
238
- "pluggy"
239
- ];
240
- function userTracebackFrames(traceback) {
241
- const frames = [];
242
- for (const raw of traceback.split(/\r?\n/)) {
243
- const m = raw.match(/^\s*File "([^"]+)", line (\d+)(?:, in (\S+))?/);
244
- if (!m) continue;
245
- const fileName = m[1];
246
- if (NON_USER_FRAME.some((token) => fileName.includes(token))) continue;
247
- frames.push({
248
- fileName,
249
- line: parseInt(m[2], 10),
250
- functionName: m[3] && m[3] !== "<module>" ? m[3] : null
251
- });
252
- }
253
- return frames;
254
- }
255
- function sourceIndent(source, lineNumber) {
256
- if (!source) {
257
- return 0;
258
- }
259
- const line = source.split(/\r?\n/)[lineNumber - 1];
260
- if (line === void 0) {
261
- return 0;
262
- }
263
- return line.length - line.trimStart().length;
264
- }
265
- function parsePythonError(rawTraceback, source) {
266
- const traceback = rawTraceback.trimEnd();
267
- const lines = traceback.split(/\r?\n/);
268
- let errorType = "Error";
269
- let message = traceback;
270
- for (let i = lines.length - 1; i >= 0; i--) {
271
- const line = lines[i];
272
- const m = line.match(/^([A-Za-z_][A-Za-z0-9_.]*):\s?(.*)$/);
273
- if (m && !line.startsWith(" ")) {
274
- errorType = m[1];
275
- message = m[2];
276
- break;
277
- }
278
- if (line.match(/^[A-Za-z_][A-Za-z0-9_.]*$/) && !line.startsWith(" ")) {
279
- errorType = line;
280
- message = "";
281
- break;
282
- }
283
- }
284
- let fileName = null;
285
- let lineNumber = null;
286
- for (let i = lines.length - 1; i >= 0; i--) {
287
- const line = lines[i];
288
- const m = line.match(/^\s*File\s+"([^"]+)",\s+line\s+(\d+)/);
289
- if (m) {
290
- fileName = m[1];
291
- lineNumber = parseInt(m[2], 10);
292
- break;
293
- }
294
- }
295
- let column = null;
296
- if (lineNumber !== null) {
297
- for (let i = 0; i < lines.length - 1; i++) {
298
- if (/^\s*\^+\s*$/.test(lines[i + 1] ?? "")) {
299
- const sourceLine = lines[i];
300
- const caretIdx = lines[i + 1].indexOf("^");
301
- if (caretIdx >= 0) {
302
- const echoed = sourceLine.length - sourceLine.trimStart().length;
303
- column = Math.max(0, caretIdx - echoed) + sourceIndent(source, lineNumber);
304
- }
305
- }
306
- }
307
- }
308
- let nameToken = null;
309
- if (errorType === "NameError") {
310
- const nm = message.match(/name '([^']+)' is not defined/);
311
- if (nm) {
312
- nameToken = nm[1];
313
- }
314
- }
343
+ function explainTestReport(event, fileName, level) {
315
344
  return {
316
- errorType,
317
- message,
318
- traceback,
319
- fileName,
320
- lineNumber,
321
- column,
322
- nameToken
345
+ ...event,
346
+ tests: event.tests.map(
347
+ ({ error, ...test }) => error ? { ...test, finding: serializeFinding(findRuntimeFinding(fileName, level, error)) } : test
348
+ )
323
349
  };
324
350
  }
325
351
 
326
- // src/common/errors/nameErrorExplainer.ts
327
- function explainNameError(parsed) {
328
- const name = parsed.nameToken ?? "this name";
329
- const didYouMean = extractDidYouMean(parsed.message);
330
- const headline = `Python doesn't know what \`${name}\` means.`;
331
- const howToFix = [
332
- `Check the spelling of \`${name}\` (Python is case-sensitive).`,
333
- `Make sure \`${name}\` is defined before this line runs.`,
334
- `If \`${name}\` should be text, put it in quotes: \`"${name}"\`.`
335
- ];
336
- if (didYouMean.length > 0) {
337
- howToFix.unshift(
338
- `Python suggested: ${didYouMean.map((s) => "`" + s + "`").join(", ")} - try one of those.`
339
- );
352
+ // src/run/examplarSource.ts
353
+ var MAX_BUNDLE_BYTES = 4 * 1024 * 1024;
354
+ var DIRECTIVE_RE = /^#\s*examplar\s+(\S+)\s*$/;
355
+ function parseExamplarDirective(source) {
356
+ const hits = [];
357
+ const lines = source.split(/\r?\n/);
358
+ for (let i = 0; i < lines.length; i++) {
359
+ const match = DIRECTIVE_RE.exec(lines[i].trim());
360
+ if (match) {
361
+ hits.push({ url: match[1], line: i + 1 });
362
+ }
363
+ }
364
+ if (hits.length === 0) {
365
+ return { kind: "none" };
340
366
  }
341
- return { headline, howToFix };
367
+ if (hits.length > 1) {
368
+ return {
369
+ kind: "error",
370
+ line: hits[1].line,
371
+ message: `there is more than one \`#examplar\` line (lines ${hits.map((h) => h.line).join(" and ")}). Keep the one you want.`
372
+ };
373
+ }
374
+ const { url, line } = hits[0];
375
+ const problem = validateBundleUrl(url);
376
+ return problem ? { kind: "error", message: problem, line } : { kind: "found", url, line };
342
377
  }
343
- function extractDidYouMean(message) {
344
- const m = message.match(/Did you mean:\s*([^?]+)\??/i);
345
- if (!m) {
346
- return [];
378
+ function validateBundleUrl(url) {
379
+ let parsed;
380
+ try {
381
+ parsed = new URL(url);
382
+ } catch {
383
+ return `\`${url}\` is not a URL.`;
384
+ }
385
+ if (parsed.protocol === "https:") {
386
+ return null;
347
387
  }
348
- return m[1].split(/,|\bor\b/).map((s) => s.trim().replace(/^['"`]|['"`]$/g, "")).filter((s) => s.length > 0);
388
+ if (parsed.protocol === "http:") {
389
+ const local = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
390
+ return local ? null : `\`${url}\` must use https (plain http is only allowed for localhost).`;
391
+ }
392
+ return `\`${url}\` must be an https address.`;
349
393
  }
350
-
351
- // src/common/analyzers/nameErrorAnalyzer.ts
352
- var nameErrorAnalyzer = {
353
- handles: ["NameError"],
354
- analyze(input) {
355
- const { parsedError, fileName, level } = input;
356
- if (parsedError.errorType !== "NameError") {
357
- return null;
358
- }
359
- const explanation = explainNameError(parsedError);
360
- return {
361
- id: "name-error",
362
- errorType: "NameError",
363
- message: parsedError.message,
364
- headline: explanation.headline,
365
- howToFix: explanation.howToFix,
366
- fileName,
367
- lineNumber: parsedError.lineNumber,
368
- column: parsedError.column,
369
- nameToken: parsedError.nameToken,
370
- severity: "error",
371
- raw: parsedError.traceback,
372
- origin: "runtime",
373
- level
394
+ async function loadBundle(url, store, fetchImpl = globalThis.fetch) {
395
+ const cached = await store.read(url).catch(() => void 0);
396
+ let response;
397
+ try {
398
+ response = await fetchImpl(url, {
399
+ headers: cached?.etag ? { "If-None-Match": cached.etag } : {}
400
+ });
401
+ } catch (err) {
402
+ const why = errorText(err);
403
+ return cached ? { json: cached.json, fromCache: true, note: `could not reach ${url} (${why}); using the cached copy` } : { fromCache: false, error: `could not reach ${url} (${why}), and nothing is cached` };
404
+ }
405
+ if (response.status === 304 && cached) {
406
+ return { json: cached.json, fromCache: true };
407
+ }
408
+ if (!response.ok) {
409
+ return cached ? {
410
+ json: cached.json,
411
+ fromCache: true,
412
+ note: `${url} returned ${response.status}; using the cached copy`
413
+ } : { fromCache: false, error: `${url} returned ${response.status}` };
414
+ }
415
+ const json = await response.text();
416
+ if (json.length > MAX_BUNDLE_BYTES) {
417
+ return cached ? { json: cached.json, fromCache: true, note: `${url} is unexpectedly large; using the cached copy` } : {
418
+ fromCache: false,
419
+ error: `${url} is ${json.length} bytes, past the ${MAX_BUNDLE_BYTES}-byte limit`
374
420
  };
375
421
  }
376
- };
422
+ if (!looksLikeBundle(json)) {
423
+ return cached ? { json: cached.json, fromCache: true, note: `${url} did not return a bundle; using the cached copy` } : { fromCache: false, error: `${url} did not return an Examplar bundle` };
424
+ }
425
+ const etag = response.headers.get("etag") ?? void 0;
426
+ try {
427
+ await store.write(url, { json, etag });
428
+ } catch (err) {
429
+ return { json, fromCache: false, note: `could not keep a copy for offline use (${errorText(err)})` };
430
+ }
431
+ return { json, fromCache: false };
432
+ }
433
+ function looksLikeBundle(json) {
434
+ try {
435
+ const bundle = JSON.parse(json);
436
+ return typeof bundle === "object" && bundle !== null && "examplar" in bundle && Array.isArray(bundle.wheats) && Array.isArray(bundle.chaffs);
437
+ } catch {
438
+ return false;
439
+ }
440
+ }
377
441
 
378
- // src/common/level.ts
379
- var DEFAULT_LEVEL = "raw";
380
- var HEADER_RE = /^#\s*level\s+([a-z]+)\s*$/;
381
- var LEVEL_NAMES = [
382
- "raw",
383
- "beginner",
384
- "intermediate",
385
- "advanced"
386
- ];
387
- function parseLevel(source) {
388
- const lines = source.split(/\r?\n/);
389
- for (const raw of lines) {
390
- const line = raw.trim();
391
- if (line.length === 0) {
392
- continue;
393
- }
394
- const match = HEADER_RE.exec(line);
395
- if (!match) {
396
- return DEFAULT_LEVEL;
397
- }
398
- const name = match[1];
399
- for (const level of LEVEL_NAMES) {
400
- if (name === level) return level;
401
- }
402
- return DEFAULT_LEVEL;
442
+ // src/pyodide/sharedMemory.ts
443
+ var STDIN_SAB_BYTES = 64 * 1024;
444
+ var STDIN_STATE_INDEX = 0;
445
+ var STDIN_LENGTH_INDEX = 1;
446
+ var STDIN_REQUEST_INDEX = 2;
447
+ var STDIN_PAYLOAD_OFFSET = 16;
448
+ var STDIN_STATE_WAITING = 0;
449
+ var STDIN_STATE_DATA = 1;
450
+ var STDIN_STATE_EOF = 2;
451
+ var STDIN_STATE_INTERRUPTED = 3;
452
+ function tryCreateStdinBuffer() {
453
+ try {
454
+ return new SharedArrayBuffer(STDIN_SAB_BYTES);
455
+ } catch {
456
+ return null;
403
457
  }
404
- return DEFAULT_LEVEL;
405
458
  }
406
- function levelRejectsBoolAsNumber(level) {
407
- return level === "beginner" || level === "intermediate";
459
+ function writeStdin(sab, request, bytes) {
460
+ const state = new Int32Array(sab);
461
+ if (Atomics.load(state, STDIN_REQUEST_INDEX) !== request || Atomics.load(state, STDIN_STATE_INDEX) !== STDIN_STATE_WAITING) {
462
+ return -1;
463
+ }
464
+ let n = 0;
465
+ if (bytes !== null) {
466
+ n = Math.min(bytes.length, sab.byteLength - STDIN_PAYLOAD_OFFSET);
467
+ new Uint8Array(sab, STDIN_PAYLOAD_OFFSET).set(bytes.subarray(0, n));
468
+ Atomics.store(state, STDIN_LENGTH_INDEX, n);
469
+ }
470
+ const answer = bytes === null ? STDIN_STATE_EOF : STDIN_STATE_DATA;
471
+ const was = Atomics.compareExchange(state, STDIN_STATE_INDEX, STDIN_STATE_WAITING, answer);
472
+ if (was !== STDIN_STATE_WAITING) {
473
+ return -1;
474
+ }
475
+ Atomics.notify(state, STDIN_STATE_INDEX);
476
+ return n;
408
477
  }
409
- function levelHasStaticChecks(level) {
410
- return level === "beginner" || level === "intermediate";
478
+ function interruptStdin(sab) {
479
+ const state = new Int32Array(sab);
480
+ if (Atomics.compareExchange(state, STDIN_STATE_INDEX, STDIN_STATE_WAITING, STDIN_STATE_INTERRUPTED) === STDIN_STATE_WAITING) {
481
+ Atomics.notify(state, STDIN_STATE_INDEX);
482
+ }
483
+ }
484
+ var INTERRUPT_SAB_BYTES = 2;
485
+ var INTERRUPT_SIGINT = 2;
486
+ var INTERRUPT_ACK_INDEX = 1;
487
+ var INTERRUPT_RETRY_MS = 15;
488
+ var INTERRUPT_RETRY_LIMIT_MS = 1e4;
489
+ function tryCreateInterruptBuffer() {
490
+ try {
491
+ return new SharedArrayBuffer(INTERRUPT_SAB_BYTES);
492
+ } catch {
493
+ return null;
494
+ }
495
+ }
496
+ function requestInterrupt(sab, stillRunning, retryMs = INTERRUPT_RETRY_MS, limitMs = INTERRUPT_RETRY_LIMIT_MS) {
497
+ const view = new Uint8Array(sab);
498
+ if (view.length > INTERRUPT_ACK_INDEX) {
499
+ Atomics.store(view, INTERRUPT_ACK_INDEX, 0);
500
+ }
501
+ Atomics.store(view, 0, INTERRUPT_SIGINT);
502
+ if (view.length <= INTERRUPT_ACK_INDEX) {
503
+ return;
504
+ }
505
+ const deadline = Date.now() + limitMs;
506
+ const retry = () => {
507
+ if (Atomics.load(view, INTERRUPT_ACK_INDEX) !== 0 || !stillRunning() || Date.now() > deadline) {
508
+ return;
509
+ }
510
+ if (Atomics.load(view, 0) === 0) {
511
+ Atomics.store(view, 0, INTERRUPT_SIGINT);
512
+ }
513
+ setTimeout(retry, retryMs);
514
+ };
515
+ setTimeout(retry, retryMs);
411
516
  }
412
517
 
413
- // src/common/errors/typeCheckExplainer.ts
414
- var FRIENDLY_TYPES = {
415
- int: "a whole number",
416
- float: "a number",
417
- complex: "a complex number",
418
- str: "a string",
419
- bool: "`True` or `False`",
420
- bytes: "bytes",
421
- list: "a list",
422
- dict: "a dictionary",
423
- set: "a set",
424
- tuple: "a tuple",
425
- None: "`None`",
426
- NoneType: "`None`"
518
+ // src/pyodide/runtime.ts
519
+ var NOT_RUNNING = "Python is not running.";
520
+ var PythonLostError = class extends Error {
521
+ constructor() {
522
+ super("Python stopped completely.");
523
+ this.name = "PythonLostError";
524
+ }
427
525
  };
428
- function cleanTypeName(type) {
429
- return type.startsWith("__main__.") ? type.slice("__main__.".length) : type;
430
- }
431
- function describeType(type) {
432
- const name = cleanTypeName(type);
433
- if (name === "None" || name === "NoneType") return "`None`";
434
- const friendly = FRIENDLY_TYPES[name];
435
- return friendly ? `${friendly} (\`${name}\`)` : `\`${name}\``;
436
- }
437
- function describeTypes(types) {
438
- const described = types.map(describeType);
439
- if (described.length === 0) return "a different type";
440
- if (described.length === 1) return described[0];
441
- return `${described.slice(0, -1).join(", ")} or ${described[described.length - 1]}`;
442
- }
443
- function boolAsNumberNote(expected, actual, level) {
444
- if (actual !== "bool" || expected.length === 0) return null;
445
- if (!expected.some((type) => type === "int" || type === "float")) return null;
446
- if (level !== void 0 && !levelRejectsBoolAsNumber(level)) return null;
447
- return "At `#level beginner` and `#level intermediate`, `True` and `False` are not accepted as numbers, even though Python counts them as `1` and `0`.";
448
- }
449
- function conversionFor(expected, actual) {
450
- if (expected.length !== 1 || actual === null) return null;
451
- const to = expected[0];
452
- const numeric = actual === "int" || actual === "float" || actual === "bool";
453
- if (to === "int" && (actual === "str" || actual === "float")) return "int";
454
- if (to === "float" && actual === "str") return "float";
455
- if (to === "str" && numeric) return "str";
456
- return null;
526
+ var StoppedError = class extends Error {
527
+ constructor(message) {
528
+ super(message);
529
+ this.name = "StoppedError";
530
+ }
531
+ };
532
+ function deliverDisplay(display, onEvent, fileName) {
533
+ onEvent(display.kind === "image" || display.kind === "table" ? { ...display, source: fileName } : display);
457
534
  }
458
- var ELEMENT_RE = /^(item \d+|key .+|value of key .+|\[.+\]) of (.+)$/;
459
- function membersPhrase(element) {
460
- if (element.startsWith("the value for key")) return "every value in";
461
- if (element.startsWith("key ")) return "every key in";
462
- return "every item in";
463
- }
464
- function describeElement(element) {
465
- if (element.startsWith("value of key ")) {
466
- return `the value for key ${element.slice("value of key ".length)}`;
467
- }
468
- if (element.startsWith("[")) return "one of the values";
469
- return element;
470
- }
471
- function parseTypeCheckMessage(message) {
472
- const unknown = {
473
- kind: "unknown",
474
- name: null,
475
- element: null,
476
- actual: null,
477
- expected: []
478
- };
479
- const lines = message.split(/\r?\n/);
480
- const first = lines[0].trim();
481
- let expected = [];
482
- let subject = null;
483
- const unionAt = first.indexOf(" did not match any element in the union:");
484
- if (unionAt >= 0) {
485
- subject = first.slice(0, unionAt);
486
- for (const raw of lines.slice(1)) {
487
- const m = raw.match(/^\s*([A-Za-z_][A-Za-z0-9_.\[\], ]*):/);
488
- if (m) expected.push(m[1].trim());
535
+ var WorkerConnection = class {
536
+ constructor(options) {
537
+ this.options = options;
538
+ }
539
+ options;
540
+ worker = null;
541
+ nextId = 1;
542
+ pending = /* @__PURE__ */ new Map();
543
+ initPromise = null;
544
+ stdinBuffer = null;
545
+ interruptBuffer = null;
546
+ stdinHandler = null;
547
+ /** The stdin request being answered, or null; a Stop ends it. */
548
+ stdinRequest = null;
549
+ /** Stdin read but not yet given: what did not fit, or what came after a Stop. */
550
+ stdinCarry = new Uint8Array(0);
551
+ /** The handler's answer being waited for, which the next request shares. */
552
+ stdinNext = null;
553
+ packageNoteHandler = null;
554
+ pythonLostHandler = null;
555
+ /** Start the worker, once - or again, if the last attempt failed. */
556
+ async initialize() {
557
+ if (!this.initPromise) {
558
+ this.initPromise = this.doInitialize().catch((err) => {
559
+ this.initPromise = null;
560
+ this.worker?.terminate();
561
+ this.worker = null;
562
+ throw err;
563
+ });
489
564
  }
490
- } else {
491
- const predicates = [
492
- [/^(.*) is not an instance of (.+)$/, (m) => [m[2]]],
493
- [/^(.*) is neither float or int$/, () => ["float"]],
494
- [/^(.*) is not None$/, () => ["None"]],
495
- [/^(.*) is not a ([A-Za-z_][A-Za-z0-9_]*)$/, (m) => [m[2]]]
496
- ];
497
- for (const [re, pick] of predicates) {
498
- const m = first.match(re);
499
- if (m) {
500
- subject = m[1];
501
- expected = pick(m);
502
- break;
565
+ await this.initPromise;
566
+ }
567
+ async doInitialize() {
568
+ const indexUrl = this.options.indexUrl();
569
+ const worker = this.options.spawn({
570
+ onMessage: (msg) => this.handleMessage(msg, worker),
571
+ onError: (err) => this.rejectAllPending(err),
572
+ onExit: () => this.pythonLost(worker)
573
+ });
574
+ this.worker = worker;
575
+ this.stdinBuffer = tryCreateStdinBuffer();
576
+ this.interruptBuffer = tryCreateInterruptBuffer();
577
+ const packageCacheDir = this.options.packageCacheDir?.(indexUrl);
578
+ const started = this.send("init", {
579
+ indexUrl,
580
+ ...packageCacheDir ? { packageCacheDir } : {},
581
+ ...this.stdinBuffer ? { stdinBuffer: this.stdinBuffer } : {},
582
+ ...this.interruptBuffer ? { interruptBuffer: this.interruptBuffer } : {}
583
+ });
584
+ const limit = this.options.startTimeoutMs;
585
+ if (limit === void 0) {
586
+ await started;
587
+ return;
588
+ }
589
+ let timer;
590
+ const timedOut = new Promise((_, reject) => {
591
+ timer = setTimeout(
592
+ () => reject(new Error(`Python did not start within ${Math.round(limit / 1e3)} seconds`)),
593
+ limit
594
+ );
595
+ });
596
+ try {
597
+ await Promise.race([started, timedOut]);
598
+ } finally {
599
+ clearTimeout(timer);
600
+ }
601
+ }
602
+ /** Whether Python has been started, or is starting. */
603
+ get started() {
604
+ return this.initPromise !== null;
605
+ }
606
+ /** A request, once Python has started; `live` is where its output goes as it runs. */
607
+ async request(type, fields, live = null) {
608
+ await this.initialize();
609
+ return this.send(type, fields, live);
610
+ }
611
+ /** `fn(*args)` in Python, and what it returns; `live` streams its output. */
612
+ async call(fn, args, live) {
613
+ return await this.request("call", { fn, args, live: live !== void 0 }, live ?? null);
614
+ }
615
+ /**
616
+ * Ask a running program to stop, by raising `KeyboardInterrupt` at the
617
+ * interpreter's next bytecode check. Synchronous on purpose: the worker is
618
+ * blocked inside `runPython` and would not read a message until it
619
+ * returned. Returns false when there is no interrupt channel (no
620
+ * `SharedArrayBuffer`), so callers can say so rather than appear to work.
621
+ */
622
+ interrupt() {
623
+ if (!this.interruptBuffer) {
624
+ return false;
625
+ }
626
+ if (this.stdinBuffer && this.stdinRequest !== null) {
627
+ this.stdinRequest = null;
628
+ interruptStdin(this.stdinBuffer);
629
+ }
630
+ const running = new Set(this.pending.keys());
631
+ requestInterrupt(this.interruptBuffer, () => {
632
+ for (const id of running) {
633
+ if (this.pending.has(id)) return true;
634
+ }
635
+ return false;
636
+ });
637
+ return true;
638
+ }
639
+ /**
640
+ * Told when Python had to be replaced - its worker stopped, or it could
641
+ * no longer run - and so every session's names are gone. The request in
642
+ * flight fails with `PythonLostError`; the next starts a new Python.
643
+ */
644
+ setPythonLostHandler(handler) {
645
+ this.pythonLostHandler = handler;
646
+ }
647
+ /**
648
+ * Who hears what Pyodide says while it loads a package: "Loading pytest,
649
+ * ..." or, `failed`, why it could not. Without one, the host's log.
650
+ */
651
+ setPackageNoteHandler(handler) {
652
+ this.packageNoteHandler = handler;
653
+ }
654
+ /**
655
+ * Register the handler asked for the next of stdin when a running program
656
+ * reads it - `input()`, `sys.stdin.read()`. It resolves with the next text
657
+ * or bytes, exactly as the program should read them (a line typed is that
658
+ * line and its newline), or `null` at the end of stdin. The worker waits
659
+ * until it does, or until a Stop, which raises `KeyboardInterrupt` in the
660
+ * read.
661
+ */
662
+ setStdinHandler(handler) {
663
+ this.stdinHandler = handler;
664
+ }
665
+ dispose() {
666
+ this.worker?.terminate();
667
+ this.worker = null;
668
+ this.initPromise = null;
669
+ this.stdinBuffer = null;
670
+ this.interruptBuffer = null;
671
+ this.rejectAllPending(new Error("Runtime disposed"));
672
+ }
673
+ /** Send a request to the worker as it is now, and resolve with its reply. */
674
+ send(type, fields, live = null) {
675
+ const worker = this.worker;
676
+ if (!worker) {
677
+ return Promise.reject(new Error(NOT_RUNNING));
678
+ }
679
+ const id = this.nextId++;
680
+ return new Promise((resolve4, reject) => {
681
+ this.pending.set(id, { resolve: resolve4, reject, live });
682
+ worker.post({ id, type, ...fields });
683
+ });
684
+ }
685
+ handleMessage(msg, worker) {
686
+ if (msg.type === "display") {
687
+ const live = this.pending.get(msg.requestId)?.live;
688
+ if (live) {
689
+ deliverDisplay(msg.payload, live.onEvent, live.fileName);
503
690
  }
691
+ return;
692
+ }
693
+ if (msg.type === "stdinRequest") {
694
+ void this.handleStdinRequest(msg.request);
695
+ return;
696
+ }
697
+ if (msg.type === "packageNote") {
698
+ if (this.packageNoteHandler) this.packageNoteHandler(msg.text, msg.failed);
699
+ else console.error(msg.text);
700
+ return;
701
+ }
702
+ if (msg.type === "error" && msg.kind === "finished") {
703
+ this.pythonLost(worker);
704
+ return;
705
+ }
706
+ const pending = this.pending.get(msg.id);
707
+ if (!pending) {
708
+ return;
709
+ }
710
+ this.pending.delete(msg.id);
711
+ if (msg.type === "error") {
712
+ pending.reject(msg.kind === "interrupted" ? new StoppedError(msg.message) : new Error(msg.message));
713
+ } else {
714
+ pending.resolve(msg.value);
715
+ }
716
+ }
717
+ /**
718
+ * Python is gone: the worker exited (only a Node worker can say so), or
719
+ * the interpreter in it is finished and refuses everything. Either way
720
+ * nothing sent to it can succeed. Fail what was waiting, end the worker,
721
+ * and forget it, so that the next request starts a new Python.
722
+ *
723
+ * Every session's names went with the old one - but they were gone
724
+ * already: this follows Python stopping by itself, and never ends a
725
+ * Python that could still run. Whoever set `setPythonLostHandler` is told,
726
+ * so that each file can say so rather than only the one that was running.
727
+ */
728
+ pythonLost(worker) {
729
+ if (this.worker !== worker) return;
730
+ this.rejectAllPending(new PythonLostError());
731
+ this.worker = null;
732
+ this.initPromise = null;
733
+ worker.terminate();
734
+ this.pythonLostHandler?.();
735
+ }
736
+ rejectAllPending(err) {
737
+ for (const pending of this.pending.values()) {
738
+ pending.reject(err);
504
739
  }
740
+ this.pending.clear();
505
741
  }
506
- if (subject === null) return unknown;
507
- let actual = null;
508
- const withActual = subject.match(/^(.*) \(([^()]*)\)$/);
509
- if (withActual) {
510
- subject = withActual[1];
511
- actual = withActual[2];
512
- }
513
- let element = null;
514
- const elementMatch = subject.match(ELEMENT_RE);
515
- if (elementMatch) {
516
- element = describeElement(elementMatch[1]);
517
- subject = elementMatch[2];
518
- }
519
- if (subject === "the return value") {
520
- return { kind: "return", name: null, element, actual, expected };
521
- }
522
- const argument = subject.match(/^argument "(.+)"$/);
523
- if (argument) {
524
- return { kind: "argument", name: argument[1], element, actual, expected };
525
- }
526
- const assigned = subject.match(/^value assigned to (.+)$/);
527
- if (assigned) {
528
- return { kind: "variable", name: assigned[1], element, actual, expected };
529
- }
530
- return { ...unknown, element, actual, expected };
531
- }
532
- function explainTypeCheckError(message, functionName, level) {
533
- const parsed = parseTypeCheckMessage(message);
534
- const wanted = describeTypes(parsed.expected);
535
- const owner = functionName ? `\`${functionName}\`` : "this function";
536
- const named = parsed.name ? `\`${parsed.name}\`` : "a value";
537
- const convert = conversionFor(parsed.expected, parsed.actual);
538
- const boolNote = boolAsNumberNote(parsed.expected, parsed.actual, level);
539
- const annotation = parsed.expected.length === 1 ? cleanTypeName(parsed.expected[0]) : null;
540
- if (parsed.kind === "argument") {
541
- if (parsed.element) {
542
- return {
543
- headline: `${owner} expects ${membersPhrase(parsed.element)} ${named} to be ${wanted}, but ${parsed.element} is not.`,
544
- howToFix: [
545
- `Look at ${parsed.element} of the ${parsed.actual ?? "value"} you passed for ${named}.`,
546
- "Every one has to match the annotation, not just the first."
547
- ]
548
- };
549
- }
550
- const got = parsed.actual ? `, but got ${describeType(parsed.actual)}` : "";
551
- const howToFix = [`Check the value you passed for ${named} on this line.`];
552
- if (boolNote) howToFix.push(boolNote);
553
- if (convert) {
554
- howToFix.push(`If it should be ${wanted}, convert it with \`${convert}(...)\`.`);
555
- }
556
- if (annotation && parsed.actual) {
557
- howToFix.push(
558
- `Or, if ${named} really should be ${describeType(parsed.actual)}, change its annotation in ${owner} from \`${annotation}\` to \`${cleanTypeName(parsed.actual)}\`.`
559
- );
742
+ /**
743
+ * The next of stdin from the handler. One call at a time: a request a
744
+ * Stop ended leaves its call waiting, and the next request - a program
745
+ * that caught the `KeyboardInterrupt` and asked again - shares it, so
746
+ * the line typed answers that one.
747
+ */
748
+ nextStdin() {
749
+ if (this.stdinNext === null) {
750
+ const call = (async () => {
751
+ try {
752
+ const next = this.stdinHandler ? await this.stdinHandler() : null;
753
+ return typeof next === "string" ? new TextEncoder().encode(next) : next;
754
+ } catch {
755
+ return null;
756
+ }
757
+ })();
758
+ this.stdinNext = call;
759
+ void call.then(() => {
760
+ if (this.stdinNext === call) this.stdinNext = null;
761
+ });
560
762
  }
561
- return {
562
- headline: `${owner} expects ${named} to be ${wanted}${got}.`,
563
- howToFix
564
- };
763
+ return this.stdinNext;
565
764
  }
566
- if (parsed.kind === "return") {
567
- if (parsed.actual === "None" || parsed.actual === "NoneType") {
568
- return {
569
- headline: `${owner} should return ${wanted}, but it finished without returning a value.`,
570
- howToFix: [
571
- "Make sure every path through the function reaches a `return`.",
572
- "An `if` with no `else` falls off the end, and Python then returns `None`.",
573
- `Or annotate the return type as \`None\` if ${owner} is not meant to return anything.`
574
- ]
575
- };
765
+ async handleStdinRequest(request) {
766
+ this.stdinRequest = request;
767
+ let bytes = this.stdinCarry;
768
+ if (bytes.length === 0) {
769
+ bytes = await this.nextStdin();
770
+ if (this.stdinRequest !== request) {
771
+ if (bytes !== null && this.stdinRequest === null) {
772
+ this.stdinCarry = concatBytes(this.stdinCarry, bytes);
773
+ }
774
+ return;
775
+ }
576
776
  }
577
- const got = parsed.actual ? describeType(parsed.actual) : "something else";
578
- const howToFix = [];
579
- const wantsNone = parsed.expected.length === 1 && /^(None|NoneType)$/.test(parsed.expected[0]);
580
- if (wantsNone) {
581
- howToFix.push("Use a bare `return`, or drop the `return` entirely.");
582
- } else if (convert) {
583
- howToFix.push(`Convert the returned value with \`${convert}(...)\`.`);
777
+ this.stdinRequest = null;
778
+ if (!this.stdinBuffer) {
779
+ return;
780
+ }
781
+ const given = writeStdin(this.stdinBuffer, request, bytes);
782
+ if (bytes === null) {
783
+ this.stdinCarry = new Uint8Array(0);
584
784
  } else {
585
- howToFix.push(`Return ${wanted} from this line.`);
785
+ this.stdinCarry = bytes.subarray(Math.max(given, 0));
586
786
  }
587
- if (boolNote) howToFix.push(boolNote);
588
- if (annotation && parsed.actual) {
589
- howToFix.push(
590
- `Or change ${owner}'s return annotation from \`${annotation}\` to \`${cleanTypeName(parsed.actual)}\`.`
787
+ }
788
+ };
789
+ function concatBytes(a, b) {
790
+ const out = new Uint8Array(a.length + b.length);
791
+ out.set(a);
792
+ out.set(b, a.length);
793
+ return out;
794
+ }
795
+
796
+ // src/run/examplar.ts
797
+ async function runExamplarStep(runtime, bundles, host, code, fileName) {
798
+ const bundle = await fetchBundle(bundles, host, code);
799
+ if (bundle === null) {
800
+ return null;
801
+ }
802
+ host.status("Checking your tests...");
803
+ try {
804
+ await runtime.mountWorkspaceFiles([]);
805
+ } catch (err) {
806
+ if (!(err instanceof StoppedError)) {
807
+ host.examplarCard(
808
+ failedCard(
809
+ bundle,
810
+ `the known implementations were not run: your files could not be set aside first (${errorText(err)})`
811
+ )
591
812
  );
592
813
  }
593
- return {
594
- headline: `${owner} says it returns ${wanted}, but this line returns ${got}.`,
595
- howToFix
596
- };
814
+ return false;
597
815
  }
598
- if (parsed.kind === "variable") {
599
- if (parsed.element) {
600
- return {
601
- headline: `${named} is annotated so ${membersPhrase(parsed.element)} it is ${wanted}, but ${parsed.element} is not.`,
602
- howToFix: [`Look at ${parsed.element} of the value assigned to ${named}.`]
603
- };
604
- }
605
- const got = parsed.actual ? describeType(parsed.actual) : "a different type";
606
- const howToFix = [`Assign ${wanted} to ${named}.`];
607
- if (boolNote) howToFix.push(boolNote);
608
- if (convert) {
609
- howToFix.push(`You can convert the value with \`${convert}(...)\`.`);
610
- }
611
- if (annotation && parsed.actual) {
612
- howToFix.push(
613
- `Or change ${named}'s annotation from \`${annotation}\` to \`${cleanTypeName(parsed.actual)}\`.`
614
- );
816
+ try {
817
+ await runtime.ensurePytest();
818
+ } catch {
819
+ }
820
+ if (host.stopRequested()) {
821
+ return false;
822
+ }
823
+ let result;
824
+ try {
825
+ result = await runtime.examplarRun(code, bundle.json, fileName);
826
+ } catch (err) {
827
+ if (!(err instanceof StoppedError)) {
828
+ host.examplarCard(failedCard(bundle, errorText(err)));
615
829
  }
616
- return {
617
- headline: `${named} is annotated as ${wanted}, but ${got} was assigned here.`,
618
- howToFix
619
- };
830
+ return false;
831
+ }
832
+ for (const entry of buildExamplarEntries(bundle, result)) {
833
+ host.examplarCard(entry);
834
+ }
835
+ const provides = result.provides ?? [];
836
+ const defines = result.wheats?.[0]?.studentDefines ?? [];
837
+ return provides.length > 0 && provides.every((name) => defines.includes(name));
838
+ }
839
+ async function fetchBundle(bundles, host, code) {
840
+ const directive = parseExamplarDirective(code);
841
+ if (directive.kind === "none") {
842
+ return null;
843
+ }
844
+ if (directive.kind === "error") {
845
+ host.say(`Line ${directive.line}: ${directive.message}`, "problem");
846
+ return null;
620
847
  }
848
+ host.status("Loading implementations...");
849
+ const load = await loadBundle(directive.url, bundles);
850
+ if (load.note) {
851
+ host.say(`Known implementations: ${load.note}`, "note");
852
+ }
853
+ if (!load.json) {
854
+ host.say(`Could not load the known implementations from ${directive.url}: ${load.error}`, "problem");
855
+ return null;
856
+ }
857
+ return { url: directive.url, json: load.json, cached: load.fromCache };
858
+ }
859
+ function failedCard(bundle, problem) {
621
860
  return {
622
- headline: "A value does not match the type annotation written for it.",
623
- howToFix: [
624
- message.split(/\r?\n/)[0].trim(),
625
- "Either pass a value of the annotated type, or change the annotation."
626
- ]
861
+ kind: "examplar",
862
+ url: bundle.url,
863
+ cached: bundle.cached,
864
+ card: "failed",
865
+ problem,
866
+ body: [{ kind: "line", tone: "bad", text: problem }]
627
867
  };
628
868
  }
629
-
630
- // src/common/analyzers/typeCheckAnalyzer.ts
631
- var HANDLED = ["TypeCheckError", "typeguard.TypeCheckError"];
632
- function messageWithDetail(traceback, fallback) {
633
- const lines = traceback.split(/\r?\n/);
634
- for (let i = lines.length - 1; i >= 0; i--) {
635
- const line = lines[i];
636
- const marker = line.indexOf("TypeCheckError:");
637
- if (line.startsWith(" ") || marker < 0) continue;
638
- const detail = [line.slice(marker + "TypeCheckError:".length).trim()];
639
- for (let j = i + 1; j < lines.length; j++) {
640
- if (lines[j].trim() === "") continue;
641
- if (!lines[j].startsWith(" ")) break;
642
- detail.push(lines[j]);
643
- }
644
- return detail.join("\n");
645
- }
646
- return fallback;
647
- }
648
- var typeCheckAnalyzer = {
649
- handles: HANDLED,
650
- analyze(input) {
651
- const { parsedError, fileName, level } = input;
652
- if (!HANDLED.includes(parsedError.errorType)) {
653
- return null;
869
+ function buildExamplarEntries(bundle, result) {
870
+ if (!result.ok) {
871
+ return [failedCard(bundle, result.error ?? "the known implementations could not be run")];
872
+ }
873
+ if (result.timedOut) {
874
+ return [failedCard(bundle, timedOutProblem(result.timedOut))];
875
+ }
876
+ const wheats = result.wheats ?? [];
877
+ const unloadable = wheats.find((w) => !w.loaded) ?? (result.chaffs ?? []).find((c) => !c.loaded);
878
+ if (unloadable) {
879
+ const which = unloadable.targets === void 0 ? "correct" : "buggy";
880
+ return [
881
+ failedCard(
882
+ bundle,
883
+ `a known ${which} implementation could not be loaded (${unloadable.errorType}: ${unloadable.errorMessage}). The bundle may need rebuilding.`
884
+ )
885
+ ];
886
+ }
887
+ const disagreed = /* @__PURE__ */ new Set();
888
+ const raised = /* @__PURE__ */ new Map();
889
+ for (const wheat of wheats) {
890
+ for (const [test, outcome] of Object.entries(wheat.tests)) {
891
+ if (outcome.outcome === "fail") {
892
+ disagreed.add(test);
893
+ } else if (outcome.outcome === "error" && !raised.has(test)) {
894
+ raised.set(test, shownError(outcome.message ?? outcome.outcome));
895
+ }
654
896
  }
655
- const message = messageWithDetail(parsedError.traceback, parsedError.message);
656
- const parsed = parseTypeCheckMessage(message);
657
- const frames = userTracebackFrames(parsedError.traceback);
658
- const innermost = frames.length > 0 ? frames[frames.length - 1] : null;
659
- const blamed = parsed.kind === "argument" && frames.length >= 2 ? frames[frames.length - 2] : innermost;
660
- const explanation = explainTypeCheckError(
661
- message,
662
- innermost ? innermost.functionName : null,
663
- level
664
- );
665
- return {
666
- id: "type-check",
667
- // Not a real Python exception name; "TypeCheckError" means nothing to
668
- // a student, and "TypeError" would name a different Python error.
669
- errorType: "TypeMismatch",
670
- message,
671
- headline: explanation.headline,
672
- howToFix: explanation.howToFix,
673
- fileName: blamed ? blamed.fileName : fileName,
674
- lineNumber: blamed ? blamed.line : parsedError.lineNumber,
675
- column: null,
676
- nameToken: parsed.name,
677
- severity: "error",
678
- raw: parsedError.traceback,
679
- origin: "runtime",
680
- level
897
+ }
898
+ const unloadedFiles = (wheats[0]?.unloaded ?? []).filter((u) => isFileAccessError(u.error));
899
+ for (const test of disagreed) {
900
+ raised.delete(test);
901
+ }
902
+ const attribution = result.attribution ?? {};
903
+ const chaffs = result.chaffs ?? [];
904
+ const skipped = new Set(result.chaffsSkipped ?? []);
905
+ return (result.provides ?? []).map((name) => {
906
+ const tests = Object.entries(attribution).filter(([, names]) => names.includes(name)).map(([test]) => test);
907
+ const failures = tests.filter((test) => disagreed.has(test));
908
+ const errors = tests.filter((test) => raised.has(test)).map((test) => ({ test, message: raised.get(test) }));
909
+ const mine = chaffs.filter((c) => c.targets === name);
910
+ const missed = mine.filter((c) => c.loaded && Object.values(c.tests).every((t) => t.outcome === "pass")).map((c) => c.id);
911
+ const card = {
912
+ kind: "examplar",
913
+ url: bundle.url,
914
+ cached: bundle.cached,
915
+ card: "function",
916
+ name,
917
+ testCount: tests.length,
918
+ allPass: tests.length > 0 && failures.length === 0 && errors.length === 0,
919
+ failures,
920
+ errors,
921
+ hint: errors.length > 0 && (errors.some((e) => isFileAccessError(e.message)) || unloadedFiles.length > 0) ? UNMOUNTED_HINT + unloadedFiles.map((u) => ` Line ${u.line} could not run here: ${u.error}.`).join("") : void 0,
922
+ total: mine.length,
923
+ caught: mine.length - missed.length,
924
+ missed,
925
+ // The primitive gates phase two per function, so `mine` is normally
926
+ // already empty here. Deciding again from this function's own verdict
927
+ // rather than trusting the flag keeps a coverage number from ever
928
+ // appearing beside a test that did not pass.
929
+ pending: failures.length > 0 || errors.length > 0 || skipped.has(name)
681
930
  };
931
+ return { ...card, body: cardBody(card) };
932
+ });
933
+ }
934
+ function cardBody(card) {
935
+ if (card.testCount === 0) {
936
+ return [{ kind: "line", tone: "note", text: "No tests yet." }];
937
+ }
938
+ const CORRECT = "Against correct implementations: ";
939
+ const BUGGY = "Against buggy implementations: ";
940
+ const total = card.testCount;
941
+ const some = (n) => total === 1 ? "your test" : `${n} of your ${total} tests`;
942
+ const does = (n) => n === 1 ? "s" : "";
943
+ const body = [];
944
+ if (card.allPass) {
945
+ body.push({
946
+ kind: "line",
947
+ tone: "good",
948
+ text: total === 1 ? `${CORRECT}your test passes.` : `${CORRECT}all ${total} of your tests pass.`
949
+ });
682
950
  }
683
- };
684
-
685
- // src/common/analyzers/registry.ts
686
- var analyzers = [nameErrorAnalyzer, typeCheckAnalyzer];
687
- function findRuntimeFinding(source, fileName, level, parsedError) {
688
- for (const analyzer of analyzers) {
689
- if (!analyzer.handles.includes(parsedError.errorType)) {
690
- continue;
951
+ if (card.failures.length > 0) {
952
+ const n = card.failures.length;
953
+ body.push({ kind: "line", tone: "bad", text: `${CORRECT}${some(n)} expect${does(n)} the wrong answer:` });
954
+ for (const test of card.failures) {
955
+ body.push({ kind: "item", tone: "bad", name: test });
691
956
  }
692
- const finding = analyzer.analyze({ source, fileName, level, parsedError });
693
- if (finding) {
694
- return finding;
957
+ }
958
+ if (card.errors.length > 0) {
959
+ body.push({ kind: "line", tone: "warn", text: `${CORRECT}${some(card.errors.length)} could not run here:` });
960
+ for (const { test, message } of card.errors) {
961
+ body.push({ kind: "item", tone: "warn", name: test, detail: message });
695
962
  }
963
+ if (card.hint) body.push({ kind: "line", tone: "note", text: card.hint });
696
964
  }
697
- return null;
698
- }
699
-
700
- // src/common/analyzers/runtimeFinding.ts
701
- function findingForErrorEvent(event, source, fileName, level) {
702
- const traceback = event.traceback || `${event.errorType}: ${event.message}`;
703
- const parsed = parsePythonError(traceback, source);
704
- if (parsed.lineNumber === null && event.lineNumber != null) {
705
- parsed.lineNumber = event.lineNumber;
965
+ if (card.pending) {
966
+ body.push({ kind: "line", tone: "note", text: `${BUGGY}waiting until all your tests of ${card.name} pass.` });
967
+ } else if (card.caught === card.total) {
968
+ body.push({ kind: "line", tone: "good", text: `${BUGGY}caught all ${card.total}.` });
969
+ } else {
970
+ body.push({
971
+ kind: "line",
972
+ tone: "warn",
973
+ text: `${BUGGY}caught ${card.caught} of ${card.total} - missed ${card.missed.join(", ")}.`
974
+ });
706
975
  }
707
- if (parsed.column === null && event.column != null) {
708
- parsed.column = event.column;
976
+ return body;
977
+ }
978
+ var UNMOUNTED_HINT = "Your tests are checked on their own, so files next to your program are not available while that happens.";
979
+ function isFileAccessError(message) {
980
+ return /^(FileNotFoundError|IsADirectoryError|NotADirectoryError|PermissionError|ModuleNotFoundError|ImportError):/.test(
981
+ message
982
+ );
983
+ }
984
+ function shownError(message) {
985
+ if (isFileAccessError(message) || /^(NameError|UnboundLocalError):/.test(message)) {
986
+ return message;
709
987
  }
710
- if (parsed.fileName === null && event.fileName) {
711
- parsed.fileName = event.fileName;
988
+ return message.split(":")[0];
989
+ }
990
+ function timedOutProblem(stuck) {
991
+ const against = stuck.kind === "wheat" ? "a known correct implementation" : "a known buggy implementation";
992
+ if (stuck.test === null) {
993
+ return `Loading your definitions with ${against} took more than ${stuck.seconds} seconds, so the check stopped.`;
712
994
  }
713
- return findRuntimeFinding(source, fileName, level, parsed);
995
+ return `\`${stuck.test}\` ran for more than ${stuck.seconds} seconds against ${against}, so the check stopped. ` + (stuck.kind === "wheat" ? "A test that runs that long is usually stuck in a loop." : "That implementation may never finish on what this test gives it; tell your course staff.");
714
996
  }
715
997
 
716
- // src/common/pyodideRunner.ts
717
- var ANY_IMPORT_RE = /(^|\n)[ \t]*(import|from)[ \t]+\S/;
718
- var PANDAS_METHOD_RE = /\.\s*to_pandas\s*\(/;
719
- var NEEDS_PACKAGES_RE_LIST = [PANDAS_METHOD_RE];
720
- function needsPackages(code) {
721
- return ANY_IMPORT_RE.test(code) || NEEDS_PACKAGES_RE_LIST.some((re) => re.test(code));
998
+ // src/run/packages.ts
999
+ var MAY_NEED_PACKAGES_RE = /\bimport\b|__import__|\.\s*to_pandas\s*\(/;
1000
+ function needsPackages(code, siblings = []) {
1001
+ return [code, ...siblings.map((sibling) => sibling.text)].some((text) => MAY_NEED_PACKAGES_RE.test(text));
722
1002
  }
723
1003
 
724
- // src/cli/files.ts
725
- var fs = __toESM(require("node:fs/promises"));
726
- var path = __toESM(require("node:path"));
727
-
728
- // src/common/workspaceFilePolicy.ts
1004
+ // src/core/files.ts
729
1005
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
730
1006
  var MAX_TOTAL_BYTES = 8 * 1024 * 1024;
731
- var MAX_FILES = 50;
1007
+ var MAX_FILES = 100;
1008
+ var MB = 1024 * 1024;
1009
+ var WHY_LEFT_OUT = {
1010
+ count: () => `at most ${MAX_FILES} files next to a program are`,
1011
+ size: () => `each file can be at most ${MAX_FILE_BYTES / MB} MB`,
1012
+ total: () => `the files together can be at most ${MAX_TOTAL_BYTES / MB} MB`,
1013
+ unreadable: () => "it could not be read",
1014
+ notLoaded: () => "it was not loaded, so saving it would replace a file the program never saw",
1015
+ changed: () => "it changed on disk while the program ran",
1016
+ python: () => "PLL never overwrites or deletes a .py file",
1017
+ hidden: () => "PLL saves nothing into hidden folders",
1018
+ unsaved: () => "it has unsaved changes in the editor; save it and run again",
1019
+ failed: () => "it could not be written"
1020
+ };
1021
+ function leftOutNotes(leftOut, action) {
1022
+ const notes = [];
1023
+ for (const reason of Object.keys(WHY_LEFT_OUT)) {
1024
+ const kept = leftOut.filter((file) => file.reason === reason);
1025
+ if (kept.length === 0) {
1026
+ continue;
1027
+ }
1028
+ const names = kept.map((file) => file.detail ? `${file.name} (${file.detail})` : file.name);
1029
+ const shown = names.slice(0, 5).join(", ");
1030
+ const more = names.length > 5 ? ` and ${names.length - 5} more` : "";
1031
+ notes.push(`Not ${action}: ${shown}${more} - ${WHY_LEFT_OUT[reason](action)}.`);
1032
+ }
1033
+ return notes;
1034
+ }
732
1035
  var MOUNT_EXTENSIONS = /* @__PURE__ */ new Set([
733
1036
  ".csv",
734
- ".txt",
735
1037
  ".tsv",
1038
+ ".txt",
1039
+ ".text",
736
1040
  ".json",
737
1041
  ".md",
738
1042
  ".dat",
739
1043
  ".xml",
1044
+ ".html",
1045
+ ".htm",
1046
+ ".log",
1047
+ ".yaml",
1048
+ ".yml",
1049
+ ".toml",
1050
+ ".ini",
1051
+ ".cfg",
740
1052
  ".py",
741
- // Pictures, for `load_image`. `.svg` is not in BINARY_EXTENSIONS below
742
- // because it is text, and mounting it as text is lossless.
743
1053
  ".svg",
744
1054
  ".png",
745
1055
  ".jpg",
@@ -747,43 +1057,26 @@ var MOUNT_EXTENSIONS = /* @__PURE__ */ new Set([
747
1057
  ".gif",
748
1058
  ".webp"
749
1059
  ]);
750
- var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
751
- var WRITEBACK_EXTENSIONS = /* @__PURE__ */ new Set([
752
- ".csv",
753
- ".txt",
754
- ".tsv",
755
- ".json",
756
- ".md",
757
- ".dat",
758
- ".xml"
759
- ]);
1060
+ var TOOL_FOLDERS = /* @__PURE__ */ new Set(["__pycache__", "node_modules", "venv", "env", "site-packages"]);
760
1061
  function extensionOf(name) {
761
- const dot = name.lastIndexOf(".");
762
- if (dot <= 0) {
763
- return "";
764
- }
765
- return name.slice(dot).toLowerCase();
1062
+ const base = name.slice(name.lastIndexOf("/") + 1);
1063
+ const dot = base.lastIndexOf(".");
1064
+ return dot <= 0 ? "" : base.slice(dot).toLowerCase();
766
1065
  }
767
- function isSafeBasename(name) {
768
- if (!name || name === "." || name === "..") {
769
- return false;
770
- }
771
- if (name.startsWith(".")) {
772
- return false;
773
- }
774
- if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
1066
+ function isSafePath(name) {
1067
+ if (!name || name.includes("\\") || name.includes("\0") || name.startsWith("/")) {
775
1068
  return false;
776
1069
  }
777
- return true;
1070
+ return name.split("/").every((part) => part !== "" && part !== "." && part !== "..");
778
1071
  }
779
- function isMountableName(name) {
780
- return isSafeBasename(name) && MOUNT_EXTENSIONS.has(extensionOf(name));
1072
+ function isHiddenPath(name) {
1073
+ return name.split("/").some((part) => part.startsWith(".") || TOOL_FOLDERS.has(part));
781
1074
  }
782
- function isWritebackName(name) {
783
- return isSafeBasename(name) && WRITEBACK_EXTENSIONS.has(extensionOf(name));
1075
+ function isWalkedFolder(name) {
1076
+ return !name.startsWith(".") && !TOOL_FOLDERS.has(name);
784
1077
  }
785
- function isBinaryMountName(name) {
786
- return BINARY_EXTENSIONS.has(extensionOf(name));
1078
+ function isMountableName(name) {
1079
+ return isSafePath(name) && !isHiddenPath(name) && MOUNT_EXTENSIONS.has(extensionOf(name));
787
1080
  }
788
1081
  function utf8ByteLength(text) {
789
1082
  return new TextEncoder().encode(text).byteLength;
@@ -791,125 +1084,480 @@ function utf8ByteLength(text) {
791
1084
  function contentsByteLength(contents) {
792
1085
  return typeof contents === "string" ? utf8ByteLength(contents) : contents.byteLength;
793
1086
  }
794
- function decodeMountContents(data) {
795
- if (data.byteLength > MAX_FILE_BYTES) {
796
- return void 0;
797
- }
798
- for (let i = 0; i < data.byteLength; i++) {
799
- if (data[i] === 0) {
800
- return void 0;
801
- }
802
- }
803
- return new TextDecoder("utf-8").decode(data);
1087
+ function contentsBytes(contents) {
1088
+ return typeof contents === "string" ? new TextEncoder().encode(contents) : contents;
804
1089
  }
805
- function selectMountableFiles(candidates) {
806
- const selected = [];
1090
+ async function readSiblingFiles(folder) {
1091
+ let entries;
1092
+ try {
1093
+ entries = await folder.files();
1094
+ } catch {
1095
+ return { files: [], leftOut: [], loaded: {} };
1096
+ }
1097
+ const depth = (name) => name.split("/").length;
1098
+ const ordered = entries.filter((entry) => isMountableName(entry.name)).sort((a, b) => depth(a.name) - depth(b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
1099
+ const files = [];
1100
+ const leftOut = [];
1101
+ const loaded = {};
807
1102
  let total = 0;
808
- for (const candidate of candidates) {
809
- if (selected.length >= MAX_FILES) {
810
- break;
1103
+ for (const entry of ordered) {
1104
+ if (files.length >= MAX_FILES) {
1105
+ leftOut.push({ name: entry.name, reason: "count" });
1106
+ continue;
811
1107
  }
812
- if (!isMountableName(candidate.name)) {
1108
+ if (entry.size > MAX_FILE_BYTES) {
1109
+ leftOut.push({ name: entry.name, reason: "size" });
813
1110
  continue;
814
1111
  }
815
1112
  let contents;
816
- if (isBinaryMountName(candidate.name)) {
817
- const raw = typeof candidate.contents === "string" ? new TextEncoder().encode(candidate.contents) : candidate.contents;
818
- if (raw.byteLength > MAX_FILE_BYTES) {
819
- continue;
820
- }
821
- contents = raw;
822
- } else if (typeof candidate.contents === "string") {
823
- if (utf8ByteLength(candidate.contents) > MAX_FILE_BYTES) {
824
- continue;
825
- }
826
- if (candidate.contents.includes("\0")) {
827
- continue;
828
- }
829
- contents = candidate.contents;
830
- } else {
831
- const decoded = decodeMountContents(candidate.contents);
832
- if (decoded === void 0) {
833
- continue;
834
- }
835
- contents = decoded;
1113
+ try {
1114
+ contents = await folder.read(entry.name);
1115
+ } catch (err) {
1116
+ leftOut.push({ name: entry.name, reason: "unreadable", detail: errorCode(err) });
1117
+ continue;
836
1118
  }
837
1119
  const size = contentsByteLength(contents);
1120
+ if (size > MAX_FILE_BYTES) {
1121
+ leftOut.push({ name: entry.name, reason: "size" });
1122
+ continue;
1123
+ }
838
1124
  if (total + size > MAX_TOTAL_BYTES) {
1125
+ leftOut.push({ name: entry.name, reason: "total" });
839
1126
  continue;
840
1127
  }
841
- selected.push({ name: candidate.name, contents });
1128
+ files.push({ name: entry.name, contents });
1129
+ loaded[entry.name] = { size: entry.size, mtimeMs: entry.mtimeMs };
842
1130
  total += size;
843
1131
  }
844
- return selected;
1132
+ return { files, leftOut, loaded };
845
1133
  }
846
- function selectWritebackFiles(files) {
847
- const selected = [];
1134
+ async function writeSiblingFiles(folder, changes, loaded) {
1135
+ const written = [];
1136
+ const deleted = [];
1137
+ const leftOut = [];
848
1138
  let total = 0;
849
- for (const file of files) {
850
- if (selected.length >= MAX_FILES) {
851
- break;
1139
+ const refusal = async (name) => {
1140
+ if (!isSafePath(name)) return "hidden";
1141
+ if (isHiddenPath(name)) return "hidden";
1142
+ if (folder.unsaved?.(name)) return "unsaved";
1143
+ const now = await folder.stat(name).catch(() => null);
1144
+ const before = loaded[name];
1145
+ if (now !== null && extensionOf(name) === ".py") return "python";
1146
+ if (before === void 0) return now === null ? null : "notLoaded";
1147
+ if (now === null || now.size !== before.size || now.mtimeMs !== before.mtimeMs) return "changed";
1148
+ return null;
1149
+ };
1150
+ for (const file of [...changes.files].sort((a, b) => a.name < b.name ? -1 : 1)) {
1151
+ const reason = await refusal(file.name);
1152
+ if (reason !== null) {
1153
+ leftOut.push({ name: file.name, reason });
1154
+ continue;
852
1155
  }
853
- if (!isWritebackName(file.name)) {
1156
+ const bytes = contentsBytes(file.contents);
1157
+ if (written.length >= MAX_FILES) {
1158
+ leftOut.push({ name: file.name, reason: "count" });
854
1159
  continue;
855
1160
  }
856
- if (typeof file.contents !== "string") {
1161
+ if (bytes.byteLength > MAX_FILE_BYTES) {
1162
+ leftOut.push({ name: file.name, reason: "size" });
857
1163
  continue;
858
1164
  }
859
- const size = utf8ByteLength(file.contents);
860
- if (size > MAX_FILE_BYTES || total + size > MAX_TOTAL_BYTES) {
1165
+ if (total + bytes.byteLength > MAX_TOTAL_BYTES) {
1166
+ leftOut.push({ name: file.name, reason: "total" });
861
1167
  continue;
862
1168
  }
863
- if (file.contents.includes("\0")) {
1169
+ try {
1170
+ await folder.write(file.name, bytes);
1171
+ written.push(file.name);
1172
+ total += bytes.byteLength;
1173
+ } catch (err) {
1174
+ leftOut.push({ name: file.name, reason: "failed", detail: errorCode(err) });
1175
+ }
1176
+ }
1177
+ for (const name of [...changes.deleted].sort()) {
1178
+ const reason = await refusal(name);
1179
+ if (reason !== null) {
1180
+ leftOut.push({ name, reason });
864
1181
  continue;
865
1182
  }
866
- selected.push(file);
867
- total += size;
1183
+ try {
1184
+ await folder.remove(name);
1185
+ deleted.push(name);
1186
+ } catch (err) {
1187
+ leftOut.push({ name, reason: "failed", detail: errorCode(err) });
1188
+ }
868
1189
  }
869
- return selected;
1190
+ return { written, deleted, leftOut };
1191
+ }
1192
+ function errorCode(err) {
1193
+ const code = err?.code;
1194
+ const byCode = {
1195
+ EACCES: "permission denied",
1196
+ EPERM: "permission denied",
1197
+ EROFS: "read-only file system",
1198
+ ENOSPC: "no space left",
1199
+ EISDIR: "a folder is in the way",
1200
+ ENOTDIR: "a file is in the way",
1201
+ NoPermissions: "permission denied"
1202
+ };
1203
+ if (typeof code === "string" && byCode[code]) return byCode[code];
1204
+ const message = err instanceof Error ? err.message : String(err);
1205
+ return message.split("\n")[0].slice(0, 80);
870
1206
  }
871
1207
 
872
- // src/cli/files.ts
873
- async function collectSiblingFiles(scriptPath) {
874
- const folder = path.dirname(path.resolve(scriptPath));
875
- let entries;
1208
+ // src/run/plan.ts
1209
+ var STOPPED = {
1210
+ beforeStart: "Stopped before the program started. Nothing was run.",
1211
+ checking: "Stopped while checking your tests. Your own tests and the program were not run.",
1212
+ afterChecking: "Stopped before the program started.",
1213
+ input: "Stopped. Your input was not run.",
1214
+ program: "Stopped. The tests were not run.",
1215
+ tests: "Stopped during the tests. The rest of the tests were not run.",
1216
+ thrown: "Stopped."
1217
+ };
1218
+ async function runFilePlan(runtime, host, plan) {
1219
+ const { code, fileName, sessionKey } = plan;
1220
+ const level = parseLevel(code);
1221
+ const tally = new Tally();
1222
+ const header = levelHeaderFinding(code, fileName, level);
1223
+ if (header !== null) {
1224
+ host.staticFindings([header]);
1225
+ host.say("The file was not run.", "problem");
1226
+ return tally.summary("blocked");
1227
+ }
1228
+ host.level(level);
1229
+ const checked = await staticChecks(runtime, host, code, fileName, level, null);
1230
+ if (checked !== "passed") {
1231
+ return tally.summary(checked);
1232
+ }
1233
+ const beforeStart = STOPPED.beforeStart;
1234
+ const outcome = await withFiles(runtime, host, fileName, beforeStart, async (files, starting) => {
1235
+ await loadPackages(runtime, host, code, await files.sources());
1236
+ if (stopped(host, STOPPED.beforeStart)) {
1237
+ return "stopped";
1238
+ }
1239
+ const complete = await runExamplarStep(runtime, plan.bundles, host, code, fileName);
1240
+ if (complete !== null && stopped(host, STOPPED.checking)) {
1241
+ return "stopped";
1242
+ }
1243
+ const beforeProgram = complete === null ? STOPPED.beforeStart : STOPPED.afterChecking;
1244
+ await files.mount();
1245
+ const withTests = plan.runTests && (complete !== false || plan.testsOfIncompleteFile === true) && await testsToRun(runtime, host, code);
1246
+ if (stopped(host, beforeProgram)) {
1247
+ return "stopped";
1248
+ }
1249
+ host.status("Running...");
1250
+ starting();
1251
+ const onEvent = tally.counting(programEvents(host, fileName, level));
1252
+ const program = () => runtime.runFile({ code, fileName, sessionKey, level, withTests }, onEvent);
1253
+ await (host.aroundProgram ? host.aroundProgram(program) : program());
1254
+ if (withTests) {
1255
+ sayWhyTestsStopped(host, tally);
1256
+ }
1257
+ const stopEnded = tally.testsStopped || tally.stopReached || tally.raised && host.stopRequested();
1258
+ return stopEnded ? "stopped" : "ran";
1259
+ });
1260
+ return tally.summary(outcome);
1261
+ }
1262
+ var Tally = class {
1263
+ raised = false;
1264
+ testFailures = 0;
1265
+ exitCode = null;
1266
+ testsRan = false;
1267
+ testsStopped = false;
1268
+ stopReached = false;
1269
+ counting(onEvent) {
1270
+ return (event) => {
1271
+ if (event.kind === "error") {
1272
+ this.raised = true;
1273
+ } else if (event.kind === "testReport") {
1274
+ this.testsRan = true;
1275
+ this.testsStopped = event.stopped === true;
1276
+ this.testFailures += event.failed + event.errors;
1277
+ } else if (event.kind === "done") {
1278
+ if (event.exitCode !== void 0) this.exitCode = event.exitCode;
1279
+ if (event.stopped) this.stopReached = true;
1280
+ }
1281
+ onEvent(event);
1282
+ };
1283
+ }
1284
+ summary(outcome) {
1285
+ return {
1286
+ outcome,
1287
+ raised: this.raised,
1288
+ testFailures: this.testFailures,
1289
+ exitCode: this.exitCode
1290
+ };
1291
+ }
1292
+ };
1293
+ function sayWhyTestsStopped(host, tally) {
1294
+ if (tally.testsRan) {
1295
+ if (tally.testsStopped) host.say(STOPPED.tests, "problem");
1296
+ return;
1297
+ }
1298
+ if (host.stopRequested()) {
1299
+ host.say(STOPPED.program, "problem");
1300
+ } else if (tally.exitCode !== null) {
1301
+ host.say("The tests were not run: the program ended itself first, with `sys.exit()`.", "problem");
1302
+ } else if (tally.raised) {
1303
+ host.say("The tests were not run, because of the error above.", "problem");
1304
+ }
1305
+ }
1306
+ async function staticChecks(runtime, host, code, fileName, level, sessionKey) {
1307
+ if (!levelHasStaticChecks(level)) {
1308
+ return "passed";
1309
+ }
1310
+ const input = sessionKey !== null;
1311
+ host.status("Checking...");
1312
+ let findings;
876
1313
  try {
877
- entries = await fs.readdir(folder, { withFileTypes: true });
878
- } catch {
879
- return [];
1314
+ findings = await runtime.staticAnalyze({
1315
+ code,
1316
+ fileName,
1317
+ level,
1318
+ ...input ? { sessionKey } : {}
1319
+ });
1320
+ } catch (err) {
1321
+ if (err instanceof StoppedError) {
1322
+ host.say(input ? STOPPED.input : STOPPED.beforeStart, "problem");
1323
+ return "stopped";
1324
+ }
1325
+ host.say(`Static analysis failed (${errorText(err)}). Running anyway.`, "note");
1326
+ return "passed";
880
1327
  }
881
- const candidates = [];
882
- for (const entry of entries) {
883
- if (!entry.isFile() || !isMountableName(entry.name)) {
884
- continue;
1328
+ host.staticFindings(findings);
1329
+ const errors = findings.filter((finding) => finding.severity === "error").length;
1330
+ if (errors === 0) {
1331
+ return "passed";
1332
+ }
1333
+ host.say(
1334
+ `Static analysis found ${errors} problem${errors === 1 ? "" : "s"}. ` + (input ? "Your input was not run." : "The file was not run."),
1335
+ "problem"
1336
+ );
1337
+ return "blocked";
1338
+ }
1339
+ async function withFiles(runtime, host, fileName, beforeStart, run) {
1340
+ let mounted = null;
1341
+ let started = false;
1342
+ let lost = false;
1343
+ let selecting = null;
1344
+ const select = () => selecting ??= host.siblingFiles();
1345
+ const files = {
1346
+ async sources() {
1347
+ try {
1348
+ return pythonSources(await select());
1349
+ } catch (err) {
1350
+ if (err instanceof StoppedError) throw err;
1351
+ return [];
1352
+ }
1353
+ },
1354
+ async mount() {
1355
+ mounted = await mount(runtime, host, select);
885
1356
  }
886
- try {
887
- candidates.push({ name: entry.name, contents: await fs.readFile(path.join(folder, entry.name)) });
888
- } catch {
1357
+ };
1358
+ try {
1359
+ return await run(files, () => {
1360
+ started = true;
1361
+ });
1362
+ } catch (err) {
1363
+ lost = err instanceof PythonLostError;
1364
+ if (err instanceof StoppedError) {
1365
+ host.say(started ? STOPPED.thrown : beforeStart, "problem");
1366
+ return "stopped";
1367
+ }
1368
+ throw err;
1369
+ } finally {
1370
+ if (mounted !== null && !lost) {
1371
+ await writeBack(runtime, host, fileName, mounted);
1372
+ }
1373
+ }
1374
+ }
1375
+ function pythonSources(selection) {
1376
+ const decoder = new TextDecoder();
1377
+ return selection.files.filter((file) => file.name.endsWith(".py")).map((file) => ({
1378
+ name: file.name,
1379
+ text: typeof file.contents === "string" ? file.contents : decoder.decode(file.contents)
1380
+ }));
1381
+ }
1382
+ async function loadPackages(runtime, host, code, siblings) {
1383
+ if (!needsPackages(code, siblings)) {
1384
+ return;
1385
+ }
1386
+ host.status("Loading libraries...");
1387
+ try {
1388
+ await runtime.ensurePackages(code, siblings);
1389
+ } catch (err) {
1390
+ if (!(err instanceof StoppedError)) {
1391
+ host.say(`Could not load libraries (${errorText(err)}). Continuing; imports may fail.`, "note");
1392
+ }
1393
+ }
1394
+ }
1395
+ async function mount(runtime, host, select) {
1396
+ host.status("Loading files...");
1397
+ try {
1398
+ const selection = await select();
1399
+ await runtime.mountWorkspaceFiles(selection.files, selection.leftOut);
1400
+ for (const note of leftOutNotes(selection.leftOut, "loaded")) {
1401
+ host.say(note, "problem");
1402
+ }
1403
+ return selection;
1404
+ } catch (err) {
1405
+ if (err instanceof StoppedError) {
1406
+ throw err;
1407
+ }
1408
+ host.say(`Could not load files next to this script (${errorText(err)}). open() may fail.`, "note");
1409
+ return null;
1410
+ }
1411
+ }
1412
+ async function writeBack(runtime, host, fileName, mounted) {
1413
+ try {
1414
+ const changes = await runtime.collectWorkspaceFiles();
1415
+ if (changes.files.length === 0 && changes.deleted.length === 0) {
1416
+ return;
1417
+ }
1418
+ const { written, deleted, leftOut } = await host.writeBack(changes, mounted.loaded);
1419
+ if (written.length > 0) {
1420
+ host.say(`Saved ${written.join(", ")} next to ${fileName}.`, "note");
1421
+ }
1422
+ if (deleted.length > 0) {
1423
+ host.say(`Deleted ${deleted.join(", ")} next to ${fileName}.`, "note");
1424
+ }
1425
+ for (const note of leftOutNotes(leftOut, "saved")) {
1426
+ host.say(note, "problem");
1427
+ }
1428
+ } catch (err) {
1429
+ host.say(`Could not save files next to this script (${errorText(err)}).`, "problem");
1430
+ }
1431
+ }
1432
+ async function testsToRun(runtime, host, code) {
1433
+ try {
1434
+ if (!await runtime.hasTests(code)) {
1435
+ return false;
1436
+ }
1437
+ } catch (err) {
1438
+ if (!(err instanceof StoppedError)) {
1439
+ host.say(`Could not check for tests (${errorText(err)}). Skipping them.`, "note");
1440
+ }
1441
+ return false;
1442
+ }
1443
+ host.status("Loading pytest...");
1444
+ try {
1445
+ await runtime.ensurePytest();
1446
+ return true;
1447
+ } catch (err) {
1448
+ if (!(err instanceof StoppedError)) {
1449
+ host.say(`Could not load pytest (${errorText(err)}). Skipping tests.`, "note");
1450
+ }
1451
+ return false;
1452
+ }
1453
+ }
1454
+ function programEvents(host, fileName, level) {
1455
+ return (event) => {
1456
+ if (event.kind === "error") {
1457
+ host.runtimeFinding(findRuntimeFinding(fileName, level, event.error));
1458
+ } else if (event.kind === "testReport") {
1459
+ host.event(explainTestReport(event, fileName, level));
1460
+ } else {
1461
+ host.event(event);
889
1462
  }
1463
+ };
1464
+ }
1465
+ function stopped(host, text) {
1466
+ if (!host.stopRequested()) {
1467
+ return false;
890
1468
  }
891
- return selectMountableFiles(candidates);
1469
+ host.say(text, "problem");
1470
+ return true;
1471
+ }
1472
+
1473
+ // src/cli/bundleStore.ts
1474
+ var import_node_crypto = require("node:crypto");
1475
+ var fs = __toESM(require("node:fs/promises"));
1476
+ var import_node_os = require("node:os");
1477
+ var path = __toESM(require("node:path"));
1478
+ function cacheDir() {
1479
+ return path.join(cacheRoot(), "examplar");
1480
+ }
1481
+ function cacheRoot() {
1482
+ const usable = (dir) => dir && path.isAbsolute(dir) ? dir : void 0;
1483
+ const base = usable(process.env.PLL_CACHE_DIR) ?? usable(process.env.XDG_CACHE_HOME) ?? ((0, import_node_os.homedir)() ? path.join((0, import_node_os.homedir)(), ".cache") : (0, import_node_os.tmpdir)());
1484
+ return path.join(base, "pll-python");
1485
+ }
1486
+ function createFileStore(dir = cacheDir()) {
1487
+ const fileFor = (url) => path.join(dir, (0, import_node_crypto.createHash)("sha256").update(url).digest("hex").slice(0, 32) + ".json");
1488
+ return {
1489
+ async read(url) {
1490
+ try {
1491
+ const raw = await fs.readFile(fileFor(url), "utf8");
1492
+ const entry = JSON.parse(raw);
1493
+ if (entry.url !== void 0 && entry.url !== url) return void 0;
1494
+ return typeof entry.json === "string" ? { json: entry.json, etag: entry.etag } : void 0;
1495
+ } catch {
1496
+ return void 0;
1497
+ }
1498
+ },
1499
+ async write(url, entry) {
1500
+ await fs.mkdir(dir, { recursive: true });
1501
+ const body = JSON.stringify({ url, json: entry.json, etag: entry.etag });
1502
+ const target = fileFor(url);
1503
+ const temp = `${target}.${process.pid}.tmp`;
1504
+ await fs.writeFile(temp, body, "utf8");
1505
+ await fs.rename(temp, target);
1506
+ }
1507
+ };
1508
+ }
1509
+
1510
+ // src/cli/files.ts
1511
+ var fs2 = __toESM(require("node:fs/promises"));
1512
+ var path2 = __toESM(require("node:path"));
1513
+ function siblingFolder(scriptPath) {
1514
+ const folder = path2.dirname(path2.resolve(scriptPath));
1515
+ const at = (name) => path2.join(folder, ...name.split("/"));
1516
+ return {
1517
+ async files() {
1518
+ const out = [];
1519
+ const seen = /* @__PURE__ */ new Set();
1520
+ const walk = async (dir, prefix) => {
1521
+ const real = await fs2.realpath(dir);
1522
+ if (seen.has(real)) return;
1523
+ seen.add(real);
1524
+ for (const name of await fs2.readdir(dir)) {
1525
+ const full = path2.join(dir, name);
1526
+ const stat2 = await fs2.stat(full).catch(() => null);
1527
+ if (stat2 === null) continue;
1528
+ if (stat2.isDirectory()) {
1529
+ if (isWalkedFolder(name)) await walk(full, `${prefix}${name}/`);
1530
+ } else if (stat2.isFile()) {
1531
+ out.push({ name: `${prefix}${name}`, size: stat2.size, mtimeMs: stat2.mtimeMs });
1532
+ }
1533
+ }
1534
+ };
1535
+ await walk(folder, "");
1536
+ return out;
1537
+ },
1538
+ read: (name) => fs2.readFile(at(name)),
1539
+ async stat(name) {
1540
+ const stat2 = await fs2.stat(at(name)).catch(() => null);
1541
+ return stat2 === null ? null : { size: stat2.size, mtimeMs: stat2.mtimeMs };
1542
+ },
1543
+ async write(name, bytes) {
1544
+ await fs2.mkdir(path2.dirname(at(name)), { recursive: true });
1545
+ await fs2.writeFile(at(name), bytes);
1546
+ },
1547
+ remove: (name) => fs2.unlink(at(name))
1548
+ };
892
1549
  }
893
- async function writeBackSiblingFiles(scriptPath, files) {
894
- const folder = path.dirname(path.resolve(scriptPath));
895
- const written = [];
896
- for (const file of selectWritebackFiles(files)) {
897
- try {
898
- if (typeof file.contents !== "string") {
899
- continue;
900
- }
901
- await fs.writeFile(path.join(folder, file.name), file.contents, "utf8");
902
- written.push(file.name);
903
- } catch {
904
- }
905
- }
906
- return written;
1550
+ function collectSiblingFiles(scriptPath) {
1551
+ return readSiblingFiles(siblingFolder(scriptPath));
1552
+ }
1553
+ function writeBackSiblingFiles(scriptPath, changes, loaded) {
1554
+ return writeSiblingFiles(siblingFolder(scriptPath), changes, loaded);
907
1555
  }
908
1556
 
909
1557
  // src/cli/run.ts
910
1558
  var EXIT = {
911
1559
  ok: 0,
912
- /** The program raised. */
1560
+ /** The program raised, or Ctrl+C stopped the run. */
913
1561
  programError: 1,
914
1562
  /** Level checks found problems, so the file was not run. */
915
1563
  blocked: 2,
@@ -919,104 +1567,140 @@ var EXIT = {
919
1567
  usage: 64
920
1568
  };
921
1569
  async function runFile(runtime, view, opts) {
922
- const fileName = path2.basename(opts.file);
923
- let source;
1570
+ const fileName = path3.basename(opts.file);
1571
+ let bytes;
924
1572
  try {
925
- source = await fs2.readFile(opts.file, "utf8");
1573
+ bytes = await fs3.readFile(opts.file);
926
1574
  } catch (err) {
927
- view.problem(`Cannot read ${opts.file}: ${errText(err)}`);
1575
+ view.problem(`pll: ${cannotRead(opts.file, err)}`);
928
1576
  return EXIT.usage;
929
1577
  }
930
- const level = parseLevel(source);
931
- view.note(`${fileName} [${level}]`);
932
- if (levelHasStaticChecks(level)) {
933
- const blocked = await staticChecks(runtime, view, source, fileName, level);
934
- if (blocked) {
935
- return EXIT.blocked;
936
- }
1578
+ const decoded = decodeSource(bytes, fileName);
1579
+ if ("problem" in decoded) {
1580
+ view.problem(decoded.problem);
1581
+ return EXIT.programError;
937
1582
  }
938
- await ensurePackages(runtime, view, source);
939
- try {
940
- await runtime.mountWorkspaceFiles(await collectSiblingFiles(opts.file));
941
- } catch (err) {
942
- view.note(`Could not load files next to this script (${errText(err)}).`);
1583
+ const source = decoded.text;
1584
+ const start = await startPython(runtime, opts.stopRequested ?? (() => false));
1585
+ if (start === "stopped") {
1586
+ view.problem("Stopped before the program started. Nothing was run.");
1587
+ return EXIT.programError;
943
1588
  }
944
- const onEvent = (event) => {
945
- if (event.kind === "error") {
946
- const finding = findingForErrorEvent(event, source, fileName, level);
947
- if (finding) {
948
- view.runtimeFinding(finding);
949
- return;
950
- }
951
- }
952
- view.handle(event);
1589
+ if (start !== "started") {
1590
+ view.problem(`pll: Python could not start: ${start.problem}`);
1591
+ return EXIT.usage;
1592
+ }
1593
+ const host = {
1594
+ level: (level) => view.note(`${fileName} [${level}]`),
1595
+ staticFindings: (findings) => view.findings(findings),
1596
+ runtimeFinding: (finding) => view.runtimeFinding(finding),
1597
+ event: (event) => view.handle(event),
1598
+ say: (text, kind) => kind === "problem" ? view.problem(text) : view.note(text),
1599
+ status: () => void 0,
1600
+ stopRequested: opts.stopRequested ?? (() => false),
1601
+ siblingFiles: () => collectSiblingFiles(opts.file),
1602
+ writeBack: (changes, loaded) => writeBackSiblingFiles(opts.file, changes, loaded),
1603
+ examplarCard: (card) => view.examplarCard(card)
953
1604
  };
954
- if (opts.runTests) {
955
- await maybeRunTests(runtime, view, source, fileName, level, onEvent);
1605
+ const summary = await runFilePlan(runtime, host, {
1606
+ code: source,
1607
+ fileName,
1608
+ sessionKey: opts.file,
1609
+ runTests: opts.runTests,
1610
+ testsOfIncompleteFile: true,
1611
+ bundles: createFileStore()
1612
+ });
1613
+ return exitCodeOf(summary);
1614
+ }
1615
+ function exitCodeOf(summary) {
1616
+ if (summary.outcome === "blocked") return EXIT.blocked;
1617
+ if (summary.outcome === "stopped" || summary.raised) return EXIT.programError;
1618
+ if (summary.exitCode !== null && processStatus(summary.exitCode) !== 0) {
1619
+ return processStatus(summary.exitCode);
956
1620
  }
957
- await runtime.runFile({ code: source, fileName, sessionKey: opts.file, level }, onEvent);
958
- await writeBack(runtime, view, opts.file);
959
- if (view.sawError) return EXIT.programError;
960
- if (view.testFailures > 0) return EXIT.testsFailed;
1621
+ if (summary.testFailures > 0) return EXIT.testsFailed;
961
1622
  return EXIT.ok;
962
1623
  }
963
- async function staticChecks(runtime, view, source, fileName, level) {
964
- let raw;
965
- try {
966
- raw = await runtime.staticAnalyze({ code: source, fileName, level });
967
- } catch (err) {
968
- view.note(`Static analysis failed (${errText(err)}); running anyway.`);
969
- return false;
970
- }
971
- const findings = enrichStaticFindings(raw, level, fileName);
972
- if (findings.length === 0) {
973
- return false;
974
- }
975
- view.findings(findings);
976
- view.problem(`Static analysis found ${findings.length} problem(s). File not run.`);
977
- return true;
1624
+ function processStatus(code) {
1625
+ if (!Number.isSafeInteger(code)) return EXIT.programError;
1626
+ return (code % 256 + 256) % 256;
978
1627
  }
979
- async function ensurePackages(runtime, view, source) {
980
- if (!needsPackages(source)) {
981
- return;
982
- }
1628
+ async function startPython(runtime, stopRequested) {
1629
+ let watch;
1630
+ const stop = new Promise((resolve4) => {
1631
+ watch = setInterval(() => stopRequested() && resolve4("stopped"), 50);
1632
+ });
983
1633
  try {
984
- await runtime.ensurePackages(source);
985
- } catch (err) {
986
- const message = errText(err);
987
- if (!/syntaxerror|invalid syntax/i.test(message)) {
988
- view.note(`Could not load libraries (${message}). Continuing.`);
989
- }
1634
+ return await Promise.race([
1635
+ runtime.initialize().then(
1636
+ () => "started",
1637
+ (err) => ({ problem: errorText(err) })
1638
+ ),
1639
+ stop
1640
+ ]);
1641
+ } finally {
1642
+ clearInterval(watch);
990
1643
  }
991
1644
  }
992
- async function maybeRunTests(runtime, view, source, fileName, level, onEvent) {
1645
+ var CODING_RE = /^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)/;
1646
+ function decodeSource(bytes, fileName) {
993
1647
  try {
994
- if (!await runtime.hasTests(source)) {
995
- return;
996
- }
997
- await runtime.ensurePytest();
998
- } catch (err) {
999
- view.note(`Could not check for tests (${errText(err)}); skipping them.`);
1000
- return;
1648
+ return { text: new TextDecoder("utf-8", { fatal: true }).decode(bytes) };
1649
+ } catch {
1001
1650
  }
1002
- await runtime.runTests({ code: source, fileName, sessionKey: fileName, level }, onEvent);
1003
- }
1004
- async function writeBack(runtime, view, file) {
1005
- try {
1006
- const changed = await runtime.collectWorkspaceFiles();
1007
- if (changed.length === 0) {
1008
- return;
1009
- }
1010
- const written = await writeBackSiblingFiles(file, changed);
1011
- if (written.length > 0) {
1012
- view.note(`Saved ${written.join(", ")} next to ${path2.basename(file)}.`);
1651
+ const head = Array.from(bytes.subarray(0, 400), (b) => String.fromCharCode(b)).join("");
1652
+ const [first = "", second = ""] = head.split(/\r?\n/);
1653
+ const cookie = CODING_RE.exec(first) ?? (/^[ \t\f]*(#.*)?$/.test(first) ? CODING_RE.exec(second) : null);
1654
+ const encoding = cookie?.[1].toLowerCase().replace(/_/g, "-");
1655
+ if (encoding === "latin-1" || encoding === "iso-8859-1" || encoding === "latin1") {
1656
+ return { text: Array.from(bytes, (b) => String.fromCharCode(b)).join("") };
1657
+ }
1658
+ if (encoding !== void 0) {
1659
+ try {
1660
+ return { text: new TextDecoder(encoding, { fatal: true }).decode(bytes) };
1661
+ } catch {
1662
+ const named = cookie?.[1];
1663
+ return { problem: `SyntaxError: ${fileName} says it is in ${named}, but cannot be read as that.` };
1013
1664
  }
1014
- } catch (err) {
1015
- view.note(`Could not save files next to this script (${errText(err)}).`);
1016
1665
  }
1666
+ const at = firstInvalidUtf8(bytes);
1667
+ const byte = bytes[at.index].toString(16);
1668
+ return {
1669
+ problem: `SyntaxError: ${fileName} is not saved as UTF-8 (byte 0x${byte} on line ${at.line}). Save it as UTF-8, or declare its encoding on its first line: \`# -*- coding: latin-1 -*-\`.`
1670
+ };
1017
1671
  }
1018
- function errText(err) {
1019
- return err instanceof Error ? err.message : String(err);
1672
+ function firstInvalidUtf8(bytes) {
1673
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1674
+ let low = 0;
1675
+ let high = bytes.length;
1676
+ while (low < high) {
1677
+ const mid = Math.ceil((low + high) / 2);
1678
+ try {
1679
+ decoder.decode(bytes.subarray(0, mid));
1680
+ low = mid;
1681
+ } catch {
1682
+ high = mid - 1;
1683
+ }
1684
+ }
1685
+ let index = Math.min(low, bytes.length - 1);
1686
+ while (index < bytes.length - 1 && bytes[index] < 128) index++;
1687
+ let line = 1;
1688
+ for (let i = 0; i < index; i++) if (bytes[i] === 10) line++;
1689
+ return { index, line };
1690
+ }
1691
+ function cannotRead(file, err) {
1692
+ const shown = path3.relative(process.cwd(), file) || file;
1693
+ switch (err.code) {
1694
+ case "ENOENT":
1695
+ return `cannot open ${shown}: there is no such file`;
1696
+ case "EISDIR":
1697
+ return `${shown} is a folder, not a file`;
1698
+ case "EACCES":
1699
+ case "EPERM":
1700
+ return `cannot open ${shown}: permission denied`;
1701
+ default:
1702
+ return `cannot open ${shown}: ${errorText(err)}`;
1703
+ }
1020
1704
  }
1021
1705
 
1022
1706
  // src/cli/examplar.ts
@@ -1043,66 +1727,60 @@ The bytecode is compiled by the Pyodide this package pins, so it always
1043
1727
  matches the interpreter that will run it - which is the reason to build
1044
1728
  bundles with this tool rather than a local python.
1045
1729
 
1046
- -o FILE write here instead of stdout
1730
+ -o, --out FILE write here instead of stdout
1047
1731
  --verify TESTS check the bundle with your own suite: it must pass on
1048
1732
  every wheat and fail on every chaff. A chaff no test can
1049
1733
  catch is a broken chaff, and better found now. Chaffs are
1050
1734
  only checked once the wheats pass, since a suite that is
1051
1735
  wrong fails on everything.
1052
1736
  `;
1737
+ var EXAMPLAR_USAGE_HINT = "Usage: pll examplar build <dir> [-o bundle.json] [--verify tests.py]. `pll examplar --help` says more.";
1738
+ var BUILD_OPTIONS = {
1739
+ out: { type: "string", short: "o", needs: "a file" },
1740
+ verify: { type: "string", needs: "a test file" },
1741
+ help: { type: "boolean", short: "h" }
1742
+ };
1053
1743
  function parseExamplarArgs(argv) {
1054
- const args = { help: false };
1744
+ if (argv[0] === "-h" || argv[0] === "--help") {
1745
+ return { help: true };
1746
+ }
1055
1747
  if (argv[0] !== "build") {
1056
- args.error = argv.length === 0 ? "examplar needs a subcommand" : `unknown examplar subcommand ${argv[0]}`;
1057
- if (argv[0] === "-h" || argv[0] === "--help") {
1058
- args.error = void 0;
1059
- args.help = true;
1060
- }
1061
- return args;
1748
+ return {
1749
+ help: false,
1750
+ error: argv.length === 0 ? "examplar needs a subcommand" : `unknown examplar subcommand ${argv[0]}`
1751
+ };
1062
1752
  }
1063
- for (let i = 1; i < argv.length; i++) {
1064
- const arg = argv[i];
1065
- if (arg === "-h" || arg === "--help") args.help = true;
1066
- else if (arg === "-o") {
1067
- args.out = argv[++i];
1068
- if (args.out === void 0) {
1069
- args.error = "-o needs a file";
1070
- return args;
1071
- }
1072
- } else if (arg === "--verify") {
1073
- args.verify = argv[++i];
1074
- if (args.verify === void 0) {
1075
- args.error = "--verify needs a test file";
1076
- return args;
1077
- }
1078
- } else if (arg.startsWith("-")) {
1079
- args.error = `unknown option ${arg}`;
1080
- return args;
1081
- } else if (args.dir === void 0) {
1082
- args.dir = arg;
1083
- } else {
1084
- args.error = `unexpected extra argument ${arg}`;
1085
- return args;
1086
- }
1753
+ const line = parseCommandLine(argv.slice(1), BUILD_OPTIONS);
1754
+ if ("error" in line) {
1755
+ return { help: false, error: line.error };
1087
1756
  }
1088
- if (!args.help && args.dir === void 0) {
1757
+ const { values, positionals } = line;
1758
+ const args = {
1759
+ dir: positionals[0],
1760
+ out: values.out,
1761
+ verify: values.verify,
1762
+ help: values.help === true
1763
+ };
1764
+ if (positionals.length > 1) {
1765
+ args.error = `unexpected extra argument ${positionals[1]}`;
1766
+ } else if (!args.help && args.dir === void 0) {
1089
1767
  args.error = "no directory given";
1090
1768
  }
1091
1769
  return args;
1092
1770
  }
1093
1771
  async function readDir(folder) {
1094
1772
  try {
1095
- return await fs3.readdir(folder, { withFileTypes: true });
1773
+ return await fs4.readdir(folder, { withFileTypes: true });
1096
1774
  } catch {
1097
1775
  throw new Error(`${folder} does not exist. See \`pll examplar --help\`.`);
1098
1776
  }
1099
1777
  }
1100
1778
  async function readWheats(dir) {
1101
- const folder = path3.join(dir, "wheats");
1779
+ const folder = path4.join(dir, "wheats");
1102
1780
  const out = {};
1103
1781
  for (const entry of await readDir(folder)) {
1104
1782
  if (!entry.isFile() || !entry.name.endsWith(".py")) continue;
1105
- out[entry.name.slice(0, -3)] = await fs3.readFile(path3.join(folder, entry.name), "utf8");
1783
+ out[entry.name.slice(0, -3)] = await fs4.readFile(path4.join(folder, entry.name), "utf8");
1106
1784
  }
1107
1785
  if (Object.keys(out).length === 0) {
1108
1786
  throw new Error(`${folder} has no .py files in it.`);
@@ -1110,7 +1788,7 @@ async function readWheats(dir) {
1110
1788
  return out;
1111
1789
  }
1112
1790
  async function readChaffs(dir) {
1113
- const folder = path3.join(dir, "chaffs");
1791
+ const folder = path4.join(dir, "chaffs");
1114
1792
  const out = {};
1115
1793
  const loose = [];
1116
1794
  for (const entry of await readDir(folder)) {
@@ -1119,11 +1797,11 @@ async function readChaffs(dir) {
1119
1797
  continue;
1120
1798
  }
1121
1799
  if (!entry.isDirectory()) continue;
1122
- const inner = path3.join(folder, entry.name);
1800
+ const inner = path4.join(folder, entry.name);
1123
1801
  const group = {};
1124
- for (const file of await fs3.readdir(inner, { withFileTypes: true })) {
1802
+ for (const file of await fs4.readdir(inner, { withFileTypes: true })) {
1125
1803
  if (!file.isFile() || !file.name.endsWith(".py")) continue;
1126
- group[file.name.slice(0, -3)] = await fs3.readFile(path3.join(inner, file.name), "utf8");
1804
+ group[file.name.slice(0, -3)] = await fs4.readFile(path4.join(inner, file.name), "utf8");
1127
1805
  }
1128
1806
  if (Object.keys(group).length > 0) {
1129
1807
  out[entry.name] = group;
@@ -1131,7 +1809,7 @@ async function readChaffs(dir) {
1131
1809
  }
1132
1810
  if (loose.length > 0) {
1133
1811
  throw new Error(
1134
- `${path3.join(folder, loose[0])} is not inside a function's directory. Chaffs go in \`chaffs/<function>/\`, named after the function they break - move it to \`chaffs/<function>/${loose[0]}\`.`
1812
+ `${path4.join(folder, loose[0])} is not inside a function's directory. Chaffs go in \`chaffs/<function>/\`, named after the function they break - move it to \`chaffs/<function>/${loose[0]}\`.`
1135
1813
  );
1136
1814
  }
1137
1815
  if (Object.keys(out).length === 0) {
@@ -1147,16 +1825,16 @@ async function runExamplar(runtime, view, argv) {
1147
1825
  }
1148
1826
  if (args.error !== void 0) {
1149
1827
  process.stderr.write(`pll: ${args.error}
1150
-
1151
- ${EXAMPLAR_USAGE}`);
1828
+ ${EXAMPLAR_USAGE_HINT}
1829
+ `);
1152
1830
  return EXIT.usage;
1153
1831
  }
1154
- const dir = path3.resolve(args.dir);
1832
+ const dir = path4.resolve(args.dir);
1155
1833
  let sources;
1156
1834
  try {
1157
1835
  sources = { wheats: await readWheats(dir), chaffs: await readChaffs(dir) };
1158
1836
  } catch (err) {
1159
- view.problem(`pll: ${err instanceof Error ? err.message : String(err)}`);
1837
+ view.problem(`pll: ${errorText(err)}`);
1160
1838
  return EXIT.usage;
1161
1839
  }
1162
1840
  const built = await runtime.examplarBuild(JSON.stringify(sources));
@@ -1178,616 +1856,370 @@ ${EXAMPLAR_USAGE}`);
1178
1856
  if (args.out === void 0) {
1179
1857
  process.stdout.write(json + "\n");
1180
1858
  } else {
1181
- await fs3.writeFile(path3.resolve(args.out), json + "\n", "utf8");
1859
+ await fs4.writeFile(path4.resolve(args.out), json + "\n", "utf8");
1182
1860
  view.note(`wrote ${args.out} (${json.length} bytes)`);
1183
1861
  }
1184
1862
  return EXIT.ok;
1185
- }
1186
- function caught(impl) {
1187
- return !impl.loaded || Object.values(impl.tests).some((t) => t.outcome !== "pass");
1188
- }
1189
- async function verify(runtime, view, bundle, testsPath) {
1190
- let testSource;
1191
- try {
1192
- testSource = await fs3.readFile(path3.resolve(testsPath), "utf8");
1193
- } catch (err) {
1194
- view.problem(`pll: cannot read ${testsPath}: ${err instanceof Error ? err.message : err}`);
1195
- return EXIT.usage;
1196
- }
1197
- try {
1198
- await runtime.ensurePytest();
1199
- } catch {
1200
- view.note("could not load pytest; assertion messages will be terse.");
1201
- }
1202
- const result = await runtime.examplarRun(
1203
- testSource,
1204
- JSON.stringify(bundle)
1205
- );
1206
- if (!result.ok) {
1207
- view.problem(`pll: ${result.error ?? "the bundle could not be run"}`);
1208
- return EXIT.usage;
1209
- }
1210
- let bad = 0;
1211
- view.problem(`verifying with ${path3.basename(testsPath)}:`);
1212
- for (const wheat of result.wheats ?? []) {
1213
- const failures = Object.entries(wheat.tests).filter(([, t]) => t.outcome !== "pass");
1214
- if (wheat.loaded && failures.length === 0) {
1215
- view.note(` ok wheat ${wheat.id}: all ${Object.keys(wheat.tests).length} pass`);
1216
- continue;
1217
- }
1218
- bad += 1;
1219
- view.problem(` BAD wheat ${wheat.id}: your own tests do not all pass on it`);
1220
- if (!wheat.loaded) {
1221
- view.problem(` ${wheat.error_type}: ${wheat.error_message}`);
1222
- }
1223
- for (const [name, t] of failures) {
1224
- view.problem(` ${name}: ${(t.message ?? t.outcome).split("\n")[0]}`);
1225
- }
1226
- }
1227
- for (const chaff of result.chaffs ?? []) {
1228
- const where = `chaff ${chaff.targets}/${chaff.id}`;
1229
- if (caught(chaff)) {
1230
- const by = Object.entries(chaff.tests).filter(([, t]) => t.outcome !== "pass").map(([name]) => name);
1231
- view.note(` ok ${where}: caught by ${by.join(", ")}`);
1232
- continue;
1233
- }
1234
- bad += 1;
1235
- view.problem(` BAD ${where}: no test catches it - it would never count`);
1236
- }
1237
- const attributed = Object.values(result.attribution ?? {});
1238
- for (const name of result.chaffs_skipped ?? []) {
1239
- if (attributed.some((names) => names.includes(name))) {
1240
- view.problem(` --- ${name} chaffs not checked: its tests have to pass first`);
1241
- continue;
1242
- }
1243
- bad += 1;
1244
- view.problem(
1245
- ` BAD your own suite has no tests for ${name}, so its chaffs are unproven`
1246
- );
1247
- }
1248
- if (bad > 0) {
1249
- view.problem(`pll: ${bad} problem(s) with this bundle; not written.`);
1250
- return EXIT.testsFailed;
1251
- }
1252
- view.problem("verified.");
1253
- return EXIT.ok;
1254
- }
1255
-
1256
- // src/cli/runtime.ts
1257
- var path5 = __toESM(require("node:path"));
1258
-
1259
- // src/desktop/pyodideRuntime.ts
1260
- var fs4 = __toESM(require("node:fs"));
1261
- var path4 = __toESM(require("node:path"));
1262
- var import_node_worker_threads = require("node:worker_threads");
1263
-
1264
- // src/common/deliverResult.ts
1265
- function deliverRunResult(result, onEvent, fileName) {
1266
- if (result.displays) {
1267
- for (const display of result.displays) {
1268
- deliverDisplay(display, onEvent, fileName);
1269
- }
1270
- }
1271
- if (result.result_repr !== null && result.result_repr !== void 0) {
1272
- onEvent({ kind: "result", repr: result.result_repr });
1273
- }
1274
- if (!result.ok && result.error_type) {
1275
- onEvent({
1276
- kind: "error",
1277
- errorType: result.error_type,
1278
- message: result.error_message ?? "",
1279
- traceback: result.traceback ?? "",
1280
- lineNumber: result.line_number,
1281
- column: result.column,
1282
- fileName
1283
- });
1284
- }
1285
- onEvent({ kind: "done" });
1286
- }
1287
- function deliverDisplay(display, onEvent, fileName) {
1288
- switch (display.type) {
1289
- case "stdout":
1290
- onEvent({ kind: "stdout", text: display.text });
1291
- break;
1292
- case "stderr":
1293
- onEvent({ kind: "stderr", text: display.text });
1294
- break;
1295
- case "image":
1296
- onEvent({
1297
- kind: "image",
1298
- svg: display.data,
1299
- width: display.width,
1300
- height: display.height,
1301
- source: fileName
1302
- });
1303
- break;
1304
- case "reactor":
1305
- onEvent({
1306
- kind: "reactor",
1307
- id: display.id,
1308
- title: display.title,
1309
- tickRate: display.tick_rate,
1310
- ticking: display.ticking,
1311
- wantsKeys: display.wants_keys,
1312
- wantsMouse: display.wants_mouse,
1313
- register: display.register,
1314
- frame: display.frame,
1315
- index: display.index,
1316
- length: display.length,
1317
- atEnd: display.at_end,
1318
- stopped: display.stopped,
1319
- valueRepr: display.value_repr
1320
- });
1321
- break;
1322
- case "table":
1323
- onEvent({
1324
- kind: "table",
1325
- columns: display.columns,
1326
- rows: display.rows,
1327
- rowCount: display.row_count,
1328
- shownCount: display.shown_count,
1329
- truncated: display.truncated,
1330
- source: fileName
1331
- });
1332
- break;
1333
- }
1334
- }
1335
- function asPlain(value) {
1336
- if (value instanceof Map) {
1337
- const obj = {};
1338
- for (const [k, v] of value.entries()) {
1339
- obj[String(k)] = asPlain(v);
1340
- }
1341
- return obj;
1342
- }
1343
- if (Array.isArray(value)) {
1344
- return value.map(asPlain);
1345
- }
1346
- return value;
1347
- }
1348
- function friendlyTypeCheckMessage(message, level) {
1349
- const marker = message.indexOf("TypeCheckError:");
1350
- if (marker < 0) {
1351
- return message;
1352
- }
1353
- const detail = message.slice(marker + "TypeCheckError:".length).trim();
1354
- if (!detail) {
1355
- return message;
1356
- }
1357
- const explanation = explainTypeCheckError(detail, null, level);
1358
- return [explanation.headline, ...explanation.howToFix.map((line) => `- ${line}`)].join(
1359
- "\n"
1360
- );
1361
- }
1362
- function adaptTestCase(raw, level) {
1363
- const row = asPlain(raw) ?? {};
1364
- const line = row.line_number;
1365
- const message = row.message == null ? null : String(row.message);
1366
- return {
1367
- name: String(row.name ?? ""),
1368
- outcome: String(row.outcome ?? "failed"),
1369
- lineNumber: typeof line === "number" ? line : null,
1370
- message: message === null ? null : friendlyTypeCheckMessage(message, level),
1371
- stdout: row.stdout == null ? null : String(row.stdout)
1372
- };
1373
- }
1374
- function deliverTestResult(result, onEvent, fileName, level) {
1375
- if (result.internal_error && result.error_type) {
1376
- onEvent({
1377
- kind: "error",
1378
- errorType: result.error_type,
1379
- message: result.error_message ?? "",
1380
- traceback: result.traceback ?? "",
1381
- lineNumber: result.line_number,
1382
- column: result.column,
1383
- fileName
1384
- });
1385
- onEvent({ kind: "done" });
1386
- return;
1387
- }
1388
- const tests = Array.isArray(result.tests) ? result.tests.map((row) => adaptTestCase(row, level)) : [];
1389
- onEvent({
1390
- kind: "testReport",
1391
- fileName,
1392
- passed: result.passed ?? 0,
1393
- failed: result.failed ?? 0,
1394
- skipped: result.skipped ?? 0,
1395
- errors: result.errors ?? 0,
1396
- tests
1397
- });
1398
- onEvent({ kind: "done" });
1399
- }
1400
-
1401
- // src/common/interruptBuffer.ts
1402
- var INTERRUPT_SAB_BYTES = 1;
1403
- var INTERRUPT_SIGINT = 2;
1404
- function tryCreateInterruptBuffer() {
1405
- try {
1406
- return new SharedArrayBuffer(INTERRUPT_SAB_BYTES);
1407
- } catch {
1408
- return null;
1409
- }
1410
- }
1411
- function signalInterrupt(sab) {
1412
- Atomics.store(new Uint8Array(sab), 0, INTERRUPT_SIGINT);
1413
- }
1414
-
1415
- // src/common/stdinBuffer.ts
1416
- var STDIN_SAB_BYTES = 64 * 1024;
1417
- var STDIN_STATE_INDEX = 0;
1418
- var STDIN_LENGTH_INDEX = 1;
1419
- var STDIN_PAYLOAD_OFFSET = 8;
1420
- var STDIN_STATE_LINE = 1;
1421
- var STDIN_STATE_EOF = 2;
1422
- function tryCreateStdinBuffer() {
1423
- try {
1424
- return new SharedArrayBuffer(STDIN_SAB_BYTES);
1425
- } catch {
1426
- return null;
1427
- }
1428
- }
1429
- function writeStdinLine(sab, line) {
1430
- const state = new Int32Array(sab);
1431
- if (line === null) {
1432
- Atomics.store(state, STDIN_STATE_INDEX, STDIN_STATE_EOF);
1433
- Atomics.notify(state, STDIN_STATE_INDEX);
1434
- return;
1435
- }
1436
- const encoded = new TextEncoder().encode(line);
1437
- const max = sab.byteLength - STDIN_PAYLOAD_OFFSET;
1438
- const n = Math.min(encoded.length, max);
1439
- const bytes = new Uint8Array(sab, STDIN_PAYLOAD_OFFSET);
1440
- bytes.set(encoded.subarray(0, n));
1441
- Atomics.store(state, STDIN_LENGTH_INDEX, n);
1442
- Atomics.store(state, STDIN_STATE_INDEX, STDIN_STATE_LINE);
1443
- Atomics.notify(state, STDIN_STATE_INDEX);
1444
- }
1445
-
1446
- // src/common/workerRuntime.ts
1447
- var WorkerPythonRuntime = class {
1448
- worker = null;
1449
- nextId = 1;
1450
- pending = /* @__PURE__ */ new Map();
1451
- initPromise = null;
1452
- stdinBuffer = null;
1453
- interruptBuffer = null;
1454
- stdinHandler = null;
1455
- /** Event sink for the in-flight runFile, so live displays can stream. */
1456
- live = null;
1457
- async initialize() {
1458
- if (!this.initPromise) {
1459
- this.initPromise = this.doInitialize();
1460
- }
1461
- await this.initPromise;
1462
- }
1463
- async doInitialize() {
1464
- const indexUrl = this.resolveIndexUrl();
1465
- this.worker = this.spawn({
1466
- onMessage: (msg) => this.handleMessage(msg),
1467
- onError: (err) => this.rejectAllPending(err)
1468
- });
1469
- this.stdinBuffer = tryCreateStdinBuffer();
1470
- this.interruptBuffer = tryCreateInterruptBuffer();
1471
- await this.request(
1472
- {
1473
- type: "init",
1474
- indexUrl,
1475
- ...this.stdinBuffer ? { stdinBuffer: this.stdinBuffer } : {},
1476
- ...this.interruptBuffer ? { interruptBuffer: this.interruptBuffer } : {}
1477
- },
1478
- "ready"
1479
- );
1480
- }
1481
- async runFile(request, onEvent) {
1482
- await this.initialize();
1483
- this.live = { onEvent, fileName: request.fileName };
1484
- try {
1485
- const { result } = await this.request({ type: "runFile", ...request }, "result");
1486
- deliverRunResult(result, onEvent, request.fileName);
1487
- } finally {
1488
- this.live = null;
1489
- }
1490
- }
1491
- async replEval(request, onEvent) {
1492
- await this.initialize();
1493
- const { result } = await this.request({ type: "replEval", ...request }, "result");
1494
- deliverRunResult(result, onEvent, "<repl>");
1495
- }
1496
- async checkReplComplete(code) {
1497
- await this.initialize();
1498
- const { result } = await this.request({ type: "checkSyntax", code }, "syntax");
1499
- return {
1500
- status: result.status,
1501
- errorType: result.error_type,
1502
- message: result.message,
1503
- lineNumber: result.lineno,
1504
- offset: result.offset
1505
- };
1506
- }
1507
- async hasTests(code) {
1508
- await this.initialize();
1509
- const { result } = await this.request({ type: "hasTests", code }, "hasTests");
1510
- return result;
1511
- }
1512
- async ensurePackages(code) {
1513
- await this.initialize();
1514
- await this.request({ type: "loadPackages", code }, "packagesReady");
1515
- }
1516
- async ensurePytest() {
1517
- await this.initialize();
1518
- await this.request({ type: "loadPytest" }, "pytestReady");
1519
- }
1520
- async runTests(request, onEvent) {
1521
- await this.initialize();
1522
- const { result } = await this.request(
1523
- {
1524
- type: "runTests",
1525
- code: request.code,
1526
- fileName: request.fileName,
1527
- level: request.level
1528
- },
1529
- "testResult"
1530
- );
1531
- deliverTestResult(result, onEvent, request.fileName, request.level);
1532
- }
1533
- async staticAnalyze(request) {
1534
- await this.initialize();
1535
- const { result } = await this.request({ type: "staticAnalyze", ...request }, "static");
1536
- return result ?? [];
1537
- }
1538
- async mountWorkspaceFiles(files) {
1539
- await this.initialize();
1540
- await this.request({ type: "mountWorkspace", files }, "workspaceReady");
1541
- }
1542
- async collectWorkspaceFiles() {
1543
- await this.initialize();
1544
- const { files } = await this.request({ type: "collectWorkspace" }, "workspaceFiles");
1545
- return files;
1546
- }
1547
- async examplarBuild(sources) {
1548
- await this.initialize();
1549
- const { result } = await this.request({ type: "examplarBuild", sources }, "examplarBuilt");
1550
- return result;
1863
+ }
1864
+ function caught(impl) {
1865
+ return Object.values(impl.tests).some((t) => t.outcome !== "pass");
1866
+ }
1867
+ async function verify(runtime, view, bundle, testsPath) {
1868
+ let testSource;
1869
+ try {
1870
+ testSource = await fs4.readFile(path4.resolve(testsPath), "utf8");
1871
+ } catch (err) {
1872
+ view.problem(`pll: cannot read ${testsPath}: ${errorText(err)}`);
1873
+ return EXIT.usage;
1551
1874
  }
1552
- async examplarRun(testSource, bundle) {
1553
- await this.initialize();
1554
- const { result } = await this.request(
1555
- { type: "examplarRun", testSource, bundle },
1556
- "examplarRan"
1557
- );
1558
- return result;
1875
+ try {
1876
+ await runtime.ensurePytest();
1877
+ } catch {
1878
+ view.note("could not load pytest; assertion messages will be terse.");
1559
1879
  }
1560
- async reactorStep(reactorId, event) {
1561
- await this.initialize();
1562
- const { result } = await this.request(
1563
- { type: "reactorStep", reactorId, event },
1564
- "reactorFrame"
1565
- );
1566
- return result;
1880
+ const result = await runtime.examplarRun(
1881
+ testSource,
1882
+ JSON.stringify(bundle),
1883
+ path4.basename(testsPath)
1884
+ );
1885
+ if (!result.ok) {
1886
+ view.problem(`pll: ${result.error ?? "the bundle could not be run"}`);
1887
+ return EXIT.usage;
1567
1888
  }
1568
- async reactorSeek(reactorId, index) {
1569
- await this.initialize();
1570
- const { result } = await this.request(
1571
- { type: "reactorSeek", reactorId, index },
1572
- "reactorFrame"
1889
+ let bad = 0;
1890
+ view.problem(`verifying with ${path4.basename(testsPath)}:`);
1891
+ if (result.timedOut) {
1892
+ const { kind, id, targets, test, seconds } = result.timedOut;
1893
+ const where = kind === "wheat" ? `wheat ${id}` : `chaff ${targets}/${id}`;
1894
+ view.problem(
1895
+ test !== null ? ` BAD ${where}: ${test} ran for more than ${seconds} seconds, which stops every student's check` : ` BAD ${where}: loading took more than ${seconds} seconds, which stops every student's check`
1573
1896
  );
1574
- return result;
1575
- }
1576
- async reactorDispose(reactorId) {
1577
- await this.initialize();
1578
- await this.request({ type: "reactorDispose", reactorId }, "reactorDisposed");
1897
+ view.problem("pll: the bundle has a problem; not written.");
1898
+ return EXIT.testsFailed;
1579
1899
  }
1580
- interrupt() {
1581
- if (!this.interruptBuffer) {
1582
- return false;
1900
+ for (const wheat of result.wheats ?? []) {
1901
+ const failures = Object.entries(wheat.tests).filter(([, t]) => t.outcome !== "pass");
1902
+ if (wheat.loaded && failures.length === 0) {
1903
+ view.note(` ok wheat ${wheat.id}: all ${Object.keys(wheat.tests).length} pass`);
1904
+ continue;
1583
1905
  }
1584
- signalInterrupt(this.interruptBuffer);
1585
- return true;
1586
- }
1587
- setStdinHandler(handler) {
1588
- this.stdinHandler = handler;
1589
- }
1590
- dispose() {
1591
- this.worker?.terminate();
1592
- this.worker = null;
1593
- this.initPromise = null;
1594
- this.stdinBuffer = null;
1595
- this.interruptBuffer = null;
1596
- this.live = null;
1597
- this.rejectAllPending(new Error("Runtime disposed"));
1598
- }
1599
- /** Send `msg` and resolve with the reply, asserting it is of type `expected`. */
1600
- async request(msg, expected) {
1601
- const worker = this.worker;
1602
- if (!worker) {
1603
- throw new Error("Worker not initialized");
1906
+ bad += 1;
1907
+ view.problem(` BAD wheat ${wheat.id}: your own tests do not all pass on it`);
1908
+ if (!wheat.loaded) {
1909
+ view.problem(` ${wheat.errorType}: ${wheat.errorMessage}`);
1604
1910
  }
1605
- const id = this.nextId++;
1606
- const reply = await new Promise((resolve4, reject) => {
1607
- this.pending.set(id, { resolve: resolve4, reject });
1608
- worker.post({ id, ...msg });
1609
- });
1610
- if (reply.type !== expected) {
1611
- throw new Error(`Pyodide worker replied "${reply.type}", expected "${expected}"`);
1911
+ for (const [name, t] of failures) {
1912
+ view.problem(` ${name}: ${(t.message ?? t.outcome).split("\n")[0]}`);
1612
1913
  }
1613
- return reply;
1614
1914
  }
1615
- handleMessage(msg) {
1616
- if (msg.type === "display") {
1617
- if (this.live) {
1618
- deliverDisplay(msg.payload, this.live.onEvent, this.live.fileName);
1619
- }
1620
- return;
1621
- }
1622
- if (msg.type === "stdinRequest") {
1623
- void this.handleStdinRequest();
1624
- return;
1625
- }
1626
- const pending = this.pending.get(msg.id);
1627
- if (!pending) {
1628
- return;
1915
+ for (const chaff of result.chaffs ?? []) {
1916
+ const where = `chaff ${chaff.targets}/${chaff.id}`;
1917
+ if (!chaff.loaded) {
1918
+ bad += 1;
1919
+ view.problem(` BAD ${where}: ${chaff.errorMessage}`);
1920
+ continue;
1629
1921
  }
1630
- this.pending.delete(msg.id);
1631
- if (msg.type === "error") {
1632
- pending.reject(new Error(msg.message));
1633
- } else {
1634
- pending.resolve(msg);
1922
+ if (caught(chaff)) {
1923
+ const by = Object.entries(chaff.tests).filter(([, t]) => t.outcome !== "pass").map(([name]) => name);
1924
+ view.note(` ok ${where}: caught by ${by.join(", ")}`);
1925
+ continue;
1635
1926
  }
1927
+ bad += 1;
1928
+ view.problem(` BAD ${where}: no test catches it - it would never count`);
1636
1929
  }
1637
- rejectAllPending(err) {
1638
- for (const pending of this.pending.values()) {
1639
- pending.reject(err);
1930
+ const attributed = Object.values(result.attribution ?? {});
1931
+ for (const name of result.chaffsSkipped ?? []) {
1932
+ if (attributed.some((names) => names.includes(name))) {
1933
+ view.problem(` --- ${name} chaffs not checked: its tests have to pass first`);
1934
+ continue;
1640
1935
  }
1641
- this.pending.clear();
1936
+ bad += 1;
1937
+ view.problem(
1938
+ ` BAD your own suite has no tests for ${name}, so its chaffs are unproven`
1939
+ );
1642
1940
  }
1643
- async handleStdinRequest() {
1644
- let line = null;
1645
- try {
1646
- line = this.stdinHandler ? await this.stdinHandler() : null;
1647
- } catch {
1648
- line = null;
1649
- }
1650
- if (this.stdinBuffer) {
1651
- writeStdinLine(this.stdinBuffer, line);
1652
- }
1941
+ if (bad > 0) {
1942
+ view.problem(`pll: ${bad} problem(s) with this bundle; not written.`);
1943
+ return EXIT.testsFailed;
1653
1944
  }
1654
- };
1945
+ view.problem("verified.");
1946
+ return EXIT.ok;
1947
+ }
1948
+
1949
+ // src/cli/runtime.ts
1950
+ var fs6 = __toESM(require("node:fs"));
1951
+ var path6 = __toESM(require("node:path"));
1655
1952
 
1656
1953
  // src/desktop/pyodideRuntime.ts
1657
- var DesktopPyodideRuntime = class extends WorkerPythonRuntime {
1658
- constructor(paths) {
1659
- super();
1660
- this.paths = paths;
1661
- }
1662
- paths;
1663
- resolveIndexUrl() {
1664
- const found = this.paths.indexUrlCandidates.find(
1665
- (dir) => fs4.existsSync(path4.join(dir, "pyodide.asm.wasm"))
1666
- );
1667
- if (!found) {
1668
- throw new Error(this.paths.missingAssetsHint);
1669
- }
1670
- return found;
1954
+ var fs5 = __toESM(require("node:fs"));
1955
+ var path5 = __toESM(require("node:path"));
1956
+ var import_node_worker_threads = require("node:worker_threads");
1957
+
1958
+ // src/run/python.ts
1959
+ var Python = class extends WorkerConnection {
1960
+ /** Run a file: its output, errors and test report arrive through `onEvent`, then `done`. */
1961
+ async runFile(request, onEvent) {
1962
+ const { code, fileName, sessionKey, level = DEFAULT_LEVEL, withTests = false } = request;
1963
+ const live = { onEvent, fileName };
1964
+ const result = await this.call("_pll_run_file", [code, fileName, sessionKey, level, withTests], live);
1965
+ deliverRunResult(result, onEvent, fileName);
1671
1966
  }
1672
- spawn(handlers) {
1673
- const worker = new import_node_worker_threads.Worker(this.paths.workerPath);
1674
- worker.on("message", (msg) => handlers.onMessage(msg));
1675
- worker.on(
1676
- "error",
1677
- (err) => handlers.onError(err instanceof Error ? err : new Error(String(err)))
1678
- );
1679
- return {
1680
- post: (msg) => worker.postMessage(msg),
1681
- terminate: () => void worker.terminate()
1682
- };
1967
+ /** Evaluate a prompt line against its file's session. */
1968
+ async replEval(request, onEvent) {
1969
+ const { code, sessionKey, level = DEFAULT_LEVEL } = request;
1970
+ const result = await this.call("_pll_repl_eval", [code, sessionKey, level]);
1971
+ deliverRunResult(result, onEvent, "<repl>");
1972
+ }
1973
+ /**
1974
+ * Whether `code` is a complete prompt input (`codeop.compile_command`).
1975
+ * `whole` judges several lines submitted at once as one input (`exec`),
1976
+ * not as a shell line.
1977
+ */
1978
+ checkReplComplete(code, whole = false) {
1979
+ return this.call("_pll_repl_check", [code, whole]);
1980
+ }
1981
+ /** True if `code` contains pytest-style `test_*` functions or `Test*` classes. */
1982
+ hasTests(code) {
1983
+ return this.call("_pll_has_tests", [code]);
1984
+ }
1985
+ /**
1986
+ * Load any Pyodide packages the code imports (pandas, numpy, ...) - it or
1987
+ * the student's `.py` files beside it - wherever the import is. A no-op
1988
+ * when nothing imported maps to a known package; needs network the first
1989
+ * time it loads one.
1990
+ */
1991
+ async ensurePackages(code, siblings = []) {
1992
+ const found = await this.call("_pll_package_imports", [
1993
+ code,
1994
+ JSON.stringify(siblings)
1995
+ ]);
1996
+ await this.request("load", found);
1997
+ }
1998
+ /** Load pytest; needs network the first time. */
1999
+ async ensurePytest() {
2000
+ await this.request("load", { modules: ["pytest"], network: false });
2001
+ }
2002
+ /**
2003
+ * The language level's static checks of a file, each finding worded.
2004
+ * None for a level without checks, or a file that does not parse - its
2005
+ * run says the SyntaxError.
2006
+ */
2007
+ staticAnalyze(request) {
2008
+ const { code, level, fileName, sessionKey = null } = request;
2009
+ return this.call("_pll_static_findings", [code, level, fileName, sessionKey]);
2010
+ }
2011
+ /**
2012
+ * Copy the files under the program's folder into Pyodide's work directory
2013
+ * so `open("data/cars.csv")` / `pd.read_csv("cars.csv")` see them.
2014
+ * Replaces any files from a previous mount. `leftOut` are the files a
2015
+ * limit kept back, so reading one says why it is not there.
2016
+ */
2017
+ async mountWorkspaceFiles(files, leftOut = []) {
2018
+ await this.request("mount", { files, leftOut });
2019
+ }
2020
+ /** What Python made, changed and deleted in the work directory since the last mount. */
2021
+ collectWorkspaceFiles() {
2022
+ return this.request("collect", {});
2023
+ }
2024
+ /**
2025
+ * Compile Examplar wheats and chaffs into a bundle. `sources` is the JSON
2026
+ * of `{wheats: {id: source}, chaffs: {id: source}}`. Authoring only; the
2027
+ * bytecode is produced by *this* interpreter so its magic number matches
2028
+ * by construction.
2029
+ */
2030
+ examplarBuild(sources) {
2031
+ return this.call("_pll_examplar_build", [sources]);
2032
+ }
2033
+ /**
2034
+ * Run a student's tests against every implementation in a bundle.
2035
+ * `fileName` is the student's file, whose own errors are told from the
2036
+ * implementation's.
2037
+ */
2038
+ examplarRun(testSource, bundle, fileName) {
2039
+ return this.call("_pll_examplar_run", [testSource, bundle, fileName]);
2040
+ }
2041
+ /**
2042
+ * Apply one event to a running reactor and get the frame it produced.
2043
+ * `event` is the JSON of `{kind, ...}`; see `Reactor.react`. What its
2044
+ * handlers print or show arrives through `output`, as a run's does.
2045
+ */
2046
+ reactorStep(reactorId, event, output) {
2047
+ return this.call("_pll_reactor_step", [reactorId, event], output);
2048
+ }
2049
+ /** Show an earlier or later recorded frame, applying no event. */
2050
+ reactorSeek(reactorId, index) {
2051
+ return this.call("_pll_reactor_seek", [reactorId, index]);
2052
+ }
2053
+ /** Forget a reactor, so its recorded states can be collected. */
2054
+ async reactorDispose(reactorId) {
2055
+ await this.call("_pll_reactor_dispose", [reactorId]);
2056
+ }
2057
+ /** Forget a session and the names its runs defined. */
2058
+ async endSession(sessionKey) {
2059
+ if (!this.started) return;
2060
+ await this.call("_pll_end_session", [sessionKey]);
1683
2061
  }
1684
2062
  };
2063
+ function deliverRunResult(result, onEvent, fileName) {
2064
+ for (const display of result.displays) {
2065
+ deliverDisplay(display, onEvent, fileName);
2066
+ }
2067
+ if (result.resultRepr !== null) {
2068
+ onEvent({ kind: "result", repr: result.resultRepr });
2069
+ }
2070
+ if (result.error !== null) {
2071
+ onEvent({ kind: "error", error: result.error, fileName });
2072
+ }
2073
+ if (result.tests !== null) {
2074
+ onEvent({ kind: "testReport", fileName, ...result.tests });
2075
+ }
2076
+ onEvent({
2077
+ kind: "done",
2078
+ ...result.exitCode !== null ? { exitCode: result.exitCode } : {},
2079
+ ...result.stopped ? { stopped: true } : {}
2080
+ });
2081
+ }
2082
+
2083
+ // src/desktop/pyodideRuntime.ts
2084
+ function desktopPython(paths) {
2085
+ return new Python({
2086
+ indexUrl() {
2087
+ const found = paths.indexUrlCandidates.find((dir) => fs5.existsSync(path5.join(dir, "pyodide.asm.wasm")));
2088
+ if (!found) {
2089
+ throw new Error(paths.missingAssetsHint);
2090
+ }
2091
+ return found;
2092
+ },
2093
+ packageCacheDir: paths.packageCacheDir,
2094
+ startTimeoutMs: paths.startTimeoutMs,
2095
+ spawn(handlers) {
2096
+ const worker = new import_node_worker_threads.Worker(paths.workerPath);
2097
+ worker.on("message", (msg) => handlers.onMessage(msg));
2098
+ worker.on("error", (err) => console.error("Pyodide worker failed:", err));
2099
+ worker.on("exit", () => handlers.onExit?.());
2100
+ return {
2101
+ post: (msg) => worker.postMessage(msg),
2102
+ terminate: () => void worker.terminate()
2103
+ };
2104
+ }
2105
+ });
2106
+ }
1685
2107
 
1686
2108
  // src/cli/runtime.ts
1687
2109
  function createCliRuntime() {
1688
- return new DesktopPyodideRuntime({
1689
- // `pyodide` is external in the bundle, so this resolves at run time
1690
- // from the installed package rather than being inlined.
1691
- indexUrlCandidates: [path5.dirname(require.resolve("pyodide"))],
1692
- workerPath: path5.join(__dirname, "worker.cjs"),
1693
- missingAssetsHint: "Could not find Pyodide's assets. Reinstall pll-python so its `pyodide` dependency is present."
2110
+ return desktopPython({
2111
+ indexUrlCandidates: pyodideDirectory(),
2112
+ workerPath: path6.join(__dirname, "worker.cjs"),
2113
+ missingAssetsHint: "Could not find Pyodide's assets. Reinstall pll-python so its `pyodide` dependency is present.",
2114
+ // Ample for a first start on a slow machine; an autograder waits no
2115
+ // longer than that for a Pyodide that will never start.
2116
+ startTimeoutMs: 12e4,
2117
+ packageCacheDir: (indexUrl) => writable(indexUrl) ? void 0 : userPackageCache(indexUrl)
1694
2118
  });
1695
2119
  }
2120
+ function writable(dir) {
2121
+ try {
2122
+ fs6.accessSync(dir, fs6.constants.W_OK);
2123
+ return true;
2124
+ } catch {
2125
+ return false;
2126
+ }
2127
+ }
2128
+ function userPackageCache(indexUrl) {
2129
+ let version = "unknown";
2130
+ try {
2131
+ const manifest = fs6.readFileSync(path6.join(indexUrl, "package.json"), "utf8");
2132
+ version = JSON.parse(manifest).version;
2133
+ } catch {
2134
+ }
2135
+ return path6.join(cacheRoot(), `pyodide-${version}`);
2136
+ }
2137
+ function pyodideDirectory() {
2138
+ try {
2139
+ return [path6.dirname(require.resolve("pyodide"))];
2140
+ } catch {
2141
+ return [];
2142
+ }
2143
+ }
1696
2144
 
1697
2145
  // src/cli/stdin.ts
1698
- var readline = __toESM(require("node:readline"));
1699
- function createLineReader() {
1700
- let rl = null;
1701
- let closed = false;
1702
- const pending = [];
2146
+ function createStdinReader() {
2147
+ let started = false;
2148
+ let ended = false;
2149
+ const waiting = [];
1703
2150
  const buffered = [];
1704
- function ensure() {
1705
- if (rl) return rl;
1706
- rl = readline.createInterface({ input: process.stdin, terminal: false });
1707
- rl.on("line", (line) => {
1708
- const next = pending.shift();
1709
- if (next) next(line);
1710
- else buffered.push(line);
1711
- });
1712
- const finish = () => {
1713
- closed = true;
1714
- while (pending.length > 0) pending.shift()?.(null);
1715
- };
1716
- rl.on("close", finish);
1717
- process.stdin.on("end", finish);
1718
- return rl;
2151
+ const onData = (chunk) => {
2152
+ const next = waiting.shift();
2153
+ if (next) next(chunk);
2154
+ else buffered.push(chunk);
2155
+ };
2156
+ const onEnd = () => {
2157
+ ended = true;
2158
+ while (waiting.length > 0) waiting.shift()?.(null);
2159
+ };
2160
+ function start() {
2161
+ if (started) return;
2162
+ started = true;
2163
+ process.stdin.on("data", onData);
2164
+ process.stdin.on("end", onEnd);
2165
+ process.stdin.on("error", onEnd);
1719
2166
  }
1720
2167
  return {
1721
2168
  read() {
1722
- if (buffered.length > 0) {
1723
- return Promise.resolve(buffered.shift() ?? null);
2169
+ const chunk = buffered.shift();
2170
+ if (chunk) {
2171
+ return Promise.resolve(chunk);
1724
2172
  }
1725
- if (closed) {
2173
+ if (ended) {
1726
2174
  return Promise.resolve(null);
1727
2175
  }
1728
- ensure();
1729
- return new Promise((resolve4) => pending.push(resolve4));
2176
+ start();
2177
+ return new Promise((resolve4) => waiting.push(resolve4));
1730
2178
  },
1731
2179
  close() {
1732
- closed = true;
1733
- rl?.close();
1734
- rl = null;
2180
+ ended = true;
2181
+ if (started) {
2182
+ process.stdin.off("data", onData);
2183
+ process.stdin.off("end", onEnd);
2184
+ process.stdin.off("error", onEnd);
2185
+ process.stdin.pause();
2186
+ }
1735
2187
  }
1736
2188
  };
1737
2189
  }
1738
2190
 
1739
2191
  // src/cli/view.ts
1740
- var fs5 = __toESM(require("node:fs"));
1741
- var path6 = __toESM(require("node:path"));
1742
-
1743
- // src/common/analyzers/findingLocation.ts
1744
- function findingLocation(finding) {
1745
- if (finding.lineNumber === null) {
1746
- return null;
1747
- }
1748
- if (finding.fileName === "<repl>" || finding.fileName === "<input>") {
1749
- return null;
1750
- }
1751
- const column = typeof finding.column === "number" && Number.isFinite(finding.column) ? finding.column : null;
1752
- const label = column !== null ? `${finding.fileName}:${finding.lineNumber}:${column + 1}` : `${finding.fileName}:${finding.lineNumber}`;
1753
- return {
1754
- fileName: finding.fileName,
1755
- line: finding.lineNumber,
1756
- column,
1757
- label
1758
- };
1759
- }
1760
-
1761
- // src/common/errorFormatter.ts
1762
- function formatFriendlyError(finding) {
1763
- const lines = [];
1764
- lines.push(`${finding.errorType}: ${finding.headline}`);
1765
- const location = findingLocation(finding);
1766
- if (location) {
1767
- lines.push(` at ${location.label}`);
1768
- }
1769
- if (finding.howToFix.length > 0) {
1770
- lines.push("");
1771
- lines.push("How to fix:");
1772
- for (const item of finding.howToFix) {
1773
- lines.push(` - ${item}`);
1774
- }
1775
- }
1776
- return lines;
1777
- }
1778
-
1779
- // src/cli/view.ts
2192
+ var fs7 = __toESM(require("node:fs"));
2193
+ var path7 = __toESM(require("node:path"));
1780
2194
  var ESC = String.fromCharCode(27);
1781
2195
  var CliView = class {
1782
2196
  constructor(opts) {
1783
2197
  this.opts = opts;
1784
2198
  }
1785
2199
  opts;
1786
- /** True once the program raised, so the exit code can reflect it. */
1787
- sawError = false;
1788
- /** Failing + erroring tests across all reports. */
1789
- testFailures = 0;
1790
2200
  imageCount = 0;
2201
+ /** Whether the program's last output on each stream left a line unfinished. */
2202
+ midLine = { stdout: false, stderr: false };
2203
+ /** The program's own output, as it wrote it. */
2204
+ program(stream, text) {
2205
+ if (text === "") return;
2206
+ process[stream].write(text);
2207
+ this.midLine[stream] = !text.endsWith("\n");
2208
+ }
2209
+ /**
2210
+ * PLL's own line, on a line of its own: after `print("Total:", end="")`
2211
+ * it would otherwise run on from the program's. Both streams are one
2212
+ * terminal when both are terminals.
2213
+ */
2214
+ say(text) {
2215
+ const oneTerminal = process.stdout.isTTY === true && process.stderr.isTTY === true;
2216
+ if (this.midLine.stderr || oneTerminal && this.midLine.stdout) {
2217
+ process.stderr.write("\n");
2218
+ this.midLine.stderr = false;
2219
+ if (oneTerminal) this.midLine.stdout = false;
2220
+ }
2221
+ process.stderr.write(text + "\n");
2222
+ }
1791
2223
  paint(code, text) {
1792
2224
  return this.opts.color ? `${ESC}[${code}m${text}${ESC}[0m` : text;
1793
2225
  }
@@ -1800,21 +2232,24 @@ var CliView = class {
1800
2232
  green(text) {
1801
2233
  return this.paint("32", text);
1802
2234
  }
2235
+ yellow(text) {
2236
+ return this.paint("33", text);
2237
+ }
1803
2238
  /** PLL's own commentary. Always stderr, silenced by --quiet. */
1804
2239
  note(text) {
1805
2240
  if (!this.opts.quiet) {
1806
- process.stderr.write(text + "\n");
2241
+ this.say(text);
1807
2242
  }
1808
2243
  }
1809
2244
  /** Something the user must see even under --quiet. */
1810
2245
  problem(text) {
1811
- process.stderr.write(text + "\n");
2246
+ this.say(text);
1812
2247
  }
1813
2248
  /** Static-analysis findings, in the same words the editor uses. */
1814
2249
  findings(findings) {
1815
2250
  for (const finding of findings) {
1816
2251
  const lines = formatFriendlyError(finding);
1817
- this.problem(this.red(lines[0]));
2252
+ this.problem(finding.severity === "warning" ? this.yellow(lines[0]) : this.red(lines[0]));
1818
2253
  for (const line of lines.slice(1)) {
1819
2254
  this.problem(line);
1820
2255
  }
@@ -1823,7 +2258,6 @@ var CliView = class {
1823
2258
  }
1824
2259
  /** A friendly finding for a runtime error, in place of the traceback. */
1825
2260
  runtimeFinding(finding) {
1826
- this.sawError = true;
1827
2261
  const lines = formatFriendlyError(finding);
1828
2262
  this.problem(this.red(lines[0]));
1829
2263
  for (const line of lines.slice(1)) {
@@ -1833,21 +2267,21 @@ var CliView = class {
1833
2267
  handle(event) {
1834
2268
  switch (event.kind) {
1835
2269
  case "stdout":
1836
- process.stdout.write(event.text);
2270
+ this.program("stdout", event.text);
1837
2271
  break;
1838
2272
  case "stderr":
1839
- process.stderr.write(event.text);
2273
+ this.program("stderr", event.text);
1840
2274
  break;
1841
2275
  case "result":
1842
2276
  if (event.repr !== null && event.repr !== void 0) {
1843
- process.stdout.write(event.repr + "\n");
2277
+ this.program("stdout", event.repr + "\n");
1844
2278
  }
1845
2279
  break;
1846
2280
  case "image":
1847
2281
  this.image(event.svg, event.width, event.height);
1848
2282
  break;
1849
2283
  case "table":
1850
- process.stdout.write(renderTable(event) + "\n");
2284
+ this.program("stdout", renderTable(event) + "\n");
1851
2285
  break;
1852
2286
  case "reactor": {
1853
2287
  const which = event.title === "reactor" ? "reactor" : `reactor "${event.title}"`;
@@ -1859,16 +2293,32 @@ var CliView = class {
1859
2293
  case "testReport":
1860
2294
  this.testReport(event);
1861
2295
  break;
1862
- case "error": {
1863
- this.sawError = true;
1864
- const body = event.traceback || `${event.errorType}: ${event.message}`;
1865
- process.stderr.write(this.red(body.trimEnd()) + "\n");
2296
+ case "error":
2297
+ this.problem(this.red(`${event.error.errorType}: ${event.error.message}`));
1866
2298
  break;
1867
- }
1868
2299
  case "done":
1869
2300
  break;
1870
2301
  }
1871
2302
  }
2303
+ /**
2304
+ * One card of the Examplar verdict: the same lines the panel shows, so a
2305
+ * test is named and a buggy implementation is given by its id - nothing
2306
+ * more, in either place. Commentary, so on stderr and silenced by --quiet.
2307
+ */
2308
+ examplarCard(card) {
2309
+ this.note(this.dim("examplar: ") + (card.card === "function" ? card.name : "your tests"));
2310
+ const tone = { good: this.green, bad: this.red, warn: this.yellow, note: this.dim };
2311
+ for (const block of card.body) {
2312
+ if (block.kind === "line") {
2313
+ this.note(" " + tone[block.tone].call(this, block.text));
2314
+ continue;
2315
+ }
2316
+ this.note(` ${block.name}`);
2317
+ for (const line of (block.detail ?? "").split("\n")) {
2318
+ if (line.trim()) this.note(this.dim(` ${line}`));
2319
+ }
2320
+ }
2321
+ }
1872
2322
  image(svg, width, height) {
1873
2323
  this.imageCount += 1;
1874
2324
  const dir = this.opts.saveImagesDir;
@@ -1878,33 +2328,53 @@ var CliView = class {
1878
2328
  );
1879
2329
  return;
1880
2330
  }
1881
- const target = path6.join(dir, `image-${this.imageCount}.svg`);
2331
+ const target = path7.join(dir, `image-${this.imageCount}.svg`);
1882
2332
  try {
1883
- fs5.mkdirSync(dir, { recursive: true });
1884
- fs5.writeFileSync(target, svg, "utf8");
2333
+ fs7.mkdirSync(dir, { recursive: true });
2334
+ fs7.writeFileSync(target, svg, "utf8");
1885
2335
  this.note(this.dim(`[image ${width}x${height} -> ${target}]`));
1886
2336
  } catch (err) {
1887
- this.problem(`Could not write ${target}: ${errText2(err)}`);
2337
+ this.problem(`Could not write ${target}: ${errorText(err)}`);
1888
2338
  }
1889
2339
  }
1890
2340
  testReport(event) {
1891
2341
  const bad = event.failed + event.errors;
1892
- this.testFailures += bad;
1893
- const summary = `${event.passed} passed` + (event.failed ? `, ${event.failed} failed` : "") + (event.errors ? `, ${event.errors} errored` : "") + (event.skipped ? `, ${event.skipped} skipped` : "");
1894
- this.problem((bad === 0 ? this.green("tests: ") : this.red("tests: ")) + summary);
2342
+ const parts = [
2343
+ ...event.passed ? [`${event.passed} passed`] : [],
2344
+ ...event.failed ? [`${event.failed} failed`] : [],
2345
+ ...event.errors ? [`${event.errors} error${event.errors === 1 ? "" : "s"}`] : [],
2346
+ ...event.skipped ? [`${event.skipped} skipped`] : [],
2347
+ ...event.stopped ? [event.stoppedIn ? `stopped during ${event.stoppedIn}` : "stopped before any test ran"] : []
2348
+ ];
2349
+ const summary = parts.length > 0 ? parts.join(", ") : "no tests collected";
2350
+ const label = event.stopped ? this.yellow("Tests: ") : bad === 0 ? this.green("Tests: ") : this.red("Tests: ");
2351
+ this.problem(label + summary);
1895
2352
  for (const test of event.tests) {
2353
+ const printed = (test.stdout ?? "").replace(/\s+$/, "");
2354
+ const where = test.lineNumber === null ? "" : ` (line ${test.lineNumber})`;
1896
2355
  if (test.outcome === "passed") {
1897
2356
  this.note(this.dim(` ok ${test.name}`));
2357
+ for (const line of printed ? printed.split("\n") : []) {
2358
+ this.note(this.dim(" " + line));
2359
+ }
2360
+ continue;
2361
+ }
2362
+ if (test.outcome === "skipped") {
2363
+ this.note(this.yellow(` skip ${test.name}${where}`));
2364
+ if (test.message) this.note(this.dim(` ${test.message}`));
2365
+ continue;
2366
+ }
2367
+ if (test.outcome === "stopped") {
2368
+ this.problem(this.yellow(` STOPPED ${test.name}${where}`));
1898
2369
  continue;
1899
2370
  }
1900
- const where = test.lineNumber === null ? "" : ` (line ${test.lineNumber})`;
1901
2371
  this.problem(this.red(` ${test.outcome.toUpperCase()} ${test.name}${where}`));
1902
- for (const line of (test.message ?? "").split("\n")) {
2372
+ const body = test.finding ? findingLines(test.finding) : (test.message ?? "").split("\n");
2373
+ for (const line of body) {
1903
2374
  if (line.trim()) {
1904
2375
  this.problem(" " + line);
1905
2376
  }
1906
2377
  }
1907
- const printed = (test.stdout ?? "").replace(/\s+$/, "");
1908
2378
  if (printed) {
1909
2379
  this.problem(this.dim(" output:"));
1910
2380
  for (const line of printed.split("\n")) {
@@ -1914,26 +2384,49 @@ var CliView = class {
1914
2384
  }
1915
2385
  }
1916
2386
  };
1917
- function errText2(err) {
1918
- return err instanceof Error ? err.message : String(err);
2387
+ function findingLines(finding) {
2388
+ return [
2389
+ `${finding.errorType}: ${finding.headline}`,
2390
+ ...finding.location ? [`at ${finding.location.label}`] : [],
2391
+ ...finding.howToFix.map((line) => `- ${line}`)
2392
+ ];
1919
2393
  }
1920
2394
  function renderTable(table) {
1921
- const widths = table.columns.map(
1922
- (name, i) => Math.max(name.length, ...table.rows.map((row) => (row[i] ?? "").length), 0)
2395
+ const columns = table.columns.map(visible);
2396
+ const rows = table.rows.map((row) => columns.map((_, i) => visible(row[i] ?? "")));
2397
+ const widths = columns.map(
2398
+ (name, i) => Math.max(displayWidth(name), ...rows.map((row) => displayWidth(row[i])), 0)
1923
2399
  );
1924
- const line = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
2400
+ const pad = (cell, width) => cell + " ".repeat(Math.max(0, width - displayWidth(cell)));
2401
+ const line = (cells) => cells.map((cell, i) => pad(cell, widths[i])).join(" ").trimEnd();
1925
2402
  const out = [
1926
- line(table.columns),
2403
+ line(columns),
1927
2404
  widths.map((w) => "-".repeat(w)).join(" ").trimEnd()
1928
2405
  ];
1929
- for (const row of table.rows) {
1930
- out.push(line(table.columns.map((_, i) => row[i] ?? "")));
2406
+ for (const row of rows) {
2407
+ out.push(line(row));
1931
2408
  }
1932
2409
  out.push(
1933
2410
  table.truncated ? `(${table.shownCount} of ${table.rowCount} rows)` : `(${table.rowCount} row${table.rowCount === 1 ? "" : "s"})`
1934
2411
  );
1935
2412
  return out.join("\n");
1936
2413
  }
2414
+ var ESCAPES = { " ": "\\t", "\n": "\\n", "\r": "\\r" };
2415
+ function visible(text) {
2416
+ return text.replace(
2417
+ /[\u0000-\u001f\u007f-\u009f]/g,
2418
+ (c) => ESCAPES[c] ?? `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`
2419
+ );
2420
+ }
2421
+ var WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]|[\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u;
2422
+ function displayWidth(text) {
2423
+ let width = 0;
2424
+ for (const char of text) {
2425
+ if (new RegExp("\\p{Mark}|\\u200d|\\ufe0f", "u").test(char)) continue;
2426
+ width += WIDE.test(char) ? 2 : 1;
2427
+ }
2428
+ return width;
2429
+ }
1937
2430
 
1938
2431
  // src/cli/main.ts
1939
2432
  var USAGE = `pll - run Python with PLL's language levels, outside the editor
@@ -1948,96 +2441,138 @@ Subcommands
1948
2441
  examplar build author an Examplar bundle; see \`pll examplar --help\`
1949
2442
 
1950
2443
  Options
1951
- --no-tests do not run the file's \`test_*\` functions first
2444
+ --no-tests do not run the file's \`test_*\` functions, which
2445
+ otherwise run once the program finishes
1952
2446
  --save-images <dir> write pictures there as .svg (they cannot be drawn
1953
2447
  in a terminal); without this, each one prints a note
1954
- -q, --quiet only the program's own output
2448
+ -q, --quiet only the program's own output and what went wrong:
2449
+ errors, failed tests, files not loaded or not saved
1955
2450
  --no-color never use ANSI colour
1956
2451
  -h, --help this message
1957
- -v, --version print the version
2452
+ -V, --version print the version
1958
2453
 
1959
2454
  The program's own stdout is the only thing on stdout, so it can be piped;
1960
2455
  everything PLL says about the run goes to stderr.
1961
2456
 
1962
2457
  Exit codes
1963
- 0 ran, tests passed 2 level checks blocked it
1964
- 1 the program raised 3 a test failed
2458
+ 0 ran, and any tests passed
2459
+ 1 the program raised, or was stopped
2460
+ 2 level checks found problems, so it was not run
2461
+ 3 a test failed
2462
+ 64 bad usage, or Python could not start
2463
+ 130 a second Ctrl+C gave up waiting for it to stop
2464
+ 141 what it printed could no longer be read (\`| head\`)
2465
+ 143 it was ended by SIGTERM
2466
+ A program that ends with \`sys.exit(n)\` exits with n, as it would under
2467
+ \`python\`; \`sys.exit("message")\` prints the message and exits with 1.
1965
2468
  `;
2469
+ var USAGE_HINT = "Usage: pll <file.py> [options]. `pll --help` says more.";
2470
+ function sayPackageNotes(runtime, view) {
2471
+ let saidDownload = false;
2472
+ runtime.setPackageNoteHandler((text, failed) => {
2473
+ if (failed) {
2474
+ view.problem(text);
2475
+ return;
2476
+ }
2477
+ if (/^Didn't find package \S+ locally/.test(text) && !saidDownload) {
2478
+ saidDownload = true;
2479
+ view.note("Downloading packages: the first time one is used, it needs the network.");
2480
+ }
2481
+ });
2482
+ }
1966
2483
  function colorDefault() {
1967
2484
  return process.stderr.isTTY === true && !process.env.NO_COLOR;
1968
2485
  }
1969
- function parseArgs(argv) {
2486
+ var OPTIONS = {
2487
+ "no-tests": { type: "boolean" },
2488
+ "save-images": { type: "string", needs: "a directory" },
2489
+ quiet: { type: "boolean", short: "q" },
2490
+ "no-color": { type: "boolean" },
2491
+ help: { type: "boolean", short: "h" },
2492
+ version: { type: "boolean", short: "V" }
2493
+ };
2494
+ var LEADING_OPTIONS = {
2495
+ quiet: OPTIONS.quiet,
2496
+ "no-color": OPTIONS["no-color"]
2497
+ };
2498
+ function parseArgs2(argv) {
2499
+ const line = parseCommandLine(argv, OPTIONS);
2500
+ if ("error" in line) {
2501
+ return {
2502
+ runTests: true,
2503
+ quiet: false,
2504
+ color: colorDefault(),
2505
+ help: false,
2506
+ version: false,
2507
+ error: line.error
2508
+ };
2509
+ }
2510
+ const { values, positionals } = line;
1970
2511
  const args = {
1971
- runTests: true,
1972
- quiet: false,
1973
- color: colorDefault(),
1974
- help: false,
1975
- version: false
2512
+ file: positionals[0],
2513
+ runTests: values["no-tests"] !== true,
2514
+ saveImagesDir: values["save-images"],
2515
+ quiet: values.quiet === true,
2516
+ color: colorDefault() && values["no-color"] !== true,
2517
+ help: values.help === true,
2518
+ version: values.version === true
1976
2519
  };
1977
- for (let i = 0; i < argv.length; i++) {
1978
- const arg = argv[i];
1979
- if (arg === "--no-tests") args.runTests = false;
1980
- else if (arg === "-q" || arg === "--quiet") args.quiet = true;
1981
- else if (arg === "--no-color") args.color = false;
1982
- else if (arg === "-h" || arg === "--help") args.help = true;
1983
- else if (arg === "-v" || arg === "--version") args.version = true;
1984
- else if (arg === "--save-images") {
1985
- const dir = argv[++i];
1986
- if (dir === void 0) {
1987
- args.error = "--save-images needs a directory";
1988
- return args;
1989
- }
1990
- args.saveImagesDir = dir;
1991
- } else if (arg.startsWith("-")) {
1992
- args.error = `unknown option ${arg}`;
1993
- return args;
1994
- } else if (args.file === void 0) {
1995
- args.file = arg;
1996
- } else {
1997
- args.error = `unexpected extra argument ${arg}`;
1998
- return args;
1999
- }
2000
- }
2001
- if (!args.help && !args.version && args.file === void 0) {
2520
+ if (positionals.length > 1) {
2521
+ args.error = `unexpected extra argument ${positionals[1]}`;
2522
+ } else if (!args.help && !args.version && args.file === void 0) {
2002
2523
  args.error = "no file given";
2003
- }
2004
- if (args.file !== void 0 && !args.file.endsWith(".py")) {
2524
+ } else if (args.file !== void 0 && !args.file.endsWith(".py")) {
2005
2525
  args.error = `${args.file} is not a .py file`;
2526
+ } else if (args.saveImagesDir !== void 0 && isFile(args.saveImagesDir)) {
2527
+ args.error = `--save-images needs a directory, and ${args.saveImagesDir} is a file`;
2006
2528
  }
2007
2529
  return args;
2008
2530
  }
2531
+ function isFile(at) {
2532
+ try {
2533
+ return !fs8.statSync(at).isDirectory();
2534
+ } catch {
2535
+ return false;
2536
+ }
2537
+ }
2009
2538
  async function main(argv) {
2010
- const firstPositional = argv.find((arg) => !arg.startsWith("-"));
2011
- if (firstPositional === "examplar") {
2012
- const at = argv.indexOf("examplar");
2013
- const leading = argv.slice(0, at);
2539
+ const at = subcommandIndex(argv, "examplar", OPTIONS);
2540
+ if (at !== -1) {
2541
+ const leading = parseCommandLine(argv.slice(0, at), LEADING_OPTIONS);
2542
+ if ("error" in leading) {
2543
+ process.stderr.write(`pll: ${leading.error} before examplar
2544
+ ${USAGE_HINT}
2545
+ `);
2546
+ return EXIT.usage;
2547
+ }
2014
2548
  const view2 = new CliView({
2015
- quiet: leading.includes("-q") || leading.includes("--quiet"),
2016
- color: colorDefault() && !leading.includes("--no-color")
2549
+ quiet: leading.values.quiet === true,
2550
+ color: colorDefault() && leading.values["no-color"] !== true
2017
2551
  });
2018
2552
  const runtime2 = createCliRuntime();
2553
+ sayPackageNotes(runtime2, view2);
2019
2554
  try {
2020
2555
  return await runExamplar(runtime2, view2, argv.slice(at + 1));
2021
2556
  } catch (err) {
2022
- view2.problem(`pll: ${err instanceof Error ? err.message : String(err)}`);
2557
+ view2.problem(`pll: ${errorText(err)}`);
2023
2558
  return EXIT.usage;
2024
2559
  } finally {
2025
2560
  runtime2.dispose();
2026
2561
  }
2027
2562
  }
2028
- const args = parseArgs(argv);
2563
+ const args = parseArgs2(argv);
2029
2564
  if (args.help) {
2030
2565
  process.stdout.write(USAGE);
2031
2566
  return EXIT.ok;
2032
2567
  }
2033
2568
  if (args.version) {
2034
- process.stdout.write("0.2.0\n");
2569
+ process.stdout.write("0.5.0\n");
2035
2570
  return EXIT.ok;
2036
2571
  }
2037
2572
  if (args.error !== void 0) {
2038
2573
  process.stderr.write(`pll: ${args.error}
2039
-
2040
- ${USAGE}`);
2574
+ ${USAGE_HINT}
2575
+ `);
2041
2576
  return EXIT.usage;
2042
2577
  }
2043
2578
  const view = new CliView({
@@ -2046,7 +2581,8 @@ ${USAGE}`);
2046
2581
  color: args.color
2047
2582
  });
2048
2583
  const runtime = createCliRuntime();
2049
- const reader = createLineReader();
2584
+ sayPackageNotes(runtime, view);
2585
+ const reader = createStdinReader();
2050
2586
  runtime.setStdinHandler(() => reader.read());
2051
2587
  let interrupted = false;
2052
2588
  const onSigint = () => {
@@ -2060,14 +2596,23 @@ ${USAGE}`);
2060
2596
  }
2061
2597
  };
2062
2598
  process.on("SIGINT", onSigint);
2599
+ const onBrokenPipe = (err) => {
2600
+ if (err.code === "EPIPE") {
2601
+ runtime.dispose();
2602
+ process.exit(141);
2603
+ }
2604
+ };
2605
+ process.stdout.on("error", onBrokenPipe);
2606
+ process.stderr.on("error", onBrokenPipe);
2063
2607
  try {
2064
2608
  return await runFile(runtime, view, {
2065
- file: path7.resolve(args.file),
2066
- runTests: args.runTests
2609
+ file: path8.resolve(args.file),
2610
+ runTests: args.runTests,
2611
+ stopRequested: () => interrupted
2067
2612
  });
2068
2613
  } catch (err) {
2069
- view.problem(`pll: ${err instanceof Error ? err.message : String(err)}`);
2070
- return EXIT.usage;
2614
+ view.problem(`pll: ${errorText(err)}`);
2615
+ return err instanceof PythonLostError ? EXIT.programError : EXIT.usage;
2071
2616
  } finally {
2072
2617
  process.off("SIGINT", onSigint);
2073
2618
  reader.close();
@@ -2084,4 +2629,3 @@ main(process.argv.slice(2)).then(
2084
2629
  process.exit(EXIT.usage);
2085
2630
  }
2086
2631
  );
2087
- //# sourceMappingURL=cli.cjs.map