mirror of
https://github.com/apple/swift.git
synced 2025-12-14 20:36:38 +01:00
Merge pull request #85901 from slavapestov/too-complex-source-loc-6.3
Sema: Fix source location bookkeeping for 'reasonable time' diagnostic [6.3]
This commit is contained in:
@@ -236,36 +236,23 @@ public:
|
||||
|
||||
|
||||
class ExpressionTimer {
|
||||
public:
|
||||
using AnchorType = llvm::PointerUnion<Expr *, ConstraintLocator *>;
|
||||
|
||||
private:
|
||||
AnchorType Anchor;
|
||||
ASTContext &Context;
|
||||
ConstraintSystem &CS;
|
||||
llvm::TimeRecord StartTime;
|
||||
|
||||
/// The number of seconds from creation until
|
||||
/// this timer is considered expired.
|
||||
unsigned ThresholdInSecs;
|
||||
|
||||
bool PrintDebugTiming;
|
||||
bool PrintWarning;
|
||||
|
||||
public:
|
||||
const static unsigned NoLimit = (unsigned) -1;
|
||||
|
||||
ExpressionTimer(AnchorType Anchor, ConstraintSystem &CS,
|
||||
unsigned thresholdInSecs);
|
||||
ExpressionTimer(ConstraintSystem &CS, unsigned thresholdInSecs);
|
||||
|
||||
~ExpressionTimer();
|
||||
|
||||
AnchorType getAnchor() const { return Anchor; }
|
||||
|
||||
SourceRange getAffectedRange() const;
|
||||
|
||||
unsigned getWarnLimit() const {
|
||||
return Context.TypeCheckerOpts.WarnLongExpressionTypeChecking;
|
||||
}
|
||||
unsigned getWarnLimit() const;
|
||||
llvm::TimeRecord startedAt() const { return StartTime; }
|
||||
|
||||
/// Return the elapsed process time (including fractional seconds)
|
||||
@@ -2159,7 +2146,9 @@ struct ClosureIsolatedByPreconcurrency {
|
||||
/// solution of which assigns concrete types to each of the type variables.
|
||||
/// Constraint systems are typically generated given an (untyped) expression.
|
||||
class ConstraintSystem {
|
||||
private:
|
||||
ASTContext &Context;
|
||||
SourceRange CurrentRange;
|
||||
|
||||
public:
|
||||
DeclContext *DC;
|
||||
@@ -5390,6 +5379,9 @@ private:
|
||||
/// \returns The selected conjunction.
|
||||
Constraint *selectConjunction();
|
||||
|
||||
void diagnoseTooComplex(SourceLoc fallbackLoc,
|
||||
SolutionResult &result);
|
||||
|
||||
/// Solve the system of constraints generated from provided expression.
|
||||
///
|
||||
/// \param target The target to generate constraints from.
|
||||
@@ -5487,6 +5479,8 @@ private:
|
||||
compareSolutions(ConstraintSystem &cs, ArrayRef<Solution> solutions,
|
||||
const SolutionDiff &diff, unsigned idx1, unsigned idx2);
|
||||
|
||||
void startExpressionTimer();
|
||||
|
||||
public:
|
||||
/// Increase the score of the given kind for the current (partial) solution
|
||||
/// along the current solver path.
|
||||
@@ -5524,7 +5518,6 @@ public:
|
||||
std::optional<unsigned> findBestSolution(SmallVectorImpl<Solution> &solutions,
|
||||
bool minimize);
|
||||
|
||||
public:
|
||||
/// Apply a given solution to the target, producing a fully
|
||||
/// type-checked target or \c None if an error occurred.
|
||||
///
|
||||
@@ -5577,7 +5570,14 @@ public:
|
||||
/// resolved before any others.
|
||||
void optimizeConstraints(Expr *e);
|
||||
|
||||
void startExpressionTimer(ExpressionTimer::AnchorType anchor);
|
||||
/// Set the current sub-expression (of a multi-statement closure, etc) for
|
||||
/// the purposes of diagnosing "reasonable time" errors.
|
||||
void startExpression(ASTNode node);
|
||||
|
||||
/// The source range of the target being type checked.
|
||||
SourceRange getCurrentSourceRange() const {
|
||||
return CurrentRange;
|
||||
}
|
||||
|
||||
/// Determine if we've already explored too many paths in an
|
||||
/// attempt to solve this expression.
|
||||
@@ -5590,53 +5590,7 @@ public:
|
||||
return range.isValid() ? range : std::optional<SourceRange>();
|
||||
}
|
||||
|
||||
bool isTooComplex(size_t solutionMemory) {
|
||||
if (isAlreadyTooComplex.first)
|
||||
return true;
|
||||
|
||||
auto CancellationFlag = getASTContext().CancellationFlag;
|
||||
if (CancellationFlag && CancellationFlag->load(std::memory_order_relaxed))
|
||||
return true;
|
||||
|
||||
auto markTooComplex = [&](SourceRange range, StringRef reason) {
|
||||
if (isDebugMode()) {
|
||||
if (solverState)
|
||||
llvm::errs().indent(solverState->getCurrentIndent());
|
||||
llvm::errs() << "(too complex: " << reason << ")\n";
|
||||
}
|
||||
isAlreadyTooComplex = {true, range};
|
||||
return true;
|
||||
};
|
||||
|
||||
auto used = getASTContext().getSolverMemory() + solutionMemory;
|
||||
MaxMemory = std::max(used, MaxMemory);
|
||||
auto threshold = getASTContext().TypeCheckerOpts.SolverMemoryThreshold;
|
||||
if (MaxMemory > threshold) {
|
||||
// No particular location for OoM problems.
|
||||
return markTooComplex(SourceRange(), "exceeded memory limit");
|
||||
}
|
||||
|
||||
if (Timer && Timer->isExpired()) {
|
||||
// Disable warnings about expressions that go over the warning
|
||||
// threshold since we're arbitrarily ending evaluation and
|
||||
// emitting an error.
|
||||
Timer->disableWarning();
|
||||
|
||||
return markTooComplex(Timer->getAffectedRange(), "exceeded time limit");
|
||||
}
|
||||
|
||||
auto &opts = getASTContext().TypeCheckerOpts;
|
||||
|
||||
// Bail out once we've looked at a really large number of choices.
|
||||
if (opts.SolverScopeThreshold && NumSolverScopes > opts.SolverScopeThreshold)
|
||||
return markTooComplex(SourceRange(), "exceeded scope limit");
|
||||
|
||||
// Bail out once we've taken a really large number of steps.
|
||||
if (opts.SolverTrailThreshold && NumTrailSteps > opts.SolverTrailThreshold)
|
||||
return markTooComplex(SourceRange(), "exceeded trail limit");
|
||||
|
||||
return false;
|
||||
}
|
||||
bool isTooComplex(size_t solutionMemory);
|
||||
|
||||
bool isTooComplex(ArrayRef<Solution> solutions) {
|
||||
if (isAlreadyTooComplex.first)
|
||||
|
||||
@@ -76,8 +76,10 @@ public:
|
||||
SolutionResult(const SolutionResult &other) = delete;
|
||||
|
||||
SolutionResult(SolutionResult &&other)
|
||||
: kind(other.kind), numSolutions(other.numSolutions),
|
||||
solutions(other.solutions) {
|
||||
: kind(other.kind),
|
||||
numSolutions(other.numSolutions),
|
||||
solutions(other.solutions),
|
||||
TooComplexAt(other.TooComplexAt) {
|
||||
emittedDiagnostic = false;
|
||||
other.kind = Error;
|
||||
other.numSolutions = 0;
|
||||
|
||||
@@ -1053,9 +1053,7 @@ TypeChecker::applyResultBuilderBodyTransform(FuncDecl *func, Type builderType) {
|
||||
|
||||
case SolutionResult::Kind::TooComplex:
|
||||
reportSolutionsToSolutionCallback(salvagedResult);
|
||||
func->diagnose(diag::expression_too_complex)
|
||||
.highlight(func->getBodySourceRange());
|
||||
salvagedResult.markAsDiagnosed();
|
||||
cs.diagnoseTooComplex(func->getLoc(), salvagedResult);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
||||
@@ -829,9 +829,7 @@ bool ConstraintSystem::Candidate::solve(
|
||||
|
||||
// Allocate new constraint system for sub-expression.
|
||||
ConstraintSystem cs(DC, std::nullopt);
|
||||
|
||||
// Set up expression type checker timer for the candidate.
|
||||
cs.startExpressionTimer(E);
|
||||
cs.startExpression(E);
|
||||
|
||||
// Generate constraints for the new system.
|
||||
if (auto generatedExpr = cs.generateConstraints(E, DC)) {
|
||||
@@ -1455,18 +1453,7 @@ ConstraintSystem::solve(SyntacticElementTarget &target,
|
||||
return std::nullopt;
|
||||
|
||||
case SolutionResult::TooComplex: {
|
||||
auto affectedRange = solution.getTooComplexAt();
|
||||
|
||||
// If affected range is unknown, let's use whole
|
||||
// target.
|
||||
if (!affectedRange)
|
||||
affectedRange = target.getSourceRange();
|
||||
|
||||
getASTContext()
|
||||
.Diags.diagnose(affectedRange->Start, diag::expression_too_complex)
|
||||
.highlight(*affectedRange);
|
||||
|
||||
solution.markAsDiagnosed();
|
||||
diagnoseTooComplex(target.getLoc(), solution);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -1501,6 +1488,19 @@ ConstraintSystem::solve(SyntacticElementTarget &target,
|
||||
llvm_unreachable("Loop always returns");
|
||||
}
|
||||
|
||||
void ConstraintSystem::diagnoseTooComplex(SourceLoc fallbackLoc,
|
||||
SolutionResult &result) {
|
||||
auto affectedRange = result.getTooComplexAt();
|
||||
|
||||
SourceLoc loc = (affectedRange ? affectedRange->Start : fallbackLoc);
|
||||
auto diag = getASTContext().Diags.diagnose(loc, diag::expression_too_complex);
|
||||
|
||||
if (affectedRange)
|
||||
diag.highlight(*affectedRange);
|
||||
|
||||
result.markAsDiagnosed();
|
||||
}
|
||||
|
||||
SolutionResult
|
||||
ConstraintSystem::solveImpl(SyntacticElementTarget &target,
|
||||
FreeTypeVariableBinding allowFreeTypeVariables) {
|
||||
@@ -1518,9 +1518,8 @@ ConstraintSystem::solveImpl(SyntacticElementTarget &target,
|
||||
|
||||
assert(!solverState && "cannot be used directly");
|
||||
|
||||
// Set up the expression type checker timer.
|
||||
if (Expr *expr = target.getAsExpr())
|
||||
startExpressionTimer(expr);
|
||||
startExpression(expr);
|
||||
|
||||
if (generateConstraints(target, allowFreeTypeVariables))
|
||||
return SolutionResult::forError();
|
||||
@@ -1701,8 +1700,7 @@ bool ConstraintSystem::solveForCodeCompletion(
|
||||
// Tell the constraint system what the contextual type is.
|
||||
setContextualInfo(expr, target.getExprContextualTypeInfo());
|
||||
|
||||
// Set up the expression type checker timer.
|
||||
startExpressionTimer(expr);
|
||||
startExpression(expr);
|
||||
|
||||
shrink(expr);
|
||||
}
|
||||
|
||||
@@ -823,9 +823,13 @@ bool ConjunctionStep::attempt(const ConjunctionElement &element) {
|
||||
// (expression) gets a fresh time slice to get solved. This
|
||||
// is important for closures with large number of statements
|
||||
// in them.
|
||||
if (CS.Timer) {
|
||||
if (CS.Timer)
|
||||
CS.Timer.reset();
|
||||
CS.startExpressionTimer(element.getLocator());
|
||||
|
||||
{
|
||||
auto *locator = element.getLocator();
|
||||
auto anchor = simplifyLocatorToAnchor(locator);
|
||||
CS.startExpression(anchor ? anchor : locator->getAnchor());
|
||||
}
|
||||
|
||||
auto success = element.attempt(CS);
|
||||
|
||||
@@ -867,8 +867,7 @@ class ConjunctionStep : public BindingStep<ConjunctionElementProducer> {
|
||||
|
||||
/// The number of milliseconds until outer constraint system
|
||||
/// is considered "too complex" if timer is enabled.
|
||||
std::optional<std::pair<ExpressionTimer::AnchorType, unsigned>>
|
||||
OuterTimeRemaining = std::nullopt;
|
||||
std::optional<unsigned> OuterTimeRemaining = std::nullopt;
|
||||
|
||||
/// Conjunction constraint associated with this step.
|
||||
Constraint *Conjunction;
|
||||
@@ -910,7 +909,7 @@ public:
|
||||
|
||||
if (cs.Timer) {
|
||||
auto remainingTime = cs.Timer->getRemainingProcessTimeInSeconds();
|
||||
OuterTimeRemaining.emplace(cs.Timer->getAnchor(), remainingTime);
|
||||
OuterTimeRemaining.emplace(remainingTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,11 +924,8 @@ public:
|
||||
if (HadFailure)
|
||||
restoreBestScore();
|
||||
|
||||
if (OuterTimeRemaining) {
|
||||
auto anchor = OuterTimeRemaining->first;
|
||||
auto remainingTime = OuterTimeRemaining->second;
|
||||
CS.Timer.emplace(anchor, CS, remainingTime);
|
||||
}
|
||||
if (OuterTimeRemaining)
|
||||
CS.Timer.emplace(CS, *OuterTimeRemaining);
|
||||
}
|
||||
|
||||
StepResult resume(bool prevFailed) override;
|
||||
|
||||
@@ -54,44 +54,82 @@ using namespace inference;
|
||||
|
||||
#define DEBUG_TYPE "ConstraintSystem"
|
||||
|
||||
ExpressionTimer::ExpressionTimer(AnchorType Anchor, ConstraintSystem &CS,
|
||||
unsigned thresholdInSecs)
|
||||
: Anchor(Anchor), Context(CS.getASTContext()),
|
||||
StartTime(llvm::TimeRecord::getCurrentTime()),
|
||||
ThresholdInSecs(thresholdInSecs),
|
||||
PrintDebugTiming(CS.getASTContext().TypeCheckerOpts.DebugTimeExpressions),
|
||||
PrintWarning(true) {}
|
||||
void ConstraintSystem::startExpression(ASTNode node) {
|
||||
CurrentRange = node.getSourceRange();
|
||||
|
||||
SourceRange ExpressionTimer::getAffectedRange() const {
|
||||
ASTNode anchor;
|
||||
startExpressionTimer();
|
||||
}
|
||||
|
||||
if (auto *locator = Anchor.dyn_cast<ConstraintLocator *>()) {
|
||||
anchor = simplifyLocatorToAnchor(locator);
|
||||
// If locator couldn't be simplified down to a single AST
|
||||
// element, let's use its root.
|
||||
if (!anchor)
|
||||
anchor = locator->getAnchor();
|
||||
} else {
|
||||
anchor = cast<Expr *>(Anchor);
|
||||
bool ConstraintSystem::isTooComplex(size_t solutionMemory) {
|
||||
if (isAlreadyTooComplex.first)
|
||||
return true;
|
||||
|
||||
auto CancellationFlag = getASTContext().CancellationFlag;
|
||||
if (CancellationFlag && CancellationFlag->load(std::memory_order_relaxed))
|
||||
return true;
|
||||
|
||||
auto markTooComplex = [&](SourceRange range, StringRef reason) {
|
||||
if (isDebugMode()) {
|
||||
if (solverState)
|
||||
llvm::errs().indent(solverState->getCurrentIndent());
|
||||
llvm::errs() << "(too complex: " << reason << ")\n";
|
||||
}
|
||||
isAlreadyTooComplex = {true, range};
|
||||
return true;
|
||||
};
|
||||
|
||||
auto used = getASTContext().getSolverMemory() + solutionMemory;
|
||||
MaxMemory = std::max(used, MaxMemory);
|
||||
auto threshold = getASTContext().TypeCheckerOpts.SolverMemoryThreshold;
|
||||
if (MaxMemory > threshold) {
|
||||
// Bail once we've used too much constraint solver arena memory.
|
||||
return markTooComplex(getCurrentSourceRange(), "exceeded memory limit");
|
||||
}
|
||||
|
||||
return anchor.getSourceRange();
|
||||
if (Timer && Timer->isExpired()) {
|
||||
// Disable warnings about expressions that go over the warning
|
||||
// threshold since we're arbitrarily ending evaluation and
|
||||
// emitting an error.
|
||||
Timer->disableWarning();
|
||||
|
||||
return markTooComplex(getCurrentSourceRange(), "exceeded time limit");
|
||||
}
|
||||
|
||||
auto &opts = getASTContext().TypeCheckerOpts;
|
||||
|
||||
// Bail out once we've looked at a really large number of choices.
|
||||
if (opts.SolverScopeThreshold && NumSolverScopes > opts.SolverScopeThreshold)
|
||||
return markTooComplex(getCurrentSourceRange(), "exceeded scope limit");
|
||||
|
||||
// Bail out once we've taken a really large number of steps.
|
||||
if (opts.SolverTrailThreshold && NumTrailSteps > opts.SolverTrailThreshold)
|
||||
return markTooComplex(getCurrentSourceRange(), "exceeded trail limit");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
ExpressionTimer::ExpressionTimer(ConstraintSystem &CS, unsigned thresholdInSecs)
|
||||
: CS(CS),
|
||||
StartTime(llvm::TimeRecord::getCurrentTime()),
|
||||
ThresholdInSecs(thresholdInSecs),
|
||||
PrintWarning(true) {}
|
||||
|
||||
unsigned ExpressionTimer::getWarnLimit() const {
|
||||
return CS.getASTContext().TypeCheckerOpts.WarnLongExpressionTypeChecking;
|
||||
}
|
||||
|
||||
ExpressionTimer::~ExpressionTimer() {
|
||||
auto elapsed = getElapsedProcessTimeInFractionalSeconds();
|
||||
unsigned elapsedMS = static_cast<unsigned>(elapsed * 1000);
|
||||
auto &ctx = CS.getASTContext();
|
||||
|
||||
if (PrintDebugTiming) {
|
||||
auto range = CS.getCurrentSourceRange();
|
||||
|
||||
if (ctx.TypeCheckerOpts.DebugTimeExpressions) {
|
||||
// Round up to the nearest 100th of a millisecond.
|
||||
llvm::errs() << llvm::format("%0.2f", std::ceil(elapsed * 100000) / 100)
|
||||
<< "ms\t";
|
||||
if (auto *E = Anchor.dyn_cast<Expr *>()) {
|
||||
E->getLoc().print(llvm::errs(), Context.SourceMgr);
|
||||
} else {
|
||||
auto *locator = cast<ConstraintLocator *>(Anchor);
|
||||
locator->dump(&Context.SourceMgr, llvm::errs());
|
||||
}
|
||||
range.Start.print(llvm::errs(), ctx.SourceMgr);
|
||||
llvm::errs() << "\n";
|
||||
}
|
||||
|
||||
@@ -103,13 +141,11 @@ ExpressionTimer::~ExpressionTimer() {
|
||||
if (WarnLimit == 0 || elapsedMS < WarnLimit)
|
||||
return;
|
||||
|
||||
auto sourceRange = getAffectedRange();
|
||||
|
||||
if (sourceRange.Start.isValid()) {
|
||||
Context.Diags
|
||||
.diagnose(sourceRange.Start, diag::debug_long_expression, elapsedMS,
|
||||
if (range.Start.isValid()) {
|
||||
ctx.Diags
|
||||
.diagnose(range.Start, diag::debug_long_expression, elapsedMS,
|
||||
WarnLimit)
|
||||
.highlight(sourceRange);
|
||||
.highlight(range);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +176,7 @@ ConstraintSystem::~ConstraintSystem() {
|
||||
}
|
||||
}
|
||||
|
||||
void ConstraintSystem::startExpressionTimer(ExpressionTimer::AnchorType anchor) {
|
||||
void ConstraintSystem::startExpressionTimer() {
|
||||
ASSERT(!Timer);
|
||||
|
||||
const auto &opts = getASTContext().TypeCheckerOpts;
|
||||
@@ -156,7 +192,7 @@ void ConstraintSystem::startExpressionTimer(ExpressionTimer::AnchorType anchor)
|
||||
timeout = ExpressionTimer::NoLimit;
|
||||
}
|
||||
|
||||
Timer.emplace(anchor, *this, timeout);
|
||||
Timer.emplace(*this, timeout);
|
||||
}
|
||||
|
||||
void ConstraintSystem::incrementScopeCounter() {
|
||||
@@ -3731,6 +3767,10 @@ void constraints::simplifyLocator(ASTNode &anchor,
|
||||
case ConstraintLocator::Condition: {
|
||||
if (auto *condStmt = getAsStmt<LabeledConditionalStmt>(anchor)) {
|
||||
anchor = &condStmt->getCond().front();
|
||||
} else if (auto *whileStmt = getAsStmt<RepeatWhileStmt>(anchor)) {
|
||||
anchor = whileStmt->getCond();
|
||||
} else if (auto *assertStmt = getAsStmt<PoundAssertStmt>(anchor)) {
|
||||
anchor = assertStmt->getCondition();
|
||||
} else {
|
||||
anchor = castToExpr<TernaryExpr>(anchor)->getCondExpr();
|
||||
}
|
||||
|
||||
19
test/Constraints/too_complex_source_location.swift
Normal file
19
test/Constraints/too_complex_source_location.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
// RUN: %target-typecheck-verify-swift -solver-scope-threshold=10
|
||||
|
||||
// Note: the scope threshold is intentionally set low so that the expression will fail.
|
||||
//
|
||||
// The purpose of the test is to ensure the diagnostic points at the second statement in
|
||||
// the closure, and not the closure itself.
|
||||
//
|
||||
// If the expression becomes very fast and we manage to type check it with fewer than
|
||||
// 10 scopes, please *do not* remove the expected error! Instead, make the expression
|
||||
// more complex again.
|
||||
|
||||
let s = ""
|
||||
let n = 0
|
||||
|
||||
let closure = {
|
||||
let _ = 0
|
||||
let _ = "" + s + "" + s + "" + s + "" + n + "" // expected-error {{reasonable time}}
|
||||
let _ = 0
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// RUN: %target-typecheck-verify-swift -target %target-cpu-apple-macosx10.15 -swift-version 5
|
||||
// REQUIRES: objc_interop
|
||||
// REQUIRES: OS=macosx
|
||||
|
||||
// https://forums.swift.org/t/roadmap-for-improving-the-type-checker/82952/9
|
||||
//
|
||||
// The purpose of the test is to ensure the diagnostic points at the right statement in
|
||||
// the function body, and not the function declaration itself.
|
||||
//
|
||||
// Ideally, we would produce a useful diagnostic here. Once we are able to do that, we
|
||||
// will need to devise a new test which complains with 'reasonable time' to ensure the
|
||||
// source location remains correct.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@State var selection = ""
|
||||
|
||||
@State var a: Int?
|
||||
@State var b: Int?
|
||||
@State var c: Int?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack {
|
||||
Picker(selection: $selection) {
|
||||
ForEach(["a", "b", "c"], id: \.self) {
|
||||
Text($0) // expected-error {{reasonable time}}
|
||||
.foregroundStyl(.red) // Typo is here
|
||||
}
|
||||
} label: {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.onChange(of: a) { oldValue, newValue in
|
||||
}
|
||||
.onChange(of: b) { oldValue, newValue in
|
||||
}
|
||||
.onChange(of: c) { oldValue, newValue in
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ struct rdar19612086 {
|
||||
let x = 1.0
|
||||
|
||||
var description : String {
|
||||
return "\(i)" + Stringly(format: "%.2f", x) + // expected-error {{reasonable time}}
|
||||
"\(i+1)" + Stringly(format: "%.2f", x) +
|
||||
return "\(i)" + Stringly(format: "%.2f", x) +
|
||||
"\(i+1)" + Stringly(format: "%.2f", x) + // expected-error {{reasonable time}}
|
||||
"\(i+2)" + Stringly(format: "%.2f", x) +
|
||||
"\(i+3)" + Stringly(format: "%.2f", x) +
|
||||
"\(i+4)" + Stringly(format: "%.2f", x) +
|
||||
|
||||
Reference in New Issue
Block a user