logicaffeine_cli/project/shape.rs
1//! What a project IS — decided once, read by every `largo` command.
2//!
3//! A project is one of four shapes, and the shape follows from two facts: does it have a site
4//! (`pages/` or `static/`), and does its entry program `Serve`?
5//!
6//! | shape | `pages/` or `static/` | the entry `Serve`s |
7//! |-----------------------------|-----------------------|--------------------|
8//! | [`ProjectShape::Site`] | yes | no, or no entry |
9//! | [`ProjectShape::FullStack`] | yes | yes |
10//! | [`ProjectShape::Backend`] | no | yes |
11//! | [`ProjectShape::Program`] | no | no |
12//!
13//! `Serve` is an AST fact over the entry's assembled closure — a `Serve` in an imported module
14//! counts, one named in a string or a comment does not. What each command does with a shape is a
15//! table on the shape itself ([`ProjectShape::serve_plan`], [`ProjectShape::build_plan`]), so
16//! `largo serve` and `largo build` cannot disagree about what a project is, and `check`, `test`
17//! and `fmt` read their program set from the same [`Project`].
18//!
19//! A site needs no entry program: a `## Page` mounts itself. `Largo.toml` names an entry only
20//! when the project has one — an `entry` the manifest DECLARES must exist, while the conventional
21//! default (`src/main.lg`) may be absent from a site.
22//!
23//! [`Project::shape`] is the STRICT decision (a parse failure is an error); [`Project::shape_or_best_effort`]
24//! is the same decision made from what can still be read when the entry does not parse — the file
25//! layout and a lexical `Serve` scan — for `largo serve --watch`, which must bind a port and show
26//! the located error rather than refuse to start over an author's typo.
27
28use std::path::{Path, PathBuf};
29
30use logicaffeine_language::ast::stmt::Stmt;
31
32use crate::project::manifest::Manifest;
33use crate::ui::CliError;
34
35/// What a project is. Decided by [`Project::shape`]; every command reads it rather than re-deriving
36/// a piece of it.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub enum ProjectShape {
39 /// Pages and assets and no `Serve` backend: a website that is files.
40 Site,
41 /// A `Serve` program and nothing else to serve.
42 Backend,
43 /// A `Serve` program beside `pages/` or `static/`.
44 FullStack,
45 /// Neither — a program.
46 Program,
47}
48
49/// What `largo serve` stands up.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ServePlan {
52 /// `pages/` and `static/` from memory, rebuilt and reloaded on every save. With `proxy`, a
53 /// request nothing local answers goes to the entry's `Serve` program running as a child;
54 /// without it, it is answered `404`, exactly as a static host answers it.
55 Pages {
56 /// Whether a backend child answers what the site does not.
57 proxy: bool,
58 },
59 /// The entry's `Serve` binds the port itself, on the interpreter.
60 Backend,
61 /// The entry program itself: its WebAssembly bundle when it builds to one, else the
62 /// interpreter runs it.
63 Program,
64}
65
66/// What a plain `largo build` produces.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum BuildPlan {
69 /// `dist/` — the pre-rendered pages, their modules and `static/`, for any static host. No
70 /// cargo, no rustc: there is no program to compile.
71 StaticExport,
72 /// One native binary. With `embeds_site`, the pages and `static/` ride inside it and are
73 /// answered before the program's own routes.
74 Binary {
75 /// Whether the site is baked into the binary.
76 embeds_site: bool,
77 },
78}
79
80impl ProjectShape {
81 /// Every shape, for the censuses that must cover them all.
82 pub const ALL: [ProjectShape; 4] = [
83 ProjectShape::Site,
84 ProjectShape::Backend,
85 ProjectShape::FullStack,
86 ProjectShape::Program,
87 ];
88
89 /// The shape two facts decide.
90 pub fn from_facts(has_site: bool, entry_serves: bool) -> ProjectShape {
91 match (has_site, entry_serves) {
92 (true, false) => ProjectShape::Site,
93 (true, true) => ProjectShape::FullStack,
94 (false, true) => ProjectShape::Backend,
95 (false, false) => ProjectShape::Program,
96 }
97 }
98
99 /// What `largo serve` does with this shape.
100 pub fn serve_plan(self) -> ServePlan {
101 match self {
102 ProjectShape::Site => ServePlan::Pages { proxy: false },
103 ProjectShape::FullStack => ServePlan::Pages { proxy: true },
104 ProjectShape::Backend => ServePlan::Backend,
105 ProjectShape::Program => ServePlan::Program,
106 }
107 }
108
109 /// What a plain `largo build` does with this shape.
110 pub fn build_plan(self) -> BuildPlan {
111 match self {
112 ProjectShape::Site => BuildPlan::StaticExport,
113 ProjectShape::FullStack => BuildPlan::Binary { embeds_site: true },
114 ProjectShape::Backend | ProjectShape::Program => {
115 BuildPlan::Binary { embeds_site: false }
116 }
117 }
118 }
119
120 /// Whether the project has a site — pages and assets that `check`, `test`, `fmt`, `serve` and
121 /// `build` all read.
122 pub fn has_site(self) -> bool {
123 matches!(self, ProjectShape::Site | ProjectShape::FullStack)
124 }
125
126 /// The shape in the words a message uses.
127 pub fn describe(self) -> &'static str {
128 match self {
129 ProjectShape::Site => "a site (pages and assets, no `Serve` backend)",
130 ProjectShape::FullStack => {
131 "a full-stack site (pages and assets beside a `Serve` backend)"
132 }
133 ProjectShape::Backend => "a backend (a `Serve` program)",
134 ProjectShape::Program => "a program",
135 }
136 }
137}
138
139/// A project on disk: its manifest, its entry program if it has one, and its directories.
140///
141/// Loading reads no source. [`Project::shape`] parses the entry; commands that only need the
142/// program set (`check`, `test`, `fmt`) never pay for — or fail on — that parse.
143#[derive(Debug, Clone)]
144pub struct Project {
145 /// The directory holding `Largo.toml`.
146 pub root: PathBuf,
147 /// The parsed manifest.
148 pub manifest: Manifest,
149 /// The entry program, or `None` for a site that has none.
150 pub entry: Option<PathBuf>,
151}
152
153impl Project {
154 /// Load the project rooted at `root`.
155 pub fn load(root: &Path) -> Result<Project, CliError> {
156 let manifest = Manifest::load(root).map_err(|e| CliError::new(e.to_string()))?;
157 let entry = entry_file(root, &manifest)?;
158 Ok(Project {
159 root: root.to_path_buf(),
160 manifest,
161 entry,
162 })
163 }
164
165 /// `pages/` — one program per `.lg` file, each served at its own URL.
166 pub fn pages_dir(&self) -> PathBuf {
167 self.root.join("pages")
168 }
169
170 /// `static/` — files served verbatim at root-relative paths.
171 pub fn static_dir(&self) -> PathBuf {
172 self.root.join("static")
173 }
174
175 /// Whether the project has a site: a `pages/` or a `static/` directory.
176 pub fn has_site(&self) -> bool {
177 self.pages_dir().is_dir() || self.static_dir().is_dir()
178 }
179
180 /// The shape: [`ProjectShape::from_facts`] over [`Project::has_site`] and whether the entry's
181 /// assembled program contains a `Serve` statement.
182 ///
183 /// An entry that does not assemble or does not parse is an error here rather than a guess: a
184 /// full-stack site whose backend has a syntax error must not quietly become a site without
185 /// one. The error is the same rendered diagnostic `largo check` prints.
186 pub fn shape(&self) -> Result<ProjectShape, CliError> {
187 let serves = match &self.entry {
188 None => false,
189 Some(entry) => {
190 let dep_roots =
191 crate::project::deps::resolve_dep_roots(&self.root, &self.manifest)?;
192 let closure = crate::closure::assemble_closure_with_deps(entry, dep_roots.clone())
193 .map_err(|e| CliError::new(format!("could not assemble project: {e}")))?;
194 match program_serves(&closure.source) {
195 Some(serves) => serves,
196 None => return Err(crate::diagnostics::entry_failure(entry, dep_roots)),
197 }
198 }
199 };
200 Ok(ProjectShape::from_facts(self.has_site(), serves))
201 }
202
203 /// The entry program, or the error that says this project is a site and has none — for the
204 /// commands (`run`, `emit`, `doc`, …) that are about a program.
205 pub fn require_entry(&self) -> Result<&Path, CliError> {
206 self.entry
207 .as_deref()
208 .ok_or_else(|| site_without_entry(&self.root))
209 }
210
211 /// The shape, but NEVER refusing over a parse error: [`Project::shape`]'s exact answer when
212 /// the entry parses, and a shape decided from what can still be read — `has_site()` (a file-
213 /// system fact, unaffected by any parse) and a LEXICAL (not grammatical) scan for a `Serve`
214 /// statement — when it does not.
215 ///
216 /// A watch-mode dev server exists to survive the author's mistakes: starting `largo serve
217 /// --watch` on a page mid-typo, or a save that breaks it, must still bind a port and show the
218 /// located error, never refuse to start. `Project::shape` remains the STRICT form for every
219 /// other command (`check`, `build`, `test`, `fmt`, `doctor`) — they want "and it currently
220 /// parses" folded into the answer, and their own pipelines report the parse error at the
221 /// right place regardless (`plan_web_bundle`'s own comment: "the compile step below reports
222 /// that at its line, so it is not reported twice here").
223 pub fn shape_or_best_effort(&self) -> ProjectShape {
224 let serves = match &self.entry {
225 None => false,
226 Some(entry) => {
227 let source = crate::project::deps::resolve_dep_roots(&self.root, &self.manifest)
228 .ok()
229 .and_then(|dep_roots| {
230 crate::closure::assemble_closure_with_deps(entry, dep_roots).ok()
231 })
232 .map(|closure| closure.source)
233 .or_else(|| std::fs::read_to_string(entry).ok());
234 match &source {
235 Some(src) => {
236 program_serves(src).unwrap_or_else(|| source_lexes_a_serve_statement(src))
237 }
238 None => false,
239 }
240 }
241 };
242 ProjectShape::from_facts(self.has_site(), serves)
243 }
244}
245
246/// Whether `source` LEXES a bare `Serve` statement-start token anywhere — tokenizing almost never
247/// fails where PARSING a full grammar can (a lexer classifies words; it does not validate
248/// sentences), so a typo elsewhere in the file — even one that stops the parser before it ever
249/// reaches a `Serve` nested in an `If` or a function — does not blind this to a `Serve` the author
250/// clearly wrote. Two things the parser would never read as a `Serve` statement, and neither can
251/// this: anything the lexer folded into a string or an interpolation (`Show "Serve on port 8080
252/// with handle."` is ONE token there, never separately lexed as the bare word `Serve`, exactly
253/// like [`program_serves`]'s "an AST fact, not a substring guess" — this is that same fact, read
254/// one level down, at the token the parser could not reach); and anything inside a `## Note` —
255/// documentation, which the parser never turns into statements at all, so a `Serve` mentioned in
256/// its prose ("`Serve` is a word this program never calls as a statement") must not read as one.
257fn source_lexes_a_serve_statement(source: &str) -> bool {
258 use logicaffeine_language::token::{BlockType, TokenType};
259 let mut interner = logicaffeine_base::Interner::new();
260 let tokens = logicaffeine_language::lexer::Lexer::new(source, &mut interner).tokenize();
261 let mut in_note = false;
262 for t in &tokens {
263 if let TokenType::BlockHeader { block_type } = t.kind {
264 in_note = matches!(block_type, BlockType::Note);
265 continue;
266 }
267 if in_note {
268 continue;
269 }
270 if !matches!(
271 t.kind,
272 TokenType::StringLiteral(_) | TokenType::InterpolatedString(_)
273 ) && interner.resolve(t.lexeme) == "Serve"
274 {
275 return true;
276 }
277 }
278 false
279}
280
281/// The error a command that needs a program gets from a site that has none.
282pub(crate) fn site_without_entry(root: &Path) -> CliError {
283 CliError::with_hint(
284 format!(
285 "{} is a site: its pages are in pages/ and it has no entry program",
286 root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "this project".into())
287 ),
288 "`largo serve` shows it, `largo build` exports it to dist/, and `largo test` runs its pages' tests",
289 )
290}
291
292/// The project's entry file: the manifest's `entry`, else its `.md` sibling.
293///
294/// `Ok(None)` only for a site whose manifest declares no `entry` — the conventional default is
295/// allowed to be absent there. A DECLARED entry that is missing is an error for every shape, or a
296/// misspelled backend would silently turn a full-stack site into a site with no API.
297///
298/// The entry is confined to the project: absolute paths and `..` escapes are rejected, so a
299/// distributed manifest can never point commands at arbitrary files on the machine.
300pub(crate) fn entry_file(root: &Path, manifest: &Manifest) -> Result<Option<PathBuf>, CliError> {
301 let declared = Path::new(&manifest.package.entry);
302 if declared.is_absolute()
303 || declared
304 .components()
305 .any(|c| matches!(c, std::path::Component::ParentDir))
306 {
307 return Err(CliError::with_hint(
308 format!(
309 "the manifest entry {:?} escapes the project directory",
310 manifest.package.entry
311 ),
312 "`entry` must be a relative path inside the project (like `src/main.lg`)",
313 ));
314 }
315 let entry = root.join(declared);
316 if entry.exists() {
317 return Ok(Some(entry));
318 }
319 let md = entry.with_extension("md");
320 if md.exists() {
321 return Ok(Some(md));
322 }
323 let has_site = root.join("pages").is_dir() || root.join("static").is_dir();
324 if has_site && !manifest_declares_entry(root) {
325 return Ok(None);
326 }
327 Err(CliError::with_hint(
328 format!(
329 "entry point not found: {} (also tried {})",
330 entry.display(),
331 md.display()
332 ),
333 "check the `entry` field in Largo.toml",
334 ))
335}
336
337/// Whether `Largo.toml` names an `entry` itself, as opposed to leaving the default in force.
338fn manifest_declares_entry(root: &Path) -> bool {
339 std::fs::read_to_string(root.join("Largo.toml"))
340 .ok()
341 .and_then(|text| text.parse::<toml::Table>().ok())
342 .and_then(|table| {
343 table
344 .get("package")
345 .and_then(|p| p.get("entry"))
346 .map(|_| ())
347 })
348 .is_some()
349}
350
351/// Whether an assembled program contains a `Serve` statement anywhere — top level, nested in
352/// control flow, or inside a function. `None` when it does not parse.
353pub(crate) fn program_serves(source: &str) -> Option<bool> {
354 logicaffeine_compile::ui_bridge::with_parsed_program(source, |parsed, _interner| match parsed {
355 Ok((stmts, _types, _policies)) => Some(stmts_contain_serve(stmts)),
356 Err(_) => None,
357 })
358}
359
360/// Recursive `Stmt::Serve` search over every block-bearing statement.
361fn stmts_contain_serve(stmts: &[Stmt]) -> bool {
362 stmts.iter().any(stmt_contains_serve)
363}
364
365fn stmt_contains_serve(stmt: &Stmt) -> bool {
366 match stmt {
367 Stmt::Serve { .. } => true,
368 Stmt::If {
369 then_block,
370 else_block,
371 ..
372 } => {
373 stmts_contain_serve(then_block) || else_block.map_or(false, |b| stmts_contain_serve(b))
374 }
375 Stmt::While { body, .. } | Stmt::Repeat { body, .. } | Stmt::Zone { body, .. } => {
376 stmts_contain_serve(body)
377 }
378 Stmt::Concurrent { tasks } | Stmt::Parallel { tasks } => stmts_contain_serve(tasks),
379 Stmt::FunctionDef { body, .. } => stmts_contain_serve(body),
380 Stmt::Inspect { arms, .. } => arms.iter().any(|arm| stmts_contain_serve(arm.body)),
381 _ => false,
382 }
383}
384
385#[cfg(test)]
386#[path = "../../../../crates/logicaffeine_tests/unit/logicaffeine_cli/project/shape/tests.rs"]
387mod tests;