Skip to content

externs: refuse to guess an undecidable module prefix - #50

Merged
simontreanor merged 1 commit into
mainfrom
fix/extern-import-diagnostic
Jul 31, 2026
Merged

externs: refuse to guess an undecidable module prefix#50
simontreanor merged 1 commit into
mainfrom
fix/extern-import-diagnostic

Conversation

@simontreanor

Copy link
Copy Markdown
Owner

Closes dogfooding finding #7 (see #45), implementing option (b) as you settled it.

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.

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.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 — verified on CPython 3.14: 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 now a compile error naming the fix:

error: cannot tell which part of `sys.stdout.flush` names the module: `stdout` is
lowercase, so it could be a submodule (like `os.path`) or an object (like
`sys.stdout`), and only the running environment knows which; declare it with
`extern import sys` — or `extern import sys.stdout` if `stdout` really is a module

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.

Two implementation points worth review:

  • The check lives in the type checker, not lowering, so pyfun check reports it and not just compile. PY_BUILTIN_TYPES moved to types so both phases share one list rather than drifting.
  • It does not stop the extern registering. A bad import would otherwise also report every use of the name as unbound, burying the real diagnostic under cascades.

What still needs no declaration: a capitalised segment settles itself (pathlib.Path.read_textPath is 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.pyfun used the undecidable shape and now carry extern 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.

`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.
@simontreanor
simontreanor merged commit 1e3ed9a into main Jul 31, 2026
11 checks passed
@simontreanor
simontreanor deleted the fix/extern-import-diagnostic branch July 31, 2026 18:24
@simontreanor simontreanor mentioned this pull request Jul 31, 2026
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant