diff --git a/compiler/src/main/pascal/uCodeGenQBE.pas b/compiler/src/main/pascal/uCodeGenQBE.pas index 47de193..bcdaaf4 100644 --- a/compiler/src/main/pascal/uCodeGenQBE.pas +++ b/compiler/src/main/pascal/uCodeGenQBE.pas @@ -8043,6 +8043,14 @@ begin Exit(T); end; + if SameText(FC.Name,'FileAge') then + begin + L := EmitExpr(TASTExpr(FC.Args.Items[0])); + T := AllocTemp; + EmitLine(Format(' %s =l call $_FileAge(l %s)', [T, L])); + Exit(T); + end; + { Environment and process } if SameText(FC.Name,'GetEnvVar') or SameText(FC.Name,'GetEnvironmentVariable') then begin diff --git a/compiler/src/main/pascal/uSemantic.pas b/compiler/src/main/pascal/uSemantic.pas index a0627f4..df9b267 100644 --- a/compiler/src/main/pascal/uSemantic.pas +++ b/compiler/src/main/pascal/uSemantic.pas @@ -7238,6 +7238,16 @@ begin Exit; end; + if SameText(AExpr.Name, 'FileAge') then + begin + if AExpr.Args.Count <> 1 then + SemanticError('FileAge requires exactly 1 argument', AExpr.Line, AExpr.Col); + AnalyseExpr(TASTExpr(AExpr.Args.Items[0])); + Result := FTable.TypeInt64; + AExpr.ResolvedType := Result; + Exit; + end; + if SameText(AExpr.Name, 'ForceDirectories') then begin if AExpr.Args.Count <> 1 then diff --git a/compiler/src/main/pascal/uSymbolTable.pas b/compiler/src/main/pascal/uSymbolTable.pas index 2850661..0623bc2 100644 --- a/compiler/src/main/pascal/uSymbolTable.pas +++ b/compiler/src/main/pascal/uSymbolTable.pas @@ -1593,6 +1593,7 @@ begin Sym := TSymbol.Create('WriteFile', skProcedure, nil); Define(Sym); Sym := TSymbol.Create('AppendFile', skProcedure, nil); Define(Sym); Sym := TSymbol.Create('FileExists', skFunction, FTypeBoolean); Define(Sym); + Sym := TSymbol.Create('FileAge', skFunction, FTypeInt64); Define(Sym); Sym := TSymbol.Create('DeleteFile', skProcedure, nil); Define(Sym); Sym := TSymbol.Create('CurrentExceptionMessage', skFunction, FTypeString); Define(Sym); { Environment and process } diff --git a/compiler/src/test/pascal/cp.test.e2e.sysutils.pas b/compiler/src/test/pascal/cp.test.e2e.sysutils.pas index 255ea8a..bf4591f 100644 --- a/compiler/src/test/pascal/cp.test.e2e.sysutils.pas +++ b/compiler/src/test/pascal/cp.test.e2e.sysutils.pas @@ -47,6 +47,7 @@ type procedure TestRun_BoolToStr_TrueAndFalse; procedure TestRun_PlatformConstants; procedure TestRun_GetCurrentDir_ReturnsNonEmpty; + procedure TestRun_FileAge_ReturnsTimestamp; end; implementation @@ -551,6 +552,17 @@ const end. '''; + SrcFileAge = + ''' + program P; + var Age: Int64; + begin + Age := FileAge(ParamStr(1)); + WriteLn(Age > 0); + WriteLn(FileAge('__no_such_file_xyz__')) + end. + '''; + procedure TE2ESysUtilsTests.TestRun_RenameFile_Works; var Output: string; @@ -676,6 +688,35 @@ begin AssertEquals('GetCurrentDir non-empty', 'True', Trim(Output)); end; +procedure TE2ESysUtilsTests.TestRun_FileAge_ReturnsTimestamp; +var + Output: string; + RCode: Integer; + TmpFile: string; + Lines: TStringList; +begin + if not ToolchainAvailable then begin Ignore('toolchain unavailable'); Exit; end; + TmpFile := GetTempFileName('', 'blaise_fileage_test'); + Lines := TStringList.Create; + Lines.Add('x'); + Lines.SaveToFile(TmpFile); + Lines.Free; + try + AssertTrue('compile+run', + CompileAndRun(SrcFileAge, Output, RCode, [TmpFile])); + Lines := TStringList.Create; + try + Lines.Text := Trim(Output); + AssertEquals('existing file age > 0', 'True', Lines.Strings[0]); + AssertEquals('missing file age = -1', '-1', Lines.Strings[1]); + finally + Lines.Free; + end; + finally + if FileExists(TmpFile) then DeleteFile(TmpFile); + end; +end; + initialization RegisterTest(TE2ESysUtilsTests); diff --git a/compiler/src/test/pascal/cp.test.selfhosting.pas b/compiler/src/test/pascal/cp.test.selfhosting.pas index ca2cb8f..29ec883 100644 --- a/compiler/src/test/pascal/cp.test.selfhosting.pas +++ b/compiler/src/test/pascal/cp.test.selfhosting.pas @@ -52,6 +52,8 @@ type procedure TestCodegen_ReadFile_CallsRTL; procedure TestCodegen_WriteFile_CallsRTL; procedure TestCodegen_FileExists_CallsRTL; + procedure TestSemantic_FileAge_ReturnsInt64; + procedure TestCodegen_FileAge_CallsRTL; { ------------------------------------------------------------------ } { GetEnvVar / Exec / Halt (Gap 2 — environment and process) } @@ -211,6 +213,15 @@ const end. '''; + SrcFileAge = + ''' + program P; + var A: Int64; + begin + A := FileAge('test.txt') + end. + '''; + { Gap 2: environment and process } SrcGetEnvVar = ''' @@ -501,6 +512,36 @@ begin AssertTrue('FileExists calls _FileExists', Pos('_FileExists', IR) > 0); end; +procedure TSelfHostingTests.TestSemantic_FileAge_ReturnsInt64; +var + Lex: TLexer; + Par: TParser; + SA: TSemanticAnalyser; + Prog: TProgram; + Ass: TAssignment; +begin + Lex := TLexer.Create(SrcFileAge); + Par := TParser.Create(Lex); + Prog := Par.Parse; + Par.Free; + Lex.Free; + SA := TSemanticAnalyser.Create; + SA.Analyse(Prog); + SA.Free; + Ass := TAssignment(Prog.Block.Stmts[0]); + AssertEquals('FileAge returns Int64', + Ord(tyInt64), Ord(Ass.Expr.ResolvedType.Kind)); + Prog.Free; +end; + +procedure TSelfHostingTests.TestCodegen_FileAge_CallsRTL; +var + IR: string; +begin + IR := GenIR(SrcFileAge); + AssertTrue('FileAge calls _FileAge', Pos('_FileAge', IR) > 0); +end; + { ------------------------------------------------------------------ } { Gap 2: environment and process } { ------------------------------------------------------------------ } diff --git a/runtime/src/main/pascal/rtl.platform.pas b/runtime/src/main/pascal/rtl.platform.pas index 5d68202..670a7ce 100644 --- a/runtime/src/main/pascal/rtl.platform.pas +++ b/runtime/src/main/pascal/rtl.platform.pas @@ -32,6 +32,7 @@ type function ReadFile(const APath: string): string; virtual; abstract; procedure WriteFile(const APath, AContent: string); virtual; abstract; procedure AppendFile(const APath, AContent: string); virtual; abstract; + function FileAge(const APath: string): Int64; virtual; abstract; { Directory operations } function DirectoryExists(const APath: string): Boolean; virtual; abstract; diff --git a/runtime/src/main/pascal/rtl.platform.posix.pas b/runtime/src/main/pascal/rtl.platform.posix.pas index dfb01ad..f6ac5d4 100644 --- a/runtime/src/main/pascal/rtl.platform.posix.pas +++ b/runtime/src/main/pascal/rtl.platform.posix.pas @@ -40,6 +40,7 @@ type function ReadFile(const APath: string): string; override; procedure WriteFile(const APath, AContent: string); override; procedure AppendFile(const APath, AContent: string); override; + function FileAge(const APath: string): Int64; override; { Directory operations } function DirectoryExists(const APath: string): Boolean; override; @@ -220,6 +221,7 @@ function _RenameFile(OldPath, NewPath: Pointer): Integer; function _ReadFile(Path: Pointer): Pointer; procedure _WriteFile(Path, Content: Pointer); procedure _AppendFile(Path, Content: Pointer); +function _FileAge(Path: Pointer): Int64; { Directory operations } function _DirectoryExists(Path: Pointer): Integer; @@ -499,6 +501,14 @@ begin libc_close(Fd); end; +function TRtlPlatformPosix.FileAge(const APath: string): Int64; +var + St: TStatBuf; +begin + if libc_stat(StrData(Pointer(APath)), @St) <> 0 then begin Result := -1; Exit end; + Result := St.Mtime; +end; + { ================================================================== } { TRtlPlatformPosix — Directory operations } { ================================================================== } @@ -1205,6 +1215,11 @@ begin GRtlPlatform.AppendFile(string(PChar(Path)), string(PChar(Content))); end; +function _FileAge(Path: Pointer): Int64; +begin + Result := GRtlPlatform.FileAge(string(PChar(Path))); +end; + function _DirectoryExists(Path: Pointer): Integer; begin if GRtlPlatform.DirectoryExists(string(PChar(Path))) then Result := 1 else Result := 0; diff --git a/tools/kanban/src/main/pascal/kanban.data.pas b/tools/kanban/src/main/pascal/kanban.data.pas index e9a479e..abf0401 100644 --- a/tools/kanban/src/main/pascal/kanban.data.pas +++ b/tools/kanban/src/main/pascal/kanban.data.pas @@ -15,6 +15,10 @@ uses Classes, Contnrs, StrUtils, DateUtils; type TTaskStatus = (tsTodo, tsInProgress, tsDone); + TIntHolder = class + FValue: Integer; + end; + TTask = class FId: Integer; FTitle: string; @@ -32,9 +36,12 @@ type FTasks: TObjectList; FNextId: Integer; FFilePath: string; + FLastMtime: Int64; + FDeletedIds: TObjectList; function GetCount: Integer; procedure ParseLine(const Line: string; CurrentStatus: TTaskStatus); function FormatTask(Task: TTask): string; + function IsDeleted(AId: Integer): Boolean; public constructor Create(const AFilePath: string); destructor Destroy; override; @@ -50,6 +57,8 @@ type function HasDetail(AId: Integer): Boolean; function LoadDetail(AId: Integer): string; procedure SaveDetail(AId: Integer; const AContent: string); + function HasExternalChanges: Boolean; + function MergeFromDisk: Integer; property Count: Integer read GetCount; property FilePath: string read FFilePath; end; @@ -63,11 +72,14 @@ begin inherited Create; FFilePath := AFilePath; FTasks := TObjectList.Create(True); - FNextId := 1 + FDeletedIds := TObjectList.Create(True); + FNextId := 1; + FLastMtime := -1 end; destructor TBoard.Destroy; begin + FDeletedIds.Free; FTasks.Free; inherited Destroy end; @@ -209,7 +221,8 @@ begin end finally Lines.Free - end + end; + FLastMtime := FileAge(FFilePath) end; procedure TBoard.Save; @@ -218,6 +231,8 @@ var I: Integer; Task: TTask; begin + if Self.HasExternalChanges then + Self.MergeFromDisk; Lines := TStringList.Create; try Lines.Add('#next-id: ' + IntToStr(FNextId)); @@ -257,7 +272,8 @@ begin Lines.SaveToFile(FFilePath) finally Lines.Free - end + end; + FLastMtime := FileAge(FFilePath) end; function TBoard.AddTask(const ATitle: string; AStatus: TTaskStatus): TTask; @@ -277,11 +293,26 @@ begin FTasks.Add(Result) end; +function TBoard.IsDeleted(AId: Integer): Boolean; +var + I: Integer; +begin + I := 0; + while I < FDeletedIds.Count do + begin + if TIntHolder(FDeletedIds.Get(I)).FValue = AId then + Exit(True); + I := I + 1 + end; + Result := False +end; + procedure TBoard.DeleteTask(AId: Integer); var I: Integer; Task: TTask; DetailPath: string; + Holder: TIntHolder; begin I := 0; while I < FTasks.Count do @@ -293,6 +324,9 @@ begin if FileExists(DetailPath) then DeleteFile(DetailPath); FTasks.Delete(I); + Holder := TIntHolder.Create; + Holder.FValue := AId; + FDeletedIds.Add(Holder); Exit end; I := I + 1 @@ -395,6 +429,73 @@ begin WriteFile(Self.GetDetailPath(AId), AContent) end; +function TBoard.HasExternalChanges: Boolean; +var + CurrentMtime: Int64; +begin + CurrentMtime := FileAge(FFilePath); + Result := (CurrentMtime <> -1) and (CurrentMtime <> FLastMtime) +end; + +function TBoard.MergeFromDisk: Integer; +var + DiskBoard: TBoard; + I, Merged, MaxId: Integer; + DiskTask, MemTask, NewTask: TTask; +begin + Merged := 0; + DiskBoard := TBoard.Create(FFilePath); + try + DiskBoard.Load; + + MaxId := FNextId; + if DiskBoard.FNextId > MaxId then + MaxId := DiskBoard.FNextId; + + I := 0; + while I < DiskBoard.FTasks.Count do + begin + DiskTask := TTask(DiskBoard.FTasks.Get(I)); + if Self.IsDeleted(DiskTask.FId) then + begin + I := I + 1; + Continue + end; + MemTask := Self.FindById(DiskTask.FId); + if MemTask = nil then + begin + NewTask := TTask.Create; + NewTask.FId := DiskTask.FId; + NewTask.FTitle := DiskTask.FTitle; + NewTask.FStatus := DiskTask.FStatus; + NewTask.FCreated := DiskTask.FCreated; + NewTask.FPriority := DiskTask.FPriority; + FTasks.Add(NewTask); + Merged := Merged + 1 + end + else if MemTask.FTitle <> DiskTask.FTitle then + begin + MemTask.FId := MaxId; + MaxId := MaxId + 1; + NewTask := TTask.Create; + NewTask.FId := DiskTask.FId; + NewTask.FTitle := DiskTask.FTitle; + NewTask.FStatus := DiskTask.FStatus; + NewTask.FCreated := DiskTask.FCreated; + NewTask.FPriority := DiskTask.FPriority; + FTasks.Add(NewTask); + Merged := Merged + 1 + end; + I := I + 1 + end; + FNextId := MaxId + finally + DiskBoard.Free + end; + FLastMtime := FileAge(FFilePath); + Result := Merged +end; + function CreatedToLocalDate(const AUtcStr: string): string; var UtcDT: TDateTime; diff --git a/tools/kanban/src/main/pascal/kanban.ui.pas b/tools/kanban/src/main/pascal/kanban.ui.pas index 029c338..4bd7e2c 100644 --- a/tools/kanban/src/main/pascal/kanban.ui.pas +++ b/tools/kanban/src/main/pascal/kanban.ui.pas @@ -28,6 +28,7 @@ type FInputPrompt: string; FStatusMsg: string; FNeedsRedraw: Boolean; + FPollCounter: Integer; procedure DrawBoard; procedure DrawColumn(ColIndex: Integer; Row, Col, Width, Height: Integer); procedure DrawStatusBar; @@ -64,7 +65,8 @@ begin FInputBuf := ''; FInputPrompt := ''; FStatusMsg := 'q:quit a:add d:delete Enter:details h/l:move task ?:help'; - FNeedsRedraw := True + FNeedsRedraw := True; + FPollCounter := 0 end; function TKanbanUI.ColumnTitle(ColIndex: Integer): string; @@ -343,6 +345,7 @@ begin FTerm.ShowCursor; FTerm.BufFlush; FTerm.DisableRawMode; + FBoard.MergeFromDisk; FBoard.Save; Halt(0) end; @@ -594,7 +597,7 @@ end; procedure TKanbanUI.Run; var - Key: Integer; + Key, Merged: Integer; begin FTerm.EnableRawMode; try @@ -611,6 +614,27 @@ begin Key := FTerm.ReadKey; + if Key = KEY_NONE then + begin + FPollCounter := FPollCounter + 1; + if FPollCounter >= 20 then + begin + FPollCounter := 0; + if FBoard.HasExternalChanges then + begin + Merged := FBoard.MergeFromDisk; + if Merged > 0 then + begin + FStatusMsg := 'Reloaded ' + IntToStr(Merged) + ' new task(s) from disk'; + Self.ClampRow; + FNeedsRedraw := True + end + end + end + end + else + FPollCounter := 0; + if FViewMode = vmBoard then Self.HandleBoardKey(Key) else if FViewMode = vmDetail then