diff --git a/CHANGELOG.md b/CHANGELOG.md index 121835b..df8725a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- Versioned `pasweave.json` project configuration (`--config`) covering + source, output, comment styles, visibility, project title and branding, + repository/source-link settings, discovery, compiler settings, and coverage + thresholds. Relative paths resolve from the configuration directory and + cannot escape it; unknown keys, wrong types, unsupported versions, and + invalid values are rejected with input errors (exit code 2). +- `--visibility=public|all` selects whether private declarations are + documented, and `--no-recursive` disables recursion inherited from a + configuration. +- The effective configuration is recorded in `api-model.json` + (`configuration`, `configurationSource`) and the configuration file is part + of the incremental build fingerprint. + +### Documentation + +- Added the [project configuration](docs/project-configuration.md) guide and + linked it from the README and documentation index. + ## [0.7.0] - 2026-09-19 ### Added diff --git a/README.md b/README.md index 54c127b..1a604d0 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,20 @@ PasWeave writes a deterministic `manifest.json` and only ever removes files it created before. See [safe incremental builds](docs/incremental-builds.md) for the cache key, invalidation rules, and interruption recovery. +### Reproduce a build from a committed configuration + +Commit one `pasweave.json` and rebuild with a single flag; explicit +command-line values still override it: + +~~~text +pasweave build --config=pasweave.json +pasweave build --config=pasweave.json --project-name "Nightly API" +~~~ + +See [project configuration](docs/project-configuration.md) for the schema, +precedence rules, path validation, and the effective configuration recorded in +`api-model.json`. + ## 📖 Documentation Every guide is indexed in [docs/README.md](docs/README.md). Start with @@ -239,6 +253,7 @@ Every guide is indexed in [docs/README.md](docs/README.md). Start with | Guide | What it covers | |---|---| | [Documentation index](docs/README.md) | Grouped links to every guide, ADR, and release note | +| [Project configuration](docs/project-configuration.md) | One committed `pasweave.json`, precedence, and reproducibility | | [Documentation comments](docs/documentation-comments.md) | Comment forms, association, and directives | | [Generated output](docs/generated-output.md) | HTML, Markdown, JSON, diagnostics, and exit codes | | [Source discovery](docs/source-discovery.md) | Recursion, include/exclude globs, and safety | diff --git a/docs/README.md b/docs/README.md index 5c4e962..7b745b4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ page that matches your task. | Guide | What it covers | |---|---| +| [Project configuration](project-configuration.md) | `pasweave.json` schema, precedence, path rules, visibility policy, and reproducibility | | [Source discovery](source-discovery.md) | Recursion, include/exclude globs, precedence, and safety | | [Compiler-aware parsing](compiler-aware-parsing.md) | Unit/include paths, defines, targets, and precedence | | [Lazarus projects and packages](lazarus-projects.md) | `.lpi`/`.lpk` inputs, build modes, package graphs, and diagnostics | diff --git a/docs/generated-output.md b/docs/generated-output.md index 66d882c..56c1695 100644 --- a/docs/generated-output.md +++ b/docs/generated-output.md @@ -86,7 +86,10 @@ deliberately unresolved. When source links are configured, the top-level model records the normalized `repositoryUrl` and `sourceLinkTemplate`. The effective branding tokens (`projectMark`, `themeAccent`, `themeAccentAlt`, and `themeFont`) are recorded -alongside them. These fields are additive schema-v1 changes. +alongside them. `configurationSource` names the `pasweave.json` file used, and +`configuration` records the effective normalized build options (the same text +hashed into the incremental fingerprint). These fields are additive schema-v1 +changes. See the [project configuration guide](project-configuration.md). `diagnostics.json` contains the same stable diagnostic codes shown on the HTML and Markdown indexes, ready for CI systems to consume. diff --git a/docs/project-configuration.md b/docs/project-configuration.md new file mode 100644 index 0000000..dbca52a --- /dev/null +++ b/docs/project-configuration.md @@ -0,0 +1,148 @@ +# Project configuration + +A project can commit one `pasweave.json` file and reproduce a build without +restating stable options on every command line: + +~~~text +pasweave build --config=pasweave.json +~~~ + +The file is declarative data only. It cannot run scripts, load plugins, fetch +remote resources, or expand environment variables; those stay out of scope +through `v1.0.0`. + +## Precedence + +Values are applied in this order, lowest first: + +1. documented defaults (`comments: slash`, `visibility: public`, output + `build/docs`, failure threshold `error`); +2. the project configuration file; +3. explicit command-line options. + +Command-line options therefore always win. For repeatable options +(`include`, `exclude`, `unitPaths`, `includePaths`, `defines`, +`packagePaths`), the first command-line instance replaces the configured list +rather than appending to it. `--no-recursive` overrides +`discovery.recursive: true`, and `--visibility` overrides the configured +policy. + +## Paths + +Every path in the file is relative to the directory containing +`pasweave.json`: + +- `source` (input unit, directory, or Lazarus project/package); +- `output` (generated documentation root); +- `compiler.unitPaths`, `compiler.includePaths`, `compiler.packagePaths`. + +Absolute paths and parent traversal that escapes the configuration directory +are rejected, so a committed configuration stays portable and cannot read or +write outside the project. + +## Schema (version 1) + +Only the listed keys are accepted at each level; unknown keys and wrong types +are errors. + +~~~json +{ + "version": 1, + "source": "src", + "output": "build/docs", + "comments": "slash", + "visibility": "public", + "project": { + "name": "My API", + "mark": "API", + "repositoryUrl": "https://github.com/example/project", + "sourceLinkTemplate": "blob/main/{path}#L{line}", + "theme": { + "accent": "#7c3aed", + "accent2": "#0e7490", + "font": "Avenir Next" + } + }, + "discovery": { + "recursive": true, + "include": ["src/**"], + "exclude": ["generated/**", "vendor/**"] + }, + "compiler": { + "unitPaths": ["packages/core/src"], + "includePaths": ["include"], + "defines": ["USE_FAST_MATH"], + "targetOs": "linux", + "targetCpu": "aarch64", + "buildMode": "Release", + "packagePaths": ["packages"] + }, + "coverage": { + "minimum": 90, + "failOn": "warning" + } +} +~~~ + +| Key | Meaning | +|---|---| +| `version` | Required; must be `1`. | +| `source` | Input to document when no positional input is given. | +| `output` | Output root; default `build/docs`. | +| `comments` | Comment styles: `slash`, `brace`, `paren`, comma-separated, or `all`. | +| `visibility` | `public` (default) documents the public API only; `all` also documents private and strict-private declarations. | +| `project.name` | Display title; otherwise derived from the input. | +| `project.mark` | 1–4 character header mark. | +| `project.repositoryUrl` | Repository origin; must be paired with `project.sourceLinkTemplate`. | +| `project.sourceLinkTemplate` | Repository-relative template with `{path}` and `{line}`. | +| `project.theme.*` | Accent colors and body font; same validation as the CLI options. | +| `discovery.recursive` | Enables recursive discovery; `--no-recursive` overrides it. | +| `discovery.include` / `exclude` | Repeatable globs with the same rules as `--include`/`--exclude`; exclusions win. | +| `compiler.unitPaths` / `includePaths` | Search paths, validated to exist. | +| `compiler.defines` | Conditional defines; plain Pascal identifiers. | +| `compiler.targetOs` / `targetCpu` | Normalized like `--target-os`/`--target-cpu`. | +| `compiler.buildMode` | Required for `.lpi` inputs when no default mode is unique. | +| `compiler.packagePaths` | Additional Lazarus package roots. | +| `coverage.minimum` | Documentation coverage gate (`0`–`100`). | +| `coverage.failOn` | `error` (default) or `warning`. | + +## Reproducibility + +The effective normalized configuration is written to `api-model.json` as +`configuration`, alongside `configurationSource` (the file path, or empty when +none was used). The configuration file is also part of the incremental build +fingerprint, so editing it invalidates cached output. Combined with +`manifest.json`, a recorded build can be reproduced from the same commit, +configuration file, and command line. + +## Validation and errors + +Configuration problems are input errors: the CLI exits with code `2` and +prints a message naming the offending key, for example +`unknown configuration key: project.naem` or +`compiler.targetOs is not a supported FPC target: plan9`. Invalid JSON, a +missing file, an unsupported `version`, absolute paths, and escaping path +traversal are rejected the same way. + +`pasweave build --help` lists `--config`, `--visibility`, and `--no-recursive`. + +## Examples + +Document a directory with committed sources and links: + +~~~text +pasweave build --config=pasweave.json +~~~ + +Override the title for one run without editing the file: + +~~~text +pasweave build --config=pasweave.json --project-name "Nightly API" +~~~ + +Document internal declarations too (the private members are rendered, and +coverage follows the same population): + +~~~text +pasweave build --config=pasweave.json --visibility=all --clean +~~~ diff --git a/src/cli/PasWeave.CLI.pas b/src/cli/PasWeave.CLI.pas index 54968fe..d11f368 100644 --- a/src/cli/PasWeave.CLI.pas +++ b/src/cli/PasWeave.CLI.pas @@ -19,12 +19,13 @@ function RunPasWeave: Integer; implementation uses - Classes, SysUtils, PasWeave.Comments, PasWeave.Compiler, + Classes, SysUtils, PasWeave.Comments, PasWeave.Compiler, PasWeave.Config, PasWeave.Diagnostics, PasWeave.Incremental, PasWeave.Lazarus, PasWeave.Lazarus.Support, PasWeave.Model, PasWeave.Model.JSON, PasWeave.Output, PasWeave.Parser, PasWeave.SourceLinks, PasWeave.Render.Markdown, PasWeave.Render.HTML, - PasWeave.Render.HTML.Assets, PasWeave.Validation, PasWeave.Version; + PasWeave.Render.HTML.Assets, PasWeave.Render.Support, PasWeave.Validation, + PasWeave.Version; procedure PrintUsage; begin @@ -32,9 +33,11 @@ procedure PrintUsage; WriteLn; WriteLn('Usage:'); WriteLn(' pasweave --version'); - WriteLn(' pasweave build [--output ]'); + WriteLn(' pasweave build [] [--output ]'); + WriteLn(' [--config=] [--visibility=]'); WriteLn(' [--project-name ] [--doc-comments=]'); - WriteLn(' [--recursive] [--include=] [--exclude=]'); + WriteLn(' [--recursive|--no-recursive] [--include=]'); + WriteLn(' [--exclude=]'); WriteLn(' [--unit-path=] [--include-path=]'); WriteLn(' [--define=] [--target-os=] [--target-cpu=]'); WriteLn(' [--build-mode=] [--package-path=]'); @@ -84,6 +87,14 @@ procedure PrintUsage; WriteLn(' --theme-font= sets the primary body font family'); WriteLn(' defaults reproduce the built-in light and dark reader schemes'); WriteLn; + WriteLn('Project configuration:'); + WriteLn(' --config= loads a versioned project configuration'); + WriteLn(' explicit command-line values override configuration values'); + WriteLn(' relative file paths resolve from the configuration directory'); + WriteLn(' --no-recursive disables recursion inherited from a configuration'); + WriteLn(' --visibility=public (default) documents the public API only'); + WriteLn(' --visibility=all also documents private declarations'); + WriteLn; WriteLn('Documentation comment styles:'); WriteLn(' slash = /// lines (PasWeave convention; plain // is ignored)'); WriteLn(' brace = { ... }; paren = (* ... *)'); @@ -118,6 +129,17 @@ function MatchValueOption(const AParam, AName: string; var AIndex: Integer; AValue := Copy(AParam, Length(Prefix) + 1, MaxInt); end; +/// True when the command line asks for usage help before any build work. +function HelpRequestedOnCommandLine: Boolean; +var + Index: Integer; +begin + Result := False; + for Index := 2 to ParamCount do + if (ParamStr(Index) = '--help') or (ParamStr(Index) = '-h') then + Exit(True); +end; + procedure PrintDiagnostic(ADiagnostic: TDiagnostic; AVerbose: Boolean); var Location: string; @@ -188,6 +210,16 @@ function RunBuild: Integer; NewManifest: TManifest; NewPaths: TStringList; ParseHandled: Boolean; + ConfigFilename: string; + ConfigSource: string; + Config: TProjectConfig; + VisibilityPolicy: TRenderVisibilityPolicy; + IncludeExplicit: Boolean; + ExcludeExplicit: Boolean; + UnitPathExplicit: Boolean; + IncludePathExplicit: Boolean; + DefineExplicit: Boolean; + PackagePathExplicit: Boolean; procedure AppendConfigLine(const ALine: string); begin @@ -268,6 +300,113 @@ function RunBuild: Integer; end; end; + { Reads --config before the main parse so configuration values are applied + before CLI overrides. } + procedure ScanConfigOption; + var + Index: Integer; + Value: string; + Seen: Boolean; + begin + Seen := False; + Index := 2; + while Index <= ParamCount do + begin + if MatchValueOption(ParamStr(Index), '--config', Index, Value) then + begin + if Seen then + raise EPasWeaveInputError.Create( + 'only one --config option may be supplied'); + ConfigFilename := Value; + Seen := True; + end; + Inc(Index); + end; + end; + + { Resolves a validated configuration-relative path against the + configuration file's directory. } + function ResolveConfigPath(const AValue: string): string; + begin + Result := ExpandFileName(IncludeTrailingPathDelimiter(Config.Directory) + + AValue); + end; + + { Fills the build options from the loaded configuration. Values are applied + before ParseCommandLine, so explicit CLI values win. } + procedure ApplyProjectConfig; + var + Index: Integer; + begin + ConfigSource := Config.Filename; + VisibilityPolicy := Config.Visibility; + if Config.OutputPath <> '' then + OutputPath := ResolveConfigPath(Config.OutputPath); + if Config.SourcePath <> '' then + SourcePath := ResolveConfigPath(Config.SourcePath); + if Config.ProjectName <> '' then + begin + ProjectName := Config.ProjectName; + ProjectNameExplicit := True; + end; + if Config.ProjectMark <> '' then + begin + ProjectMark := Config.ProjectMark; + HasProjectMark := True; + end; + if Config.RepositoryURL <> '' then + RepositoryURL := Config.RepositoryURL; + if Config.SourceLinkTemplate <> '' then + SourceLinkTemplate := Config.SourceLinkTemplate; + if Config.ThemeAccent <> '' then + begin + ThemeAccent := Config.ThemeAccent; + HasThemeAccent := True; + end; + if Config.ThemeAccentAlt <> '' then + begin + ThemeAccentAlt := Config.ThemeAccentAlt; + HasThemeAccentAlt := True; + end; + if Config.ThemeFont <> '' then + begin + ThemeFont := Config.ThemeFont; + HasThemeFont := True; + end; + if Config.HasComments then + CommentStyles := Config.Comments; + if Config.Recursive then + DiscoveryOptions.Recursive := True; + for Index := 0 to Config.IncludePatterns.Count - 1 do + DiscoveryOptions.AddIncludePattern(Config.IncludePatterns[Index]); + for Index := 0 to Config.ExcludePatterns.Count - 1 do + DiscoveryOptions.AddExcludePattern(Config.ExcludePatterns[Index]); + for Index := 0 to Config.UnitPaths.Count - 1 do + CompilerOptions.AddUnitPath(ResolveConfigPath(Config.UnitPaths[Index])); + for Index := 0 to Config.IncludePaths.Count - 1 do + CompilerOptions.AddIncludePath( + ResolveConfigPath(Config.IncludePaths[Index])); + for Index := 0 to Config.Defines.Count - 1 do + CompilerOptions.AddDefine(Config.Defines[Index]); + if Config.TargetOS <> '' then + CompilerOptions.SetTargetOS(Config.TargetOS); + if Config.TargetCPU <> '' then + CompilerOptions.SetTargetCPU(Config.TargetCPU); + if Config.BuildMode <> '' then + BuildMode := Config.BuildMode; + for Index := 0 to Config.PackagePaths.Count - 1 do + PackagePaths.Add(ResolveConfigPath(Config.PackagePaths[Index])); + if Config.HasMinimumCoverage then + begin + MinimumCoverage := Config.MinimumCoverage; + HasMinimumCoverage := True; + end; + if (Config.FailOn <> '') and + not TryParseDiagnosticSeverity(Config.FailOn, FailureSeverity) then + raise EPasWeaveInputError.Create( + 'configuration coverage.failOn is invalid'); + end; + procedure ParseCommandLine; var OptionValue: string; @@ -291,18 +430,71 @@ function RunBuild: Integer; 'a comma-separated combination, or all)', [OptionValue]); end + else if MatchValueOption(ParamStr(I), '--config', I, OptionValue) then + begin + { Already read by ScanConfigOption; keep the parser total. } + if ConfigFilename = '' then + ConfigFilename := OptionValue; + end + else if MatchValueOption(ParamStr(I), '--visibility', I, OptionValue) then + begin + if SameText(OptionValue, 'public') then + VisibilityPolicy := rvpPublicAPI + else if SameText(OptionValue, 'all') then + VisibilityPolicy := rvpAllDeclarations + else + raise EPasWeaveInputError.Create( + '--visibility must be public or all'); + end else if ParamStr(I) = '--recursive' then DiscoveryOptions.Recursive := True + else if ParamStr(I) = '--no-recursive' then + DiscoveryOptions.Recursive := False else if MatchValueOption(ParamStr(I), '--include', I, OptionValue) then - DiscoveryOptions.AddIncludePattern(OptionValue) + begin + if not IncludeExplicit then + begin + DiscoveryOptions.IncludePatterns.Clear; + IncludeExplicit := True; + end; + DiscoveryOptions.AddIncludePattern(OptionValue); + end else if MatchValueOption(ParamStr(I), '--exclude', I, OptionValue) then - DiscoveryOptions.AddExcludePattern(OptionValue) + begin + if not ExcludeExplicit then + begin + DiscoveryOptions.ExcludePatterns.Clear; + ExcludeExplicit := True; + end; + DiscoveryOptions.AddExcludePattern(OptionValue); + end else if MatchValueOption(ParamStr(I), '--unit-path', I, OptionValue) then - CompilerOptions.AddUnitPath(OptionValue) + begin + if not UnitPathExplicit then + begin + CompilerOptions.UnitPaths.Clear; + UnitPathExplicit := True; + end; + CompilerOptions.AddUnitPath(OptionValue); + end else if MatchValueOption(ParamStr(I), '--include-path', I, OptionValue) then - CompilerOptions.AddIncludePath(OptionValue) + begin + if not IncludePathExplicit then + begin + CompilerOptions.IncludePaths.Clear; + IncludePathExplicit := True; + end; + CompilerOptions.AddIncludePath(OptionValue); + end else if MatchValueOption(ParamStr(I), '--define', I, OptionValue) then - CompilerOptions.AddDefine(OptionValue) + begin + if not DefineExplicit then + begin + CompilerOptions.Defines.Clear; + DefineExplicit := True; + end; + CompilerOptions.AddDefine(OptionValue); + end else if MatchValueOption(ParamStr(I), '--target-os', I, OptionValue) then CompilerOptions.SetTargetOS(OptionValue) else if MatchValueOption(ParamStr(I), '--target-cpu', I, OptionValue) then @@ -310,7 +502,14 @@ function RunBuild: Integer; else if MatchValueOption(ParamStr(I), '--build-mode', I, OptionValue) then BuildMode := OptionValue else if MatchValueOption(ParamStr(I), '--package-path', I, OptionValue) then - PackagePaths.Add(OptionValue) + begin + if not PackagePathExplicit then + begin + PackagePaths.Clear; + PackagePathExplicit := True; + end; + PackagePaths.Add(OptionValue); + end else if MatchValueOption(ParamStr(I), '--repository-url', I, OptionValue) then RepositoryURL := OptionValue else if MatchValueOption(ParamStr(I), '--source-link-template', I, OptionValue) then @@ -385,6 +584,7 @@ function RunBuild: Integer; var I: Integer; begin + SetRenderVisibilityPolicy(VisibilityPolicy); if SourcePath = '' then raise EPasWeaveInputError.Create('missing unit or source directory'); { Validate before fingerprinting so a typo reports a clean input error @@ -458,6 +658,10 @@ function RunBuild: Integer; AppendConfigValue('fail-on', DiagnosticSeverityName(FailureSeverity)); AppendConfigValue('output', StringReplace(OutputPath, '\', '/', [rfReplaceAll])); + AppendConfigValue('visibility', VisibilityPolicyName(VisibilityPolicy)); + if ConfigSource <> '' then + AppendConfigValue('config', StringReplace(ConfigSource, '\', '/', + [rfReplaceAll])); if IsLazarusInput then InputFiles := EnumerateInputFiles(SourcePath, True, nil, CompilerOptions, @@ -465,6 +669,8 @@ function RunBuild: Integer; else InputFiles := EnumerateInputFiles(SourcePath, False, DiscoveryOptions, CompilerOptions, nil, nil); + if ConfigSource <> '' then + InputFiles.Add(StringReplace(ConfigSource, '\', '/', [rfReplaceAll])); try Fingerprint := ComputeBuildFingerprint(ConfigText, InputFiles, ThirdPartyAssetFingerprint); @@ -526,6 +732,10 @@ function RunBuild: Integer; Project.ThemeAccentAlt := ThemeAccentAlt; if HasThemeFont then Project.ThemeFont := ThemeFont; + Project.ConfigurationSource := ConfigSource; + Project.ConfigurationText := ConfigText; + if ConfigSource <> '' then + WriteLn('Using configuration ', ConfigSource); if HasMinimumCoverage then AddDocumentationCoverageDiagnostic(Project, MinimumCoverage); OutputFile := IncludeTrailingPathDelimiter(OutputPath) + 'api-model.json'; @@ -625,6 +835,17 @@ function RunBuild: Integer; LazarusConfiguration := nil; ParseHandled := False; try + ScanConfigOption; + if (ConfigFilename <> '') and not HelpRequestedOnCommandLine then + begin + Config := LoadProjectConfig(ConfigFilename); + try + ApplyProjectConfig; + finally + Config.Free; + Config := nil; + end; + end; ParseCommandLine; if ParseHandled then Exit(0); @@ -666,6 +887,12 @@ function RunPasWeave: Integer; WriteLn(StdErr, 'Run "pasweave --help" for usage.'); Result := 2; end; + on E: EProjectConfigError do + begin + WriteLn(StdErr, 'pasweave: ', E.Message); + WriteLn(StdErr, 'Run "pasweave --help" for usage.'); + Result := 2; + end; on E: ECompilerConfigurationError do begin WriteLn(StdErr, 'pasweave: ', E.Message); diff --git a/src/cli/PasWeave.Config.pas b/src/cli/PasWeave.Config.pas new file mode 100644 index 0000000..e650fe1 --- /dev/null +++ b/src/cli/PasWeave.Config.pas @@ -0,0 +1,550 @@ +/// Loads and validates the versioned `pasweave.json` project configuration. +/// +/// The configuration is strictly declarative: unknown keys, wrong types, +/// unsupported versions, absolute paths, and parent traversal that escapes the +/// configuration directory are rejected with a descriptive error. Relative +/// paths are resolved against the configuration file's directory by the +/// caller, so the effective configuration stays reproducible across machines. +unit PasWeave.Config; + +{$mode objfpc}{$H+}{$J+} + +interface + +uses + Classes, SysUtils, PasWeave.Comments, PasWeave.Diagnostics, PasWeave.Model; + +const + /// Supported `version` value in `pasweave.json`. + ProjectConfigVersion = 1; + /// Default configuration filename used with `--config` discovery. + ProjectConfigFilename = 'pasweave.json'; + +type + /// Raised for any invalid, unreadable, or unsupported configuration. + EProjectConfigError = class(Exception); + + /// Parsed and validated `pasweave.json` contents. + TProjectConfig = class + public + /// Absolute path of the loaded file. + Filename: string; + /// Directory used as the base for every relative path. + Directory: string; + /// Relative source input (`source`). + SourcePath: string; + /// Relative output directory (`output`). + OutputPath: string; + /// Project title (`project.name`). + ProjectName: string; + /// Header brand mark (`project.mark`). + ProjectMark: string; + /// Repository origin (`project.repositoryUrl`). + RepositoryURL: string; + /// Repository-relative line template (`project.sourceLinkTemplate`). + SourceLinkTemplate: string; + /// Primary accent color (`project.theme.accent`). + ThemeAccent: string; + /// Secondary accent color (`project.theme.accent2`). + ThemeAccentAlt: string; + /// Body font family (`project.theme.font`). + ThemeFont: string; + /// Documentation comment styles (`comments`); default `slash`. + Comments: TDocumentationCommentStyles; + /// True when `comments` was present. + HasComments: Boolean; + /// Declaration visibility policy (`visibility`). + Visibility: TRenderVisibilityPolicy; + /// Recursive discovery (`discovery.recursive`). + Recursive: Boolean; + /// Owned include/exclude globs (`discovery.include`/`exclude`). + IncludePatterns: TStringList; + ExcludePatterns: TStringList; + /// Owned compiler search paths and defines. + UnitPaths: TStringList; + IncludePaths: TStringList; + Defines: TStringList; + PackagePaths: TStringList; + /// Normalized target selection (`compiler.targetOs`/`targetCpu`). + TargetOS: string; + TargetCPU: string; + /// Lazarus build mode (`compiler.buildMode`). + BuildMode: string; + /// Documentation coverage minimum (`coverage.minimum`). + MinimumCoverage: Integer; + /// True when `coverage.minimum` was present. + HasMinimumCoverage: Boolean; + /// Failure threshold (`coverage.failOn`); empty uses the default. + FailOn: string; + constructor Create; + destructor Destroy; override; + end; + +/// Loads and validates one configuration file. +/// +/// @param AFilename Path to `pasweave.json`. +/// @returns An owned configuration; the caller frees it. +/// @raises EProjectConfigError for unreadable files, invalid JSON, unknown +/// keys, wrong types, unsupported versions, or unsafe paths. +function LoadProjectConfig(const AFilename: string): TProjectConfig; + +/// Stable lowercase name of a visibility policy. +function VisibilityPolicyName(APolicy: TRenderVisibilityPolicy): string; + +implementation + +uses + FPJSON, JSONParser, PasWeave.Compiler, PasWeave.FS, PasWeave.Parser; + +type + TJSONKind = (jkObject, jkString, jkBoolean, jkInteger, jkArray); + +procedure Fail(const AMessage: string); +begin + raise EProjectConfigError.Create(AMessage); +end; + +function KeyPath(const AParent, AKey: string): string; +begin + if AParent = '' then + Result := AKey + else + Result := AParent + '.' + AKey; +end; + +procedure EnsureKnownKeys(AObject: TJSONObject; + const AAllowed: array of string; const APath: string); +var + I: Integer; + J: Integer; + Allowed: Boolean; +begin + for I := 0 to AObject.Count - 1 do + begin + Allowed := False; + for J := Low(AAllowed) to High(AAllowed) do + if SameText(AObject.Names[I], AAllowed[J]) then + begin + Allowed := True; + Break; + end; + if not Allowed then + Fail('unknown configuration key: ' + KeyPath(APath, AObject.Names[I])); + end; +end; + +procedure ExpectKind(AItem: TJSONData; AKind: TJSONKind; + const APath: string); +var + Matches: Boolean; +begin + case AKind of + jkObject: Matches := AItem.JSONType = jtObject; + jkString: Matches := AItem.JSONType = jtString; + jkBoolean: Matches := AItem.JSONType = jtBoolean; + jkInteger: Matches := AItem.JSONType = jtNumber; + jkArray: Matches := AItem.JSONType = jtArray; + else + Matches := False; + end; + if not Matches then + Fail('configuration key has the wrong type: ' + APath); +end; + +function OptionalString(AObject: TJSONObject; const AKey, APath: string; + out AValue: string): Boolean; +var + Item: TJSONData; +begin + AValue := ''; + Item := AObject.Find(AKey); + Result := Assigned(Item); + if not Result then + Exit; + ExpectKind(Item, jkString, KeyPath(APath, AKey)); + AValue := Item.AsString; +end; + +function OptionalBoolean(AObject: TJSONObject; const AKey, APath: string; + out AValue: Boolean): Boolean; +var + Item: TJSONData; +begin + AValue := False; + Item := AObject.Find(AKey); + Result := Assigned(Item); + if not Result then + Exit; + ExpectKind(Item, jkBoolean, KeyPath(APath, AKey)); + AValue := Item.AsBoolean; +end; + +function OptionalInteger(AObject: TJSONObject; const AKey, APath: string; + out AValue: Integer): Boolean; +var + Item: TJSONData; +begin + AValue := 0; + Item := AObject.Find(AKey); + Result := Assigned(Item); + if not Result then + Exit; + ExpectKind(Item, jkInteger, KeyPath(APath, AKey)); + AValue := Item.AsInteger; +end; + +procedure AddStringArray(AObject: TJSONObject; const AKey, APath: string; + ATarget: TStrings); +var + Item: TJSONData; + Elements: TJSONArray; + I: Integer; +begin + Item := AObject.Find(AKey); + if not Assigned(Item) then + Exit; + ExpectKind(Item, jkArray, KeyPath(APath, AKey)); + Elements := TJSONArray(Item); + for I := 0 to Elements.Count - 1 do + begin + if Elements[I].JSONType <> jtString then + Fail(KeyPath(APath, AKey) + ' entries must be strings'); + ATarget.Add(Elements[I].AsString); + end; +end; + +{ Validates a relative path and rejects absolute paths and traversal that + escapes the configuration directory. Returns the normalized original so the + effective configuration does not depend on the host. } +function CheckRelativePath(const AValue: string; + const APath: string): string; +var + Normalised: string; + Start: Integer; + Stop: Integer; + Segment: string; + Depth: Integer; +begin + Normalised := StringReplace(Trim(AValue), '\', '/', [rfReplaceAll]); + if Normalised = '' then + Fail(APath + ' must not be empty'); + if (Normalised[1] = '/') or + ((Length(Normalised) >= 2) and (Normalised[2] = ':')) then + Fail(APath + ' must be relative to the configuration directory: ' + + AValue); + Depth := 0; + Start := 1; + while Start <= Length(Normalised) do + begin + Stop := Start; + while (Stop <= Length(Normalised)) and (Normalised[Stop] <> '/') do + Inc(Stop); + Segment := Copy(Normalised, Start, Stop - Start); + if Segment = '' then + Fail(APath + ' contains an empty path segment: ' + AValue) + else if Segment = '.' then + Fail(APath + ' must not contain "." segments: ' + AValue) + else if Segment = '..' then + begin + Dec(Depth); + if Depth < 0 then + Fail(APath + ' escapes the configuration directory: ' + AValue); + end + else + Inc(Depth); + Start := Stop + 1; + end; + Result := Normalised; +end; + +procedure AddPathArray(AObject: TJSONObject; const AKey, APath: string; + ATarget: TStrings); +var + Values: TStringList; + I: Integer; +begin + Values := TStringList.Create; + try + AddStringArray(AObject, AKey, APath, Values); + for I := 0 to Values.Count - 1 do + ATarget.Add(CheckRelativePath(Values[I], + KeyPath(APath, AKey) + '[' + IntToStr(I) + ']')); + finally + Values.Free; + end; +end; + +procedure LoadComments(const AValue, APath: string; + AConfig: TProjectConfig); +begin + if not TryParseDocumentationCommentStyles(AValue, AConfig.Comments) then + Fail(APath + ' is not a supported comment style: ' + AValue); + AConfig.HasComments := True; +end; + +procedure LoadVisibility(const AValue, APath: string; AConfig: TProjectConfig); +begin + if SameText(AValue, 'public') then + AConfig.Visibility := rvpPublicAPI + else if SameText(AValue, 'all') then + AConfig.Visibility := rvpAllDeclarations + else + Fail(APath + ' must be "public" or "all": ' + AValue); +end; + +procedure LoadProjectSection(AObject: TJSONObject; AConfig: TProjectConfig); +var + Value: string; + Theme: TJSONData; + ThemeObject: TJSONObject; +begin + EnsureKnownKeys(AObject, + ['name', 'mark', 'repositoryUrl', 'sourceLinkTemplate', 'theme'], + 'project'); + if OptionalString(AObject, 'name', 'project', Value) then + AConfig.ProjectName := Value; + if OptionalString(AObject, 'mark', 'project', Value) then + begin + if not IsValidProjectMark(Value) then + Fail('project.mark must be 1 to 4 alphanumeric characters'); + AConfig.ProjectMark := Value; + end; + if OptionalString(AObject, 'repositoryUrl', 'project', Value) then + AConfig.RepositoryURL := Value; + if OptionalString(AObject, 'sourceLinkTemplate', 'project', Value) then + AConfig.SourceLinkTemplate := Value; + Theme := AObject.Find('theme'); + if not Assigned(Theme) then + Exit; + ExpectKind(Theme, jkObject, 'project.theme'); + ThemeObject := TJSONObject(Theme); + EnsureKnownKeys(ThemeObject, ['accent', 'accent2', 'font'], + 'project.theme'); + if OptionalString(ThemeObject, 'accent', 'project.theme', Value) then + begin + if not IsValidThemeColor(Value) then + Fail('project.theme.accent must be a #RGB, #RRGGBB, or #RRGGBBAA color'); + AConfig.ThemeAccent := Value; + end; + if OptionalString(ThemeObject, 'accent2', 'project.theme', Value) then + begin + if not IsValidThemeColor(Value) then + Fail('project.theme.accent2 must be a #RGB, #RRGGBB, or #RRGGBBAA color'); + AConfig.ThemeAccentAlt := Value; + end; + if OptionalString(ThemeObject, 'font', 'project.theme', Value) then + begin + if not IsValidThemeFont(Value) then + Fail('project.theme.font must be a safe font family name'); + AConfig.ThemeFont := Value; + end; +end; + +procedure LoadDiscoverySection(AObject: TJSONObject; AConfig: TProjectConfig); +var + Value: Boolean; + Probe: TSourceDiscoveryOptions; + Include: TStringList; + Exclude: TStringList; + I: Integer; +begin + EnsureKnownKeys(AObject, ['recursive', 'include', 'exclude'], 'discovery'); + if OptionalBoolean(AObject, 'recursive', 'discovery', Value) then + AConfig.Recursive := Value; + { Reuse the discovery glob validator so config and CLI rules cannot drift. } + Include := TStringList.Create; + Exclude := TStringList.Create; + Probe := TSourceDiscoveryOptions.Create; + try + AddStringArray(AObject, 'include', 'discovery', Include); + AddStringArray(AObject, 'exclude', 'discovery', Exclude); + try + for I := 0 to Include.Count - 1 do + Probe.AddIncludePattern(Include[I]); + for I := 0 to Exclude.Count - 1 do + Probe.AddExcludePattern(Exclude[I]); + except + on E: EPasWeaveInputError do + Fail('discovery include/exclude ' + E.Message); + end; + for I := 0 to Probe.IncludePatterns.Count - 1 do + AConfig.IncludePatterns.Add(Probe.IncludePatterns[I]); + for I := 0 to Probe.ExcludePatterns.Count - 1 do + AConfig.ExcludePatterns.Add(Probe.ExcludePatterns[I]); + finally + Probe.Free; + Exclude.Free; + Include.Free; + end; +end; + +procedure LoadCompilerSection(AObject: TJSONObject; AConfig: TProjectConfig); +var + Value: string; + Normalised: string; + I: Integer; +begin + EnsureKnownKeys(AObject, + ['unitPaths', 'includePaths', 'defines', 'targetOs', 'targetCpu', + 'buildMode', 'packagePaths'], 'compiler'); + AddPathArray(AObject, 'unitPaths', 'compiler', AConfig.UnitPaths); + AddPathArray(AObject, 'includePaths', 'compiler', AConfig.IncludePaths); + AddPathArray(AObject, 'packagePaths', 'compiler', AConfig.PackagePaths); + AddStringArray(AObject, 'defines', 'compiler', AConfig.Defines); + for I := 0 to AConfig.Defines.Count - 1 do + if not IsValidConditionalDefine(AConfig.Defines[I]) then + Fail('compiler.defines[' + IntToStr(I) + + '] must be a plain Pascal identifier: ' + AConfig.Defines[I]); + if OptionalString(AObject, 'targetOs', 'compiler', Value) then + begin + if not TryNormaliseTargetOS(Value, Normalised) then + Fail('compiler.targetOs is not a supported FPC target: ' + Value); + AConfig.TargetOS := Normalised; + end; + if OptionalString(AObject, 'targetCpu', 'compiler', Value) then + begin + if not TryNormaliseTargetCPU(Value, Normalised) then + Fail('compiler.targetCpu is not a supported FPC target: ' + Value); + AConfig.TargetCPU := Normalised; + end; + if OptionalString(AObject, 'buildMode', 'compiler', Value) then + begin + if Trim(Value) = '' then + Fail('compiler.buildMode must not be empty'); + AConfig.BuildMode := Value; + end; +end; + +procedure LoadCoverageSection(AObject: TJSONObject; AConfig: TProjectConfig); +var + Minimum: Integer; + Value: string; + Severity: TDiagnosticSeverity; +begin + EnsureKnownKeys(AObject, ['minimum', 'failOn'], 'coverage'); + if OptionalInteger(AObject, 'minimum', 'coverage', Minimum) then + begin + if (Minimum < 0) or (Minimum > 100) then + Fail('coverage.minimum must be an integer from 0 to 100'); + AConfig.MinimumCoverage := Minimum; + AConfig.HasMinimumCoverage := True; + end; + if OptionalString(AObject, 'failOn', 'coverage', Value) then + begin + if not TryParseDiagnosticSeverity(Value, Severity) then + Fail('coverage.failOn must be warning or error'); + AConfig.FailOn := DiagnosticSeverityName(Severity); + end; +end; + +constructor TProjectConfig.Create; +begin + inherited Create; + Visibility := rvpPublicAPI; + Comments := DefaultDocumentationCommentStyles; + IncludePatterns := TStringList.Create; + ExcludePatterns := TStringList.Create; + UnitPaths := TStringList.Create; + IncludePaths := TStringList.Create; + Defines := TStringList.Create; + PackagePaths := TStringList.Create; +end; + +destructor TProjectConfig.Destroy; +begin + PackagePaths.Free; + Defines.Free; + IncludePaths.Free; + UnitPaths.Free; + ExcludePatterns.Free; + IncludePatterns.Free; + inherited Destroy; +end; + +function VisibilityPolicyName(APolicy: TRenderVisibilityPolicy): string; +begin + case APolicy of + rvpPublicAPI: Result := 'public'; + rvpAllDeclarations: Result := 'all'; + end; +end; + +function LoadProjectConfig(const AFilename: string): TProjectConfig; +var + Root: TJSONData; + RootObject: TJSONObject; + Section: TJSONData; + Version: Integer; + Value: string; +begin + if not FileExists(AFilename) then + raise EProjectConfigError.CreateFmt( + 'configuration file does not exist: %s', [AFilename]); + try + Root := GetJSON(ReadFileToString(AFilename)); + except + on E: Exception do + raise EProjectConfigError.CreateFmt( + 'configuration file is not valid JSON: %s: %s', + [AFilename, E.Message]); + end; + try + if Root.JSONType <> jtObject then + Fail('configuration root must be a JSON object'); + RootObject := TJSONObject(Root); + EnsureKnownKeys(RootObject, + ['version', 'source', 'output', 'project', 'comments', 'discovery', + 'compiler', 'visibility', 'coverage'], ''); + if not OptionalInteger(RootObject, 'version', '', Version) then + Fail('configuration is missing the required version key'); + if Version <> ProjectConfigVersion then + Fail(Format('unsupported configuration version: %d (expected %d)', + [Version, ProjectConfigVersion])); + + Result := TProjectConfig.Create; + try + Result.Filename := ExpandFileName(AFilename); + Result.Directory := ExtractFileDir(Result.Filename); + if OptionalString(RootObject, 'source', '', Value) then + Result.SourcePath := CheckRelativePath(Value, 'source'); + if OptionalString(RootObject, 'output', '', Value) then + Result.OutputPath := CheckRelativePath(Value, 'output'); + if OptionalString(RootObject, 'comments', '', Value) then + LoadComments(Value, 'comments', Result); + if OptionalString(RootObject, 'visibility', '', Value) then + LoadVisibility(Value, 'visibility', Result); + + Section := RootObject.Find('project'); + if Assigned(Section) then + begin + ExpectKind(Section, jkObject, 'project'); + LoadProjectSection(TJSONObject(Section), Result); + end; + Section := RootObject.Find('discovery'); + if Assigned(Section) then + begin + ExpectKind(Section, jkObject, 'discovery'); + LoadDiscoverySection(TJSONObject(Section), Result); + end; + Section := RootObject.Find('compiler'); + if Assigned(Section) then + begin + ExpectKind(Section, jkObject, 'compiler'); + LoadCompilerSection(TJSONObject(Section), Result); + end; + Section := RootObject.Find('coverage'); + if Assigned(Section) then + begin + ExpectKind(Section, jkObject, 'coverage'); + LoadCoverageSection(TJSONObject(Section), Result); + end; + except + Result.Free; + raise; + end; + finally + Root.Free; + end; +end; + +end. diff --git a/src/model/PasWeave.Model.JSON.pas b/src/model/PasWeave.Model.JSON.pas index 2aea016..1fb5252 100644 --- a/src/model/PasWeave.Model.JSON.pas +++ b/src/model/PasWeave.Model.JSON.pas @@ -226,6 +226,8 @@ function ProjectToJSON(AProject: TDocProject): UTF8String; Root.Add('themeAccent', AProject.ThemeAccent); Root.Add('themeAccentAlt', AProject.ThemeAccentAlt); Root.Add('themeFont', AProject.ThemeFont); + Root.Add('configurationSource', AProject.ConfigurationSource); + Root.Add('configuration', AProject.ConfigurationText); Root.Add('units', UnitsToJSON(AProject.Units)); Root.Add('warnings', DiagnosticListToJSON(AProject.Warnings)); Root.Add('errors', DiagnosticListToJSON(AProject.Errors)); diff --git a/src/model/PasWeave.Model.pas b/src/model/PasWeave.Model.pas index d450a38..d939291 100644 --- a/src/model/PasWeave.Model.pas +++ b/src/model/PasWeave.Model.pas @@ -44,6 +44,14 @@ interface trkImplementation ); + /// Which declarations become part of browseable documentation and coverage. + TRenderVisibilityPolicy = ( + /// Public API only: private and strict-private stay JSON-only. + rvpPublicAPI, + /// Every declaration, including private and strict-private members. + rvpAllDeclarations + ); + /// One structured documentation directive from a comment group. TDocDirective = class public @@ -136,6 +144,10 @@ TDocProject = class ThemeAccentAlt: string; /// Body font family; see @link(IsValidThemeFont). ThemeFont: string; + /// Path of the project configuration used, when any. + ConfigurationSource: string; + /// Normalized effective configuration text used for the build fingerprint. + ConfigurationText: string; /// Owned `TDocUnit` objects in discovery order. Units: TObjectList; /// Owned warnings (`TDiagnostic`). diff --git a/src/render/PasWeave.Render.Support.pas b/src/render/PasWeave.Render.Support.pas index 41b2072..b56a3c1 100644 --- a/src/render/PasWeave.Render.Support.pas +++ b/src/render/PasWeave.Render.Support.pas @@ -48,6 +48,13 @@ function FindUnitByName(AProject: TDocProject; const AName: string): TDocUnit; function IsDirectlyRenderable(ASymbol: TDocSymbol): Boolean; function IsEffectivelyRenderable(AUnit: TDocUnit; ASymbol: TDocSymbol): Boolean; +/// Selects which declarations renderers and validation treat as documented. +/// +/// Defaults to the public API policy; the CLI applies the effective project +/// configuration before any rendering or validation runs. +procedure SetRenderVisibilityPolicy(APolicy: TRenderVisibilityPolicy); +/// Returns the active visibility policy. +function RenderVisibilityPolicy: TRenderVisibilityPolicy; function SortedSymbols(AUnit: TDocUnit; AKinds: TSymbolKinds): TStringList; function UnitSymbol(AUnit: TDocUnit): TDocSymbol; function IndexedSymbolCount(AUnit: TDocUnit): Integer; @@ -60,6 +67,9 @@ implementation uses SysUtils; +var + GRenderVisibilityPolicy: TRenderVisibilityPolicy = rvpPublicAPI; + procedure AppendLine(var AOutput: UTF8String; const ALine: UTF8String = ''); begin AOutput := AOutput + ALine + #10; @@ -241,7 +251,18 @@ function FindUnitByName(AProject: TDocProject; const AName: string): TDocUnit; function IsDirectlyRenderable(ASymbol: TDocSymbol): Boolean; begin - Result := not (ASymbol.Visibility in [svPrivate, svStrictPrivate]); + Result := (GRenderVisibilityPolicy = rvpAllDeclarations) or + not (ASymbol.Visibility in [svPrivate, svStrictPrivate]); +end; + +procedure SetRenderVisibilityPolicy(APolicy: TRenderVisibilityPolicy); +begin + GRenderVisibilityPolicy := APolicy; +end; + +function RenderVisibilityPolicy: TRenderVisibilityPolicy; +begin + Result := GRenderVisibilityPolicy; end; function IsEffectivelyRenderable(AUnit: TDocUnit; diff --git a/tests/PasWeave.ConfigTests.pas b/tests/PasWeave.ConfigTests.pas new file mode 100644 index 0000000..29acb3d --- /dev/null +++ b/tests/PasWeave.ConfigTests.pas @@ -0,0 +1,206 @@ +unit PasWeave.ConfigTests; + +{$mode objfpc}{$H+}{$J+} + +interface + +procedure RunConfigTests; + +implementation + +uses + SysUtils, PasWeave.Comments, PasWeave.Config, PasWeave.Model, + PasWeave.TestSupport; + +const + ConfigDirectory = 'build/config-test'; + +procedure WriteConfig(const AName, AJSON: string); +begin + WriteTextFile(ConfigDirectory + '/' + AName, AJSON); +end; + +procedure ExpectRejected(const AName, AJSON, AExpectedFragment: string); +var + ErrorMessage: string; +begin + WriteConfig(AName, AJSON); + ErrorMessage := ''; + try + LoadProjectConfig(ConfigDirectory + '/' + AName).Free; + except + on E: EProjectConfigError do + ErrorMessage := E.Message; + end; + Check(ErrorMessage <> '', AName + ' should be rejected'); + Check(Pos(AExpectedFragment, ErrorMessage) > 0, + AName + ' should explain the problem (got: ' + ErrorMessage + ')'); +end; + +procedure CheckValidConfiguration; +var + Config: TProjectConfig; +begin + WriteConfig('valid.json', + '{' + + ' "version": 1,' + + ' "source": "src",' + + ' "output": "build/docs",' + + ' "comments": "slash, brace",' + + ' "visibility": "all",' + + ' "project": {' + + ' "name": "Config Fixture",' + + ' "mark": "CFG",' + + ' "repositoryUrl": "https://github.com/example/project",' + + ' "sourceLinkTemplate": "blob/main/{path}#L{line}",' + + ' "theme": {' + + ' "accent": "#123456",' + + ' "accent2": "#abcdef",' + + ' "font": "Avenir Next"' + + ' }' + + ' },' + + ' "discovery": {' + + ' "recursive": true,' + + ' "include": ["src/**"],' + + ' "exclude": ["generated/**", "vendor/**"]' + + ' },' + + ' "compiler": {' + + ' "unitPaths": ["packages/core/src"],' + + ' "includePaths": ["include"],' + + ' "defines": ["USE_FAST_MATH"],' + + ' "targetOs": "macOS",' + + ' "targetCpu": "arm64",' + + ' "buildMode": "Release",' + + ' "packagePaths": ["packages"]' + + ' },' + + ' "coverage": {' + + ' "minimum": 90,' + + ' "failOn": "warning"' + + ' }' + + '}'); + Config := LoadProjectConfig(ConfigDirectory + '/valid.json'); + try + Check(Config.SourcePath = 'src', 'source should be retained'); + Check(Config.OutputPath = 'build/docs', 'output should be retained'); + Check(Config.HasComments and (Config.Comments = + [dcsSlash, dcsBrace]), 'comment styles should parse'); + Check(Config.Visibility = rvpAllDeclarations, + 'visibility "all" should select every declaration'); + Check(Config.ProjectName = 'Config Fixture', 'project name should parse'); + Check(Config.ProjectMark = 'CFG', 'project mark should parse'); + Check(Config.RepositoryURL = 'https://github.com/example/project', + 'repository URL should parse'); + Check(Config.SourceLinkTemplate = 'blob/main/{path}#L{line}', + 'source-link template should parse'); + Check((Config.ThemeAccent = '#123456') and + (Config.ThemeAccentAlt = '#abcdef') and + (Config.ThemeFont = 'Avenir Next'), 'theme tokens should parse'); + Check(Config.Recursive, 'recursive discovery should parse'); + Check((Config.IncludePatterns.Count = 1) and + (Config.IncludePatterns[0] = 'src/**'), 'include globs should parse'); + Check((Config.ExcludePatterns.Count = 2) and + (Config.ExcludePatterns[1] = 'vendor/**'), 'exclude globs should parse'); + Check((Config.UnitPaths.Count = 1) and + (Config.UnitPaths[0] = 'packages/core/src'), 'unit paths should parse'); + Check((Config.IncludePaths.Count = 1) and + (Config.IncludePaths[0] = 'include'), 'include paths should parse'); + Check((Config.Defines.Count = 1) and + (Config.Defines[0] = 'USE_FAST_MATH'), 'defines should parse'); + Check((Config.TargetOS = 'darwin') and (Config.TargetCPU = 'aarch64'), + 'target aliases should normalize like the CLI'); + Check(Config.BuildMode = 'Release', 'build mode should parse'); + Check((Config.PackagePaths.Count = 1) and + (Config.PackagePaths[0] = 'packages'), 'package paths should parse'); + Check(Config.HasMinimumCoverage and (Config.MinimumCoverage = 90), + 'coverage minimum should parse'); + Check(Config.FailOn = 'warning', 'failure threshold should parse'); + finally + Config.Free; + end; +end; + +procedure CheckMinimalConfiguration; +var + Config: TProjectConfig; +begin + WriteConfig('minimal.json', '{"version": 1}'); + Config := LoadProjectConfig(ConfigDirectory + '/minimal.json'); + try + Check((Config.SourcePath = '') and (Config.OutputPath = ''), + 'absent paths should stay empty for CLI defaults'); + Check(not Config.HasComments and (Config.Comments = + DefaultDocumentationCommentStyles), 'absent comments should use default'); + Check(Config.Visibility = rvpPublicAPI, + 'absent visibility should use the public API policy'); + Check(not Config.HasMinimumCoverage, 'absent coverage should stay unset'); + finally + Config.Free; + end; +end; + +procedure CheckRejectedConfigurations; +begin + ExpectRejected('unknown-root.json', + '{"version": 1, "sourcez": "src"}', 'unknown configuration key'); + ExpectRejected('unknown-nested.json', + '{"version": 1, "project": {"naem": "x"}}', + 'unknown configuration key: project.naem'); + ExpectRejected('wrong-type.json', '{"version": 1, "source": 5}', + 'wrong type: source'); + ExpectRejected('missing-version.json', '{"source": "src"}', + 'missing the required version'); + ExpectRejected('future-version.json', '{"version": 2}', + 'unsupported configuration version'); + ExpectRejected('non-object-root.json', '[1, 2]', + 'root must be a JSON object'); + ExpectRejected('invalid-json.json', '{not json', + 'not valid JSON'); + ExpectRejected('absolute-path.json', '{"version": 1, "source": "/etc"}', + 'must be relative'); + ExpectRejected('traversal.json', '{"version": 1, "source": "../outside"}', + 'escapes the configuration directory'); + ExpectRejected('nested-traversal.json', + '{"version": 1, "compiler": {"unitPaths": ["a/../../b"]}}', + 'escapes the configuration directory'); + ExpectRejected('empty-path.json', '{"version": 1, "output": ""}', + 'must not be empty'); + ExpectRejected('bad-comments.json', '{"version": 1, "comments": "stars"}', + 'not a supported comment style'); + ExpectRejected('bad-visibility.json', '{"version": 1, "visibility": "none"}', + 'must be "public" or "all"'); + ExpectRejected('bad-mark.json', '{"version": 1, "project": {"mark": "TOOLONG"}}', + 'mark must be 1 to 4 alphanumeric'); + ExpectRejected('bad-color.json', + '{"version": 1, "project": {"theme": {"accent": "red"}}}', + 'accent must be a #RGB'); + ExpectRejected('bad-font.json', + '{"version": 1, "project": {"theme": {"font": "x;}"}}}', + 'font must be a safe font family name'); + ExpectRejected('bad-target-os.json', + '{"version": 1, "compiler": {"targetOs": "plan9"}}', + 'not a supported FPC target'); + ExpectRejected('bad-target-cpu.json', + '{"version": 1, "compiler": {"targetCpu": "z80"}}', + 'not a supported FPC target'); + ExpectRejected('bad-define.json', '{"version": 1, "compiler": {"defines": ["2BAD"]}}', + 'plain Pascal identifier'); + ExpectRejected('bad-coverage.json', '{"version": 1, "coverage": {"minimum": 101}}', + 'integer from 0 to 100'); + ExpectRejected('bad-fail-on.json', '{"version": 1, "coverage": {"failOn": "info"}}', + 'must be warning or error'); + ExpectRejected('bad-glob.json', + '{"version": 1, "discovery": {"exclude": ["../outside"]}}', + 'discovery include/exclude'); +end; + +procedure RunConfigTests; +begin + BeginTest('project configuration'); + DeleteTree(ConfigDirectory); + CheckValidConfiguration; + CheckMinimalConfiguration; + CheckRejectedConfigurations; + DeleteTree(ConfigDirectory); +end; + +end. diff --git a/tests/test_cli.pas b/tests/test_cli.pas index 8db9afb..3e9cb9a 100644 --- a/tests/test_cli.pas +++ b/tests/test_cli.pas @@ -248,6 +248,94 @@ procedure CheckFailurePolicies; Pos('[error ', StdOut) > 0); end; +procedure CheckProjectConfiguration; +const + ProjectDirectory = 'build/cli-test/config-project'; + ConfigFile = ProjectDirectory + '/pasweave.json'; +var + StdOut: string; + StdErr: string; + ExitCode: Integer; + ModelJSON: string; +begin + DeleteTree(ProjectDirectory); + WriteTextFile(ProjectDirectory + '/src/ConfigUnit.pas', + 'unit ConfigUnit;' + #10 + + '{$mode objfpc}{$H+}{$J+}' + #10 + + 'interface' + #10 + + 'type' + #10 + + ' TBox = class' + #10 + + ' private' + #10 + + ' FSecret: Integer;' + #10 + + ' public' + #10 + + ' function Value: Integer;' + #10 + + ' end;' + #10 + + 'function Add(const A, B: Integer): Integer;' + #10 + + 'implementation' + #10 + + 'end.' + #10); + WriteTextFile(ConfigFile, + '{' + + ' "version": 1,' + + ' "source": "src",' + + ' "output": "docs",' + + ' "project": {"name": "Configured Project", "mark": "CFG"},' + + ' "comments": "slash",' + + ' "visibility": "public",' + + ' "coverage": {"minimum": 0, "failOn": "error"}' + + '}'); + + ExitCode := RunCli(['build', '--config=' + ConfigFile], StdOut, StdErr); + Expect('a configuration build should exit 0', ExitCode = 0); + Expect('a configuration build should name the config file', + Pos('Using configuration', StdOut) > 0); + Expect('config-relative output should be written', + FileExists(ProjectDirectory + '/docs/html/index.html')); + ModelJSON := string(ReadUTF8File(ProjectDirectory + + '/docs/api-model.json')); + Expect('the model should record the configuration source', + Pos('"configurationSource"', ModelJSON) > 0); + Expect('the model should record the effective configuration', + Pos('"configuration"', ModelJSON) > 0); + Expect('the configured project name should reach the model', + Pos('Configured Project', ModelJSON) > 0); + Expect('the default visibility policy should exclude private members', + Pos('FSecret', string(ReadUTF8File(ProjectDirectory + + '/docs/html/units/ConfigUnit.html'))) = 0); + + ExitCode := RunCli(['build', '--config=' + ConfigFile, '--clean', + '--visibility=all'], StdOut, StdErr); + Expect('the all-declarations policy should exit 0', ExitCode = 0); + Expect('the all-declarations policy should render private members', + Pos('FSecret', string(ReadUTF8File(ProjectDirectory + + '/docs/html/units/ConfigUnit.html'))) > 0); + + ExitCode := RunCli(['build', '--config=' + ConfigFile, + '--project-name', 'CLI Override'], StdOut, StdErr); + Expect('CLI overrides with a configuration should exit 0', ExitCode = 0); + ModelJSON := string(ReadUTF8File(ProjectDirectory + + '/docs/api-model.json')); + Expect('explicit CLI values should override the configuration', + Pos('CLI Override', ModelJSON) > 0); + + WriteTextFile(ProjectDirectory + '/bad.json', + '{"version": 1, "unknownKey": true}'); + ExitCode := RunCli(['build', '--config=' + ProjectDirectory + '/bad.json'], + StdOut, StdErr); + Expect('an unknown configuration key should exit 2', ExitCode = 2); + Expect('an unknown configuration key should be explained', + Pos('unknown configuration key', StdErr) > 0); + + ExitCode := RunCli(['build', '--config=' + ProjectDirectory + + '/missing.json'], StdOut, StdErr); + Expect('a missing configuration file should exit 2', ExitCode = 2); + + ExitCode := RunCli(['build', '--config=' + ConfigFile, '--visibility=none'], + StdOut, StdErr); + Expect('an invalid visibility option should exit 2', ExitCode = 2); + + DeleteTree(ProjectDirectory); +end; + procedure CheckSourceLinkValidation; var StdOut: string; @@ -269,6 +357,7 @@ procedure CheckSourceLinkValidation; RunCase('usage errors', @CheckUsageErrors); RunCase('build and incremental parity', @CheckBuildAndIncremental); RunCase('failure policies', @CheckFailurePolicies); + RunCase('project configuration', @CheckProjectConfiguration); RunCase('source link validation', @CheckSourceLinkValidation); DeleteTree('build/cli-test'); if Failures > 0 then diff --git a/tests/test_pasweave.pas b/tests/test_pasweave.pas index 67b7d4e..a01eff2 100644 --- a/tests/test_pasweave.pas +++ b/tests/test_pasweave.pas @@ -11,7 +11,7 @@ PasWeave.Render.Markdown, PasWeave.Render.HTML, PasWeave.Render.HTML.Markdown, PasWeave.Render.HTML.Assets, PasWeave.NavigationTests, PasWeave.SymbolIndexAndThemeTests, - PasWeave.ValidationTests, PasWeave.IncrementalTests, + PasWeave.ValidationTests, PasWeave.IncrementalTests, PasWeave.ConfigTests, PasWeave.TestSupport, PasWeave.Version; procedure CheckDocumentedExampleGoldens; @@ -1657,6 +1657,7 @@ procedure RunCase(const AName: string; ACase: TTestCase); RunCase('mathematics', @CheckMathematics); RunCase('comment dialects', @CheckCommentDialects); RunCase('source discovery', @CheckSourceDiscovery); + RunCase('project configuration', @RunConfigTests); RunCase('partial failure', @CheckPartialFailure); if Failures > 0 then begin