diff --git a/.chronus/changes/fix-using-file-namespace-conflict-2026-7-5.md b/.chronus/changes/fix-using-file-namespace-conflict-2026-7-5.md new file mode 100644 index 00000000000..3471c981964 --- /dev/null +++ b/.chronus/changes/fix-using-file-namespace-conflict-2026-7-5.md @@ -0,0 +1,19 @@ +--- +changeKind: breaking +packages: + - "@typespec/compiler" +--- + +`using` statements declared before a file-level(blockless) namespace are now resolved from the global namespace instead of the file namespace, matching C#. + +```tsp +using TypeSpec.Http; // Now resolves the global `TypeSpec` namespace instead of `_Specs_.TypeSpec` +namespace _Specs_.TypeSpec.Foo; +``` + +A `using` declared after the file namespace, or inside a namespace block, is unchanged and still resolves relative to that namespace. Code relying on a relative name in a `using` written above the file namespace must now use the fully qualified name. + +```tsp +namespace MyOrg.Service; +using Models; // Still resolves to `MyOrg.Models` +``` diff --git a/packages/compiler/src/core/binder.ts b/packages/compiler/src/core/binder.ts index ae8cc92366a..14bc738ef0e 100644 --- a/packages/compiler/src/core/binder.ts +++ b/packages/compiler/src/core/binder.ts @@ -493,6 +493,10 @@ export function createBinder(program: Program): Binder { } function bindUsingStatement(statement: UsingStatementNode) { + // Track the scope in which the using was declared. A using declared at the file level, before any + // blockless namespace declaration, is not scoped to the file namespace and resolves from the global namespace. + mutate(statement).scopeNamespace = + scope.kind === SyntaxKind.NamespaceStatement ? scope : undefined; mutate(currentFile.usings).push(statement); } diff --git a/packages/compiler/src/core/checker.ts b/packages/compiler/src/core/checker.ts index 8d2203984d9..9d989e33d61 100644 --- a/packages/compiler/src/core/checker.ts +++ b/packages/compiler/src/core/checker.ts @@ -3331,10 +3331,13 @@ export function createChecker(program: Program, resolver: NameResolver): Checker } if (scope && scope.kind === SyntaxKind.TypeSpecScript) { - // check any blockless namespace decls - for (const ns of scope.inScopeNamespaces) { - const mergedSymbol = getMergedSymbol(ns.symbol)!; - addCompletions(mergedSymbol.exports); + // The name of a `using` declared before the file namespace resolves from the global namespace only. + if (!isUsingBeforeFileNamespace(identifier)) { + // check any blockless namespace decls + for (const ns of scope.inScopeNamespaces) { + const mergedSymbol = getMergedSymbol(ns.symbol)!; + addCompletions(mergedSymbol.exports); + } } // check "global scope" declarations @@ -7257,6 +7260,19 @@ export function createChecker(program: Program, resolver: NameResolver): Checker // If this was used to get a type this is invalid, only used for validation. return errorType; } + + /** + * Check whether the given identifier is part of the name of a `using` statement declared at the file level, + * before the blockless namespace declaration. + */ + function isUsingBeforeFileNamespace(identifier: IdentifierNode): boolean { + let node: Node | undefined = identifier.parent; + while (node?.kind === SyntaxKind.MemberExpression) { + node = node.parent; + } + return node?.kind === SyntaxKind.UsingStatement && node.scopeNamespace === undefined; + } + function checkDecorators( ctx: CheckContext, targetType: Type, diff --git a/packages/compiler/src/core/name-resolver.ts b/packages/compiler/src/core/name-resolver.ts index f170db4578e..ba980b2cddf 100644 --- a/packages/compiler/src/core/name-resolver.ts +++ b/packages/compiler/src/core/name-resolver.ts @@ -162,6 +162,13 @@ export interface NameResolver { interface ResolveTypReferenceOptions { resolveDecorators?: boolean; + + /** + * Skip the namespaces the current file is scoped to(via a blockless namespace) when resolving at the file level. + * Used to resolve `using` statements declared before the blockless namespace which, like in C#, resolve from the + * global namespace instead of the file namespace. + */ + skipFileNamespaces?: boolean; } // This needs to be global to be sure to not reallocate per program. @@ -1097,11 +1104,13 @@ export function createResolver(program: Program): NameResolver { if (!binding && scope && scope.kind === SyntaxKind.TypeSpecScript) { // check any blockless namespace decls - for (const ns of scope.inScopeNamespaces) { - const mergedSymbol = getMergedSymbol(ns.symbol); - binding = tableLookup(mergedSymbol.exports!, node, options.resolveDecorators); + if (!options.skipFileNamespaces) { + for (const ns of scope.inScopeNamespaces) { + const mergedSymbol = getMergedSymbol(ns.symbol); + binding = tableLookup(mergedSymbol.exports!, node, options.resolveDecorators); - if (binding) return resolvedResult(binding); + if (binding) return resolvedResult(binding); + } } // check "global scope" declarations @@ -1245,6 +1254,9 @@ export function createResolver(program: Program): NameResolver { const parentNs = using.parent!; const { finalSymbol: usedSym, resolutionResult: usedSymResult } = resolveTypeReference( using.name, + // A using declared before the blockless namespace is not scoped to the file namespace and, like in C#, + // resolves from the global namespace. + { skipFileNamespaces: using.scopeNamespace === undefined }, ); if (~usedSymResult & ResolutionResultFlags.Resolved) { continue; // Keep going and count on checker to report those errors. diff --git a/packages/compiler/src/core/types.ts b/packages/compiler/src/core/types.ts index 9f6a3ab9e48..92a0d21f665 100644 --- a/packages/compiler/src/core/types.ts +++ b/packages/compiler/src/core/types.ts @@ -1529,6 +1529,16 @@ export interface UsingStatementNode extends BaseNode { readonly kind: SyntaxKind.UsingStatement; readonly name: IdentifierNode | MemberExpressionNode; readonly parent?: TypeSpecScriptNode | NamespaceStatementNode; + + /** + * Namespace this using statement is scoped to. + * Set by the binder. + * + * This is the enclosing namespace for a using declared inside a namespace block, or the file(blockless) namespace + * if the using is declared after it. It is `undefined` when the using is declared at the file level, before any + * blockless namespace declaration, in which case its name resolves from the global namespace. + */ + readonly scopeNamespace?: NamespaceStatementNode; } export interface OperationSignatureDeclarationNode extends BaseNode { diff --git a/packages/compiler/test/checker/using.test.ts b/packages/compiler/test/checker/using.test.ts index a6a4c7669da..de202f9bbe5 100644 --- a/packages/compiler/test/checker/using.test.ts +++ b/packages/compiler/test/checker/using.test.ts @@ -363,6 +363,98 @@ describe("compiler: using statements", () => { `); }); + describe("relative to the file namespace", () => { + it("using declared before the file namespace resolves from the global namespace", async () => { + const { Y } = await Tester.files({ + "a.tsp": `namespace Global.Sub { model X { x: int32 } }`, + }).compile(t.code` + import "./a.tsp"; + using Global.Sub; + namespace Outer.Global.Foo; + model ${t.model("Y")} { ... X } + `); + + strictEqual(Y.properties.size, 1); + }); + + it("using declared before the file namespace picks the global namespace over the file namespace sibling", async () => { + const { Y } = await Tester.files({ + "a.tsp": ` + namespace A { model G { g: int32 } } + namespace Outer.A { model L { l: int32 } } + `, + }).compile(t.code` + import "./a.tsp"; + using A; + namespace Outer.Svc; + model ${t.model("Y")} { ... G } + `); + + strictEqual(Y.properties.size, 1); + }); + + it("using declared after the file namespace still resolves relative to it", async () => { + const { Y } = await Tester.files({ + "a.tsp": `namespace MyOrg.Models { model X { x: int32 } }`, + }).compile(t.code` + import "./a.tsp"; + namespace MyOrg.Svc; + using Models; + model ${t.model("Y")} { ... X } + `); + + strictEqual(Y.properties.size, 1); + }); + + it("using declared after the file namespace is shadowed by the file namespace sibling", async () => { + const diagnostics = await Tester.files({ + "a.tsp": ` + namespace A { model G {} } + namespace Outer.A { model L {} } + `, + }).diagnose(` + import "./a.tsp"; + namespace Outer.Svc; + using A; + model Y { ... G } + `); + + expectDiagnostics(diagnostics, { + code: "invalid-ref", + message: "Unknown identifier G", + }); + }); + + it("using inside a block namespace keeps resolving relative to it", async () => { + const { Y } = await Tester.files({ + "a.tsp": `namespace MyOrg.Models { model X { x: int32 } }`, + }).compile(t.code` + import "./a.tsp"; + namespace MyOrg.Svc { + using Models; + model ${t.model("Y")} { ... X } + } + `); + + strictEqual(Y.properties.size, 1); + }); + + it("reports an unknown identifier when a using before the file namespace only resolves relatively", async () => { + const diagnostics = await Tester.files({ + "a.tsp": `namespace MyOrg.Models { model X {} }`, + }).diagnose(` + import "./a.tsp"; + using Models; + namespace MyOrg.Svc; + `); + + expectDiagnostics(diagnostics, { + code: "invalid-ref", + message: "Unknown identifier Models", + }); + }); + }); + it("works when the using'd namespace is merged after the current namespace", async () => { await Tester.files({ "other.tsp": ` diff --git a/packages/compiler/test/server/completion.test.ts b/packages/compiler/test/server/completion.test.ts index 8256053198d..7ecfe5b1a18 100644 --- a/packages/compiler/test/server/completion.test.ts +++ b/packages/compiler/test/server/completion.test.ts @@ -1351,6 +1351,54 @@ describe("identifiers", () => { ); }); + it("does not complete file namespace members in a using declared before the file namespace", async () => { + const completions = await complete( + ` + import "./lib.tsp"; + using ┆M; + namespace MyOrg.Svc; + `, + undefined, + { + "test/lib.tsp": ` + namespace MyOrg.Models { model M {} } + namespace Standalone { model S {} } + `, + }, + ); + + ok( + completions.items.some((x) => x.label === "Standalone"), + "Should complete global namespaces", + ); + ok( + !completions.items.some((x) => x.label === "Models"), + "Should not complete `Models` as usings before the file namespace resolve from the global namespace", + ); + }); + + it("completes file namespace members in a using declared after the file namespace", async () => { + const completions = await complete( + ` + import "./lib.tsp"; + namespace MyOrg.Svc; + using ┆M; + `, + undefined, + { + "test/lib.tsp": ` + namespace MyOrg.Models { model M {} } + namespace Standalone { model S {} } + `, + }, + ); + + ok( + completions.items.some((x) => x.label === "Models"), + "Should complete `Models` as usings after the file namespace resolve relative to it", + ); + }); + it("completes qualified decorators", async () => { const js = { name: "test/decorators.js", diff --git a/website/src/content/docs/docs/language-basics/namespaces.md b/website/src/content/docs/docs/language-basics/namespaces.md index a80d243fda3..d1b6cc01e97 100644 --- a/website/src/content/docs/docs/language-basics/namespaces.md +++ b/website/src/content/docs/docs/language-basics/namespaces.md @@ -96,3 +96,23 @@ namespace Two { alias C = Two.A; // This is not valid alias C = Two.B; // This is valid ``` + +### Resolving the name of a `using` statement + +Where a `using` statement is written relative to a [file-level namespace](#file-level-namespaces) changes how its name is resolved. + +A `using` written **before** the blockless namespace declaration is not scoped to it, so its name is resolved from the global namespace: + +```typespec +using Common.Models; // `Common` here is the global `Common` namespace +namespace MyOrg.Common.Service; +``` + +A `using` written **after** the blockless namespace declaration is scoped to it, so its name is resolved relative to the file namespace first: + +```typespec +namespace MyOrg.Service; +using Models; // Resolves to `MyOrg.Models` +``` + +The same applies to a `using` written inside a namespace block, which resolves relative to that namespace.