externs: refuse to guess an undecidable module prefix - #50
Merged
Conversation
`extern flush : unit -> unit = sys.stdout.flush` emitted `import sys.stdout`, which raises ImportError. The call was always right; only the import line was wrong. The old rule took the maximal leading run of lowercase segments, following PEP 8, so it worked exactly when that run happened to be a real submodule: pathlib.Path.write_text stopped at the capital and worked, os.path.join worked because os.path is a module, sys.stdout.flush did not because stdout is an object. There is no rule that fixes this from the text alone. Whether urllib.request or sys.stdout is a module belongs to the running environment, and importing only the top-level package trades one broken case for another: `import urllib` leaves urllib.request unbound, while `import os` and `import sys` do bind their attributes. So the compiler stops guessing. A lowercase segment after the first, with no `extern import` covering the target, is a compile error naming the line to add. That fits the gatekeeper rule — nothing that type-checks should surprise you at runtime — and keeps defensive try/except out of the emitted Python, which was the alternative. The check lives in the type checker rather than in lowering, so `pyfun check` reports it too, and it does not stop the extern registering, so a bad import does not also report every use of the name as unbound. Two shipped externs in examples/interop/http_fetch.pyfun needed the declaration, which is the expected cost of the trade rather than a surprise: the diagnostic asks for one line and the example now carries it.
Merged
simontreanor
added a commit
that referenced
this pull request
Jul 31, 2026
Two dogfooding reports from real programs, and the standard-library sweep they triggered. Language: * a `type` declaration can name an imported type, bare or module-qualified (#36) — the one gap that changed a program's architecture rather than its phrasing, forcing two modules into one file * field access resolves from the base's type when it is known, so two records may share a field name without prefixes (#37) * parameters destructure: tuples (#38), records (#40), and `_` * a direct self tail call lowers to a loop, so an interactive turn loop no longer walks the stack (#39, #41) Standard library — about 115 new members, taking every module to the F# core set: List (#42), Seq (#44), Set and Map (#46), String (#47), Option and Result (#48), then a member-by-member FSharp.Core audit (#51). Every built-in member now carries a one-line description and its complexity in hover and completion (#43, #49), enforced by tests. Fixes: * `pyfun run` on a single file gives the program its own stdin, so an interactive program is runnable by the command whose job is running programs (#35) * a partially applied lambda closes over its argument instead of being wrapped, so `List.map ((+) 2)` emits `lambda b: 2 + b` (#52) * every multi-argument callback's scheme put the effect variable on the wrong arrows, so `List.fold` could never accept an effectful folder (#51) * `Seq.empty` lowered to a bare `iter()`, a TypeError (#51) One source-incompatible change, which is why this is 0.4.0 and not 0.3.1: a dotted `extern` target whose module prefix cannot be decided from the text is now a compile error naming the `extern import` to add (#50). `sys.stdout.flush` used to emit `import sys.stdout` and fail at runtime; declaring `extern import sys` fixes it.
This was referenced Aug 2, 2026
simontreanor
added a commit
that referenced
this pull request
Aug 2, 2026
The course was written against a smaller language and a smaller library, so parts of it now describe a compiler that no longer behaves that way, and parts of it leave a learner unprepared for an error the compiler will actually raise. Wrong, and verified against the 0.5.0 binary: - Lesson 8's "a `let` cannot destructure a constructor" demo quoted an error that does not fire. `let Some x = Some 1` parses as a *function* named `Some` with parameter `x`, so it type-checks; the demo needs `let (Some x) = …`, and the parenthesized form is now shown with the reason the brackets matter. - Lesson 17 said a recursive function always shares Python's stack. A saturated self tail call has lowered to `while True` since #39, so the lesson now shows a hundred thousand levels of `countDown` running, and keeps the stack warning for the shape that still has one (`fact`, whose call sits under a `*`). - Four quoted typed-hole notes listed suggestions the stdlib sweep changed. Lesson 9's was worse than stale: it taught "the compiler names your answer" above a note where `String.upper` no longer appears, having been pushed past the six-fit cap by the new `string -> string` members. That exercise now normalizes case with `String.lower`, which the note does name, and the lesson says plainly that the list is a shortlist. - Lesson 18 called `async` the third built-in builder; it is the fourth. - Lesson 6 quoted emitted Python without the `_pf_t0` temporary a record update actually emits. - Lesson 5 defined `sign`, which shadows the prelude's `sign` and gives a student the wrong hover. Missing, in the order a learner hits them: - `extern import` was taught nowhere, yet since #50 a target like `sys.stdout.flush` is a hard compile error asking for exactly that line. Lesson 12 now walks the error and the fix. - Instance-access externs (`= .with_name`) were referenced by lesson 23 as something lesson 12 covers. Lesson 12 did not. Now it does, and the cross-reference is true. - Lesson 15 showed only values crossing a module boundary. Types cross too, and since the dogfooding fix a record may name another module's type, which is what lets a program split along its data. - `Seq` was used in lesson 13 before ever being introduced, and laziness was the one collection idea the course did not teach. Lesson 7 introduces it, along with the two conventions the sweep settled: total functions, and `Option` from any accessor that can come up empty. - `Format` was invisible outside quoted hole notes, and `input` was untaught, so "how do I read from the user" had no answer on the site. One compiler fix came out of writing that up: `Format.thousands` and `Format.grouped` had each other's hover documentation (`thousands` formats a float with grouping, `grouped` an integer), and the padding pair's docs omitted their fill argument.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes dogfooding finding #7 (see #45), implementing option (b) as you settled it.
extern flush : unit -> unit = sys.stdout.flushemittedimport sys.stdout, which raisesImportError. The call was always right; only the import line was wrong.Why no rule fixes this from the text. The old heuristic took the maximal leading run of lowercase segments, following PEP 8, so it worked exactly when that run happened to be a real submodule. Whether
urllib.requestorsys.stdoutis a module belongs to the running environment, and importing only the top-level package trades one broken case for another — verified on CPython 3.14:import urllibleavesurllib.requestunbound, whileimport osandimport sysdo bind their attributes.So the compiler stops guessing. A lowercase segment after the first, with no
extern importcovering the target, is now a compile error naming the fix:That fits the gatekeeper rule (nothing that type-checks should surprise you at runtime) and keeps defensive
try/exceptout of the emitted Python, which was the alternative.Two implementation points worth review:
pyfun checkreports it and not justcompile.PY_BUILTIN_TYPESmoved totypesso both phases share one list rather than drifting.What still needs no declaration: a capitalised segment settles itself (
pathlib.Path.read_text—Pathis a class), and a two-segment target has nothing to guess (math.fabs).Cost of the trade, as expected: two shipped externs in
examples/interop/http_fetch.pyfunused the undecidable shape and now carryextern import urllib.request/extern import urllib.parse. That is the diagnostic doing its job on real code rather than a surprise.Tests: the two that pinned the old heuristic are rewritten — one now asserts the rejection and its fix-it text, the other the same target with the declaration still emitting
import urllib.request— plus two new cases covering the shapes that need no declaration. Full suite, clippy and fmt clean.