Skip to content

what is the top-level scope in a source file, and what names are found there? #1136

Closed
@zygoloid

Description

@zygoloid

Originally raised in a Discord discussion.

Our status quo rule is that the top-level scope in a source file is the package scope -- that is the scope in which new unqualified declarations introduce names -- but that only names declared in the same source file are found by unqualified lookups in that scope. This leads to some surprising behavior where a (re)declaration in that scope can make information from a different library or source file visible:

package MyPackage library "X" api;
namespace MyNamespace;
namespace OurNamespace;
fn Foo() {}
fn MyNamespace.Bar() {}
fn OurNamespace.Baz() {}
package MyPackage impl;
import MyPackage library "X";
namespace OurNamespace;
fn DoStuff() {
  // Error, `Foo` not declared, did you mean `MyPackage.Foo`?
  Foo();
  // Error, `MyNamespace` not declared, did you mean `MyPackage.MyNamespace.Bar`?
  MyNamespace.Bar();
  // OK! Finds `OurNamespace` declared above, and finds `Baz` from library "X".
  OurNamespace.Baz();
}

And:

package MyPackage library "X" api;
class C {};
package MyPackage impl;
import MyPackage library "X";
// Error, what C?
let error: C;
class C;
// But after only a forward declaration it's now a complete type.
let ok: C;

It's been suggested that we should move away from this rule, and towards a model where unqualified declarations introduce names into the same scope that unqualified lookup looks into. Three models have been proposed:

  1. The file is in its own file scope, and the package is a scope within that, just like a namespace. Exported declarations are written as fn MyPackage.Foo(), and anything written at file scope without package name qualification is file local. This has the advantage of giving a default that's never silently the wrong thing (you don't accidentally export things / declare an external symbol), but the disadvantage of making the interface of a package harder to read by repeating the package name a lot.

  2. The file scope is the package scope. Imports consistently make new names appear in their declared scopes. So after an import of the same package, you may have new top-level names, because the top level is the package scope.

  3. The file scope is library scope. An impl file starts with the names from its api file visible. Imports don't change the set of names visible because you don't import your own library. The package scope is a scope within the library scope, just like a namespace. An exported declaration in an api file also injects a corresponding name into the package scope (as if by a namespace declaration for a namespace, and as if by an alias for anything else). This means that a type can only be declared in a single library within a package, never forward-declared in one library and defined in another, and that an overload set can't be split across libraries.

From discussion on Discord, we didn't like (1) due to the significant ceremony of mentioning your own package name whenever declaring any part of an exported entity. (3) is more complex than (2), and has more ceremony than (1) when mentioning parts of other libraries in your package; however, it gives us a library scope that permits a library to define its own private names that are shared within the library, and it gives us the property that every unqualified name finds a library-local result (unlike (1) and the status quo which give a file-local result).

Metadata

Metadata

Assignees

No one assigned

    Labels

    leads questionA question for the leads team

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions