Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
71e8d8d
Read/Write-only extern properties through new Definition File syntax,…
PhoenixWhitefire Oct 31, 2025
9eec76e
Revert Makefile
PhoenixWhitefire Oct 31, 2025
f7642b1
Fix compound assignments, Fix incorrect error message, Add test
PhoenixWhitefire Oct 31, 2025
48d1197
Merge branch 'luau-lang:master' into readonly-extern-props
PhoenixWhitefire Nov 18, 2025
603fd3c
Added flags "LuauExternReadWriteAttributes" and "LuauTypeCheckerVecto…
PhoenixWhitefire Nov 19, 2025
311c361
Fixes missing closing scope, Fixes tests
PhoenixWhitefire Nov 19, 2025
cf13774
Fix failing tests, Remove `LuauTypeCheckerVectorLerp2` flag
PhoenixWhitefire Nov 19, 2025
6a44c46
Reduce the influence of the `LuauVectorLerp` flag
PhoenixWhitefire Nov 19, 2025
1530c29
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Nov 30, 2025
d3ff572
I should really stop making commits on my phone
PhoenixWhitefire Nov 30, 2025
6522851
Rename `AstDeclaredExternTypeProperty::propAccess` to just `::access`
PhoenixWhitefire Dec 1, 2025
adcda5d
Minor comment edits
PhoenixWhitefire Dec 1, 2025
c7ad2e1
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Dec 5, 2025
30649ad
Add `LuauLValueCompoundAssignmentVisitLhs` flag
PhoenixWhitefire Dec 9, 2025
4f1230a
Fix tests
PhoenixWhitefire Dec 9, 2025
e12151d
Add Parser test, Add improved error reporting
PhoenixWhitefire Dec 11, 2025
cffb41e
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Dec 13, 2025
2db3da2
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Jan 10, 2026
60c8fe5
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Jan 17, 2026
2ae047c
Add additional condition to keep non-flag-enabled codepath the same
PhoenixWhitefire Jan 17, 2026
97d4351
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Jan 28, 2026
d502f7c
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Jan 31, 2026
dd2490d
Add support for two different read and write types at the same time, …
PhoenixWhitefire Feb 3, 2026
b50890c
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Feb 7, 2026
b9cccc9
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Feb 24, 2026
bd89d33
Merge branch 'master' into readonly-extern-props
PhoenixWhitefire Mar 7, 2026
0cf70c7
Fix `externPropTy` -> `propTy`
PhoenixWhitefire Mar 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 65 additions & 16 deletions Analysis/src/ConstraintGenerator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ LUAU_FASTFLAGVARIABLE(LuauUdtfIndirectAliases)
LUAU_FASTFLAGVARIABLE(LuauDisallowRedefiningBuiltinTypes)
LUAU_FASTFLAGVARIABLE(LuauUnpackRespectsAnnotations)
LUAU_FASTFLAGVARIABLE(LuauForwardPolarityForFunctionTypes)
LUAU_FASTFLAG(LuauExternReadWriteAttributes)

namespace Luau
{
Expand Down Expand Up @@ -2051,16 +2052,16 @@ ControlFlow ConstraintGenerator::visit(const ScopePtr& scope, AstStatDeclareExte
}
}

for (const AstDeclaredExternTypeProperty& prop : declaredExternType->props)
for (const AstDeclaredExternTypeProperty& externProp : declaredExternType->props)
{
Name propName(prop.name.value);
TypeId propTy = resolveType(scope, prop.ty, /* inTypeArguments */ false, /* replaceErrorWithFresh */ false, /* initialPolarity */ Polarity::Mixed);
Name propName(externProp.name.value);
TypeId propTy = resolveType(scope, externProp.ty, /* inTypeArguments */ false, /* replaceErrorWithFresh */ false, /* initialPolarity */ Polarity::Mixed);

bool assignToMetatable = isMetamethod(propName);

// Function typeArguments always take 'self', but this isn't reflected in the
// parsed annotation. Add it here.
if (prop.isMethod)
if (externProp.isMethod)
{
if (FunctionType* ftv = getMutable<FunctionType>(propTy))
{
Expand All @@ -2072,9 +2073,9 @@ ControlFlow ConstraintGenerator::visit(const ScopePtr& scope, AstStatDeclareExte
FunctionDefinition defn;

defn.definitionModuleName = module->name;
defn.definitionLocation = prop.location;
defn.definitionLocation = externProp.location;
// No data is preserved for varargLocation
defn.originalNameLocation = prop.nameLocation;
defn.originalNameLocation = externProp.nameLocation;

ftv->definition = defn;
}
Expand All @@ -2084,11 +2085,30 @@ ControlFlow ConstraintGenerator::visit(const ScopePtr& scope, AstStatDeclareExte

if (props.count(propName) == 0)
{
props[propName] = {propTy, /*deprecated*/ false, /*deprecatedSuggestion*/ "", prop.location};
Property tableProp;

if (FFlag::LuauExternReadWriteAttributes)
{
if (externProp.access == AstTableAccess::Read)
tableProp = Property::readonly(propTy);
else if (externProp.access == AstTableAccess::Write)
tableProp = Property::writeonly(propTy);
else
tableProp = Property::rw(propTy);

tableProp.location = externProp.location;
}
else
{
tableProp = {propTy, /*deprecated*/ false, /*deprecatedSuggestion*/ "", externProp.location};
}

props[propName] = tableProp;
}
else
{
Luau::Property& prop = props[propName];
bool addedWriteTypeByOverload = false;

if (auto readTy = prop.readTy)
{
Expand All @@ -2110,14 +2130,30 @@ ControlFlow ConstraintGenerator::visit(const ScopePtr& scope, AstStatDeclareExte
}
else
{
reportError(
declaredExternType->location,
GenericError{format("Cannot overload read type of non-function class member '%s'", propName.c_str())}
);
if (FFlag::LuauExternReadWriteAttributes)
{
if (externProp.access == AstTableAccess::Write && !prop.writeTy.has_value())
{
prop.writeTy = propTy;
addedWriteTypeByOverload = true;
}
else
reportError(
declaredExternType->location,
GenericError{format("Cannot overload read type of non-function extern type member '%s'", propName.c_str())}
);
}
else
{
reportError(
declaredExternType->location,
GenericError{format("Cannot overload read type of non-function extern type member '%s'", propName.c_str())}
);
}
}
}

if (auto writeTy = prop.writeTy)
if (auto writeTy = prop.writeTy; writeTy && !addedWriteTypeByOverload)
{
// We special-case this logic to keep the intersection flat; otherwise we
// would create a ton of nested intersection typeArguments.
Expand All @@ -2137,10 +2173,23 @@ ControlFlow ConstraintGenerator::visit(const ScopePtr& scope, AstStatDeclareExte
}
else
{
reportError(
declaredExternType->location,
GenericError{format("Cannot overload write type of non-function class member '%s'", propName.c_str())}
);
if (FFlag::LuauExternReadWriteAttributes)
{
if (externProp.access == AstTableAccess::Read && !prop.readTy.has_value())
prop.readTy = propTy;
else
reportError(
declaredExternType->location,
GenericError{format("Cannot overload write type of non-function extern type member '%s'", propName.c_str())}
);
}
else
{
reportError(
declaredExternType->location,
GenericError{format("Cannot overload write type of non-function extern type member '%s'", propName.c_str())}
);
}
}
}
}
Expand Down
41 changes: 40 additions & 1 deletion Analysis/src/EmbeddedBuiltinDefinitions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
LUAU_FASTFLAGVARIABLE(LuauTypeCheckerUdtfRenameClassToExtern)
LUAU_FASTFLAGVARIABLE(LuauMorePermissiveNewtableType)
LUAU_FASTFLAGVARIABLE(LuauNewMathConstantsAnalysis)
LUAU_FASTFLAGVARIABLE(LuauTypeCheckerVectorReadOnly)

namespace Luau
{
Expand Down Expand Up @@ -330,6 +331,37 @@ declare buffer: {

static const char* const kBuiltinDefinitionVectorSrc = R"BUILTIN_SRC(

-- While vector would have been better represented as a built-in primitive type, type solver extern type handling covers most of the properties
declare extern type vector with
read x: number
read y: number
read z: number
end

declare vector: {
create: @checked (x: number, y: number, z: number?) -> vector,
magnitude: @checked (vec: vector) -> number,
normalize: @checked (vec: vector) -> vector,
cross: @checked (vec1: vector, vec2: vector) -> vector,
dot: @checked (vec1: vector, vec2: vector) -> number,
angle: @checked (vec1: vector, vec2: vector, axis: vector?) -> number,
floor: @checked (vec: vector) -> vector,
ceil: @checked (vec: vector) -> vector,
abs: @checked (vec: vector) -> vector,
sign: @checked (vec: vector) -> vector,
clamp: @checked (vec: vector, min: vector, max: vector) -> vector,
max: @checked (vector, ...vector) -> vector,
min: @checked (vector, ...vector) -> vector,
lerp: @checked (vec1: vector, vec2: vector, t: number) -> vector,

zero: vector,
one: vector,
}

)BUILTIN_SRC";

static const char* const kBuiltinDefinitionVectorSrc_DEPRECATED = R"BUILTIN_SRC(

-- While vector would have been better represented as a built-in primitive type, type solver extern type handling covers most of the properties
declare extern type vector with
x: number
Expand Down Expand Up @@ -374,7 +406,14 @@ std::string getBuiltinDefinitionSource()
result += kBuiltinDefinitionDebugSrc;
result += kBuiltinDefinitionUtf8Src;
result += kBuiltinDefinitionBufferSrc;
result += kBuiltinDefinitionVectorSrc;
if (FFlag::LuauTypeCheckerVectorReadOnly)
{
result += kBuiltinDefinitionVectorSrc;
}
else
{
result += kBuiltinDefinitionVectorSrc_DEPRECATED;
}

return result;
}
Expand Down
32 changes: 28 additions & 4 deletions Analysis/src/TypeChecker2.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ LUAU_FASTFLAG(LuauReworkInfiniteTypeFinder)
LUAU_FASTFLAG(LuauExternTypesNormalizeWithShapes)
LUAU_FASTFLAGVARIABLE(LuauCheckFunctionStatementTypes)
LUAU_FASTFLAGVARIABLE(LuauComparisonToNilsIsAlwaysOk)
LUAU_FASTFLAGVARIABLE(LuauLValueCompoundAssignmentVisitLhs)
LUAU_FASTFLAG(LuauExternReadWriteAttributes)

namespace Luau
{
Expand Down Expand Up @@ -2278,6 +2280,12 @@ TypeId TypeChecker2::visit(AstExprBinary* expr, AstNode* overrideKey)
expr->op != AstExprBinary::CompareNe)
inContext.emplace(&typeContext, TypeContext::Default);

if (FFlag::LuauLValueCompoundAssignmentVisitLhs)
{
if (overrideKey && overrideKey->is<AstStatCompoundAssign>())
visit(expr->left, ValueContext::LValue); // In compound assignments, the LHS is both read-from and written-to
}

visit(expr->left, ValueContext::RValue);
visit(expr->right, ValueContext::RValue);

Expand Down Expand Up @@ -3657,21 +3665,30 @@ void TypeChecker2::checkIndexTypeFromType(
{
if (propTypes.foundOneProp())
reportError(MissingUnionProperty{tableTy, propTypes.missingProp, prop}, location);
else if (!FFlag::LuauExternReadWriteAttributes && get<ExternType>(tableTy))
{
reportError(UnknownProperty{tableTy, prop}, location);
}
// For class LValues, we don't want to report an extension error,
// because extern typeArguments come into being with full knowledge of their
// shape. We instead want to report the unknown property error of
// the `else` branch.
else if (context == ValueContext::LValue && !get<ExternType>(tableTy))
else if (context == ValueContext::LValue)
{
const auto lvPropTypes = lookupProp(norm.get(), prop, ValueContext::RValue, location, astIndexExprType, dummy);
if (lvPropTypes.foundOneProp() && lvPropTypes.noneMissingProp())
reportError(PropertyAccessViolation{tableTy, prop, PropertyAccessViolation::CannotWrite}, location);
else if (get<PrimitiveType>(tableTy) || get<FunctionType>(tableTy))
reportError(NotATable{tableTy}, location);
else
reportError(CannotExtendTable{tableTy, CannotExtendTable::Property, prop}, location);
{
if (get<ExternType>(tableTy))
reportError(UnknownProperty{tableTy, prop}, location);
else
reportError(CannotExtendTable{tableTy, CannotExtendTable::Property, prop}, location);
}
}
else if (context == ValueContext::RValue && !get<ExternType>(tableTy))
else if (context == ValueContext::RValue)
{
const auto rvPropTypes = lookupProp(norm.get(), prop, ValueContext::LValue, location, astIndexExprType, dummy);
if (rvPropTypes.foundOneProp() && rvPropTypes.noneMissingProp())
Expand Down Expand Up @@ -3733,7 +3750,14 @@ PropertyType TypeChecker2::hasIndexTypeFromType(
// is compatible with the indexer's indexType
// Construct the intersection and test inhabitedness!
if (auto property = lookupExternTypeProp(cls, prop))
return {NormalizationResult::True, context == ValueContext::LValue ? property->writeTy : property->readTy};
{
if (FFlag::LuauExternReadWriteAttributes
&& ((context == ValueContext::LValue && !property->writeTy) || (context == ValueContext::RValue && !property->readTy))
)
return {NormalizationResult::False, {}};
else
return {NormalizationResult::True, context == ValueContext::LValue ? property->writeTy : property->readTy};
}
if (cls->indexer)
{
TypeId inhabitedTestType = module->internalTypes.addType(IntersectionType{{cls->indexer->indexType, astIndexExprType}});
Expand Down
15 changes: 8 additions & 7 deletions Ast/include/Luau/Ast.h
Original file line number Diff line number Diff line change
Expand Up @@ -1043,20 +1043,21 @@ class AstStatDeclareFunction : public AstStat
AstTypePack* retTypes;
};

enum class AstTableAccess
{
Read = 0b01,
Write = 0b10,
ReadWrite = 0b11,
};

struct AstDeclaredExternTypeProperty
{
AstName name;
Location nameLocation;
AstType* ty = nullptr;
bool isMethod = false;
Location location;
};

enum class AstTableAccess
{
Read = 0b01,
Write = 0b10,
ReadWrite = 0b11,
AstTableAccess access = AstTableAccess::ReadWrite;
};

struct AstTableIndexer
Expand Down
27 changes: 26 additions & 1 deletion Ast/src/Parser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ LUAU_FASTFLAGVARIABLE(LuauExplicitTypeInstantiationSyntax)
LUAU_FASTFLAGVARIABLE(LuauCstStatDoWithStatsStart)
LUAU_FASTFLAGVARIABLE(DesugaredArrayTypeReferenceIsEmpty)
LUAU_FASTFLAGVARIABLE(LuauConst)
LUAU_FASTFLAGVARIABLE(LuauExternReadWriteAttributes)

// Clip with DebugLuauReportReturnTypeVariadicWithTypeSuffix
bool luau_telemetry_parsed_return_type_variadic_with_type_suffix = false;
Expand Down Expand Up @@ -1629,6 +1630,30 @@ AstStat* Parser::parseDeclaration(const Location& start, const AstArray<AstAttr*
}
else
{
AstTableAccess access = AstTableAccess::ReadWrite;

if (FFlag::LuauExternReadWriteAttributes)
{
if (lexer.current().type == Lexeme::Name && lexer.lookahead().type != ':')
{
if (AstName(lexer.current().name) == "read")
{
access = AstTableAccess::Read;
lexer.next();
}
else if (AstName(lexer.current().name) == "write")
{
access = AstTableAccess::Write;
lexer.next();
}
else
{
report(lexer.current().location, "Expected blank or 'read' or 'write' attribute, got '%s'", lexer.current().name);
lexer.next();
}
}
}

Location propStart = lexer.current().location;
std::optional<Name> propName = parseNameOpt("property name");

Expand All @@ -1638,7 +1663,7 @@ AstStat* Parser::parseDeclaration(const Location& start, const AstArray<AstAttr*
expectAndConsume(':', "property type annotation");
AstType* propType = parseType();
props.push_back(
AstDeclaredExternTypeProperty{propName->name, propName->location, propType, false, Location(propStart, lexer.previousLocation())}
AstDeclaredExternTypeProperty{propName->name, propName->location, propType, false, Location(propStart, lexer.previousLocation()), access}
);
}
}
Expand Down
34 changes: 34 additions & 0 deletions tests/Parser.test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ LUAU_DYNAMIC_FASTFLAG(DebugLuauReportReturnTypeVariadicWithTypeSuffix)
LUAU_FASTFLAG(LuauExplicitTypeInstantiationSyntax)
LUAU_FASTFLAG(LuauCstStatDoWithStatsStart)
LUAU_FASTFLAG(LuauConst)
LUAU_FASTFLAG(LuauExternReadWriteAttributes)

// Clip with DebugLuauReportReturnTypeVariadicWithTypeSuffix
extern bool luau_telemetry_parsed_return_type_variadic_with_type_suffix;
Expand Down Expand Up @@ -4754,4 +4755,37 @@ TEST_CASE_FIXTURE(Fixture, "explicit_type_instantiation_errors")
matchParseError("local a = x:a<<T>>", "Expected '(', '{' or <string> when parsing function call, got <eof>");
}

TEST_CASE_FIXTURE(Fixture, "extern_read_write_attributes")
{
ScopedFastFlag _[] = {
{FFlag::LuauSolverV2, true},
{FFlag::LuauExternReadWriteAttributes, true}
};

ParseResult result = tryParse(R"(
declare extern type Foo with
read ReadOnlyMember: string
write WriteOnlyMember: number
ReadWriteMember: vector
wRITE BadAttributeMember: buffer
end
)");

REQUIRE_EQ(result.errors.size(), 1);
CHECK_EQ(result.errors[0].getLocation().begin.line, 5);
CHECK_EQ(result.errors[0].getMessage(), "Expected blank or 'read' or 'write' attribute, got 'wRITE'");

AstStatBlock* stat = result.root;

REQUIRE_EQ(stat->body.size, 1);

AstStatDeclareExternType* declaredExternType = stat->body.data[0]->as<AstStatDeclareExternType>();
CHECK_EQ(declaredExternType->props.size, 4);

CHECK_EQ(declaredExternType->props.data[0].access, AstTableAccess::Read);
CHECK_EQ(declaredExternType->props.data[1].access, AstTableAccess::Write);
CHECK_EQ(declaredExternType->props.data[2].access, AstTableAccess::ReadWrite);
CHECK_EQ(declaredExternType->props.data[3].access, AstTableAccess::ReadWrite);
}

TEST_SUITE_END();
Loading